import { describe, expect, it } from 'vitest' import type { ClientHello, HostHello } from 'relay-contracts' import { createClientHandshake, createHostHandshake } from '../src/handshake.js' import { nobleEd25519Signer, nobleEd25519Verifier } from '../src/ed25519.js' import { computeEnrollFpr } from '../src/fingerprint.js' import { MemoryDevicePinStore } from '../src/keystore.js' import { unwrapAeadKey } from '../src/aead-key.js' import { FingerprintMismatchError, HandshakeStateError } from '../src/errors.js' import { boundProofProvider, makeHost, makeHostIdentity, verifyBoundProof, bytesToHex } from './helpers.js' function makeClient(hostId: string, pinStore = new MemoryDevicePinStore()) { return createClientHandshake({ aeadOffer: ['xchacha20-poly1305', 'aes-256-gcm'], deviceAuthProofProvider: boundProofProvider(), verifier: nobleEd25519Verifier(), pinStore, hostId, }) } describe('T8 handshake', () => { it('happy path: both sides derive the SAME DirectionalKeys + aead; relay sees no key', async () => { const id = makeHostIdentity() const client = makeClient('h1') const host = makeHost(id.privateKey, id.agentPubkey) const ch = await client.start() const hh = await host.onClientHello(ch) const rc = await client.onHostHello(hh, id.agentPubkey) const rh = host.result! expect(rc.aead).toBe('xchacha20-poly1305') expect(rc.aead).toBe(rh.aead) expect(bytesToHex(unwrapAeadKey(rc.keys.c2h).raw)).toBe(bytesToHex(unwrapAeadKey(rh.keys.c2h).raw)) expect(bytesToHex(unwrapAeadKey(rc.keys.h2c).raw)).toBe(bytesToHex(unwrapAeadKey(rh.keys.h2c).raw)) // The relay only ever sees the ClientHello/HostHello structs — no secret field present. expect(Object.keys(ch)).toEqual(['clientEphPub', 'clientNonce', 'aeadOffer', 'deviceAuthProof']) expect(Object.keys(hh)).toEqual(['hostEphPub', 'hostNonce', 'aeadChoice', 'enrollFpr', 'sig']) }) it('AEAD negotiation: strongest common alg; empty intersection → HandshakeStateError', async () => { const id = makeHostIdentity() const client = createClientHandshake({ aeadOffer: ['aes-256-gcm'], deviceAuthProofProvider: boundProofProvider(), verifier: nobleEd25519Verifier(), pinStore: new MemoryDevicePinStore(), hostId: 'h1', }) const host = makeHost(id.privateKey, id.agentPubkey, ['aes-256-gcm']) const hh = await host.onClientHello(await client.start()) expect(hh.aeadChoice).toBe('aes-256-gcm') const client2 = createClientHandshake({ aeadOffer: ['xchacha20-poly1305'], deviceAuthProofProvider: boundProofProvider(), verifier: nobleEd25519Verifier(), pinStore: new MemoryDevicePinStore(), hostId: 'h1', }) const hostGcmOnly = makeHost(id.privateKey, id.agentPubkey, ['aes-256-gcm']) await expect(hostGcmOnly.onClientHello(await client2.start())).rejects.toBeInstanceOf( HandshakeStateError, ) }) it('MITM abort: relay swaps hostEph/sig → sig fails vs registry pubkey → FingerprintMismatchError, no key', async () => { const id = makeHostIdentity() const evil = makeHostIdentity() const client = makeClient('h1') const host = makeHost(id.privateKey, id.agentPubkey) const ch = await client.start() const hh = await host.onClientHello(ch) // Relay forges the signature with its own key while keeping the honest enrollFpr. const forged: HostHello = { ...hh, sig: await nobleForge(evil.privateKey, hh) } await expect(client.onHostHello(forged, id.agentPubkey)).rejects.toBeInstanceOf( FingerprintMismatchError, ) expect(client.phase).toBe('aborted') }) it('finding #3: enrollFpr matches pin but supplied pubkey hashes differently → abort, no string-only match', async () => { const id = makeHostIdentity() const other = makeHostIdentity() const client = makeClient('h1') const host = makeHost(id.privateKey, id.agentPubkey) const hh = await host.onClientHello(await client.start()) // hh.enrollFpr is the honest host's; but we hand onHostHello a DIFFERENT registry pubkey. await expect(client.onHostHello(hh, other.agentPubkey)).rejects.toBeInstanceOf( FingerprintMismatchError, ) }) it('TOFU first use records the recomputed pin; a later different registry pubkey aborts (no auto-repin)', async () => { const id = makeHostIdentity() const pinStore = new MemoryDevicePinStore() const client = makeClient('h1', pinStore) const host = makeHost(id.privateKey, id.agentPubkey) await client.onHostHello(await host.onClientHello(await client.start()), id.agentPubkey) expect(await pinStore.get('h1')).toBe(computeEnrollFpr(id.agentPubkey)) const rotated = makeHostIdentity() const client2 = makeClient('h1', pinStore) const host2 = makeHost(rotated.privateKey, rotated.agentPubkey) await expect( client2.onHostHello(await host2.onClientHello(await client2.start()), rotated.agentPubkey), ).rejects.toBeInstanceOf(FingerprintMismatchError) // pin untouched expect(await pinStore.get('h1')).toBe(computeEnrollFpr(id.agentPubkey)) }) it('finding #2: a proof captured from handshake A cannot be replayed into handshake B', async () => { const id = makeHostIdentity() const clientA = makeClient('h1') const chA = await clientA.start() const clientB = makeClient('h1') const chB = await clientB.start() // Splice A's proof into B's (different clientEphPub) client_hello. const spliced: ClientHello = { ...chB, deviceAuthProof: chA.deviceAuthProof } const host = makeHost(id.privateKey, id.agentPubkey) await expect(host.onClientHello(spliced)).rejects.toBeInstanceOf(HandshakeStateError) // Sanity: the unbound proof really was bound to A, not B. expect(verifyBoundProof(chA.deviceAuthProof, chB)).toBe(false) }) it('deviceAuthProof gate (INV3): forged/absent proof → HandshakeStateError, no session', async () => { const id = makeHostIdentity() const client = makeClient('h1') const ch = await client.start() const host = makeHost(id.privateKey, id.agentPubkey) await expect(host.onClientHello({ ...ch, deviceAuthProof: 'bogus' })).rejects.toBeInstanceOf( HandshakeStateError, ) expect(host.result).toBeNull() }) it('state machine: out-of-order / double calls → HandshakeStateError; phases terminal', async () => { const id = makeHostIdentity() const client = makeClient('h1') const host = makeHost(id.privateKey, id.agentPubkey) await expect(client.onHostHello({} as HostHello, id.agentPubkey)).rejects.toBeInstanceOf( HandshakeStateError, ) const ch = await client.start() await expect(client.start()).rejects.toBeInstanceOf(HandshakeStateError) await host.onClientHello(ch) await expect(host.onClientHello(ch)).rejects.toBeInstanceOf(HandshakeStateError) expect(host.phase).toBe('established') }) it('malformed input: bad HostHello shape → parse error before any crypto', async () => { const id = makeHostIdentity() const client = makeClient('h1') await client.start() await expect( client.onHostHello({ hostEphPub: 'nope' } as unknown as HostHello, id.agentPubkey), ).rejects.toBeTruthy() }) }) // Forge a signature over the honest transcript using a different key (relay MITM attempt). import { buildTranscript } from '../src/transcript.js' async function nobleForge(evilPriv: Uint8Array, hh: HostHello): Promise { // The forger cannot know the real transcript's client fields here; sign an arbitrary transcript. // What matters for the test is that the sig does NOT verify against the honest agentPubkey. const t = buildTranscript({ clientEphPub: new Uint8Array(32), clientNonce: new Uint8Array(32), aeadOffer: ['xchacha20-poly1305'], hostEphPub: hh.hostEphPub, hostNonce: hh.hostNonce, aeadChoice: hh.aeadChoice, enrollFpr: hh.enrollFpr, }) return nobleEd25519Signer(evilPriv).sign(t) }