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:
Alejandro Gutiérrez
2026-04-04 23:02:12 +01:00
parent cdb5a75f78
commit 0c4a9591fa
15 changed files with 3546 additions and 63 deletions

View File

@@ -25,17 +25,17 @@ export async function runJoin(args: string[]): Promise<void> {
process.exit(1);
}
// 1. Parse.
// 1. Parse + verify signature client-side.
let invite;
try {
invite = parseInviteLink(link);
invite = await parseInviteLink(link);
} catch (e) {
console.error(
`claudemesh: ${e instanceof Error ? e.message : String(e)}`,
);
process.exit(1);
}
const { payload } = invite;
const { payload, token } = invite;
console.log(`Joining mesh "${payload.mesh_slug}" (${payload.mesh_id})…`);
// 2. Generate keypair.
@@ -47,10 +47,10 @@ export async function runJoin(args: string[]): Promise<void> {
try {
enroll = await enrollWithBroker({
brokerWsUrl: payload.broker_url,
meshId: payload.mesh_id,
inviteToken: token,
invitePayload: payload,
peerPubkey: keypair.publicKey,
displayName,
role: payload.role,
});
} catch (e) {
console.error(

View File

@@ -19,22 +19,24 @@ function wsToHttp(wsUrl: string): string {
return `${httpScheme}//${u.host}`;
}
import type { InvitePayload } from "./parse";
export async function enrollWithBroker(args: {
brokerWsUrl: string;
meshId: string;
inviteToken: string;
invitePayload: InvitePayload;
peerPubkey: string;
displayName: string;
role: "admin" | "member";
}): Promise<EnrollResult> {
const base = wsToHttp(args.brokerWsUrl);
const res = await fetch(`${base}/join`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mesh_id: args.meshId,
invite_token: args.inviteToken,
invite_payload: args.invitePayload,
peer_pubkey: args.peerPubkey,
display_name: args.displayName,
role: args.role,
}),
signal: AbortSignal.timeout(10_000),
});

View File

@@ -6,6 +6,7 @@
*/
import { z } from "zod";
import { ensureSodium } from "../crypto/keypair";
const invitePayloadSchema = z.object({
v: z.literal(1),
@@ -15,7 +16,8 @@ const invitePayloadSchema = z.object({
expires_at: z.number().int().positive(),
mesh_root_key: z.string().min(1),
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>;
@@ -23,9 +25,24 @@ export type InvitePayload = z.infer<typeof invitePayloadSchema>;
export interface ParsedInvite {
payload: InvitePayload;
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/")) {
throw new Error(
`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");
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 };
}