Files
web-terminal/relay-e2e/test/aead.test.ts
Yaojia Wang 2af57e6686 feat(relay): rendezvous-relay service — 7 packages + plans (contracts/transport/agent/control-plane/e2e/auth/web)
Multi-tenant reverse-tunnel service ("ngrok for Claude Code" with E2E): a
host-agent dials OUT to an operator-run relay; external devices reach the host
THROUGH the relay, routed by per-tenant subdomain, forwarding ciphertext only
(the relay never sees plaintext). Lets a customer reach their own self-hosted
web-terminal from anywhere with zero networking setup.

Packages — all tsc-strict + vitest green (656 tests), cross-package integration verified:
- relay-contracts: frozen shared contracts (mux frame codec, data model,
  capability token, E2E envelope, pairing) — the src/types.ts analog
- term-relay:   native WS mux + stateless data plane (subdomain routing, ciphertext forward)
- agent:        host-agent (pairing, per-host Ed25519 + mTLS dial-out, forwards to 127.0.0.1:3000)
- control-plane: accounts/hosts registry, pairing-code flow, routing table, provisioning
- relay-e2e:    browser<->agent E2E (X25519 ECDH through relay, AEAD, anti-replay, recoverable replay key)
- relay-auth:   Passkey/WebAuthn, capability tokens, per-host certs, deny-by-default tenant isolation
- relay-web:    browser login + Web Crypto E2E + client-side preview rendering

Security invariants INV1-15 enforced; cross-tenant isolation CI tripwire live
(.github/workflows/relay-tripwire.yml). Design + implementation-level plans in
docs/PLAN_RELAY_*.md and docs/EXPLORE_RELAY_SERVICE.md.

NOTE: generated autonomously per the reviewed plans. The security-critical
packages (relay-e2e, relay-auth) REQUIRE expert security audit before any real
deployment — passing tests prove self-consistency, not resistance to attackers.
Base app (src/, public/) unchanged; concurrent desktop work left uncommitted.
2026-07-02 06:10:16 +02:00

73 lines
3.0 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import type { AeadAlg } from 'relay-contracts'
import { aeadOpen, aeadSeal, importAeadKey, nonceLength, tagLength } from '../src/aead.js'
import { AeadOpenError, E2EError } from '../src/errors.js'
import { bytesToHex, hexToBytes } from './helpers.js'
import aeadVectors from './vectors/aead.json' with { type: 'json' }
const ALGS: AeadAlg[] = ['aes-256-gcm', 'xchacha20-poly1305']
describe('T2 aead', () => {
it('nonceLength / tagLength per alg (§4.4)', () => {
expect(nonceLength('aes-256-gcm')).toBe(12)
expect(nonceLength('xchacha20-poly1305')).toBe(24)
expect(tagLength('aes-256-gcm')).toBe(16)
})
it('KAT: each frozen vector seals to the expected ciphertext+tag and opens back', () => {
for (const v of aeadVectors) {
const key = importAeadKey(hexToBytes(v.key), v.alg as AeadAlg, 'c2h')
const sealed = aeadSeal(
key,
hexToBytes(v.nonce),
hexToBytes(v.plaintext),
hexToBytes(v.aad),
)
expect(bytesToHex(sealed.ciphertext)).toBe(v.ciphertext)
expect(bytesToHex(sealed.tag)).toBe(v.tag)
const opened = aeadOpen(key, hexToBytes(v.nonce), sealed.ciphertext, sealed.tag, hexToBytes(v.aad))
expect(bytesToHex(opened)).toBe(v.plaintext)
}
})
it.each(ALGS)('round-trips empty and 1 MiB plaintext (%s)', (alg) => {
const key = importAeadKey(new Uint8Array(32).fill(9), alg, 'c2h')
const nonce = new Uint8Array(nonceLength(alg)).fill(1)
const aad = new Uint8Array([7, 7])
for (const pt of [new Uint8Array(0), new Uint8Array(1024 * 1024).fill(0xa5)]) {
const { ciphertext, tag } = aeadSeal(key, nonce, pt, aad)
const opened = aeadOpen(key, nonce, ciphertext, tag, aad)
expect(opened).toEqual(pt)
}
})
it.each(ALGS)('INV13 substrate: cipher-bit / aad-byte / wrong-key tamper → AeadOpenError (%s)', (alg) => {
const key = importAeadKey(new Uint8Array(32).fill(3), alg, 'c2h')
const wrong = importAeadKey(new Uint8Array(32).fill(4), alg, 'c2h')
const nonce = new Uint8Array(nonceLength(alg)).fill(2)
const aad = new Uint8Array([1, 2, 3])
const { ciphertext, tag } = aeadSeal(key, nonce, new TextEncoder().encode('secret'), aad)
const flippedCt = ciphertext.slice()
flippedCt[0]! ^= 0x01
expect(() => aeadOpen(key, nonce, flippedCt, tag, aad)).toThrow(AeadOpenError)
const flippedAad = aad.slice()
flippedAad[0]! ^= 0x01
expect(() => aeadOpen(key, nonce, ciphertext, tag, flippedAad)).toThrow(AeadOpenError)
expect(() => aeadOpen(wrong, nonce, ciphertext, tag, aad)).toThrow(AeadOpenError)
})
it('nonce width mismatch → typed error, not silent truncation', () => {
const key = importAeadKey(new Uint8Array(32).fill(1), 'xchacha20-poly1305', 'c2h')
expect(() => aeadSeal(key, new Uint8Array(12), new Uint8Array(1), new Uint8Array(0))).toThrow(
E2EError,
)
})
it('importAeadKey rejects a non-32-byte key', () => {
expect(() => importAeadKey(new Uint8Array(16), 'aes-256-gcm', '')).toThrow(E2EError)
})
})