feat(broker): canonical session-hello + parent-attestation helpers

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) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-04 12:57:28 +01:00
parent 364178d95b
commit 033a2d37e1
2 changed files with 340 additions and 0 deletions

View File

@@ -138,6 +138,128 @@ export async function sealRootKeyToRecipient(params: {
export const HELLO_SKEW_MS = 60_000; 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|<parent_pubkey>|<session_pubkey>|<expires_at_ms>`
*/
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|<mesh_id>|<parent_pubkey>|<session_pubkey>|<timestamp_ms>`
*/
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. * Verify a hello's ed25519 signature + timestamp skew.
* Returns { ok: true } on success, or { ok: false, reason } describing * Returns { ok: true } on success, or { ok: false, reason } describing

View File

@@ -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<Keypair> {
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");
});
});