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>
216 lines
5.9 KiB
TypeScript
216 lines
5.9 KiB
TypeScript
/**
|
|
* Test helpers for broker integration tests.
|
|
*
|
|
* Each test gets its own fresh mesh + members via `setupTestMesh`.
|
|
* Mesh isolation in the broker logic means tests don't interfere even
|
|
* when they share a database and run in the same process — we just
|
|
* need unique meshIds per test.
|
|
*/
|
|
|
|
import { eq, inArray } from "drizzle-orm";
|
|
import sodium from "libsodium-wrappers";
|
|
import { db } from "../src/db";
|
|
import { invite, mesh, meshMember } from "@turbostarter/db/schema/mesh";
|
|
import { user } from "@turbostarter/db/schema/auth";
|
|
import { randomBytes } from "node:crypto";
|
|
import { canonicalInvite } from "../src/crypto";
|
|
|
|
const TEST_USER_ID = "test-user-integration";
|
|
|
|
/**
|
|
* Shared test user. Created once, reused across tests.
|
|
* Uses a deterministic id so we can safely cascade-delete on cleanup.
|
|
*/
|
|
export async function ensureTestUser(): Promise<string> {
|
|
const [existing] = await db
|
|
.select({ id: user.id })
|
|
.from(user)
|
|
.where(eq(user.id, TEST_USER_ID));
|
|
if (!existing) {
|
|
await db.insert(user).values({
|
|
id: TEST_USER_ID,
|
|
name: "Broker Test User",
|
|
email: "broker-test@claudemesh.test",
|
|
emailVerified: true,
|
|
});
|
|
}
|
|
return TEST_USER_ID;
|
|
}
|
|
|
|
export interface TestMesh {
|
|
meshId: string;
|
|
ownerPubkey: string;
|
|
ownerSecretKey: string;
|
|
peerA: { memberId: string; pubkey: string };
|
|
peerB: { memberId: string; pubkey: string };
|
|
cleanup: () => Promise<void>;
|
|
}
|
|
|
|
export interface TestInvite {
|
|
token: string;
|
|
payload: {
|
|
v: 1;
|
|
mesh_id: string;
|
|
mesh_slug: string;
|
|
broker_url: string;
|
|
expires_at: number;
|
|
mesh_root_key: string;
|
|
role: "admin" | "member";
|
|
owner_pubkey: string;
|
|
signature: string;
|
|
};
|
|
inviteId: string;
|
|
}
|
|
|
|
/**
|
|
* Create a test mesh + 2 members. Returns IDs + pubkeys and a
|
|
* cleanup function that cascade-deletes the mesh (and all presence,
|
|
* message_queue, member rows that reference it).
|
|
*/
|
|
export async function setupTestMesh(label: string): Promise<TestMesh> {
|
|
const userId = await ensureTestUser();
|
|
const slug = `t-${label}-${randomBytes(4).toString("hex")}`;
|
|
|
|
await sodium.ready;
|
|
const kpOwner = sodium.crypto_sign_keypair();
|
|
const ownerPubkey = sodium.to_hex(kpOwner.publicKey);
|
|
const ownerSecretKey = sodium.to_hex(kpOwner.privateKey);
|
|
|
|
const [m] = await db
|
|
.insert(mesh)
|
|
.values({
|
|
name: `Test ${label}`,
|
|
slug,
|
|
ownerUserId: userId,
|
|
ownerPubkey,
|
|
visibility: "private",
|
|
transport: "managed",
|
|
tier: "free",
|
|
})
|
|
.returning({ id: mesh.id });
|
|
if (!m) throw new Error("failed to insert test mesh");
|
|
|
|
const pubkeyA = "a".repeat(63) + randomBytes(1).toString("hex").slice(0, 1);
|
|
const pubkeyB = "b".repeat(63) + randomBytes(1).toString("hex").slice(0, 1);
|
|
|
|
const [mA] = await db
|
|
.insert(meshMember)
|
|
.values({
|
|
meshId: m.id,
|
|
userId,
|
|
peerPubkey: pubkeyA,
|
|
displayName: `peer-a-${label}`,
|
|
role: "admin",
|
|
})
|
|
.returning({ id: meshMember.id });
|
|
const [mB] = await db
|
|
.insert(meshMember)
|
|
.values({
|
|
meshId: m.id,
|
|
userId,
|
|
peerPubkey: pubkeyB,
|
|
displayName: `peer-b-${label}`,
|
|
role: "member",
|
|
})
|
|
.returning({ id: meshMember.id });
|
|
if (!mA || !mB) throw new Error("failed to insert test members");
|
|
|
|
return {
|
|
meshId: m.id,
|
|
ownerPubkey,
|
|
ownerSecretKey,
|
|
peerA: { memberId: mA.id, pubkey: pubkeyA },
|
|
peerB: { memberId: mB.id, pubkey: pubkeyB },
|
|
cleanup: async () => {
|
|
// Cascade delete takes care of members, presences, message_queue.
|
|
await db.delete(mesh).where(eq(mesh.id, m.id));
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create a signed invite row for an existing test mesh. Returns the
|
|
* token + full payload + DB invite id. Defaults: 1-hour expiry, max
|
|
* uses = 1, role = "member".
|
|
*/
|
|
export async function createTestInvite(
|
|
m: TestMesh,
|
|
opts: {
|
|
maxUses?: number;
|
|
expiresInSec?: number;
|
|
role?: "admin" | "member";
|
|
slug?: string;
|
|
brokerUrl?: string;
|
|
} = {},
|
|
): Promise<TestInvite> {
|
|
await sodium.ready;
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const expiresAt = now + (opts.expiresInSec ?? 3600);
|
|
const payload = {
|
|
v: 1 as const,
|
|
mesh_id: m.meshId,
|
|
mesh_slug: opts.slug ?? "test-slug",
|
|
broker_url: opts.brokerUrl ?? "ws://localhost:7900/ws",
|
|
expires_at: expiresAt,
|
|
mesh_root_key: "dGVzdC1tZXNoLXJvb3Qta2V5",
|
|
role: opts.role ?? ("member" as const),
|
|
owner_pubkey: m.ownerPubkey,
|
|
};
|
|
const canonical = canonicalInvite(payload);
|
|
const signature = sodium.to_hex(
|
|
sodium.crypto_sign_detached(
|
|
sodium.from_string(canonical),
|
|
sodium.from_hex(m.ownerSecretKey),
|
|
),
|
|
);
|
|
const full = { ...payload, signature };
|
|
const token = Buffer.from(JSON.stringify(full), "utf-8").toString(
|
|
"base64url",
|
|
);
|
|
const [row] = await db
|
|
.insert(invite)
|
|
.values({
|
|
meshId: m.meshId,
|
|
token,
|
|
tokenBytes: canonical,
|
|
maxUses: opts.maxUses ?? 1,
|
|
usedCount: 0,
|
|
role: opts.role ?? "member",
|
|
expiresAt: new Date(expiresAt * 1000),
|
|
createdBy: "test-user-integration",
|
|
})
|
|
.returning({ id: invite.id });
|
|
if (!row) throw new Error("invite insert failed");
|
|
return { token, payload: full, inviteId: row.id };
|
|
}
|
|
|
|
export async function generateRawKeypair(): Promise<{
|
|
publicKey: string;
|
|
secretKey: string;
|
|
}> {
|
|
await sodium.ready;
|
|
const kp = sodium.crypto_sign_keypair();
|
|
return {
|
|
publicKey: sodium.to_hex(kp.publicKey),
|
|
secretKey: sodium.to_hex(kp.privateKey),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Delete all meshes with slugs starting with "t-" (test prefix).
|
|
* Used as a safety net in afterAll if individual cleanup() didn't run.
|
|
*/
|
|
export async function cleanupAllTestMeshes(): Promise<void> {
|
|
const testMeshes = await db
|
|
.select({ id: mesh.id })
|
|
.from(mesh)
|
|
.where(eq(mesh.ownerUserId, TEST_USER_ID));
|
|
if (testMeshes.length === 0) return;
|
|
await db.delete(mesh).where(
|
|
inArray(
|
|
mesh.id,
|
|
testMeshes.map((m) => m.id),
|
|
),
|
|
);
|
|
}
|