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:
@@ -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
|
||||||
|
|||||||
218
apps/broker/tests/session-hello-signature.test.ts
Normal file
218
apps/broker/tests/session-hello-signature.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user