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>
91 lines
2.7 KiB
TypeScript
91 lines
2.7 KiB
TypeScript
/**
|
|
* `claudemesh join <invite-link>` — full join flow.
|
|
*
|
|
* 1. Parse + validate the ic://join/... link
|
|
* 2. Generate a fresh ed25519 keypair (libsodium)
|
|
* 3. POST /join to the broker → get member_id
|
|
* 4. Persist the mesh + keypair to ~/.claudemesh/config.json (0600)
|
|
* 5. Print success
|
|
*
|
|
* Signature verification + invite-token one-time-use land in Step 18.
|
|
*/
|
|
|
|
import { parseInviteLink } from "../invite/parse";
|
|
import { enrollWithBroker } from "../invite/enroll";
|
|
import { generateKeypair } from "../crypto/keypair";
|
|
import { loadConfig, saveConfig, getConfigPath } from "../state/config";
|
|
import { hostname } from "node:os";
|
|
|
|
export async function runJoin(args: string[]): Promise<void> {
|
|
const link = args[0];
|
|
if (!link) {
|
|
console.error("Usage: claudemesh join <invite-link>");
|
|
console.error("");
|
|
console.error("Example: claudemesh join ic://join/eyJ2IjoxLC4uLn0");
|
|
process.exit(1);
|
|
}
|
|
|
|
// 1. Parse + verify signature client-side.
|
|
let invite;
|
|
try {
|
|
invite = await parseInviteLink(link);
|
|
} catch (e) {
|
|
console.error(
|
|
`claudemesh: ${e instanceof Error ? e.message : String(e)}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
const { payload, token } = invite;
|
|
console.log(`Joining mesh "${payload.mesh_slug}" (${payload.mesh_id})…`);
|
|
|
|
// 2. Generate keypair.
|
|
const keypair = await generateKeypair();
|
|
|
|
// 3. Enroll with broker.
|
|
const displayName = `${hostname()}-${process.pid}`;
|
|
let enroll;
|
|
try {
|
|
enroll = await enrollWithBroker({
|
|
brokerWsUrl: payload.broker_url,
|
|
inviteToken: token,
|
|
invitePayload: payload,
|
|
peerPubkey: keypair.publicKey,
|
|
displayName,
|
|
});
|
|
} catch (e) {
|
|
console.error(
|
|
`claudemesh: broker enrollment failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
// 4. Persist.
|
|
const config = loadConfig();
|
|
config.meshes = config.meshes.filter(
|
|
(m) => m.slug !== payload.mesh_slug,
|
|
);
|
|
config.meshes.push({
|
|
meshId: payload.mesh_id,
|
|
memberId: enroll.memberId,
|
|
slug: payload.mesh_slug,
|
|
name: payload.mesh_slug,
|
|
pubkey: keypair.publicKey,
|
|
secretKey: keypair.secretKey,
|
|
brokerUrl: payload.broker_url,
|
|
joinedAt: new Date().toISOString(),
|
|
});
|
|
saveConfig(config);
|
|
|
|
// 5. Report.
|
|
console.log("");
|
|
console.log(
|
|
`✓ Joined "${payload.mesh_slug}" as ${displayName}${enroll.alreadyMember ? " (already a member — re-enrolled with same pubkey)" : ""}`,
|
|
);
|
|
console.log(` member id: ${enroll.memberId}`);
|
|
console.log(` pubkey: ${keypair.publicKey.slice(0, 16)}…`);
|
|
console.log(` broker: ${payload.broker_url}`);
|
|
console.log(` config: ${getConfigPath()}`);
|
|
console.log("");
|
|
console.log("Restart Claude Code to pick up the new mesh.");
|
|
}
|