feat(broker): invite signature verification + atomic one-time-use
Completes the v0.1.0 security model. Every /join is now gated by a
signed invite that the broker re-verifies against the mesh owner's
ed25519 pubkey, plus an atomic single-use counter.
schema (migrations/0001_demonic_karnak.sql):
- mesh.mesh.owner_pubkey: ed25519 hex of the invite signer
- mesh.invite.token_bytes: canonical signed bytes (for re-verification)
Both nullable; required for new meshes going forward.
canonical invite format (signed bytes):
`${v}|${mesh_id}|${mesh_slug}|${broker_url}|${expires_at}|
${mesh_root_key}|${role}|${owner_pubkey}`
wire format — invite payload in ic://join/<base64url(JSON)> now has:
owner_pubkey: "<64 hex>"
signature: "<128 hex>"
broker joinMesh() (apps/broker/src/broker.ts):
1. verify ed25519 signature over canonical bytes using payload's
owner_pubkey → else invite_bad_signature
2. load mesh, ensure mesh.owner_pubkey matches payload's owner_pubkey
→ else invite_owner_mismatch (prevents a malicious admin from
substituting their own owner key)
3. load invite row by token, verify mesh_id matches → else
invite_mesh_mismatch
4. expiry check → else invite_expired
5. revoked check → else invite_revoked
6. idempotency: if pubkey is already a member, return existing id
WITHOUT burning an invite use
7. atomic CAS: UPDATE used_count = used_count + 1 WHERE used_count <
max_uses → if 0 rows affected, return invite_exhausted
8. insert member with role from payload
cli side:
- apps/cli/src/invite/parse.ts: zod-validated owner_pubkey + signature
fields; client verifies signature immediately and rejects tampered
links (fail-fast before even touching the broker)
- buildSignedInvite() helper: owners sign invites client-side
- enrollWithBroker sends {invite_token, invite_payload, peer_pubkey,
display_name} (was: {mesh_id, peer_pubkey, display_name, role})
- parseInviteLink is now async (libsodium ready + verify)
seed-test-mesh.ts generates an owner keypair, sets mesh.owner_pubkey,
builds + signs an invite, stores the invite row, emits ownerPubkey +
ownerSecretKey + inviteToken + inviteLink in the output JSON.
tests — invite-signature.test.ts (9 new):
- valid signed invite → join succeeds
- tampered payload → invite_bad_signature
- signer not the mesh owner → invite_owner_mismatch
- expired invite → invite_expired
- revoked invite → invite_revoked
- exhausted (maxUses=2, 3rd join) → invite_exhausted
- idempotent re-join doesn't burn a use
- atomic single-use: 5 concurrent joins → exactly 1 success, 4 exhausted
- mesh_id payload vs DB row mismatch → invite_mesh_mismatch
verified live: tampered link blocked client-side with a clear error.
Unmodified link joins cleanly end-to-end (roundtrip.ts + join-roundtrip.ts
both pass). 64/64 tests green.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,12 +30,17 @@ import {
|
||||
} from "drizzle-orm";
|
||||
import { db } from "./db";
|
||||
import {
|
||||
invite as inviteTable,
|
||||
mesh,
|
||||
meshMember as memberTable,
|
||||
messageQueue,
|
||||
pendingStatus,
|
||||
presence,
|
||||
} from "@turbostarter/db/schema/mesh";
|
||||
import {
|
||||
canonicalInvite,
|
||||
verifyEd25519,
|
||||
} from "./crypto";
|
||||
import { env } from "./env";
|
||||
import { metrics } from "./metrics";
|
||||
import { inferStatusFromJsonl } from "./paths";
|
||||
@@ -510,37 +515,108 @@ export async function stopSweepers(): Promise<void> {
|
||||
.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
|
||||
* flow after invite-link parsing + keypair generation client-side.
|
||||
* Enroll a new member in an existing mesh.
|
||||
*
|
||||
* v0.1.0: trusts the request. Signature verification + invite-token
|
||||
* one-time-use tracking land in Step 18.
|
||||
* Requires a signed invite payload. Verifies:
|
||||
* - 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: {
|
||||
meshId: string;
|
||||
inviteToken: string;
|
||||
invitePayload: InvitePayload;
|
||||
peerPubkey: string;
|
||||
displayName: string;
|
||||
role: "admin" | "member";
|
||||
}): Promise<
|
||||
| { ok: true; memberId: string; alreadyMember?: boolean }
|
||||
| { ok: false; error: string }
|
||||
| { ok: false; error: JoinError }
|
||||
> {
|
||||
// Validate the mesh exists.
|
||||
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" };
|
||||
const { inviteToken, invitePayload, peerPubkey, displayName } = args;
|
||||
|
||||
// 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
|
||||
.select({ id: memberTable.id })
|
||||
.from(memberTable)
|
||||
.where(
|
||||
and(
|
||||
eq(memberTable.meshId, args.meshId),
|
||||
eq(memberTable.peerPubkey, args.peerPubkey),
|
||||
eq(memberTable.meshId, invitePayload.mesh_id),
|
||||
eq(memberTable.peerPubkey, peerPubkey),
|
||||
isNull(memberTable.revokedAt),
|
||||
),
|
||||
);
|
||||
@@ -548,16 +624,30 @@ export async function joinMesh(args: {
|
||||
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
|
||||
.insert(memberTable)
|
||||
.values({
|
||||
meshId: args.meshId,
|
||||
peerPubkey: args.peerPubkey,
|
||||
displayName: args.displayName,
|
||||
role: args.role,
|
||||
meshId: invitePayload.mesh_id,
|
||||
peerPubkey,
|
||||
displayName,
|
||||
role: invitePayload.role,
|
||||
})
|
||||
.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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,47 @@ export function canonicalHello(
|
||||
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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -251,21 +251,21 @@ function handleJoinPost(
|
||||
if (aborted) return;
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.concat(chunks).toString()) as {
|
||||
mesh_id?: string;
|
||||
invite_token?: string;
|
||||
invite_payload?: unknown;
|
||||
peer_pubkey?: string;
|
||||
display_name?: string;
|
||||
role?: "admin" | "member";
|
||||
};
|
||||
// Minimal shape validation.
|
||||
if (
|
||||
!payload.mesh_id ||
|
||||
!payload.invite_token ||
|
||||
!payload.invite_payload ||
|
||||
!payload.peer_pubkey ||
|
||||
!payload.display_name ||
|
||||
!payload.role
|
||||
!payload.display_name
|
||||
) {
|
||||
writeJson(res, 400, {
|
||||
ok: false,
|
||||
error: "mesh_id, peer_pubkey, display_name, role required",
|
||||
error:
|
||||
"invite_token, invite_payload, peer_pubkey, display_name required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -277,18 +277,21 @@ function handleJoinPost(
|
||||
return;
|
||||
}
|
||||
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,
|
||||
displayName: payload.display_name,
|
||||
role: payload.role,
|
||||
});
|
||||
writeJson(res, result.ok ? 200 : 400, result);
|
||||
log.info("join", {
|
||||
route: "POST /join",
|
||||
mesh_id: payload.mesh_id,
|
||||
pubkey: payload.peer_pubkey.slice(0, 12),
|
||||
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,
|
||||
});
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user