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.
73 lines
3.0 KiB
TypeScript
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)
|
|
})
|
|
})
|