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>
272 lines
8.8 KiB
TypeScript
272 lines
8.8 KiB
TypeScript
/**
|
|
* Invite signature + one-time-use tracking.
|
|
*
|
|
* Covers the full joinMesh() security envelope:
|
|
* - signed invites accepted
|
|
* - tampered payloads rejected
|
|
* - mismatched owner_pubkey rejected
|
|
* - expired / revoked / exhausted invites rejected
|
|
* - idempotency: same pubkey rejoins without burning a use
|
|
* - atomic single-use: concurrent joins produce exactly one winner
|
|
*/
|
|
|
|
import { afterAll, afterEach, describe, expect, test } from "vitest";
|
|
import { eq } from "drizzle-orm";
|
|
import { db } from "../src/db";
|
|
import { invite, mesh } from "@turbostarter/db/schema/mesh";
|
|
import { joinMesh } from "../src/broker";
|
|
import {
|
|
cleanupAllTestMeshes,
|
|
createTestInvite,
|
|
generateRawKeypair,
|
|
setupTestMesh,
|
|
type TestInvite,
|
|
type TestMesh,
|
|
} from "./helpers";
|
|
|
|
afterAll(async () => {
|
|
await cleanupAllTestMeshes();
|
|
});
|
|
|
|
describe("joinMesh — signed invites", () => {
|
|
let m: TestMesh;
|
|
afterEach(async () => m && (await m.cleanup()));
|
|
|
|
test("valid signed invite → join succeeds", async () => {
|
|
m = await setupTestMesh("inv-valid");
|
|
const inv = await createTestInvite(m);
|
|
const kp = await generateRawKeypair();
|
|
const result = await joinMesh({
|
|
inviteToken: inv.token,
|
|
invitePayload: inv.payload,
|
|
peerPubkey: kp.publicKey,
|
|
displayName: "alice",
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
if (result.ok) expect(result.memberId).toMatch(/^[A-Za-z0-9]+$/);
|
|
});
|
|
|
|
test("tampered payload → invite_bad_signature", async () => {
|
|
m = await setupTestMesh("inv-tampered");
|
|
const inv = await createTestInvite(m);
|
|
const kp = await generateRawKeypair();
|
|
const tampered = { ...inv.payload, mesh_slug: "HACKED" };
|
|
const result = await joinMesh({
|
|
inviteToken: inv.token,
|
|
invitePayload: tampered,
|
|
peerPubkey: kp.publicKey,
|
|
displayName: "mallory",
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error).toBe("invite_bad_signature");
|
|
});
|
|
|
|
test("owner key mismatch → invite_owner_mismatch", async () => {
|
|
m = await setupTestMesh("inv-owner-mismatch");
|
|
// Signer has a valid keypair but is NOT the mesh owner.
|
|
const fake = await generateRawKeypair();
|
|
// Build a properly-signed payload with the fake owner key.
|
|
const { canonicalInvite } = await import("../src/crypto");
|
|
const sodium = await import("libsodium-wrappers").then((m) => m.default);
|
|
await sodium.ready;
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const payload = {
|
|
v: 1 as const,
|
|
mesh_id: m.meshId,
|
|
mesh_slug: "x",
|
|
broker_url: "ws://localhost/ws",
|
|
expires_at: now + 3600,
|
|
mesh_root_key: "a",
|
|
role: "member" as const,
|
|
owner_pubkey: fake.publicKey, // wrong owner
|
|
};
|
|
const sig = sodium.to_hex(
|
|
sodium.crypto_sign_detached(
|
|
sodium.from_string(canonicalInvite(payload)),
|
|
sodium.from_hex(fake.secretKey),
|
|
),
|
|
);
|
|
const token = Buffer.from(
|
|
JSON.stringify({ ...payload, signature: sig }),
|
|
"utf-8",
|
|
).toString("base64url");
|
|
// Have to insert a matching invite row so broker can look it up.
|
|
await db.insert(invite).values({
|
|
meshId: m.meshId,
|
|
token,
|
|
maxUses: 1,
|
|
usedCount: 0,
|
|
role: "member",
|
|
expiresAt: new Date((now + 3600) * 1000),
|
|
createdBy: "test-user-integration",
|
|
});
|
|
|
|
const joiner = await generateRawKeypair();
|
|
const result = await joinMesh({
|
|
inviteToken: token,
|
|
invitePayload: { ...payload, signature: sig },
|
|
peerPubkey: joiner.publicKey,
|
|
displayName: "joiner",
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error).toBe("invite_owner_mismatch");
|
|
});
|
|
|
|
test("expired invite → invite_expired", async () => {
|
|
m = await setupTestMesh("inv-expired");
|
|
// Create invite with expiry in the past (we use a far-future expiry
|
|
// for signing, then back-date the DB row to simulate staleness
|
|
// without the client-side expiry check tripping).
|
|
const inv = await createTestInvite(m, { expiresInSec: 3600 });
|
|
await db
|
|
.update(invite)
|
|
.set({ expiresAt: new Date(Date.now() - 1000) })
|
|
.where(eq(invite.id, inv.inviteId));
|
|
const kp = await generateRawKeypair();
|
|
const result = await joinMesh({
|
|
inviteToken: inv.token,
|
|
invitePayload: inv.payload,
|
|
peerPubkey: kp.publicKey,
|
|
displayName: "late",
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error).toBe("invite_expired");
|
|
});
|
|
|
|
test("revoked invite → invite_revoked", async () => {
|
|
m = await setupTestMesh("inv-revoked");
|
|
const inv = await createTestInvite(m);
|
|
await db
|
|
.update(invite)
|
|
.set({ revokedAt: new Date() })
|
|
.where(eq(invite.id, inv.inviteId));
|
|
const kp = await generateRawKeypair();
|
|
const result = await joinMesh({
|
|
inviteToken: inv.token,
|
|
invitePayload: inv.payload,
|
|
peerPubkey: kp.publicKey,
|
|
displayName: "blocked",
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error).toBe("invite_revoked");
|
|
});
|
|
|
|
test("exhausted invite → invite_exhausted", async () => {
|
|
m = await setupTestMesh("inv-exhausted");
|
|
const inv = await createTestInvite(m, { maxUses: 2 });
|
|
// First two joins succeed.
|
|
const k1 = await generateRawKeypair();
|
|
const k2 = await generateRawKeypair();
|
|
const r1 = await joinMesh({
|
|
inviteToken: inv.token,
|
|
invitePayload: inv.payload,
|
|
peerPubkey: k1.publicKey,
|
|
displayName: "first",
|
|
});
|
|
const r2 = await joinMesh({
|
|
inviteToken: inv.token,
|
|
invitePayload: inv.payload,
|
|
peerPubkey: k2.publicKey,
|
|
displayName: "second",
|
|
});
|
|
expect(r1.ok).toBe(true);
|
|
expect(r2.ok).toBe(true);
|
|
// Third should be rejected.
|
|
const k3 = await generateRawKeypair();
|
|
const r3 = await joinMesh({
|
|
inviteToken: inv.token,
|
|
invitePayload: inv.payload,
|
|
peerPubkey: k3.publicKey,
|
|
displayName: "third",
|
|
});
|
|
expect(r3.ok).toBe(false);
|
|
if (!r3.ok) expect(r3.error).toBe("invite_exhausted");
|
|
});
|
|
|
|
test("idempotent re-join doesn't burn a use", async () => {
|
|
m = await setupTestMesh("inv-idempotent");
|
|
const inv = await createTestInvite(m, { maxUses: 1 });
|
|
const kp = await generateRawKeypair();
|
|
const r1 = await joinMesh({
|
|
inviteToken: inv.token,
|
|
invitePayload: inv.payload,
|
|
peerPubkey: kp.publicKey,
|
|
displayName: "alice",
|
|
});
|
|
const r2 = await joinMesh({
|
|
inviteToken: inv.token,
|
|
invitePayload: inv.payload,
|
|
peerPubkey: kp.publicKey,
|
|
displayName: "alice",
|
|
});
|
|
expect(r1.ok).toBe(true);
|
|
expect(r2.ok).toBe(true);
|
|
if (r1.ok && r2.ok) {
|
|
expect(r2.memberId).toBe(r1.memberId);
|
|
expect(r2.alreadyMember).toBe(true);
|
|
}
|
|
// usedCount should still be 1, not 2.
|
|
const [row] = await db
|
|
.select({ usedCount: invite.usedCount })
|
|
.from(invite)
|
|
.where(eq(invite.id, inv.inviteId));
|
|
expect(row?.usedCount).toBe(1);
|
|
});
|
|
|
|
test("atomic single-use: concurrent joins, exactly one wins", async () => {
|
|
m = await setupTestMesh("inv-atomic");
|
|
const inv = await createTestInvite(m, { maxUses: 1 });
|
|
// Fire 5 distinct joiners concurrently at a 1-use invite.
|
|
const joiners = await Promise.all(
|
|
Array.from({ length: 5 }).map(() => generateRawKeypair()),
|
|
);
|
|
const results = await Promise.all(
|
|
joiners.map((kp, i) =>
|
|
joinMesh({
|
|
inviteToken: inv.token,
|
|
invitePayload: inv.payload,
|
|
peerPubkey: kp.publicKey,
|
|
displayName: `racer-${i}`,
|
|
}),
|
|
),
|
|
);
|
|
const oks = results.filter((r) => r.ok);
|
|
const exhausted = results.filter(
|
|
(r) => !r.ok && r.error === "invite_exhausted",
|
|
);
|
|
expect(oks.length).toBe(1);
|
|
expect(exhausted.length).toBe(4);
|
|
});
|
|
|
|
test("wrong mesh_id in payload vs DB row → invite_mesh_mismatch", async () => {
|
|
m = await setupTestMesh("inv-mesh-mismatch");
|
|
const inv = await createTestInvite(m);
|
|
// Point the DB row at a different mesh (create another one with
|
|
// the SAME owner_pubkey so we get past the owner check).
|
|
const other = await setupTestMesh("inv-mesh-other");
|
|
try {
|
|
// Align other's owner_pubkey to m's so only mesh_id differs.
|
|
await db
|
|
.update(mesh)
|
|
.set({ ownerPubkey: m.ownerPubkey })
|
|
.where(eq(mesh.id, other.meshId));
|
|
// Re-point invite row's meshId to other.
|
|
await db
|
|
.update(invite)
|
|
.set({ meshId: other.meshId })
|
|
.where(eq(invite.id, inv.inviteId));
|
|
const kp = await generateRawKeypair();
|
|
const result = await joinMesh({
|
|
inviteToken: inv.token,
|
|
invitePayload: inv.payload, // still claims m.meshId
|
|
peerPubkey: kp.publicKey,
|
|
displayName: "cross",
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error).toBe("invite_mesh_mismatch");
|
|
} finally {
|
|
await other.cleanup();
|
|
}
|
|
});
|
|
});
|