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>
118 lines
3.6 KiB
TypeScript
118 lines
3.6 KiB
TypeScript
#!/usr/bin/env bun
|
|
/**
|
|
* Full join → connect → send round-trip.
|
|
*
|
|
* Uses a mesh already seeded in the DB (reads /tmp/cli-seed.json).
|
|
* Creates a fresh invite link, runs the join command, connects with
|
|
* the newly-generated member identity, sends a message to peer B,
|
|
* asserts receipt.
|
|
*/
|
|
|
|
// Run this script with CLAUDEMESH_CONFIG_DIR=/tmp/... set in env —
|
|
// ESM imports hoist above statements, so we can't set process.env
|
|
// after the `import { env }` side effect has already run.
|
|
import { readFileSync } from "node:fs";
|
|
import { execSync } from "node:child_process";
|
|
import { BrokerClient } from "../src/ws/client";
|
|
import type { JoinedMesh } from "../src/state/config";
|
|
import { loadConfig, getConfigPath } from "../src/state/config";
|
|
|
|
if (!process.env.CLAUDEMESH_CONFIG_DIR) {
|
|
console.error(
|
|
"Run with: CLAUDEMESH_CONFIG_DIR=/tmp/claudemesh-join-test-rt bun scripts/join-roundtrip.ts",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
execSync(`rm -rf "${process.env.CLAUDEMESH_CONFIG_DIR}"`, {
|
|
stdio: "ignore",
|
|
});
|
|
|
|
const seed = JSON.parse(readFileSync("/tmp/cli-seed.json", "utf-8")) as {
|
|
meshId: string;
|
|
peerB: { memberId: string; pubkey: string; secretKey: string };
|
|
};
|
|
|
|
async function main(): Promise<void> {
|
|
// 1. Build invite.
|
|
const link = execSync("bun scripts/make-invite.ts").toString().trim();
|
|
console.log("[rt] invite:", link.slice(0, 60) + "…");
|
|
|
|
// 2. Run `claudemesh join` with the same CONFIG_DIR.
|
|
const joinOut = execSync(`bun src/index.ts join "${link}"`, {
|
|
env: {
|
|
...process.env,
|
|
CLAUDEMESH_CONFIG_DIR: "/tmp/claudemesh-join-test-rt",
|
|
},
|
|
}).toString();
|
|
console.log("[rt] join output (tail):");
|
|
console.log(
|
|
joinOut
|
|
.split("\n")
|
|
.slice(-7)
|
|
.map((l) => " " + l)
|
|
.join("\n"),
|
|
);
|
|
|
|
// 3. Load the fresh config and connect as the new peer.
|
|
console.log(`[rt] loading config from: ${getConfigPath()}`);
|
|
const config = loadConfig();
|
|
console.log(`[rt] loaded ${config.meshes.length} mesh(es)`);
|
|
const joined = config.meshes.find((m) => m.slug === "smoke-test");
|
|
if (!joined) throw new Error("smoke-test mesh not found in config");
|
|
const joinedMesh: JoinedMesh = joined;
|
|
console.log(
|
|
`[rt] joined member_id=${joinedMesh.memberId} pubkey=${joinedMesh.pubkey.slice(0, 16)}…`,
|
|
);
|
|
|
|
// 4. Connect also as peer-B (the target) so we can observe receipt.
|
|
// Uses the real keypair from the seed (needed for crypto_box decrypt).
|
|
const targetMesh: JoinedMesh = {
|
|
...joinedMesh,
|
|
memberId: seed.peerB.memberId,
|
|
slug: "rt-join-b",
|
|
pubkey: seed.peerB.pubkey,
|
|
secretKey: seed.peerB.secretKey,
|
|
};
|
|
const joiner = new BrokerClient(joinedMesh);
|
|
const target = new BrokerClient(targetMesh);
|
|
|
|
let received = "";
|
|
target.onPush((m) => {
|
|
received = m.plaintext ?? "";
|
|
console.log(`[rt] target got: "${received}"`);
|
|
});
|
|
|
|
await Promise.all([joiner.connect(), target.connect()]);
|
|
console.log(`[rt] joiner=${joiner.status} target=${target.status}`);
|
|
|
|
const res = await joiner.send(
|
|
seed.peerB.pubkey,
|
|
"sent-by-newly-joined-peer",
|
|
"now",
|
|
);
|
|
console.log("[rt] send result:", res);
|
|
|
|
for (let i = 0; i < 30 && !received; i++) {
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
}
|
|
|
|
joiner.close();
|
|
target.close();
|
|
|
|
if (!res.ok) {
|
|
console.error("✗ FAIL: send did not ack");
|
|
process.exit(1);
|
|
}
|
|
if (received !== "sent-by-newly-joined-peer") {
|
|
console.error(`✗ FAIL: receive mismatch: "${received}"`);
|
|
process.exit(1);
|
|
}
|
|
console.log("✓ join → connect → send → receive FLOW PASSED");
|
|
process.exit(0);
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error("✗ FAIL:", e instanceof Error ? e.message : e);
|
|
process.exit(1);
|
|
});
|