Files
web-terminal/control-plane/test/session.test.ts
Yaojia Wang e7f3bd05f0 feat(tunnel): zero-touch tunnel enrollment — control-plane PKI, host agent, iOS, nginx isolation
Customers install one command / log in once; hardware-generated keys never leave the
device; CSRs return certs + subdomain; frpc + base-app run as durable services. No .p12,
no manual cert import. Implements the MVP fast-path of docs/PLAN_TUNNEL_AUTOMATION.md.

Control-plane / PKI (control-plane/):
- ca/x509-assembler.ts: single KMS-signed real X.509 issuance primitive (Ed25519 + P-256)
- ca/csr-ec.ts: P-256 PKCS#10 proof-of-possession (verifyCsrPoPEc) + CSR-key routing
- ca/frpclient-issue.ts, ca/device-issue.ts: P-256 frp-client + device leaf signers
- ca/rotate.ts + api/renew.ts: real-X.509 /renew + /device/:id/renew (mTLS current cert)
- registry/devices.ts: device registry + per-account cap/rate-limit
- auth/session.ts: device:enroll capability token mint/verify
- api/device-enroll.ts: POST /device/enroll (ownership-gated, deny-by-default)
- pairing/native-redeem.ts + shared gateAndConsumePairingCode; api/provision.ts native arm
- boot/native-ca.ts + main.ts: wire two P-256 CAs + issuers + routers (dev / KMS fail-fast)

Contracts: relay-contracts enroll right; relay-auth SPIFFE /device/ arm + spiffeIdFor(kind)

Host agent (agent/):
- transport/frpcToml.ts; provision/frpcBinary.ts + untar.ts (verify-download + traversal-safe extract)
- keys P-256 keygen/CSR/loadIdentity; service two-unit install + BIND_HOST loopback S-GATE
- net/loopbackLiteral.ts strict guard; health/probe.ts + transport/frpSupervise.ts; cli pair --install

iOS (ios/Packages/ClientTLS): SecureEnclaveKey + CertificateSigningRequest + DeviceEnrollmentClient
+ Keychain enroll refactor (SecKey/Security.framework end-to-end, avoids the -25300 trap)

Isolation (deploy/nginx): njs/getCertSub.js SAN parser + zone-anchored map -> 403

Verified: 758 tests green (control-plane 246, agent 267, relay-auth 133, relay-contracts 85,
iOS ClientTLS 27), all tsc clean; real nginx+njs docker 403/200/400; Swift CSR accepted by
the real control-plane verifier; frpc extract byte-identical to `tar -xO`. Cross-validation
caught + fixed 5 real defects (1 critical, 4 high). Remaining = infra (KMS, nginx deploy,
VPS frps, physical iPhone) per PROGRESS_LOG runbook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 16:11:13 +02:00

118 lines
4.8 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* A4 (session subsystem, FIX C-native-1) — the minimal device:enroll token mint/verify + the login
* stub seam. Proves: a minted token round-trips through relay-auth's REAL §4.3 verifier; a token
* lacking the 'enroll' right is rejected (403); a wrong audience is rejected (401); an expired token
* is rejected (401); and the single-tenant login stub resolves a credential → accountId (deny-by-default
* on a wrong / empty credential).
*/
import { describe, test, expect, beforeEach } from 'vitest'
import { configureVerifyKey } from 'relay-auth'
import { resetVerifyKeyForTest } from 'relay-auth/src/config/keys.js'
import { generateEd25519KeyPair } from 'relay-auth/src/crypto/ed25519.js'
import { signPaseto } from 'relay-auth/src/crypto/paseto.js'
import { randomBytes } from 'node:crypto'
import {
mintDeviceEnrollToken,
verifyDeviceEnrollToken,
loginToAccountId,
requireEnrollRight,
DeviceEnrollAuthError,
DEVICE_ENROLL_AUD,
} from '../src/auth/session.js'
const NOW = 1_700_000_000
const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'
/** 43-char base64url placeholder (satisfies the shared §4.3 verifier's `cnf.jkt` requirement). */
function jkt(): string {
return Buffer.from(randomBytes(32)).toString('base64url')
}
let signingKey: CryptoKey
beforeEach(async () => {
resetVerifyKeyForTest()
const pair = await generateEd25519KeyPair()
await configureVerifyKey(pair.publicKey)
signingKey = pair.privateKey
})
describe('A4 device:enroll session token', () => {
test('mint → verify round-trips and yields the accountId (rights:[enroll])', async () => {
const raw = await mintDeviceEnrollToken(ACCOUNT_A, { signingKey, now: NOW })
const { accountId } = await verifyDeviceEnrollToken(raw, { now: NOW + 5 })
expect(accountId).toBe(ACCOUNT_A)
})
test('a token missing the enroll right is rejected (403)', async () => {
// Mint an otherwise-valid device-enroll-aud token but with rights:['attach'] (no enroll).
const body = {
sub: ACCOUNT_A,
aud: DEVICE_ENROLL_AUD,
host: 'device-enroll',
rights: ['attach'],
iat: NOW,
exp: NOW + 600,
jti: 'jti-noenroll',
cnf: { jkt: jkt() },
}
const raw = await signPaseto(body, signingKey)
await expect(verifyDeviceEnrollToken(raw, { now: NOW + 5 })).rejects.toMatchObject({ status: 403 })
})
test('a token with the wrong audience is rejected (401)', async () => {
const raw = await mintDeviceEnrollToken(ACCOUNT_A, { signingKey, now: NOW, aud: 'not-device-enroll' })
await expect(verifyDeviceEnrollToken(raw, { now: NOW + 5 })).rejects.toMatchObject({ status: 401 })
})
test('an expired token is rejected (401)', async () => {
const raw = await mintDeviceEnrollToken(ACCOUNT_A, { signingKey, now: NOW, ttlSeconds: 60 })
await expect(verifyDeviceEnrollToken(raw, { now: NOW + 120 })).rejects.toMatchObject({ status: 401 })
})
test('a garbage token is rejected (401), uniform reject', async () => {
await expect(verifyDeviceEnrollToken('not-a-token', { now: NOW })).rejects.toBeInstanceOf(DeviceEnrollAuthError)
})
test('requireEnrollRight throws 403 without enroll, passes with it', () => {
const base = { sub: ACCOUNT_A, aud: DEVICE_ENROLL_AUD, host: 'x', iat: NOW, exp: NOW + 1, jti: 'j' }
expect(() => requireEnrollRight({ ...base, rights: ['attach'] })).toThrow(DeviceEnrollAuthError)
expect(() => requireEnrollRight({ ...base, rights: ['enroll'] })).not.toThrow()
})
test('ttl is minutes-scale (separate from the 3060s connect clamp)', async () => {
// A 10-minute token must still verify well past the 60s connect clamp horizon.
const raw = await mintDeviceEnrollToken(ACCOUNT_A, { signingKey, now: NOW })
const { accountId } = await verifyDeviceEnrollToken(raw, { now: NOW + 5 * 60 })
expect(accountId).toBe(ACCOUNT_A)
})
})
describe('A4 loginToAccountId stub seam (single-tenant MVP)', () => {
test('resolves an operator credential → accountId', () => {
const acct = loginToAccountId('op-secret', { operatorCredential: 'op-secret', accountId: ACCOUNT_A })
expect(acct).toBe(ACCOUNT_A)
})
test('rejects a wrong credential (401)', () => {
expect(() => loginToAccountId('wrong', { operatorCredential: 'op-secret', accountId: ACCOUNT_A })).toThrow(
DeviceEnrollAuthError,
)
})
test('rejects an empty credential (401)', () => {
expect(() => loginToAccountId('', { operatorCredential: 'op-secret', accountId: ACCOUNT_A })).toThrow(
DeviceEnrollAuthError,
)
})
test('supports an injected resolver seam (real login layer later)', () => {
const acct = loginToAccountId('pairing-xyz', { resolve: (c) => (c === 'pairing-xyz' ? ACCOUNT_A : null) })
expect(acct).toBe(ACCOUNT_A)
})
test('rejects when not configured (deny-by-default)', () => {
expect(() => loginToAccountId('anything', {})).toThrow(DeviceEnrollAuthError)
})
})