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:
@@ -12,18 +12,23 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import sodium from "libsodium-wrappers";
|
||||
import { db } from "../src/db";
|
||||
import { mesh, meshMember } from "@turbostarter/db/schema/mesh";
|
||||
import { invite, mesh, meshMember } from "@turbostarter/db/schema/mesh";
|
||||
import { user } from "@turbostarter/db/schema/auth";
|
||||
import { canonicalInvite } from "../src/crypto";
|
||||
|
||||
const USER_ID = "test-user-smoke";
|
||||
const MESH_SLUG = "smoke-test";
|
||||
const BROKER_URL = process.env.BROKER_WS_URL ?? "ws://localhost:7900/ws";
|
||||
|
||||
async function main() {
|
||||
// Generate real ed25519 keypairs so crypto_box (via ed25519→X25519
|
||||
// conversion) works in Step 18+ round-trip tests.
|
||||
await sodium.ready;
|
||||
const kpOwner = sodium.crypto_sign_keypair();
|
||||
const kpA = sodium.crypto_sign_keypair();
|
||||
const kpB = sodium.crypto_sign_keypair();
|
||||
const OWNER_PUBKEY = sodium.to_hex(kpOwner.publicKey);
|
||||
const OWNER_SECRET = sodium.to_hex(kpOwner.privateKey);
|
||||
const PEER_A_PUBKEY = sodium.to_hex(kpA.publicKey);
|
||||
const PEER_A_SECRET = sodium.to_hex(kpA.privateKey);
|
||||
const PEER_B_PUBKEY = sodium.to_hex(kpB.publicKey);
|
||||
@@ -53,6 +58,7 @@ async function main() {
|
||||
name: "Smoke Test",
|
||||
slug: MESH_SLUG,
|
||||
ownerUserId: USER_ID,
|
||||
ownerPubkey: OWNER_PUBKEY,
|
||||
visibility: "private",
|
||||
transport: "managed",
|
||||
tier: "free",
|
||||
@@ -60,6 +66,40 @@ async function main() {
|
||||
.returning({ id: mesh.id });
|
||||
if (!m) throw new Error("mesh insert failed");
|
||||
|
||||
// Build + sign an invite, store it so /join can verify.
|
||||
const expiresAtSec = Math.floor(Date.now() / 1000) + 3600;
|
||||
const invitePayload = {
|
||||
v: 1 as const,
|
||||
mesh_id: m.id,
|
||||
mesh_slug: MESH_SLUG,
|
||||
broker_url: BROKER_URL,
|
||||
expires_at: expiresAtSec,
|
||||
mesh_root_key: "c21va2UtdGVzdC1tZXNoLXJvb3Qta2V5LWRldg",
|
||||
role: "member" as const,
|
||||
owner_pubkey: OWNER_PUBKEY,
|
||||
};
|
||||
const canonical = canonicalInvite(invitePayload);
|
||||
const signature = sodium.to_hex(
|
||||
sodium.crypto_sign_detached(
|
||||
sodium.from_string(canonical),
|
||||
kpOwner.privateKey,
|
||||
),
|
||||
);
|
||||
const fullPayload = { ...invitePayload, signature };
|
||||
const token = Buffer.from(JSON.stringify(fullPayload), "utf-8").toString(
|
||||
"base64url",
|
||||
);
|
||||
await db.insert(invite).values({
|
||||
meshId: m.id,
|
||||
token,
|
||||
tokenBytes: canonical,
|
||||
maxUses: 5,
|
||||
usedCount: 0,
|
||||
role: "member",
|
||||
expiresAt: new Date(expiresAtSec * 1000),
|
||||
createdBy: USER_ID,
|
||||
});
|
||||
|
||||
const [peerA] = await db
|
||||
.insert(meshMember)
|
||||
.values({
|
||||
@@ -84,6 +124,10 @@ async function main() {
|
||||
|
||||
const seed = {
|
||||
meshId: m.id,
|
||||
ownerPubkey: OWNER_PUBKEY,
|
||||
ownerSecretKey: OWNER_SECRET,
|
||||
inviteToken: token,
|
||||
inviteLink: `ic://join/${token}`,
|
||||
peerA: {
|
||||
memberId: peerA.id,
|
||||
pubkey: PEER_A_PUBKEY,
|
||||
|
||||
Reference in New Issue
Block a user