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:
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user