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>
This commit is contained in:
372
control-plane/test/integration-native.test.ts
Normal file
372
control-plane/test/integration-native.test.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* Native-tunnel wiring E2E (fastify inject against the WIRED control-plane app from `main.ts`).
|
||||
*
|
||||
* Proves the full DEVICE path through the real composition root:
|
||||
* mint a device:enroll token (auth/session, verified by the REAL §4.3 verifier the boot wires) →
|
||||
* POST /device/enroll {csr:<real P-256 CSR>, subdomain:<owned>} → 201 with a cert that RE-PARSES,
|
||||
* CHAINS to the wired dev device-CA, and carries dNSName <sub>.terminal.yaojia.wang → present that
|
||||
* cert to POST /device/:id/renew → 201 fresh cert for the SAME subdomain.
|
||||
* Plus the A4 ownership gate end-to-end: enroll for an UNOWNED (and a foreign-owned) subdomain → 403.
|
||||
*
|
||||
* Also proves the HOST /renew path through the wired app: the initial frp-client leaf is minted via
|
||||
* the WIRED host signer (native host HTTP /enroll is a documented follow-up — the existing pairing
|
||||
* `/enroll` relay route is left untouched), then presented to /renew → 201 for the SAME subdomain.
|
||||
*
|
||||
* The verifier is the SAME seam the admin API uses (`boot/verifier.ts`), keyed off the boot env's
|
||||
* capability pubkey — so the enroll token is minted with the matching private key and travels the
|
||||
* exact verify path production uses.
|
||||
*/
|
||||
import 'reflect-metadata'
|
||||
import { describe, test, expect, beforeEach } from 'vitest'
|
||||
import * as x509 from '@peculiar/x509'
|
||||
import { webcrypto } from 'node:crypto'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { generateEd25519KeyPair, exportEd25519PublicRaw } from 'relay-auth/src/crypto/ed25519.js'
|
||||
import { createMemoryStores } from '../src/store/memory.js'
|
||||
import { createHostRegistry } from '../src/registry/hosts.js'
|
||||
import { createPairingIssuer } from '../src/pairing/issue.js'
|
||||
import { fingerprint } from '../src/ca/fingerprint.js'
|
||||
import { generateEd25519 } from '../src/util/crypto.js'
|
||||
import { buildCsr } from '../src/ca/csr.js'
|
||||
import { buildCsrEc } from '../src/ca/csr-ec.js'
|
||||
import { buildControlPlane, type BuiltControlPlane } from '../src/main.js'
|
||||
import { createCapabilityVerifier } from '../src/boot/verifier.js'
|
||||
import { mintDeviceEnrollToken } from '../src/auth/session.js'
|
||||
import { loadEnv, type ControlPlaneEnv } from '../src/env.js'
|
||||
import { bytesToBase64 } from '../src/util/bytes.js'
|
||||
import type { Stores } from '../src/store/ports.js'
|
||||
|
||||
x509.cryptoProvider.set(webcrypto)
|
||||
|
||||
const ACCOUNT_A = '11111111-1111-4111-8111-111111111111'
|
||||
const ACCOUNT_B = '22222222-2222-4222-8222-222222222222'
|
||||
const DNS_ZONE = 'terminal.yaojia.wang'
|
||||
|
||||
let signingKey: CryptoKey
|
||||
let env: ControlPlaneEnv
|
||||
|
||||
beforeEach(async () => {
|
||||
// Ed25519 capability keypair: the raw public key becomes the boot env's verify key, so the
|
||||
// device:enroll token minted with the private key verifies through the wired real §4.3 verifier.
|
||||
const pair = await generateEd25519KeyPair()
|
||||
signingKey = pair.privateKey
|
||||
const rawPub = await exportEd25519PublicRaw(pair.publicKey)
|
||||
env = loadEnv({
|
||||
PG_URL: 'postgres://u:p@localhost:5432/cp',
|
||||
REDIS_URL: 'redis://localhost:6379',
|
||||
CAPABILITY_SIGN_PUBKEY_B64: bytesToBase64(rawPub),
|
||||
CA_INTERMEDIATE_KMS_KEY_REF: 'kms://key/intermediate',
|
||||
CA_INTERMEDIATE_CERT_PATH: '/etc/cp/int.pem',
|
||||
NODE_MTLS_TRUST_BUNDLE_PATH: '/etc/cp/node-ca.pem',
|
||||
RELAY_TRUST_DOMAIN: DNS_ZONE,
|
||||
BASE_DOMAIN: 'term.example.com',
|
||||
})
|
||||
})
|
||||
|
||||
async function buildApp(): Promise<{ app: FastifyInstance; stores: Stores; built: BuiltControlPlane }> {
|
||||
const stores = createMemoryStores()
|
||||
const built = await buildControlPlane(env, { stores, verifier: createCapabilityVerifier() })
|
||||
await built.app.ready()
|
||||
return { app: built.app, stores, built }
|
||||
}
|
||||
|
||||
/** Onboard a host so `accountId` OWNS `subdomain` in the ownership source of truth (registry). */
|
||||
async function ownSubdomain(stores: Stores, accountId: string, subdomain: string): Promise<void> {
|
||||
const hosts = createHostRegistry({ hosts: stores.hosts })
|
||||
const { publicKeyRaw } = generateEd25519()
|
||||
await hosts.bindHost({ accountId, subdomain, agentPubkey: publicKeyRaw, enrollFpr: fingerprint(publicKeyRaw) })
|
||||
}
|
||||
|
||||
/** Mint a single-use pairing code bound to `accountId` (the enroll gate reads accountId from the row). */
|
||||
async function mintPairingCode(stores: Stores, accountId: string): Promise<string> {
|
||||
const issuer = createPairingIssuer({ pairing: stores.pairing, pairingTtlSec: 3600 })
|
||||
const { code } = await issuer.issuePairingCode(accountId)
|
||||
return code
|
||||
}
|
||||
|
||||
/** A fresh P-256 host identity: its real PKCS#10 CSR + the SPKI DER the agent presents as agentPubkey. */
|
||||
async function ecHostIdentity(subject = 'CN=web-terminal-host'): Promise<{ csr: Uint8Array; spki: Uint8Array }> {
|
||||
const { der: csr, keys } = await buildCsrEc(subject)
|
||||
const spki = new Uint8Array(await webcrypto.subtle.exportKey('spki', keys.publicKey))
|
||||
return { csr, spki }
|
||||
}
|
||||
|
||||
/** Bind a host whose frp-client identity is a P-256 key (so the wired host signer can issue for it). */
|
||||
async function bindP256Host(
|
||||
stores: Stores,
|
||||
accountId: string,
|
||||
subdomain: string,
|
||||
): Promise<{ hostId: string; csr: Uint8Array; spki: Uint8Array }> {
|
||||
const hosts = createHostRegistry({ hosts: stores.hosts })
|
||||
const { der: csr, keys } = await buildCsrEc(`CN=${subdomain}`)
|
||||
const spki = new Uint8Array(await webcrypto.subtle.exportKey('spki', keys.publicKey))
|
||||
const host = await hosts.bindHost({ accountId, subdomain, agentPubkey: spki, enrollFpr: fingerprint(spki) })
|
||||
return { hostId: host.hostId, csr, spki }
|
||||
}
|
||||
|
||||
function b64(bytes: Uint8Array): string {
|
||||
return Buffer.from(bytes).toString('base64')
|
||||
}
|
||||
|
||||
function sanNames(der: Uint8Array): ReturnType<x509.GeneralNames['toJSON']> {
|
||||
const leaf = new x509.X509Certificate(der)
|
||||
return leaf.getExtension(x509.SubjectAlternativeNameExtension)!.names.toJSON()
|
||||
}
|
||||
|
||||
describe('native-tunnel wiring — DEVICE e2e (enroll → renew)', () => {
|
||||
test('enroll an owned subdomain → 201 real leaf chaining to the dev device-CA → renew → 201 fresh cert, same subdomain', async () => {
|
||||
const { app, stores, built } = await buildApp()
|
||||
await ownSubdomain(stores, ACCOUNT_A, 'alice')
|
||||
const token = await mintDeviceEnrollToken(ACCOUNT_A, { signingKey })
|
||||
const { der: csr } = await buildCsrEc('CN=web-terminal-device')
|
||||
|
||||
// ── ENROLL ───────────────────────────────────────────────────────────────────────────────────
|
||||
const enroll = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/device/enroll',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { csr: b64(csr), keyAlg: 'ec-p256', subdomain: 'alice', deviceName: 'iphone' },
|
||||
})
|
||||
expect(enroll.statusCode).toBe(201)
|
||||
const enrolled = JSON.parse(enroll.body)
|
||||
expect(typeof enrolled.deviceId).toBe('string')
|
||||
expect(enrolled.deviceId.length).toBeGreaterThan(0)
|
||||
|
||||
// the returned cert re-parses as a real X.509 leaf …
|
||||
const leafDer = new Uint8Array(Buffer.from(enrolled.cert, 'base64'))
|
||||
const leaf = new x509.X509Certificate(leafDer)
|
||||
// … chains to the CA the app returned …
|
||||
const caDer = new Uint8Array(Buffer.from(enrolled.caChain[0], 'base64'))
|
||||
const caCert = new x509.X509Certificate(caDer)
|
||||
expect(await leaf.verify({ publicKey: caCert.publicKey, signatureOnly: true })).toBe(true)
|
||||
// … which IS the wired dev device-CA anchor …
|
||||
expect(b64(caDer)).toBe(b64(built.nativeTunnel.nativeCas.deviceCa.caCertDer))
|
||||
// … and carries the tenant-isolation dNSName SAN.
|
||||
expect(sanNames(leafDer)).toContainEqual({ type: 'dns', value: `alice.${DNS_ZONE}` })
|
||||
|
||||
// ── RENEW (present the enroll-issued cert as the current mTLS client cert) ──────────────────────
|
||||
const renew = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/device/${enrolled.deviceId}/renew`,
|
||||
headers: { 'x-client-cert': b64(leafDer) },
|
||||
payload: { csr: b64(csr) }, // same-key CSR
|
||||
})
|
||||
expect(renew.statusCode).toBe(201)
|
||||
const renewed = JSON.parse(renew.body)
|
||||
const renewedDer = new Uint8Array(Buffer.from(renewed.cert, 'base64'))
|
||||
expect(() => new x509.X509Certificate(renewedDer)).not.toThrow()
|
||||
// same subdomain — the renew stamps the registry scope, never client input.
|
||||
expect(sanNames(renewedDer)).toContainEqual({ type: 'dns', value: `alice.${DNS_ZONE}` })
|
||||
// and the fresh cert still chains to the device-CA anchor.
|
||||
expect(await new x509.X509Certificate(renewedDer).verify({ publicKey: caCert.publicKey, signatureOnly: true })).toBe(true)
|
||||
})
|
||||
|
||||
test('enroll for an UNOWNED subdomain → 403, no cert issued (A4 ownership gate, end-to-end)', async () => {
|
||||
const { app, stores } = await buildApp()
|
||||
await ownSubdomain(stores, ACCOUNT_A, 'alice')
|
||||
const token = await mintDeviceEnrollToken(ACCOUNT_A, { signingKey })
|
||||
const { der: csr } = await buildCsrEc('CN=web-terminal-device')
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/device/enroll',
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { csr: b64(csr), keyAlg: 'ec-p256', subdomain: 'charlie', deviceName: 'x' },
|
||||
})
|
||||
expect(res.statusCode).toBe(403)
|
||||
expect(JSON.parse(res.body).cert).toBeUndefined()
|
||||
})
|
||||
|
||||
test('account B enrolling account A\'s subdomain → 403 (cross-tenant, no cert)', async () => {
|
||||
const { app, stores } = await buildApp()
|
||||
await ownSubdomain(stores, ACCOUNT_A, 'alice')
|
||||
const tokenB = await mintDeviceEnrollToken(ACCOUNT_B, { signingKey })
|
||||
const { der: csr } = await buildCsrEc('CN=web-terminal-device')
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/device/enroll',
|
||||
headers: { authorization: `Bearer ${tokenB}` },
|
||||
payload: { csr: b64(csr), keyAlg: 'ec-p256', subdomain: 'alice', deviceName: 'x' },
|
||||
})
|
||||
expect(res.statusCode).toBe(403)
|
||||
expect(JSON.parse(res.body).cert).toBeUndefined()
|
||||
})
|
||||
|
||||
test('missing enroll token → 401 (deny-by-default through the wired verifier seam)', async () => {
|
||||
const { app, stores } = await buildApp()
|
||||
await ownSubdomain(stores, ACCOUNT_A, 'alice')
|
||||
const { der: csr } = await buildCsrEc('CN=web-terminal-device')
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/device/enroll',
|
||||
payload: { csr: b64(csr), keyAlg: 'ec-p256', subdomain: 'alice', deviceName: 'x' },
|
||||
})
|
||||
expect(res.statusCode).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('native-tunnel wiring — HOST /renew e2e', () => {
|
||||
test('initial frp-client leaf (wired host signer) → /renew → 201 fresh cert, SAME subdomain, chains to frp-client-CA', async () => {
|
||||
const { app, stores, built } = await buildApp()
|
||||
const { hostId, csr, spki } = await bindP256Host(stores, ACCOUNT_A, 'alice')
|
||||
// Initial leaf, minted by the WIRED host signer (chains to the wired frp-client-CA anchor).
|
||||
const { cert: currentCert } = await built.nativeTunnel.hostSigner.signHostLeaf(hostId, spki, csr)
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/renew',
|
||||
headers: { 'x-client-cert': b64(currentCert) },
|
||||
payload: { csr: b64(csr) },
|
||||
})
|
||||
expect(res.statusCode).toBe(201)
|
||||
const renewedDer = new Uint8Array(Buffer.from(JSON.parse(res.body).cert, 'base64'))
|
||||
expect(sanNames(renewedDer)).toContainEqual({ type: 'dns', value: `alice.${DNS_ZONE}` })
|
||||
const caCert = new x509.X509Certificate(built.nativeTunnel.nativeCas.frpClientCa.caCertDer)
|
||||
expect(await new x509.X509Certificate(renewedDer).verify({ publicKey: caCert.publicKey, signatureOnly: true })).toBe(true)
|
||||
})
|
||||
|
||||
test('a current cert from an UNTRUSTED (different-instance) frp-client-CA is rejected by the wired anchor set → 401', async () => {
|
||||
const { app } = await buildApp()
|
||||
// A second, independent wired control-plane = a DIFFERENT (untrusted) frp-client-CA. Bind the same
|
||||
// host+identity there and mint a leaf under ITS CA, then present it to the REAL app's /renew.
|
||||
const rogueStores = createMemoryStores()
|
||||
const rogue = await buildControlPlane(env, { stores: rogueStores, verifier: createCapabilityVerifier() })
|
||||
const rogueBound = await bindP256Host(rogueStores, ACCOUNT_A, 'alice')
|
||||
const { cert: rogueCert } = await rogue.nativeTunnel.hostSigner.signHostLeaf(
|
||||
rogueBound.hostId,
|
||||
rogueBound.spki,
|
||||
rogueBound.csr,
|
||||
)
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/renew',
|
||||
headers: { 'x-client-cert': b64(rogueCert) },
|
||||
payload: { csr: b64(rogueBound.csr) },
|
||||
})
|
||||
expect(res.statusCode).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('native-tunnel wiring — HOST /enroll HTTP arm (P-256 frp-client leaf)', () => {
|
||||
test('full host onboard: mint code → POST /enroll (EC CSR) → 201 frp-client leaf (chains to frp-client-CA + dNSName SAN, hostContentSecret null) → /renew → 201 SAME subdomain', async () => {
|
||||
const { app, stores, built } = await buildApp()
|
||||
const code = await mintPairingCode(stores, ACCOUNT_A)
|
||||
const { csr, spki } = await ecHostIdentity()
|
||||
|
||||
// ── ENROLL (native P-256 arm — routed by CSR key algorithm) ────────────────────────────────────
|
||||
const enroll = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/enroll',
|
||||
payload: { code, machineId: 'MB-A1B2C3', agentPubkey: b64(spki), csr: b64(csr) },
|
||||
})
|
||||
expect(enroll.statusCode).toBe(201)
|
||||
const body = JSON.parse(enroll.body)
|
||||
expect(typeof body.hostId).toBe('string')
|
||||
expect(body.hostId.length).toBeGreaterThan(0)
|
||||
expect(typeof body.subdomain).toBe('string')
|
||||
expect(body.subdomain.length).toBeGreaterThan(0)
|
||||
// native tunnel has no E2E-relay content secret (L-host-hcs).
|
||||
expect(body.hostContentSecret).toBeNull()
|
||||
|
||||
// the returned cert re-parses as a real X.509 leaf …
|
||||
const leafDer = new Uint8Array(Buffer.from(body.cert, 'base64'))
|
||||
const leaf = new x509.X509Certificate(leafDer)
|
||||
// … chains to the wired frp-client-CA anchor …
|
||||
const caCert = new x509.X509Certificate(built.nativeTunnel.nativeCas.frpClientCa.caCertDer)
|
||||
expect(await leaf.verify({ publicKey: caCert.publicKey, signatureOnly: true })).toBe(true)
|
||||
const caDer = new Uint8Array(Buffer.from(body.caChain[0], 'base64'))
|
||||
expect(b64(caDer)).toBe(b64(built.nativeTunnel.nativeCas.frpClientCa.caCertDer))
|
||||
// … and carries the AUTHORITATIVE (server-assigned) dNSName SAN <sub>.<zone>.
|
||||
expect(sanNames(leafDer)).toContainEqual({ type: 'dns', value: `${body.subdomain}.${DNS_ZONE}` })
|
||||
|
||||
// the host binding persisted the SAME authoritative subdomain (not client input).
|
||||
const host = await stores.hosts.get(body.hostId)
|
||||
expect(host?.subdomain).toBe(body.subdomain)
|
||||
expect(host?.accountId).toBe(ACCOUNT_A)
|
||||
|
||||
// ── RENEW (present the enroll-issued cert as the current mTLS client cert) ──────────────────────
|
||||
const renew = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/renew',
|
||||
headers: { 'x-client-cert': b64(leafDer) },
|
||||
payload: { csr: b64(csr) }, // same-key CSR
|
||||
})
|
||||
expect(renew.statusCode).toBe(201)
|
||||
const renewedDer = new Uint8Array(Buffer.from(JSON.parse(renew.body).cert, 'base64'))
|
||||
// the renew stamps the registry scope — SAME subdomain, never client input.
|
||||
expect(sanNames(renewedDer)).toContainEqual({ type: 'dns', value: `${body.subdomain}.${DNS_ZONE}` })
|
||||
expect(await new x509.X509Certificate(renewedDer).verify({ publicKey: caCert.publicKey, signatureOnly: true })).toBe(true)
|
||||
})
|
||||
|
||||
test('anti-smuggling: a client body requesting a DIFFERENT subdomain does NOT change the issued SAN', async () => {
|
||||
const { app, stores } = await buildApp()
|
||||
const code = await mintPairingCode(stores, ACCOUNT_A)
|
||||
const { csr, spki } = await ecHostIdentity()
|
||||
|
||||
const enroll = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/enroll',
|
||||
// the attacker tries to steer the SAN to a subdomain it was never assigned:
|
||||
payload: { code, machineId: 'x', agentPubkey: b64(spki), csr: b64(csr), subdomain: 'victim' },
|
||||
})
|
||||
expect(enroll.statusCode).toBe(201)
|
||||
const body = JSON.parse(enroll.body)
|
||||
// the SAN is the SERVER-assigned subdomain, never the client-supplied 'victim'.
|
||||
expect(body.subdomain).not.toBe('victim')
|
||||
const leafDer = new Uint8Array(Buffer.from(body.cert, 'base64'))
|
||||
expect(sanNames(leafDer)).toContainEqual({ type: 'dns', value: `${body.subdomain}.${DNS_ZONE}` })
|
||||
expect(sanNames(leafDer)).not.toContainEqual({ type: 'dns', value: `victim.${DNS_ZONE}` })
|
||||
// and the host registry bound the server-assigned subdomain, not 'victim'.
|
||||
const host = await stores.hosts.get(body.hostId)
|
||||
expect(host?.subdomain).toBe(body.subdomain)
|
||||
expect(host?.subdomain).not.toBe('victim')
|
||||
})
|
||||
|
||||
test('single-use: replaying an already-redeemed pairing code → NOT 201 (double-spend guard)', async () => {
|
||||
const { app, stores } = await buildApp()
|
||||
const code = await mintPairingCode(stores, ACCOUNT_A)
|
||||
const first = await ecHostIdentity('CN=host-1')
|
||||
const ok = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/enroll',
|
||||
payload: { code, machineId: 'a', agentPubkey: b64(first.spki), csr: b64(first.csr) },
|
||||
})
|
||||
expect(ok.statusCode).toBe(201)
|
||||
|
||||
const second = await ecHostIdentity('CN=host-2')
|
||||
const replay = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/enroll',
|
||||
payload: { code, machineId: 'a', agentPubkey: b64(second.spki), csr: b64(second.csr) },
|
||||
})
|
||||
expect(replay.statusCode).not.toBe(201)
|
||||
})
|
||||
|
||||
test('no-substitution: agentPubkey that does not match the CSR key → NOT 201', async () => {
|
||||
const { app, stores } = await buildApp()
|
||||
const code = await mintPairingCode(stores, ACCOUNT_A)
|
||||
const { csr } = await ecHostIdentity('CN=host-real')
|
||||
const other = await ecHostIdentity('CN=host-other')
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/enroll',
|
||||
// present a DIFFERENT key than the CSR embeds — the gate's no-substitution check must reject.
|
||||
payload: { code, machineId: 'a', agentPubkey: b64(other.spki), csr: b64(csr) },
|
||||
})
|
||||
expect(res.statusCode).not.toBe(201)
|
||||
})
|
||||
|
||||
test('non-regression: an Ed25519 relay /enroll still issues the PEM relay leaf + wrapped secret (native arm does not hijack it)', async () => {
|
||||
const { app, stores } = await buildApp()
|
||||
const code = await mintPairingCode(stores, ACCOUNT_A)
|
||||
const { publicKeyRaw, privateKey } = generateEd25519()
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/enroll',
|
||||
payload: { code, agentPubkey: bytesToBase64(publicKeyRaw), csr: bytesToBase64(buildCsr(privateKey, publicKeyRaw)) },
|
||||
})
|
||||
expect(res.statusCode).toBe(201)
|
||||
const body = JSON.parse(res.body)
|
||||
expect(body.cert).toContain('BEGIN CERTIFICATE') // PEM relay leaf, unchanged
|
||||
expect(typeof body.hostContentSecret).toBe('string') // base64url wrapped secret, NOT null
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user