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>
121 lines
3.1 KiB
TypeScript
121 lines
3.1 KiB
TypeScript
/**
|
|
* Broker-side ed25519 verification helpers.
|
|
*
|
|
* Used to authenticate the WS hello handshake: clients sign a canonical
|
|
* byte string with their mesh.member.peerPubkey's secret key, broker
|
|
* verifies with the claimed pubkey, then cross-checks the pubkey is a
|
|
* current member of the claimed mesh.
|
|
*/
|
|
|
|
import sodium from "libsodium-wrappers";
|
|
|
|
let ready = false;
|
|
async function ensureSodium(): Promise<typeof sodium> {
|
|
if (!ready) {
|
|
await sodium.ready;
|
|
ready = true;
|
|
}
|
|
return sodium;
|
|
}
|
|
|
|
/** Canonical hello bytes: clients sign this, broker verifies this. */
|
|
export function canonicalHello(
|
|
meshId: string,
|
|
memberId: string,
|
|
pubkey: string,
|
|
timestamp: number,
|
|
): string {
|
|
return `${meshId}|${memberId}|${pubkey}|${timestamp}`;
|
|
}
|
|
|
|
/** Canonical invite bytes — everything in the payload except the signature. */
|
|
export function canonicalInvite(fields: {
|
|
v: number;
|
|
mesh_id: string;
|
|
mesh_slug: string;
|
|
broker_url: string;
|
|
expires_at: number;
|
|
mesh_root_key: string;
|
|
role: "admin" | "member";
|
|
owner_pubkey: string;
|
|
}): string {
|
|
return `${fields.v}|${fields.mesh_id}|${fields.mesh_slug}|${fields.broker_url}|${fields.expires_at}|${fields.mesh_root_key}|${fields.role}|${fields.owner_pubkey}`;
|
|
}
|
|
|
|
/**
|
|
* Verify an ed25519 signature over arbitrary canonical bytes.
|
|
* Used by invite verification + (future) any other signed payload.
|
|
*/
|
|
export async function verifyEd25519(
|
|
canonicalText: string,
|
|
signatureHex: string,
|
|
pubkeyHex: string,
|
|
): Promise<boolean> {
|
|
if (
|
|
!/^[0-9a-f]{64}$/i.test(pubkeyHex) ||
|
|
!/^[0-9a-f]{128}$/i.test(signatureHex)
|
|
) {
|
|
return false;
|
|
}
|
|
const s = await ensureSodium();
|
|
try {
|
|
return s.crypto_sign_verify_detached(
|
|
s.from_hex(signatureHex),
|
|
s.from_string(canonicalText),
|
|
s.from_hex(pubkeyHex),
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export const HELLO_SKEW_MS = 60_000;
|
|
|
|
/**
|
|
* Verify a hello's ed25519 signature + timestamp skew.
|
|
* Returns { ok: true } on success, or { ok: false, reason } describing
|
|
* which check failed (for structured error response).
|
|
*/
|
|
export async function verifyHelloSignature(args: {
|
|
meshId: string;
|
|
memberId: string;
|
|
pubkey: string;
|
|
timestamp: number;
|
|
signature: string;
|
|
now?: number;
|
|
}): Promise<
|
|
| { ok: true }
|
|
| { ok: false; reason: "timestamp_skew" | "bad_signature" | "malformed" }
|
|
> {
|
|
const now = args.now ?? Date.now();
|
|
if (
|
|
!Number.isFinite(args.timestamp) ||
|
|
Math.abs(now - args.timestamp) > HELLO_SKEW_MS
|
|
) {
|
|
return { ok: false, reason: "timestamp_skew" };
|
|
}
|
|
if (
|
|
!/^[0-9a-f]{64}$/i.test(args.pubkey) ||
|
|
!/^[0-9a-f]{128}$/i.test(args.signature)
|
|
) {
|
|
return { ok: false, reason: "malformed" };
|
|
}
|
|
const s = await ensureSodium();
|
|
try {
|
|
const canonical = canonicalHello(
|
|
args.meshId,
|
|
args.memberId,
|
|
args.pubkey,
|
|
args.timestamp,
|
|
);
|
|
const ok = s.crypto_sign_verify_detached(
|
|
s.from_hex(args.signature),
|
|
s.from_string(canonical),
|
|
s.from_hex(args.pubkey),
|
|
);
|
|
return ok ? { ok: true } : { ok: false, reason: "bad_signature" };
|
|
} catch {
|
|
return { ok: false, reason: "malformed" };
|
|
}
|
|
}
|