/** * T8 — client + host handshake state machines (§4.4 `client_hello` → `host_hello`). * * Anti-MITM (MUST be explicit): the pin string alone is NOT the defense — `enrollFpr` hashes a * PUBLIC key a malicious relay also knows. `onHostHello` receives the raw `agentPubkey` from P3's * INDEPENDENT TLS channel (never from the relay-carried HostHello), recomputes the fpr locally, * asserts it equals BOTH `msg.enrollFpr` AND the pin outcome, and only then verifies the signature * against that registry pubkey. Any disagreement ⇒ FingerprintMismatchError, no key derived. * * Anti-proof-replay: `deviceAuthProof` is bound to THIS handshake's `clientEphPub`/`clientNonce` * (issued by P5, injected). The host's `verifyDeviceProof` receives that binding so a captured * bearer proof replayed into a different ephemeral handshake is rejected (INV3). */ import type { AeadAlg, ClientHandshake, ClientHello, DeviceAuthProofProvider, DevicePinStore, HandshakeResult, HostHello, } from 'relay-contracts' import { ClientHelloSchema, HostHelloSchema } from 'relay-contracts' import { randomBytes } from './crypto-provider.js' import { computeEnrollFpr, resolvePin, verifyPinnedFingerprint } from './fingerprint.js' import { deriveSessionKeys } from './hkdf.js' import { FingerprintMismatchError, HandshakeStateError } from './errors.js' import { buildTranscript } from './transcript.js' import { deriveSharedSecret, generateEphemeralKeyPair, type EphemeralKeyPair } from './x25519.js' const NONCE_BYTES = 32 /** Fixed AEAD strength preference (strongest first). No fallback to an unoffered alg. */ export const AEAD_PREFERENCE: readonly AeadAlg[] = ['xchacha20-poly1305', 'aes-256-gcm'] export type HandshakePhase = | 'init' | 'awaitHostHello' | 'awaitClientHello' | 'established' | 'aborted' export interface HostSigner { sign(transcript: Uint8Array): Promise } export interface HostVerifier { verify(pubkey: Uint8Array, transcript: Uint8Array, sig: Uint8Array): Promise } /** Pick the strongest alg present in both offers; null if the intersection is empty. */ function negotiate(offer: readonly AeadAlg[], supported: readonly AeadAlg[]): AeadAlg | null { for (const alg of AEAD_PREFERENCE) { if (offer.includes(alg) && supported.includes(alg)) return alg } return null } // --------------------------------------------------------------------------- // CLIENT (browser, P6) // --------------------------------------------------------------------------- export interface ClientHandshakeWithPhase extends ClientHandshake { readonly phase: HandshakePhase } export interface CreateClientHandshakeDeps { readonly aeadOffer: readonly AeadAlg[] readonly deviceAuthProofProvider: DeviceAuthProofProvider readonly verifier: HostVerifier readonly pinStore: DevicePinStore readonly hostId: string } class ClientHandshakeImpl implements ClientHandshakeWithPhase { #phase: HandshakePhase = 'init' #eph: EphemeralKeyPair | null = null #clientNonce: Uint8Array | null = null constructor(private readonly deps: CreateClientHandshakeDeps) {} get phase(): HandshakePhase { return this.#phase } async start(): Promise { if (this.#phase !== 'init') { throw new HandshakeStateError(`start() illegal in phase ${this.#phase}`) } const eph = await generateEphemeralKeyPair() const clientNonce = randomBytes(NONCE_BYTES) const deviceAuthProof = await this.deps.deviceAuthProofProvider.proofFor(this.deps.hostId, { clientEphPub: eph.publicKey, clientNonce, }) this.#eph = eph this.#clientNonce = clientNonce this.#phase = 'awaitHostHello' return { clientEphPub: eph.publicKey, clientNonce, aeadOffer: this.deps.aeadOffer, deviceAuthProof, } } async onHostHello(msg: HostHello, agentPubkey: Uint8Array): Promise { if (this.#phase !== 'awaitHostHello' || !this.#eph || !this.#clientNonce) { throw new HandshakeStateError(`onHostHello() illegal in phase ${this.#phase}`) } const parsed = HostHelloSchema.parse(msg) // (a)+(b) Host-key sourcing: recompute fpr from the REGISTRY pubkey; assert it matches BOTH the // relay-carried enrollFpr string AND the browser pin. Never a string-only fallback. const computedFpr = computeEnrollFpr(agentPubkey) if (!verifyPinnedFingerprint(agentPubkey, parsed.enrollFpr)) { this.#phase = 'aborted' throw new FingerprintMismatchError('host_hello enrollFpr != recomputed registry fpr') } const pin = await resolvePin(this.deps.pinStore, this.deps.hostId, agentPubkey) if (pin.outcome === 'mismatch') { this.#phase = 'aborted' throw new FingerprintMismatchError('pinned fingerprint mismatch (no auto-repin)') } if (!this.deps.aeadOffer.includes(parsed.aeadChoice)) { this.#phase = 'aborted' throw new HandshakeStateError('host chose an AEAD alg not in the client offer') } const transcript = buildTranscript({ clientEphPub: this.#eph.publicKey, clientNonce: this.#clientNonce, aeadOffer: this.deps.aeadOffer, hostEphPub: parsed.hostEphPub, hostNonce: parsed.hostNonce, aeadChoice: parsed.aeadChoice, enrollFpr: computedFpr, }) // (c) Verify signature against the REGISTRY pubkey (never a relay-supplied value). const ok = await this.deps.verifier.verify(agentPubkey, transcript, parsed.sig) if (!ok) { this.#phase = 'aborted' throw new FingerprintMismatchError('host_hello signature invalid') } // TOFU: record the pin only after a fully valid host_hello. if (pin.outcome === 'tofu-first-use') { await this.deps.pinStore.pin(this.deps.hostId, computedFpr) } const shared = await deriveSharedSecret(this.#eph.privateKey, parsed.hostEphPub) const keys = await deriveSessionKeys( shared, this.#clientNonce, parsed.hostNonce, parsed.aeadChoice, ) this.#phase = 'established' return { keys, aead: parsed.aeadChoice, transcript } } } export function createClientHandshake(deps: CreateClientHandshakeDeps): ClientHandshakeWithPhase { return new ClientHandshakeImpl(deps) } // --------------------------------------------------------------------------- // HOST (agent, P2) // --------------------------------------------------------------------------- export interface HostHandshake { readonly phase: HandshakePhase readonly result: HandshakeResult | null onClientHello(msg: ClientHello): Promise } export interface CreateHostHandshakeDeps { readonly signer: HostSigner readonly agentPubkey: Uint8Array readonly supported: readonly AeadAlg[] readonly verifyDeviceProof: ( proof: string, binding: { clientEphPub: Uint8Array; clientNonce: Uint8Array }, ) => Promise } class HostHandshakeImpl implements HostHandshake { #phase: HandshakePhase = 'init' #result: HandshakeResult | null = null constructor(private readonly deps: CreateHostHandshakeDeps) {} get phase(): HandshakePhase { return this.#phase } get result(): HandshakeResult | null { return this.#result } async onClientHello(msg: ClientHello): Promise { if (this.#phase !== 'init') { throw new HandshakeStateError(`onClientHello() illegal in phase ${this.#phase}`) } const parsed = ClientHelloSchema.parse(msg) // INV3 gate: proof MUST be bound to this handshake's ephemeral material. const proofOk = await this.deps.verifyDeviceProof(parsed.deviceAuthProof, { clientEphPub: parsed.clientEphPub, clientNonce: parsed.clientNonce, }) if (!proofOk) { this.#phase = 'aborted' throw new HandshakeStateError('deviceAuthProof failed verification (deny-by-default)') } const aeadChoice = negotiate(parsed.aeadOffer, this.deps.supported) if (!aeadChoice) { this.#phase = 'aborted' throw new HandshakeStateError('no common AEAD algorithm') } const eph = await generateEphemeralKeyPair() const hostNonce = randomBytes(NONCE_BYTES) const enrollFpr = computeEnrollFpr(this.deps.agentPubkey) const transcript = buildTranscript({ clientEphPub: parsed.clientEphPub, clientNonce: parsed.clientNonce, aeadOffer: parsed.aeadOffer, hostEphPub: eph.publicKey, hostNonce, aeadChoice, enrollFpr, }) const sig = await this.deps.signer.sign(transcript) const shared = await deriveSharedSecret(eph.privateKey, parsed.clientEphPub) const keys = await deriveSessionKeys(shared, parsed.clientNonce, hostNonce, aeadChoice) this.#result = { keys, aead: aeadChoice, transcript } this.#phase = 'established' return { hostEphPub: eph.publicKey, hostNonce, aeadChoice, enrollFpr, sig } } } export function createHostHandshake(deps: CreateHostHandshakeDeps): HostHandshake { return new HostHandshakeImpl(deps) }