From 033a2d37e104bc0be880dde8d53b1c1a90962096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Mon, 4 May 2026 12:57:28 +0100 Subject: [PATCH] feat(broker): canonical session-hello + parent-attestation helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the crypto primitives the 1.30.0 per-session broker presence flow needs: canonicalSessionAttestation/canonicalSessionHello bytes, and verifySessionAttestation/verifySessionHelloSignature with TTL bounds (≤24h) plus standard ed25519 + skew checks. 10 unit tests cover the hostile cases — expired attestation, over-TTL, wrong-key signing, tampered fields, and the "attacker captured the attestation but doesn't hold the session secret key" scenario. No wire changes yet — types and dispatch land in the next two commits. Spec: .artifacts/specs/2026-05-04-per-session-presence.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/broker/src/crypto.ts | 122 ++++++++++ .../tests/session-hello-signature.test.ts | 218 ++++++++++++++++++ 2 files changed, 340 insertions(+) create mode 100644 apps/broker/tests/session-hello-signature.test.ts diff --git a/apps/broker/src/crypto.ts b/apps/broker/src/crypto.ts index 65d247b..25585d1 100644 --- a/apps/broker/src/crypto.ts +++ b/apps/broker/src/crypto.ts @@ -138,6 +138,128 @@ export async function sealRootKeyToRecipient(params: { export const HELLO_SKEW_MS = 60_000; +/** Maximum lifetime of a parent attestation (24h). */ +export const SESSION_ATTESTATION_MAX_TTL_MS = 24 * 60 * 60 * 1000; + +/** + * Canonical bytes for a parent-vouches-session attestation. + * + * The parent member signs this with their stable ed25519 secret key when + * minting an attestation in `claudemesh launch`. The broker recomputes + * the same string at session_hello time and verifies the signature + * against `parent_member_pubkey`. + * + * Format: `claudemesh-session-attest|||` + */ +export function canonicalSessionAttestation( + parentMemberPubkey: string, + sessionPubkey: string, + expiresAt: number, +): string { + return `claudemesh-session-attest|${parentMemberPubkey}|${sessionPubkey}|${expiresAt}`; +} + +/** + * Canonical bytes for the session_hello signature. + * + * The session keypair (held by the daemon for the lifetime of the + * registration) signs this fresh on every WS connect, proving liveness + + * possession of the session secret key. Without this stage, an attacker + * who captured an attestation could replay it from any machine. + * + * Format: `claudemesh-session-hello||||` + */ +export function canonicalSessionHello( + meshId: string, + parentMemberPubkey: string, + sessionPubkey: string, + timestamp: number, +): string { + return `claudemesh-session-hello|${meshId}|${parentMemberPubkey}|${sessionPubkey}|${timestamp}`; +} + +/** + * Validate a parent-vouches-session attestation: lifetime bound + signature. + * Returns `{ ok: true }` on success or `{ ok: false, reason }` on failure. + * + * The TTL ceiling (24h) bounds replay damage if an attestation leaks; the + * lower bound (already in the past) blocks reuse of expired ones. + */ +export async function verifySessionAttestation(args: { + parentMemberPubkey: string; + sessionPubkey: string; + expiresAt: number; + signature: string; + now?: number; +}): Promise< + | { ok: true } + | { ok: false; reason: "expired" | "ttl_too_long" | "bad_signature" | "malformed" } +> { + const now = args.now ?? Date.now(); + if (!Number.isFinite(args.expiresAt)) { + return { ok: false, reason: "malformed" }; + } + if (args.expiresAt <= now) { + return { ok: false, reason: "expired" }; + } + if (args.expiresAt > now + SESSION_ATTESTATION_MAX_TTL_MS) { + return { ok: false, reason: "ttl_too_long" }; + } + if ( + !/^[0-9a-f]{64}$/i.test(args.parentMemberPubkey) || + !/^[0-9a-f]{64}$/i.test(args.sessionPubkey) || + !/^[0-9a-f]{128}$/i.test(args.signature) + ) { + return { ok: false, reason: "malformed" }; + } + const canonical = canonicalSessionAttestation( + args.parentMemberPubkey, + args.sessionPubkey, + args.expiresAt, + ); + const ok = await verifyEd25519(canonical, args.signature, args.parentMemberPubkey); + return ok ? { ok: true } : { ok: false, reason: "bad_signature" }; +} + +/** + * Validate the session-side hello signature: timestamp skew + signature + * by the session keypair over canonical session-hello bytes. + */ +export async function verifySessionHelloSignature(args: { + meshId: string; + parentMemberPubkey: string; + sessionPubkey: 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.parentMemberPubkey) || + !/^[0-9a-f]{64}$/i.test(args.sessionPubkey) || + !/^[0-9a-f]{128}$/i.test(args.signature) + ) { + return { ok: false, reason: "malformed" }; + } + const canonical = canonicalSessionHello( + args.meshId, + args.parentMemberPubkey, + args.sessionPubkey, + args.timestamp, + ); + const ok = await verifyEd25519(canonical, args.signature, args.sessionPubkey); + return ok ? { ok: true } : { ok: false, reason: "bad_signature" }; +} + /** * Verify a hello's ed25519 signature + timestamp skew. * Returns { ok: true } on success, or { ok: false, reason } describing diff --git a/apps/broker/tests/session-hello-signature.test.ts b/apps/broker/tests/session-hello-signature.test.ts new file mode 100644 index 0000000..e892db1 --- /dev/null +++ b/apps/broker/tests/session-hello-signature.test.ts @@ -0,0 +1,218 @@ +/** + * Session-hello signature + parent-attestation verification. + * + * Two-stage proof: + * 1. Parent member signs `canonicalSessionAttestation` (long-lived, ≤24h + * TTL) — vouches that the session pubkey belongs to them. + * 2. Session keypair signs `canonicalSessionHello` per WS-connect — proves + * liveness + possession. + * + * The broker rejects on any: expired/over-TTL attestation, bad signature, + * timestamp skew, malformed hex, or a session signature made with the + * wrong key (covers the "attestation leaked, attacker tries to use it + * without the session secret key" case). + */ + +import { beforeAll, describe, expect, test } from "vitest"; +import sodium from "libsodium-wrappers"; +import { + canonicalSessionAttestation, + canonicalSessionHello, + verifySessionAttestation, + verifySessionHelloSignature, + SESSION_ATTESTATION_MAX_TTL_MS, + HELLO_SKEW_MS, +} from "../src/crypto"; + +interface Keypair { + publicKey: string; + secretKey: string; +} + +async function makeKeypair(): Promise { + await sodium.ready; + const kp = sodium.crypto_sign_keypair(); + return { + publicKey: sodium.to_hex(kp.publicKey), + secretKey: sodium.to_hex(kp.privateKey), + }; +} + +function sign(canonical: string, secretKeyHex: string): string { + return sodium.to_hex( + sodium.crypto_sign_detached( + sodium.from_string(canonical), + sodium.from_hex(secretKeyHex), + ), + ); +} + +describe("verifySessionAttestation", () => { + let parent: Keypair; + let session: Keypair; + + beforeAll(async () => { + parent = await makeKeypair(); + session = await makeKeypair(); + }); + + test("valid attestation accepted", async () => { + const expiresAt = Date.now() + 60 * 60 * 1000; + const canonical = canonicalSessionAttestation(parent.publicKey, session.publicKey, expiresAt); + const signature = sign(canonical, parent.secretKey); + const result = await verifySessionAttestation({ + parentMemberPubkey: parent.publicKey, + sessionPubkey: session.publicKey, + expiresAt, + signature, + }); + expect(result.ok).toBe(true); + }); + + test("expired attestation rejected", async () => { + const expiresAt = Date.now() - 1_000; + const canonical = canonicalSessionAttestation(parent.publicKey, session.publicKey, expiresAt); + const signature = sign(canonical, parent.secretKey); + const result = await verifySessionAttestation({ + parentMemberPubkey: parent.publicKey, + sessionPubkey: session.publicKey, + expiresAt, + signature, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("expired"); + }); + + test("over-24h TTL rejected", async () => { + const expiresAt = Date.now() + SESSION_ATTESTATION_MAX_TTL_MS + 60_000; + const canonical = canonicalSessionAttestation(parent.publicKey, session.publicKey, expiresAt); + const signature = sign(canonical, parent.secretKey); + const result = await verifySessionAttestation({ + parentMemberPubkey: parent.publicKey, + sessionPubkey: session.publicKey, + expiresAt, + signature, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("ttl_too_long"); + }); + + test("attestation signed by wrong key rejected", async () => { + const other = await makeKeypair(); + const expiresAt = Date.now() + 60 * 60 * 1000; + const canonical = canonicalSessionAttestation(parent.publicKey, session.publicKey, expiresAt); + // Sign with a different parent — verifier still checks against + // claimed parentMemberPubkey, so it should fail. + const signature = sign(canonical, other.secretKey); + const result = await verifySessionAttestation({ + parentMemberPubkey: parent.publicKey, + sessionPubkey: session.publicKey, + expiresAt, + signature, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("bad_signature"); + }); + + test("tampered session_pubkey fails (canonical mismatch)", async () => { + const expiresAt = Date.now() + 60 * 60 * 1000; + const canonical = canonicalSessionAttestation(parent.publicKey, session.publicKey, expiresAt); + const signature = sign(canonical, parent.secretKey); + const evil = await makeKeypair(); + const result = await verifySessionAttestation({ + parentMemberPubkey: parent.publicKey, + sessionPubkey: evil.publicKey, // claim a different session pubkey + expiresAt, + signature, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("bad_signature"); + }); + + test("malformed hex rejected", async () => { + const expiresAt = Date.now() + 60 * 60 * 1000; + const result = await verifySessionAttestation({ + parentMemberPubkey: "not-hex", + sessionPubkey: session.publicKey, + expiresAt, + signature: "a".repeat(128), + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("malformed"); + }); +}); + +describe("verifySessionHelloSignature", () => { + let parent: Keypair; + let session: Keypair; + + beforeAll(async () => { + parent = await makeKeypair(); + session = await makeKeypair(); + }); + + test("valid session-hello signature accepted", async () => { + const meshId = "mesh-x"; + const timestamp = Date.now(); + const canonical = canonicalSessionHello(meshId, parent.publicKey, session.publicKey, timestamp); + const signature = sign(canonical, session.secretKey); + const result = await verifySessionHelloSignature({ + meshId, + parentMemberPubkey: parent.publicKey, + sessionPubkey: session.publicKey, + timestamp, + signature, + }); + expect(result.ok).toBe(true); + }); + + test("attacker without session secret key cannot forge session-hello", async () => { + // The hostile case: attacker captured a valid attestation but doesn't + // hold the session secret key. They try to sign session_hello with the + // parent's key — broker checks the signature against sessionPubkey, + // which fails because the parent didn't sign with the session key. + const meshId = "mesh-x"; + const timestamp = Date.now(); + const canonical = canonicalSessionHello(meshId, parent.publicKey, session.publicKey, timestamp); + const signature = sign(canonical, parent.secretKey); // wrong secret key + const result = await verifySessionHelloSignature({ + meshId, + parentMemberPubkey: parent.publicKey, + sessionPubkey: session.publicKey, + timestamp, + signature, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("bad_signature"); + }); + + test("timestamp skew rejected", async () => { + const timestamp = Date.now() - HELLO_SKEW_MS - 1_000; + const canonical = canonicalSessionHello("mesh-x", parent.publicKey, session.publicKey, timestamp); + const signature = sign(canonical, session.secretKey); + const result = await verifySessionHelloSignature({ + meshId: "mesh-x", + parentMemberPubkey: parent.publicKey, + sessionPubkey: session.publicKey, + timestamp, + signature, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("timestamp_skew"); + }); + + test("tampered meshId fails verification", async () => { + const timestamp = Date.now(); + const canonical = canonicalSessionHello("mesh-A", parent.publicKey, session.publicKey, timestamp); + const signature = sign(canonical, session.secretKey); + const result = await verifySessionHelloSignature({ + meshId: "mesh-B", // claim a different mesh + parentMemberPubkey: parent.publicKey, + sessionPubkey: session.publicKey, + timestamp, + signature, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.reason).toBe("bad_signature"); + }); +});