Compare commits
2 Commits
cdb5a75f78
...
e6e76d1b9a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6e76d1b9a | ||
|
|
0c4a9591fa |
@@ -12,18 +12,23 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import sodium from "libsodium-wrappers";
|
import sodium from "libsodium-wrappers";
|
||||||
import { db } from "../src/db";
|
import { db } from "../src/db";
|
||||||
import { mesh, meshMember } from "@turbostarter/db/schema/mesh";
|
import { invite, mesh, meshMember } from "@turbostarter/db/schema/mesh";
|
||||||
import { user } from "@turbostarter/db/schema/auth";
|
import { user } from "@turbostarter/db/schema/auth";
|
||||||
|
import { canonicalInvite } from "../src/crypto";
|
||||||
|
|
||||||
const USER_ID = "test-user-smoke";
|
const USER_ID = "test-user-smoke";
|
||||||
const MESH_SLUG = "smoke-test";
|
const MESH_SLUG = "smoke-test";
|
||||||
|
const BROKER_URL = process.env.BROKER_WS_URL ?? "ws://localhost:7900/ws";
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
// Generate real ed25519 keypairs so crypto_box (via ed25519→X25519
|
// Generate real ed25519 keypairs so crypto_box (via ed25519→X25519
|
||||||
// conversion) works in Step 18+ round-trip tests.
|
// conversion) works in Step 18+ round-trip tests.
|
||||||
await sodium.ready;
|
await sodium.ready;
|
||||||
|
const kpOwner = sodium.crypto_sign_keypair();
|
||||||
const kpA = sodium.crypto_sign_keypair();
|
const kpA = sodium.crypto_sign_keypair();
|
||||||
const kpB = sodium.crypto_sign_keypair();
|
const kpB = sodium.crypto_sign_keypair();
|
||||||
|
const OWNER_PUBKEY = sodium.to_hex(kpOwner.publicKey);
|
||||||
|
const OWNER_SECRET = sodium.to_hex(kpOwner.privateKey);
|
||||||
const PEER_A_PUBKEY = sodium.to_hex(kpA.publicKey);
|
const PEER_A_PUBKEY = sodium.to_hex(kpA.publicKey);
|
||||||
const PEER_A_SECRET = sodium.to_hex(kpA.privateKey);
|
const PEER_A_SECRET = sodium.to_hex(kpA.privateKey);
|
||||||
const PEER_B_PUBKEY = sodium.to_hex(kpB.publicKey);
|
const PEER_B_PUBKEY = sodium.to_hex(kpB.publicKey);
|
||||||
@@ -53,6 +58,7 @@ async function main() {
|
|||||||
name: "Smoke Test",
|
name: "Smoke Test",
|
||||||
slug: MESH_SLUG,
|
slug: MESH_SLUG,
|
||||||
ownerUserId: USER_ID,
|
ownerUserId: USER_ID,
|
||||||
|
ownerPubkey: OWNER_PUBKEY,
|
||||||
visibility: "private",
|
visibility: "private",
|
||||||
transport: "managed",
|
transport: "managed",
|
||||||
tier: "free",
|
tier: "free",
|
||||||
@@ -60,6 +66,40 @@ async function main() {
|
|||||||
.returning({ id: mesh.id });
|
.returning({ id: mesh.id });
|
||||||
if (!m) throw new Error("mesh insert failed");
|
if (!m) throw new Error("mesh insert failed");
|
||||||
|
|
||||||
|
// Build + sign an invite, store it so /join can verify.
|
||||||
|
const expiresAtSec = Math.floor(Date.now() / 1000) + 3600;
|
||||||
|
const invitePayload = {
|
||||||
|
v: 1 as const,
|
||||||
|
mesh_id: m.id,
|
||||||
|
mesh_slug: MESH_SLUG,
|
||||||
|
broker_url: BROKER_URL,
|
||||||
|
expires_at: expiresAtSec,
|
||||||
|
mesh_root_key: "c21va2UtdGVzdC1tZXNoLXJvb3Qta2V5LWRldg",
|
||||||
|
role: "member" as const,
|
||||||
|
owner_pubkey: OWNER_PUBKEY,
|
||||||
|
};
|
||||||
|
const canonical = canonicalInvite(invitePayload);
|
||||||
|
const signature = sodium.to_hex(
|
||||||
|
sodium.crypto_sign_detached(
|
||||||
|
sodium.from_string(canonical),
|
||||||
|
kpOwner.privateKey,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const fullPayload = { ...invitePayload, signature };
|
||||||
|
const token = Buffer.from(JSON.stringify(fullPayload), "utf-8").toString(
|
||||||
|
"base64url",
|
||||||
|
);
|
||||||
|
await db.insert(invite).values({
|
||||||
|
meshId: m.id,
|
||||||
|
token,
|
||||||
|
tokenBytes: canonical,
|
||||||
|
maxUses: 5,
|
||||||
|
usedCount: 0,
|
||||||
|
role: "member",
|
||||||
|
expiresAt: new Date(expiresAtSec * 1000),
|
||||||
|
createdBy: USER_ID,
|
||||||
|
});
|
||||||
|
|
||||||
const [peerA] = await db
|
const [peerA] = await db
|
||||||
.insert(meshMember)
|
.insert(meshMember)
|
||||||
.values({
|
.values({
|
||||||
@@ -84,6 +124,10 @@ async function main() {
|
|||||||
|
|
||||||
const seed = {
|
const seed = {
|
||||||
meshId: m.id,
|
meshId: m.id,
|
||||||
|
ownerPubkey: OWNER_PUBKEY,
|
||||||
|
ownerSecretKey: OWNER_SECRET,
|
||||||
|
inviteToken: token,
|
||||||
|
inviteLink: `ic://join/${token}`,
|
||||||
peerA: {
|
peerA: {
|
||||||
memberId: peerA.id,
|
memberId: peerA.id,
|
||||||
pubkey: PEER_A_PUBKEY,
|
pubkey: PEER_A_PUBKEY,
|
||||||
|
|||||||
@@ -30,12 +30,17 @@ import {
|
|||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import {
|
import {
|
||||||
|
invite as inviteTable,
|
||||||
mesh,
|
mesh,
|
||||||
meshMember as memberTable,
|
meshMember as memberTable,
|
||||||
messageQueue,
|
messageQueue,
|
||||||
pendingStatus,
|
pendingStatus,
|
||||||
presence,
|
presence,
|
||||||
} from "@turbostarter/db/schema/mesh";
|
} from "@turbostarter/db/schema/mesh";
|
||||||
|
import {
|
||||||
|
canonicalInvite,
|
||||||
|
verifyEd25519,
|
||||||
|
} from "./crypto";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
import { metrics } from "./metrics";
|
import { metrics } from "./metrics";
|
||||||
import { inferStatusFromJsonl } from "./paths";
|
import { inferStatusFromJsonl } from "./paths";
|
||||||
@@ -510,37 +515,108 @@ export async function stopSweepers(): Promise<void> {
|
|||||||
.where(isNull(presence.disconnectedAt));
|
.where(isNull(presence.disconnectedAt));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type JoinError =
|
||||||
|
| "mesh_not_found"
|
||||||
|
| "mesh_missing_owner_key"
|
||||||
|
| "invite_not_found"
|
||||||
|
| "invite_expired"
|
||||||
|
| "invite_exhausted"
|
||||||
|
| "invite_revoked"
|
||||||
|
| "invite_bad_signature"
|
||||||
|
| "invite_mesh_mismatch"
|
||||||
|
| "invite_owner_mismatch"
|
||||||
|
| "member_insert_failed";
|
||||||
|
|
||||||
|
export interface InvitePayload {
|
||||||
|
v: number;
|
||||||
|
mesh_id: string;
|
||||||
|
mesh_slug: string;
|
||||||
|
broker_url: string;
|
||||||
|
expires_at: number;
|
||||||
|
mesh_root_key: string;
|
||||||
|
role: "admin" | "member";
|
||||||
|
owner_pubkey: string;
|
||||||
|
signature: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enroll a new member in an existing mesh. Called by the CLI join
|
* Enroll a new member in an existing mesh.
|
||||||
* flow after invite-link parsing + keypair generation client-side.
|
|
||||||
*
|
*
|
||||||
* v0.1.0: trusts the request. Signature verification + invite-token
|
* Requires a signed invite payload. Verifies:
|
||||||
* one-time-use tracking land in Step 18.
|
* - invite row exists (looked up by token = base64 link payload)
|
||||||
|
* - not expired, not revoked, used_count < max_uses
|
||||||
|
* - payload's signature matches payload's owner_pubkey
|
||||||
|
* - payload's owner_pubkey matches mesh.owner_pubkey (prevents a
|
||||||
|
* malicious admin from substituting their own owner key)
|
||||||
|
* - payload's mesh_id matches the row's mesh_id (belt + braces)
|
||||||
|
*
|
||||||
|
* Then atomically increments used_count (CAS guarded by max_uses) and
|
||||||
|
* inserts the member. Idempotent: same pubkey enrolling twice returns
|
||||||
|
* the existing memberId WITHOUT burning an invite use.
|
||||||
*/
|
*/
|
||||||
export async function joinMesh(args: {
|
export async function joinMesh(args: {
|
||||||
meshId: string;
|
inviteToken: string;
|
||||||
|
invitePayload: InvitePayload;
|
||||||
peerPubkey: string;
|
peerPubkey: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
role: "admin" | "member";
|
|
||||||
}): Promise<
|
}): Promise<
|
||||||
| { ok: true; memberId: string; alreadyMember?: boolean }
|
| { ok: true; memberId: string; alreadyMember?: boolean }
|
||||||
| { ok: false; error: string }
|
| { ok: false; error: JoinError }
|
||||||
> {
|
> {
|
||||||
// Validate the mesh exists.
|
const { inviteToken, invitePayload, peerPubkey, displayName } = args;
|
||||||
const [m] = await db
|
|
||||||
.select({ id: mesh.id })
|
|
||||||
.from(mesh)
|
|
||||||
.where(and(eq(mesh.id, args.meshId), isNull(mesh.archivedAt)));
|
|
||||||
if (!m) return { ok: false, error: "mesh not found or archived" };
|
|
||||||
|
|
||||||
// Idempotency: same pubkey already a member → return existing id.
|
// 1. Verify invite signature.
|
||||||
|
const canonical = canonicalInvite({
|
||||||
|
v: invitePayload.v,
|
||||||
|
mesh_id: invitePayload.mesh_id,
|
||||||
|
mesh_slug: invitePayload.mesh_slug,
|
||||||
|
broker_url: invitePayload.broker_url,
|
||||||
|
expires_at: invitePayload.expires_at,
|
||||||
|
mesh_root_key: invitePayload.mesh_root_key,
|
||||||
|
role: invitePayload.role,
|
||||||
|
owner_pubkey: invitePayload.owner_pubkey,
|
||||||
|
});
|
||||||
|
const sigValid = await verifyEd25519(
|
||||||
|
canonical,
|
||||||
|
invitePayload.signature,
|
||||||
|
invitePayload.owner_pubkey,
|
||||||
|
);
|
||||||
|
if (!sigValid) return { ok: false, error: "invite_bad_signature" };
|
||||||
|
|
||||||
|
// 2. Load the mesh. Require owner_pubkey is set and matches payload.
|
||||||
|
const [m] = await db
|
||||||
|
.select({ id: mesh.id, ownerPubkey: mesh.ownerPubkey })
|
||||||
|
.from(mesh)
|
||||||
|
.where(and(eq(mesh.id, invitePayload.mesh_id), isNull(mesh.archivedAt)));
|
||||||
|
if (!m) return { ok: false, error: "mesh_not_found" };
|
||||||
|
if (!m.ownerPubkey) return { ok: false, error: "mesh_missing_owner_key" };
|
||||||
|
if (m.ownerPubkey !== invitePayload.owner_pubkey) {
|
||||||
|
return { ok: false, error: "invite_owner_mismatch" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Load the invite row. Must belong to this mesh.
|
||||||
|
const [inv] = await db
|
||||||
|
.select()
|
||||||
|
.from(inviteTable)
|
||||||
|
.where(eq(inviteTable.token, inviteToken));
|
||||||
|
if (!inv) return { ok: false, error: "invite_not_found" };
|
||||||
|
if (inv.meshId !== invitePayload.mesh_id) {
|
||||||
|
return { ok: false, error: "invite_mesh_mismatch" };
|
||||||
|
}
|
||||||
|
if (inv.revokedAt) return { ok: false, error: "invite_revoked" };
|
||||||
|
if (inv.expiresAt.getTime() < Date.now()) {
|
||||||
|
return { ok: false, error: "invite_expired" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Idempotency: if this pubkey is already a member, short-circuit
|
||||||
|
// without consuming an invite use.
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select({ id: memberTable.id })
|
.select({ id: memberTable.id })
|
||||||
.from(memberTable)
|
.from(memberTable)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(memberTable.meshId, args.meshId),
|
eq(memberTable.meshId, invitePayload.mesh_id),
|
||||||
eq(memberTable.peerPubkey, args.peerPubkey),
|
eq(memberTable.peerPubkey, peerPubkey),
|
||||||
isNull(memberTable.revokedAt),
|
isNull(memberTable.revokedAt),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -548,16 +624,30 @@ export async function joinMesh(args: {
|
|||||||
return { ok: true, memberId: existing.id, alreadyMember: true };
|
return { ok: true, memberId: existing.id, alreadyMember: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5. Atomic claim: increment used_count iff under max_uses.
|
||||||
|
const [claimed] = await db
|
||||||
|
.update(inviteTable)
|
||||||
|
.set({ usedCount: sql`${inviteTable.usedCount} + 1` })
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(inviteTable.id, inv.id),
|
||||||
|
lt(inviteTable.usedCount, inv.maxUses),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: inviteTable.id, usedCount: inviteTable.usedCount });
|
||||||
|
if (!claimed) return { ok: false, error: "invite_exhausted" };
|
||||||
|
|
||||||
|
// 6. Insert the member with the role from the payload.
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.insert(memberTable)
|
.insert(memberTable)
|
||||||
.values({
|
.values({
|
||||||
meshId: args.meshId,
|
meshId: invitePayload.mesh_id,
|
||||||
peerPubkey: args.peerPubkey,
|
peerPubkey,
|
||||||
displayName: args.displayName,
|
displayName,
|
||||||
role: args.role,
|
role: invitePayload.role,
|
||||||
})
|
})
|
||||||
.returning({ id: memberTable.id });
|
.returning({ id: memberTable.id });
|
||||||
if (!row) return { ok: false, error: "member insert failed" };
|
if (!row) return { ok: false, error: "member_insert_failed" };
|
||||||
return { ok: true, memberId: row.id };
|
return { ok: true, memberId: row.id };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,47 @@ export function canonicalHello(
|
|||||||
return `${meshId}|${memberId}|${pubkey}|${timestamp}`;
|
return `${meshId}|${memberId}|${pubkey}|${timestamp}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Canonical invite bytes — everything in the payload except the signature. */
|
||||||
|
export function canonicalInvite(fields: {
|
||||||
|
v: number;
|
||||||
|
mesh_id: string;
|
||||||
|
mesh_slug: string;
|
||||||
|
broker_url: string;
|
||||||
|
expires_at: number;
|
||||||
|
mesh_root_key: string;
|
||||||
|
role: "admin" | "member";
|
||||||
|
owner_pubkey: string;
|
||||||
|
}): string {
|
||||||
|
return `${fields.v}|${fields.mesh_id}|${fields.mesh_slug}|${fields.broker_url}|${fields.expires_at}|${fields.mesh_root_key}|${fields.role}|${fields.owner_pubkey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify an ed25519 signature over arbitrary canonical bytes.
|
||||||
|
* Used by invite verification + (future) any other signed payload.
|
||||||
|
*/
|
||||||
|
export async function verifyEd25519(
|
||||||
|
canonicalText: string,
|
||||||
|
signatureHex: string,
|
||||||
|
pubkeyHex: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (
|
||||||
|
!/^[0-9a-f]{64}$/i.test(pubkeyHex) ||
|
||||||
|
!/^[0-9a-f]{128}$/i.test(signatureHex)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const s = await ensureSodium();
|
||||||
|
try {
|
||||||
|
return s.crypto_sign_verify_detached(
|
||||||
|
s.from_hex(signatureHex),
|
||||||
|
s.from_string(canonicalText),
|
||||||
|
s.from_hex(pubkeyHex),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const HELLO_SKEW_MS = 60_000;
|
export const HELLO_SKEW_MS = 60_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -251,21 +251,21 @@ function handleJoinPost(
|
|||||||
if (aborted) return;
|
if (aborted) return;
|
||||||
try {
|
try {
|
||||||
const payload = JSON.parse(Buffer.concat(chunks).toString()) as {
|
const payload = JSON.parse(Buffer.concat(chunks).toString()) as {
|
||||||
mesh_id?: string;
|
invite_token?: string;
|
||||||
|
invite_payload?: unknown;
|
||||||
peer_pubkey?: string;
|
peer_pubkey?: string;
|
||||||
display_name?: string;
|
display_name?: string;
|
||||||
role?: "admin" | "member";
|
|
||||||
};
|
};
|
||||||
// Minimal shape validation.
|
|
||||||
if (
|
if (
|
||||||
!payload.mesh_id ||
|
!payload.invite_token ||
|
||||||
|
!payload.invite_payload ||
|
||||||
!payload.peer_pubkey ||
|
!payload.peer_pubkey ||
|
||||||
!payload.display_name ||
|
!payload.display_name
|
||||||
!payload.role
|
|
||||||
) {
|
) {
|
||||||
writeJson(res, 400, {
|
writeJson(res, 400, {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: "mesh_id, peer_pubkey, display_name, role required",
|
error:
|
||||||
|
"invite_token, invite_payload, peer_pubkey, display_name required",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -277,18 +277,21 @@ function handleJoinPost(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await joinMesh({
|
const result = await joinMesh({
|
||||||
meshId: payload.mesh_id,
|
inviteToken: payload.invite_token,
|
||||||
|
invitePayload: payload.invite_payload as Parameters<
|
||||||
|
typeof joinMesh
|
||||||
|
>[0]["invitePayload"],
|
||||||
peerPubkey: payload.peer_pubkey,
|
peerPubkey: payload.peer_pubkey,
|
||||||
displayName: payload.display_name,
|
displayName: payload.display_name,
|
||||||
role: payload.role,
|
|
||||||
});
|
});
|
||||||
writeJson(res, result.ok ? 200 : 400, result);
|
writeJson(res, result.ok ? 200 : 400, result);
|
||||||
log.info("join", {
|
log.info("join", {
|
||||||
route: "POST /join",
|
route: "POST /join",
|
||||||
mesh_id: payload.mesh_id,
|
|
||||||
pubkey: payload.peer_pubkey.slice(0, 12),
|
pubkey: payload.peer_pubkey.slice(0, 12),
|
||||||
ok: result.ok,
|
ok: result.ok,
|
||||||
already_member: "alreadyMember" in result ? result.alreadyMember : false,
|
error: !result.ok ? result.error : undefined,
|
||||||
|
already_member:
|
||||||
|
"alreadyMember" in result ? result.alreadyMember : false,
|
||||||
latency_ms: Date.now() - started,
|
latency_ms: Date.now() - started,
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -8,10 +8,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { eq, inArray } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
|
import sodium from "libsodium-wrappers";
|
||||||
import { db } from "../src/db";
|
import { db } from "../src/db";
|
||||||
import { mesh, meshMember } from "@turbostarter/db/schema/mesh";
|
import { invite, mesh, meshMember } from "@turbostarter/db/schema/mesh";
|
||||||
import { user } from "@turbostarter/db/schema/auth";
|
import { user } from "@turbostarter/db/schema/auth";
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { canonicalInvite } from "../src/crypto";
|
||||||
|
|
||||||
const TEST_USER_ID = "test-user-integration";
|
const TEST_USER_ID = "test-user-integration";
|
||||||
|
|
||||||
@@ -37,11 +39,29 @@ export async function ensureTestUser(): Promise<string> {
|
|||||||
|
|
||||||
export interface TestMesh {
|
export interface TestMesh {
|
||||||
meshId: string;
|
meshId: string;
|
||||||
|
ownerPubkey: string;
|
||||||
|
ownerSecretKey: string;
|
||||||
peerA: { memberId: string; pubkey: string };
|
peerA: { memberId: string; pubkey: string };
|
||||||
peerB: { memberId: string; pubkey: string };
|
peerB: { memberId: string; pubkey: string };
|
||||||
cleanup: () => Promise<void>;
|
cleanup: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TestInvite {
|
||||||
|
token: string;
|
||||||
|
payload: {
|
||||||
|
v: 1;
|
||||||
|
mesh_id: string;
|
||||||
|
mesh_slug: string;
|
||||||
|
broker_url: string;
|
||||||
|
expires_at: number;
|
||||||
|
mesh_root_key: string;
|
||||||
|
role: "admin" | "member";
|
||||||
|
owner_pubkey: string;
|
||||||
|
signature: string;
|
||||||
|
};
|
||||||
|
inviteId: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a test mesh + 2 members. Returns IDs + pubkeys and a
|
* Create a test mesh + 2 members. Returns IDs + pubkeys and a
|
||||||
* cleanup function that cascade-deletes the mesh (and all presence,
|
* cleanup function that cascade-deletes the mesh (and all presence,
|
||||||
@@ -51,12 +71,18 @@ export async function setupTestMesh(label: string): Promise<TestMesh> {
|
|||||||
const userId = await ensureTestUser();
|
const userId = await ensureTestUser();
|
||||||
const slug = `t-${label}-${randomBytes(4).toString("hex")}`;
|
const slug = `t-${label}-${randomBytes(4).toString("hex")}`;
|
||||||
|
|
||||||
|
await sodium.ready;
|
||||||
|
const kpOwner = sodium.crypto_sign_keypair();
|
||||||
|
const ownerPubkey = sodium.to_hex(kpOwner.publicKey);
|
||||||
|
const ownerSecretKey = sodium.to_hex(kpOwner.privateKey);
|
||||||
|
|
||||||
const [m] = await db
|
const [m] = await db
|
||||||
.insert(mesh)
|
.insert(mesh)
|
||||||
.values({
|
.values({
|
||||||
name: `Test ${label}`,
|
name: `Test ${label}`,
|
||||||
slug,
|
slug,
|
||||||
ownerUserId: userId,
|
ownerUserId: userId,
|
||||||
|
ownerPubkey,
|
||||||
visibility: "private",
|
visibility: "private",
|
||||||
transport: "managed",
|
transport: "managed",
|
||||||
tier: "free",
|
tier: "free",
|
||||||
@@ -91,6 +117,8 @@ export async function setupTestMesh(label: string): Promise<TestMesh> {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
meshId: m.id,
|
meshId: m.id,
|
||||||
|
ownerPubkey,
|
||||||
|
ownerSecretKey,
|
||||||
peerA: { memberId: mA.id, pubkey: pubkeyA },
|
peerA: { memberId: mA.id, pubkey: pubkeyA },
|
||||||
peerB: { memberId: mB.id, pubkey: pubkeyB },
|
peerB: { memberId: mB.id, pubkey: pubkeyB },
|
||||||
cleanup: async () => {
|
cleanup: async () => {
|
||||||
@@ -100,6 +128,74 @@ export async function setupTestMesh(label: string): Promise<TestMesh> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a signed invite row for an existing test mesh. Returns the
|
||||||
|
* token + full payload + DB invite id. Defaults: 1-hour expiry, max
|
||||||
|
* uses = 1, role = "member".
|
||||||
|
*/
|
||||||
|
export async function createTestInvite(
|
||||||
|
m: TestMesh,
|
||||||
|
opts: {
|
||||||
|
maxUses?: number;
|
||||||
|
expiresInSec?: number;
|
||||||
|
role?: "admin" | "member";
|
||||||
|
slug?: string;
|
||||||
|
brokerUrl?: string;
|
||||||
|
} = {},
|
||||||
|
): Promise<TestInvite> {
|
||||||
|
await sodium.ready;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const expiresAt = now + (opts.expiresInSec ?? 3600);
|
||||||
|
const payload = {
|
||||||
|
v: 1 as const,
|
||||||
|
mesh_id: m.meshId,
|
||||||
|
mesh_slug: opts.slug ?? "test-slug",
|
||||||
|
broker_url: opts.brokerUrl ?? "ws://localhost:7900/ws",
|
||||||
|
expires_at: expiresAt,
|
||||||
|
mesh_root_key: "dGVzdC1tZXNoLXJvb3Qta2V5",
|
||||||
|
role: opts.role ?? ("member" as const),
|
||||||
|
owner_pubkey: m.ownerPubkey,
|
||||||
|
};
|
||||||
|
const canonical = canonicalInvite(payload);
|
||||||
|
const signature = sodium.to_hex(
|
||||||
|
sodium.crypto_sign_detached(
|
||||||
|
sodium.from_string(canonical),
|
||||||
|
sodium.from_hex(m.ownerSecretKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const full = { ...payload, signature };
|
||||||
|
const token = Buffer.from(JSON.stringify(full), "utf-8").toString(
|
||||||
|
"base64url",
|
||||||
|
);
|
||||||
|
const [row] = await db
|
||||||
|
.insert(invite)
|
||||||
|
.values({
|
||||||
|
meshId: m.meshId,
|
||||||
|
token,
|
||||||
|
tokenBytes: canonical,
|
||||||
|
maxUses: opts.maxUses ?? 1,
|
||||||
|
usedCount: 0,
|
||||||
|
role: opts.role ?? "member",
|
||||||
|
expiresAt: new Date(expiresAt * 1000),
|
||||||
|
createdBy: "test-user-integration",
|
||||||
|
})
|
||||||
|
.returning({ id: invite.id });
|
||||||
|
if (!row) throw new Error("invite insert failed");
|
||||||
|
return { token, payload: full, inviteId: row.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateRawKeypair(): Promise<{
|
||||||
|
publicKey: string;
|
||||||
|
secretKey: string;
|
||||||
|
}> {
|
||||||
|
await sodium.ready;
|
||||||
|
const kp = sodium.crypto_sign_keypair();
|
||||||
|
return {
|
||||||
|
publicKey: sodium.to_hex(kp.publicKey),
|
||||||
|
secretKey: sodium.to_hex(kp.privateKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete all meshes with slugs starting with "t-" (test prefix).
|
* Delete all meshes with slugs starting with "t-" (test prefix).
|
||||||
* Used as a safety net in afterAll if individual cleanup() didn't run.
|
* Used as a safety net in afterAll if individual cleanup() didn't run.
|
||||||
|
|||||||
271
apps/broker/tests/invite-signature.test.ts
Normal file
271
apps/broker/tests/invite-signature.test.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
/**
|
||||||
|
* Invite signature + one-time-use tracking.
|
||||||
|
*
|
||||||
|
* Covers the full joinMesh() security envelope:
|
||||||
|
* - signed invites accepted
|
||||||
|
* - tampered payloads rejected
|
||||||
|
* - mismatched owner_pubkey rejected
|
||||||
|
* - expired / revoked / exhausted invites rejected
|
||||||
|
* - idempotency: same pubkey rejoins without burning a use
|
||||||
|
* - atomic single-use: concurrent joins produce exactly one winner
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterAll, afterEach, describe, expect, test } from "vitest";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "../src/db";
|
||||||
|
import { invite, mesh } from "@turbostarter/db/schema/mesh";
|
||||||
|
import { joinMesh } from "../src/broker";
|
||||||
|
import {
|
||||||
|
cleanupAllTestMeshes,
|
||||||
|
createTestInvite,
|
||||||
|
generateRawKeypair,
|
||||||
|
setupTestMesh,
|
||||||
|
type TestInvite,
|
||||||
|
type TestMesh,
|
||||||
|
} from "./helpers";
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanupAllTestMeshes();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("joinMesh — signed invites", () => {
|
||||||
|
let m: TestMesh;
|
||||||
|
afterEach(async () => m && (await m.cleanup()));
|
||||||
|
|
||||||
|
test("valid signed invite → join succeeds", async () => {
|
||||||
|
m = await setupTestMesh("inv-valid");
|
||||||
|
const inv = await createTestInvite(m);
|
||||||
|
const kp = await generateRawKeypair();
|
||||||
|
const result = await joinMesh({
|
||||||
|
inviteToken: inv.token,
|
||||||
|
invitePayload: inv.payload,
|
||||||
|
peerPubkey: kp.publicKey,
|
||||||
|
displayName: "alice",
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
if (result.ok) expect(result.memberId).toMatch(/^[A-Za-z0-9]+$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tampered payload → invite_bad_signature", async () => {
|
||||||
|
m = await setupTestMesh("inv-tampered");
|
||||||
|
const inv = await createTestInvite(m);
|
||||||
|
const kp = await generateRawKeypair();
|
||||||
|
const tampered = { ...inv.payload, mesh_slug: "HACKED" };
|
||||||
|
const result = await joinMesh({
|
||||||
|
inviteToken: inv.token,
|
||||||
|
invitePayload: tampered,
|
||||||
|
peerPubkey: kp.publicKey,
|
||||||
|
displayName: "mallory",
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) expect(result.error).toBe("invite_bad_signature");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("owner key mismatch → invite_owner_mismatch", async () => {
|
||||||
|
m = await setupTestMesh("inv-owner-mismatch");
|
||||||
|
// Signer has a valid keypair but is NOT the mesh owner.
|
||||||
|
const fake = await generateRawKeypair();
|
||||||
|
// Build a properly-signed payload with the fake owner key.
|
||||||
|
const { canonicalInvite } = await import("../src/crypto");
|
||||||
|
const sodium = await import("libsodium-wrappers").then((m) => m.default);
|
||||||
|
await sodium.ready;
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const payload = {
|
||||||
|
v: 1 as const,
|
||||||
|
mesh_id: m.meshId,
|
||||||
|
mesh_slug: "x",
|
||||||
|
broker_url: "ws://localhost/ws",
|
||||||
|
expires_at: now + 3600,
|
||||||
|
mesh_root_key: "a",
|
||||||
|
role: "member" as const,
|
||||||
|
owner_pubkey: fake.publicKey, // wrong owner
|
||||||
|
};
|
||||||
|
const sig = sodium.to_hex(
|
||||||
|
sodium.crypto_sign_detached(
|
||||||
|
sodium.from_string(canonicalInvite(payload)),
|
||||||
|
sodium.from_hex(fake.secretKey),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const token = Buffer.from(
|
||||||
|
JSON.stringify({ ...payload, signature: sig }),
|
||||||
|
"utf-8",
|
||||||
|
).toString("base64url");
|
||||||
|
// Have to insert a matching invite row so broker can look it up.
|
||||||
|
await db.insert(invite).values({
|
||||||
|
meshId: m.meshId,
|
||||||
|
token,
|
||||||
|
maxUses: 1,
|
||||||
|
usedCount: 0,
|
||||||
|
role: "member",
|
||||||
|
expiresAt: new Date((now + 3600) * 1000),
|
||||||
|
createdBy: "test-user-integration",
|
||||||
|
});
|
||||||
|
|
||||||
|
const joiner = await generateRawKeypair();
|
||||||
|
const result = await joinMesh({
|
||||||
|
inviteToken: token,
|
||||||
|
invitePayload: { ...payload, signature: sig },
|
||||||
|
peerPubkey: joiner.publicKey,
|
||||||
|
displayName: "joiner",
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) expect(result.error).toBe("invite_owner_mismatch");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("expired invite → invite_expired", async () => {
|
||||||
|
m = await setupTestMesh("inv-expired");
|
||||||
|
// Create invite with expiry in the past (we use a far-future expiry
|
||||||
|
// for signing, then back-date the DB row to simulate staleness
|
||||||
|
// without the client-side expiry check tripping).
|
||||||
|
const inv = await createTestInvite(m, { expiresInSec: 3600 });
|
||||||
|
await db
|
||||||
|
.update(invite)
|
||||||
|
.set({ expiresAt: new Date(Date.now() - 1000) })
|
||||||
|
.where(eq(invite.id, inv.inviteId));
|
||||||
|
const kp = await generateRawKeypair();
|
||||||
|
const result = await joinMesh({
|
||||||
|
inviteToken: inv.token,
|
||||||
|
invitePayload: inv.payload,
|
||||||
|
peerPubkey: kp.publicKey,
|
||||||
|
displayName: "late",
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) expect(result.error).toBe("invite_expired");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("revoked invite → invite_revoked", async () => {
|
||||||
|
m = await setupTestMesh("inv-revoked");
|
||||||
|
const inv = await createTestInvite(m);
|
||||||
|
await db
|
||||||
|
.update(invite)
|
||||||
|
.set({ revokedAt: new Date() })
|
||||||
|
.where(eq(invite.id, inv.inviteId));
|
||||||
|
const kp = await generateRawKeypair();
|
||||||
|
const result = await joinMesh({
|
||||||
|
inviteToken: inv.token,
|
||||||
|
invitePayload: inv.payload,
|
||||||
|
peerPubkey: kp.publicKey,
|
||||||
|
displayName: "blocked",
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) expect(result.error).toBe("invite_revoked");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("exhausted invite → invite_exhausted", async () => {
|
||||||
|
m = await setupTestMesh("inv-exhausted");
|
||||||
|
const inv = await createTestInvite(m, { maxUses: 2 });
|
||||||
|
// First two joins succeed.
|
||||||
|
const k1 = await generateRawKeypair();
|
||||||
|
const k2 = await generateRawKeypair();
|
||||||
|
const r1 = await joinMesh({
|
||||||
|
inviteToken: inv.token,
|
||||||
|
invitePayload: inv.payload,
|
||||||
|
peerPubkey: k1.publicKey,
|
||||||
|
displayName: "first",
|
||||||
|
});
|
||||||
|
const r2 = await joinMesh({
|
||||||
|
inviteToken: inv.token,
|
||||||
|
invitePayload: inv.payload,
|
||||||
|
peerPubkey: k2.publicKey,
|
||||||
|
displayName: "second",
|
||||||
|
});
|
||||||
|
expect(r1.ok).toBe(true);
|
||||||
|
expect(r2.ok).toBe(true);
|
||||||
|
// Third should be rejected.
|
||||||
|
const k3 = await generateRawKeypair();
|
||||||
|
const r3 = await joinMesh({
|
||||||
|
inviteToken: inv.token,
|
||||||
|
invitePayload: inv.payload,
|
||||||
|
peerPubkey: k3.publicKey,
|
||||||
|
displayName: "third",
|
||||||
|
});
|
||||||
|
expect(r3.ok).toBe(false);
|
||||||
|
if (!r3.ok) expect(r3.error).toBe("invite_exhausted");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("idempotent re-join doesn't burn a use", async () => {
|
||||||
|
m = await setupTestMesh("inv-idempotent");
|
||||||
|
const inv = await createTestInvite(m, { maxUses: 1 });
|
||||||
|
const kp = await generateRawKeypair();
|
||||||
|
const r1 = await joinMesh({
|
||||||
|
inviteToken: inv.token,
|
||||||
|
invitePayload: inv.payload,
|
||||||
|
peerPubkey: kp.publicKey,
|
||||||
|
displayName: "alice",
|
||||||
|
});
|
||||||
|
const r2 = await joinMesh({
|
||||||
|
inviteToken: inv.token,
|
||||||
|
invitePayload: inv.payload,
|
||||||
|
peerPubkey: kp.publicKey,
|
||||||
|
displayName: "alice",
|
||||||
|
});
|
||||||
|
expect(r1.ok).toBe(true);
|
||||||
|
expect(r2.ok).toBe(true);
|
||||||
|
if (r1.ok && r2.ok) {
|
||||||
|
expect(r2.memberId).toBe(r1.memberId);
|
||||||
|
expect(r2.alreadyMember).toBe(true);
|
||||||
|
}
|
||||||
|
// usedCount should still be 1, not 2.
|
||||||
|
const [row] = await db
|
||||||
|
.select({ usedCount: invite.usedCount })
|
||||||
|
.from(invite)
|
||||||
|
.where(eq(invite.id, inv.inviteId));
|
||||||
|
expect(row?.usedCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("atomic single-use: concurrent joins, exactly one wins", async () => {
|
||||||
|
m = await setupTestMesh("inv-atomic");
|
||||||
|
const inv = await createTestInvite(m, { maxUses: 1 });
|
||||||
|
// Fire 5 distinct joiners concurrently at a 1-use invite.
|
||||||
|
const joiners = await Promise.all(
|
||||||
|
Array.from({ length: 5 }).map(() => generateRawKeypair()),
|
||||||
|
);
|
||||||
|
const results = await Promise.all(
|
||||||
|
joiners.map((kp, i) =>
|
||||||
|
joinMesh({
|
||||||
|
inviteToken: inv.token,
|
||||||
|
invitePayload: inv.payload,
|
||||||
|
peerPubkey: kp.publicKey,
|
||||||
|
displayName: `racer-${i}`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const oks = results.filter((r) => r.ok);
|
||||||
|
const exhausted = results.filter(
|
||||||
|
(r) => !r.ok && r.error === "invite_exhausted",
|
||||||
|
);
|
||||||
|
expect(oks.length).toBe(1);
|
||||||
|
expect(exhausted.length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wrong mesh_id in payload vs DB row → invite_mesh_mismatch", async () => {
|
||||||
|
m = await setupTestMesh("inv-mesh-mismatch");
|
||||||
|
const inv = await createTestInvite(m);
|
||||||
|
// Point the DB row at a different mesh (create another one with
|
||||||
|
// the SAME owner_pubkey so we get past the owner check).
|
||||||
|
const other = await setupTestMesh("inv-mesh-other");
|
||||||
|
try {
|
||||||
|
// Align other's owner_pubkey to m's so only mesh_id differs.
|
||||||
|
await db
|
||||||
|
.update(mesh)
|
||||||
|
.set({ ownerPubkey: m.ownerPubkey })
|
||||||
|
.where(eq(mesh.id, other.meshId));
|
||||||
|
// Re-point invite row's meshId to other.
|
||||||
|
await db
|
||||||
|
.update(invite)
|
||||||
|
.set({ meshId: other.meshId })
|
||||||
|
.where(eq(invite.id, inv.inviteId));
|
||||||
|
const kp = await generateRawKeypair();
|
||||||
|
const result = await joinMesh({
|
||||||
|
inviteToken: inv.token,
|
||||||
|
invitePayload: inv.payload, // still claims m.meshId
|
||||||
|
peerPubkey: kp.publicKey,
|
||||||
|
displayName: "cross",
|
||||||
|
});
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) expect(result.error).toBe("invite_mesh_mismatch");
|
||||||
|
} finally {
|
||||||
|
await other.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -57,8 +57,8 @@ async function main(): Promise<void> {
|
|||||||
console.log(`[rt] loading config from: ${getConfigPath()}`);
|
console.log(`[rt] loading config from: ${getConfigPath()}`);
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
console.log(`[rt] loaded ${config.meshes.length} mesh(es)`);
|
console.log(`[rt] loaded ${config.meshes.length} mesh(es)`);
|
||||||
const joined = config.meshes.find((m) => m.slug === "rt-join");
|
const joined = config.meshes.find((m) => m.slug === "smoke-test");
|
||||||
if (!joined) throw new Error("rt-join mesh not found in config");
|
if (!joined) throw new Error("smoke-test mesh not found in config");
|
||||||
const joinedMesh: JoinedMesh = joined;
|
const joinedMesh: JoinedMesh = joined;
|
||||||
console.log(
|
console.log(
|
||||||
`[rt] joined member_id=${joinedMesh.memberId} pubkey=${joinedMesh.pubkey.slice(0, 16)}…`,
|
`[rt] joined member_id=${joinedMesh.memberId} pubkey=${joinedMesh.pubkey.slice(0, 16)}…`,
|
||||||
|
|||||||
@@ -1,24 +1,23 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
/**
|
/**
|
||||||
* Build a test invite link from a seeded mesh (reads /tmp/cli-seed.json).
|
* Emit the signed invite link produced by the broker's seed-test-mesh.
|
||||||
* Writes the link to stdout.
|
*
|
||||||
|
* The seed script (apps/broker/scripts/seed-test-mesh.ts) creates a
|
||||||
|
* mesh with an owner keypair and a signed invite row, then writes
|
||||||
|
* both into /tmp/cli-seed.json. We just echo its inviteLink here so
|
||||||
|
* downstream test scripts can pipe it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { encodeInviteLink } from "../src/invite/parse";
|
|
||||||
|
|
||||||
const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as {
|
const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as {
|
||||||
meshId: string;
|
inviteLink: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const link = encodeInviteLink({
|
if (!seed.inviteLink) {
|
||||||
v: 1,
|
console.error(
|
||||||
mesh_id: seed.meshId,
|
"seed missing inviteLink — re-run apps/broker/scripts/seed-test-mesh.ts",
|
||||||
mesh_slug: "rt-join",
|
);
|
||||||
broker_url: process.env.BROKER_WS_URL ?? "ws://localhost:7900/ws",
|
process.exit(1);
|
||||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
}
|
||||||
mesh_root_key: "Y2xhdWRlbWVzaC10ZXN0LW1lc2gta2V5LWRldm9ubHk",
|
console.log(seed.inviteLink);
|
||||||
role: "member",
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log(link);
|
|
||||||
|
|||||||
@@ -25,17 +25,17 @@ export async function runJoin(args: string[]): Promise<void> {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Parse.
|
// 1. Parse + verify signature client-side.
|
||||||
let invite;
|
let invite;
|
||||||
try {
|
try {
|
||||||
invite = parseInviteLink(link);
|
invite = await parseInviteLink(link);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
`claudemesh: ${e instanceof Error ? e.message : String(e)}`,
|
`claudemesh: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const { payload } = invite;
|
const { payload, token } = invite;
|
||||||
console.log(`Joining mesh "${payload.mesh_slug}" (${payload.mesh_id})…`);
|
console.log(`Joining mesh "${payload.mesh_slug}" (${payload.mesh_id})…`);
|
||||||
|
|
||||||
// 2. Generate keypair.
|
// 2. Generate keypair.
|
||||||
@@ -47,10 +47,10 @@ export async function runJoin(args: string[]): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
enroll = await enrollWithBroker({
|
enroll = await enrollWithBroker({
|
||||||
brokerWsUrl: payload.broker_url,
|
brokerWsUrl: payload.broker_url,
|
||||||
meshId: payload.mesh_id,
|
inviteToken: token,
|
||||||
|
invitePayload: payload,
|
||||||
peerPubkey: keypair.publicKey,
|
peerPubkey: keypair.publicKey,
|
||||||
displayName,
|
displayName,
|
||||||
role: payload.role,
|
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(
|
console.error(
|
||||||
|
|||||||
@@ -19,22 +19,24 @@ function wsToHttp(wsUrl: string): string {
|
|||||||
return `${httpScheme}//${u.host}`;
|
return `${httpScheme}//${u.host}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import type { InvitePayload } from "./parse";
|
||||||
|
|
||||||
export async function enrollWithBroker(args: {
|
export async function enrollWithBroker(args: {
|
||||||
brokerWsUrl: string;
|
brokerWsUrl: string;
|
||||||
meshId: string;
|
inviteToken: string;
|
||||||
|
invitePayload: InvitePayload;
|
||||||
peerPubkey: string;
|
peerPubkey: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
role: "admin" | "member";
|
|
||||||
}): Promise<EnrollResult> {
|
}): Promise<EnrollResult> {
|
||||||
const base = wsToHttp(args.brokerWsUrl);
|
const base = wsToHttp(args.brokerWsUrl);
|
||||||
const res = await fetch(`${base}/join`, {
|
const res = await fetch(`${base}/join`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
mesh_id: args.meshId,
|
invite_token: args.inviteToken,
|
||||||
|
invite_payload: args.invitePayload,
|
||||||
peer_pubkey: args.peerPubkey,
|
peer_pubkey: args.peerPubkey,
|
||||||
display_name: args.displayName,
|
display_name: args.displayName,
|
||||||
role: args.role,
|
|
||||||
}),
|
}),
|
||||||
signal: AbortSignal.timeout(10_000),
|
signal: AbortSignal.timeout(10_000),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { ensureSodium } from "../crypto/keypair";
|
||||||
|
|
||||||
const invitePayloadSchema = z.object({
|
const invitePayloadSchema = z.object({
|
||||||
v: z.literal(1),
|
v: z.literal(1),
|
||||||
@@ -15,7 +16,8 @@ const invitePayloadSchema = z.object({
|
|||||||
expires_at: z.number().int().positive(),
|
expires_at: z.number().int().positive(),
|
||||||
mesh_root_key: z.string().min(1),
|
mesh_root_key: z.string().min(1),
|
||||||
role: z.enum(["admin", "member"]),
|
role: z.enum(["admin", "member"]),
|
||||||
signature: z.string().optional(), // ed25519 b64, validated in Step 18
|
owner_pubkey: z.string().regex(/^[0-9a-f]{64}$/i),
|
||||||
|
signature: z.string().regex(/^[0-9a-f]{128}$/i),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type InvitePayload = z.infer<typeof invitePayloadSchema>;
|
export type InvitePayload = z.infer<typeof invitePayloadSchema>;
|
||||||
@@ -23,9 +25,24 @@ export type InvitePayload = z.infer<typeof invitePayloadSchema>;
|
|||||||
export interface ParsedInvite {
|
export interface ParsedInvite {
|
||||||
payload: InvitePayload;
|
payload: InvitePayload;
|
||||||
raw: string; // the original ic://join/... string
|
raw: string; // the original ic://join/... string
|
||||||
|
token: string; // base64url(JSON) — DB lookup key (everything after ic://join/)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseInviteLink(link: string): ParsedInvite {
|
/** Canonical invite bytes — must match broker's canonicalInvite(). */
|
||||||
|
export function canonicalInvite(p: {
|
||||||
|
v: number;
|
||||||
|
mesh_id: string;
|
||||||
|
mesh_slug: string;
|
||||||
|
broker_url: string;
|
||||||
|
expires_at: number;
|
||||||
|
mesh_root_key: string;
|
||||||
|
role: "admin" | "member";
|
||||||
|
owner_pubkey: string;
|
||||||
|
}): string {
|
||||||
|
return `${p.v}|${p.mesh_id}|${p.mesh_slug}|${p.broker_url}|${p.expires_at}|${p.mesh_root_key}|${p.role}|${p.owner_pubkey}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseInviteLink(link: string): Promise<ParsedInvite> {
|
||||||
if (!link.startsWith("ic://join/")) {
|
if (!link.startsWith("ic://join/")) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`invalid invite link: expected prefix "ic://join/", got "${link.slice(0, 20)}…"`,
|
`invalid invite link: expected prefix "ic://join/", got "${link.slice(0, 20)}…"`,
|
||||||
@@ -67,7 +84,36 @@ export function parseInviteLink(link: string): ParsedInvite {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { payload: parsed.data, raw: link };
|
// Verify the ed25519 signature against the embedded owner_pubkey.
|
||||||
|
// Client-side verification gives immediate feedback on tampered
|
||||||
|
// links; broker re-verifies authoritatively on /join.
|
||||||
|
const s = await ensureSodium();
|
||||||
|
const canonical = canonicalInvite({
|
||||||
|
v: parsed.data.v,
|
||||||
|
mesh_id: parsed.data.mesh_id,
|
||||||
|
mesh_slug: parsed.data.mesh_slug,
|
||||||
|
broker_url: parsed.data.broker_url,
|
||||||
|
expires_at: parsed.data.expires_at,
|
||||||
|
mesh_root_key: parsed.data.mesh_root_key,
|
||||||
|
role: parsed.data.role,
|
||||||
|
owner_pubkey: parsed.data.owner_pubkey,
|
||||||
|
});
|
||||||
|
const sigOk = (() => {
|
||||||
|
try {
|
||||||
|
return s.crypto_sign_verify_detached(
|
||||||
|
s.from_hex(parsed.data.signature),
|
||||||
|
s.from_string(canonical),
|
||||||
|
s.from_hex(parsed.data.owner_pubkey),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
if (!sigOk) {
|
||||||
|
throw new Error("invite signature invalid (link tampered?)");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { payload: parsed.data, raw: link, token: encoded };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,3 +125,52 @@ export function encodeInviteLink(payload: InvitePayload): string {
|
|||||||
const encoded = Buffer.from(json, "utf-8").toString("base64url");
|
const encoded = Buffer.from(json, "utf-8").toString("base64url");
|
||||||
return `ic://join/${encoded}`;
|
return `ic://join/${encoded}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign and assemble an invite payload → ic://join/... link.
|
||||||
|
* The canonical bytes (everything except signature) are signed with
|
||||||
|
* the mesh owner's ed25519 secret key.
|
||||||
|
*/
|
||||||
|
export async function buildSignedInvite(args: {
|
||||||
|
v: 1;
|
||||||
|
mesh_id: string;
|
||||||
|
mesh_slug: string;
|
||||||
|
broker_url: string;
|
||||||
|
expires_at: number;
|
||||||
|
mesh_root_key: string;
|
||||||
|
role: "admin" | "member";
|
||||||
|
owner_pubkey: string;
|
||||||
|
owner_secret_key: string;
|
||||||
|
}): Promise<{ link: string; token: string; payload: InvitePayload }> {
|
||||||
|
const s = await ensureSodium();
|
||||||
|
const canonical = canonicalInvite({
|
||||||
|
v: args.v,
|
||||||
|
mesh_id: args.mesh_id,
|
||||||
|
mesh_slug: args.mesh_slug,
|
||||||
|
broker_url: args.broker_url,
|
||||||
|
expires_at: args.expires_at,
|
||||||
|
mesh_root_key: args.mesh_root_key,
|
||||||
|
role: args.role,
|
||||||
|
owner_pubkey: args.owner_pubkey,
|
||||||
|
});
|
||||||
|
const signature = s.to_hex(
|
||||||
|
s.crypto_sign_detached(
|
||||||
|
s.from_string(canonical),
|
||||||
|
s.from_hex(args.owner_secret_key),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const payload: InvitePayload = {
|
||||||
|
v: args.v,
|
||||||
|
mesh_id: args.mesh_id,
|
||||||
|
mesh_slug: args.mesh_slug,
|
||||||
|
broker_url: args.broker_url,
|
||||||
|
expires_at: args.expires_at,
|
||||||
|
mesh_root_key: args.mesh_root_key,
|
||||||
|
role: args.role,
|
||||||
|
owner_pubkey: args.owner_pubkey,
|
||||||
|
signature,
|
||||||
|
};
|
||||||
|
const json = JSON.stringify(payload);
|
||||||
|
const token = Buffer.from(json, "utf-8").toString("base64url");
|
||||||
|
return { link: `ic://join/${token}`, token, payload };
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const menu = [
|
|||||||
label: "manage",
|
label: "manage",
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "settings",
|
title: "account",
|
||||||
href: pathsConfig.dashboard.user.settings.index,
|
href: pathsConfig.dashboard.user.settings.index,
|
||||||
icon: Icons.Settings,
|
icon: Icons.Settings,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { DeleteAccount } from "~/modules/user/settings/general/delete-account";
|
|||||||
import { EditAvatar } from "~/modules/user/settings/general/edit-avatar";
|
import { EditAvatar } from "~/modules/user/settings/general/edit-avatar";
|
||||||
import { EditEmail } from "~/modules/user/settings/general/edit-email";
|
import { EditEmail } from "~/modules/user/settings/general/edit-email";
|
||||||
import { EditName } from "~/modules/user/settings/general/edit-name";
|
import { EditName } from "~/modules/user/settings/general/edit-name";
|
||||||
|
import { ExportData } from "~/modules/user/settings/general/export-data";
|
||||||
import { LanguageSwitcher } from "~/modules/user/settings/general/language-switcher";
|
import { LanguageSwitcher } from "~/modules/user/settings/general/language-switcher";
|
||||||
|
|
||||||
export const generateMetadata = getMetadata({
|
export const generateMetadata = getMetadata({
|
||||||
@@ -27,6 +28,7 @@ export default async function SettingsPage() {
|
|||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
<EditName user={user} />
|
<EditName user={user} />
|
||||||
<EditEmail user={user} />
|
<EditEmail user={user} />
|
||||||
|
<ExportData />
|
||||||
<DeleteAccount />
|
<DeleteAccount />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
53
apps/web/src/modules/user/settings/general/export-data.tsx
Normal file
53
apps/web/src/modules/user/settings/general/export-data.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@turbostarter/ui-web/button";
|
||||||
|
|
||||||
|
export const ExportData = () => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const onExport = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/my/export", { credentials: "include" });
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Export failed (${res.status})`);
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as { user: { id: string } };
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||||
|
type: "application/json",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const date = new Date().toISOString().slice(0, 10);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = `claudemesh-export-${data.user.id}-${date}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Export failed");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border p-5">
|
||||||
|
<h3 className="mb-1 font-medium">Export your data</h3>
|
||||||
|
<p className="text-muted-foreground mb-4 text-sm">
|
||||||
|
Download a JSON file with your profile, meshes you own, meshes you
|
||||||
|
joined, invites you've issued, and audit events from your owned
|
||||||
|
meshes. Read-only.
|
||||||
|
</p>
|
||||||
|
<Button onClick={onExport} disabled={loading} variant="outline" size="sm">
|
||||||
|
{loading ? "Preparing…" : "Download export"}
|
||||||
|
</Button>
|
||||||
|
{error && <p className="text-destructive mt-2 text-sm">{error}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
or,
|
or,
|
||||||
sql,
|
sql,
|
||||||
} from "@turbostarter/db";
|
} from "@turbostarter/db";
|
||||||
import { invite, mesh, meshMember } from "@turbostarter/db/schema";
|
import { auditLog, invite, mesh, meshMember } from "@turbostarter/db/schema";
|
||||||
import { db } from "@turbostarter/db/server";
|
import { db } from "@turbostarter/db/server";
|
||||||
|
|
||||||
import type { GetMyMeshesInput } from "../../schema";
|
import type { GetMyMeshesInput } from "../../schema";
|
||||||
@@ -163,6 +163,87 @@ export const getMyMeshById = async ({
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getMyExport = async ({ userId }: { userId: string }) => {
|
||||||
|
const meshesOwned = await db
|
||||||
|
.select({
|
||||||
|
id: mesh.id,
|
||||||
|
name: mesh.name,
|
||||||
|
slug: mesh.slug,
|
||||||
|
visibility: mesh.visibility,
|
||||||
|
transport: mesh.transport,
|
||||||
|
tier: mesh.tier,
|
||||||
|
createdAt: mesh.createdAt,
|
||||||
|
archivedAt: mesh.archivedAt,
|
||||||
|
})
|
||||||
|
.from(mesh)
|
||||||
|
.where(eq(mesh.ownerUserId, userId));
|
||||||
|
|
||||||
|
const memberships = await db
|
||||||
|
.select({
|
||||||
|
meshId: meshMember.meshId,
|
||||||
|
meshName: mesh.name,
|
||||||
|
meshSlug: mesh.slug,
|
||||||
|
memberId: meshMember.id,
|
||||||
|
displayName: meshMember.displayName,
|
||||||
|
role: meshMember.role,
|
||||||
|
joinedAt: meshMember.joinedAt,
|
||||||
|
revokedAt: meshMember.revokedAt,
|
||||||
|
})
|
||||||
|
.from(meshMember)
|
||||||
|
.leftJoin(mesh, eq(meshMember.meshId, mesh.id))
|
||||||
|
.where(eq(meshMember.userId, userId));
|
||||||
|
|
||||||
|
const invitesSent = await db
|
||||||
|
.select({
|
||||||
|
id: invite.id,
|
||||||
|
meshId: invite.meshId,
|
||||||
|
meshSlug: mesh.slug,
|
||||||
|
role: invite.role,
|
||||||
|
maxUses: invite.maxUses,
|
||||||
|
usedCount: invite.usedCount,
|
||||||
|
expiresAt: invite.expiresAt,
|
||||||
|
createdAt: invite.createdAt,
|
||||||
|
revokedAt: invite.revokedAt,
|
||||||
|
})
|
||||||
|
.from(invite)
|
||||||
|
.leftJoin(mesh, eq(invite.meshId, mesh.id))
|
||||||
|
.where(eq(invite.createdBy, userId));
|
||||||
|
|
||||||
|
// Audit events for the user's owned meshes only (privacy: don't leak
|
||||||
|
// events from meshes the user merely joined)
|
||||||
|
const meshIds = meshesOwned.map((m) => m.id);
|
||||||
|
const auditEvents =
|
||||||
|
meshIds.length > 0
|
||||||
|
? await db
|
||||||
|
.select({
|
||||||
|
id: auditLog.id,
|
||||||
|
meshId: auditLog.meshId,
|
||||||
|
eventType: auditLog.eventType,
|
||||||
|
actorPeerId: auditLog.actorPeerId,
|
||||||
|
targetPeerId: auditLog.targetPeerId,
|
||||||
|
metadata: sql<Record<string, unknown>>`${auditLog.metadata}`,
|
||||||
|
createdAt: auditLog.createdAt,
|
||||||
|
})
|
||||||
|
.from(auditLog)
|
||||||
|
.where(
|
||||||
|
sql`${auditLog.meshId} = ANY(ARRAY[${sql.join(
|
||||||
|
meshIds.map((id) => sql`${id}`),
|
||||||
|
sql`, `,
|
||||||
|
)}]::text[])`,
|
||||||
|
)
|
||||||
|
.orderBy(desc(auditLog.createdAt))
|
||||||
|
.limit(5000)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
meshesOwned,
|
||||||
|
memberships,
|
||||||
|
invitesSent,
|
||||||
|
auditEvents,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const getMyInvitesSent = async ({ userId }: { userId: string }) =>
|
export const getMyInvitesSent = async ({ userId }: { userId: string }) =>
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
leaveMyMesh,
|
leaveMyMesh,
|
||||||
} from "./mutations";
|
} from "./mutations";
|
||||||
import {
|
import {
|
||||||
|
getMyExport,
|
||||||
getMyInvitesSent,
|
getMyInvitesSent,
|
||||||
getMyMeshById,
|
getMyMeshById,
|
||||||
getMyMeshes,
|
getMyMeshes,
|
||||||
@@ -111,4 +112,17 @@ export const myRouter = new Hono<Env>()
|
|||||||
.get("/invites", async (c) => {
|
.get("/invites", async (c) => {
|
||||||
const user = c.var.user;
|
const user = c.var.user;
|
||||||
return c.json({ sent: await getMyInvitesSent({ userId: user.id }) });
|
return c.json({ sent: await getMyInvitesSent({ userId: user.id }) });
|
||||||
|
})
|
||||||
|
.get("/export", async (c) => {
|
||||||
|
const user = c.var.user;
|
||||||
|
const data = await getMyExport({ userId: user.id });
|
||||||
|
return c.json({
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
createdAt: user.createdAt,
|
||||||
|
},
|
||||||
|
...data,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
2
packages/db/migrations/0001_demonic_karnak.sql
Normal file
2
packages/db/migrations/0001_demonic_karnak.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "mesh"."invite" ADD COLUMN "token_bytes" text;--> statement-breakpoint
|
||||||
|
ALTER TABLE "mesh"."mesh" ADD COLUMN "owner_pubkey" text;
|
||||||
2821
packages/db/migrations/meta/0001_snapshot.json
Normal file
2821
packages/db/migrations/meta/0001_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,13 @@
|
|||||||
"when": 1775336269295,
|
"when": 1775336269295,
|
||||||
"tag": "0000_living_namora",
|
"tag": "0000_living_namora",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1775339743477,
|
||||||
|
"tag": "0001_demonic_karnak",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -82,6 +82,13 @@ export const mesh = meshSchema.table("mesh", {
|
|||||||
transport: meshTransportEnum().notNull().default("managed"),
|
transport: meshTransportEnum().notNull().default("managed"),
|
||||||
maxPeers: integer(),
|
maxPeers: integer(),
|
||||||
tier: meshTierEnum().notNull().default("free"),
|
tier: meshTierEnum().notNull().default("free"),
|
||||||
|
/**
|
||||||
|
* ed25519 public key (hex) of the mesh owner / admin signer.
|
||||||
|
* Invites are signed by the corresponding secret key and verified
|
||||||
|
* by the broker on /join against this column. Nullable for existing
|
||||||
|
* rows; required for new meshes.
|
||||||
|
*/
|
||||||
|
ownerPubkey: text(),
|
||||||
createdAt: timestamp().defaultNow().notNull(),
|
createdAt: timestamp().defaultNow().notNull(),
|
||||||
archivedAt: timestamp(),
|
archivedAt: timestamp(),
|
||||||
});
|
});
|
||||||
@@ -116,6 +123,10 @@ export const meshMember = meshSchema.table("member", {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Invite tokens used to join a mesh via shareable URL.
|
* Invite tokens used to join a mesh via shareable URL.
|
||||||
|
*
|
||||||
|
* `token` — opaque DB lookup key (the ic:// link's payload)
|
||||||
|
* `tokenBytes` — canonical signed bytes that the broker re-verifies
|
||||||
|
* against mesh.ownerPubkey on every /join call
|
||||||
*/
|
*/
|
||||||
export const invite = meshSchema.table("invite", {
|
export const invite = meshSchema.table("invite", {
|
||||||
id: text().primaryKey().notNull().$defaultFn(generateId),
|
id: text().primaryKey().notNull().$defaultFn(generateId),
|
||||||
@@ -123,6 +134,7 @@ export const invite = meshSchema.table("invite", {
|
|||||||
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
|
.references(() => mesh.id, { onDelete: "cascade", onUpdate: "cascade" })
|
||||||
.notNull(),
|
.notNull(),
|
||||||
token: text().notNull().unique(),
|
token: text().notNull().unique(),
|
||||||
|
tokenBytes: text(),
|
||||||
maxUses: integer().notNull().default(1),
|
maxUses: integer().notNull().default(1),
|
||||||
usedCount: integer().notNull().default(0),
|
usedCount: integer().notNull().default(0),
|
||||||
role: meshRoleEnum().notNull().default("member"),
|
role: meshRoleEnum().notNull().default("member"),
|
||||||
|
|||||||
Reference in New Issue
Block a user