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>
201 lines
8.0 KiB
TypeScript
201 lines
8.0 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest'
|
|
import { issueCapabilityToken } from '../src/capability/issue.js'
|
|
import {
|
|
verifyCapabilityToken,
|
|
verifyDpopProof,
|
|
buildDpopProof,
|
|
readCnfJkt,
|
|
subAccountId,
|
|
hasRight,
|
|
resetDpopCacheForTest,
|
|
} from '../src/capability/verify.js'
|
|
import { jwkThumbprint } from '../src/crypto/thumbprint.js'
|
|
import { CapabilityError } from '../src/capability/errors.js'
|
|
import { encodeBase64UrlBytes } from 'relay-contracts'
|
|
import { setupP5SigningKey, makeEphemeral, principal, uuid } from './_helpers.js'
|
|
|
|
const NOW = 1_700_000_000
|
|
const AUD = 'alice.term.example.com'
|
|
|
|
async function issueFor(
|
|
signingKey: CryptoKey,
|
|
opts: { accountId?: string; host?: string; rights?: readonly ('attach' | 'manage' | 'kill')[]; ttl?: number; cnfJkt?: string },
|
|
) {
|
|
const cnfJkt = opts.cnfJkt ?? (await jwkThumbprint((await makeEphemeral()).publicRaw))
|
|
return issueCapabilityToken(
|
|
{
|
|
principal: principal(opts.accountId ?? 'acct-A'),
|
|
aud: AUD,
|
|
host: opts.host ?? uuid(),
|
|
rights: opts.rights ?? ['attach'],
|
|
ttlSeconds: opts.ttl ?? 45,
|
|
cnfJkt,
|
|
},
|
|
signingKey,
|
|
NOW,
|
|
)
|
|
}
|
|
|
|
describe('capability token (§4.3)', () => {
|
|
let signingKey: CryptoKey
|
|
beforeEach(async () => {
|
|
;({ signingKey } = await setupP5SigningKey())
|
|
resetDpopCacheForTest()
|
|
})
|
|
|
|
it('round-trips issue → verify preserving host/rights/jti and sub===accountId', async () => {
|
|
const host = uuid()
|
|
const raw = await issueFor(signingKey, { accountId: 'acct-A', host, rights: ['attach', 'manage'] })
|
|
const tok = await verifyCapabilityToken(raw, AUD, NOW + 1)
|
|
expect(tok.host).toBe(host)
|
|
expect(tok.rights).toEqual(['attach', 'manage'])
|
|
expect(tok.jti.length).toBeGreaterThan(0)
|
|
expect(subAccountId(tok)).toBe('acct-A')
|
|
})
|
|
|
|
it('sets sub to principal.accountId, never a client value', async () => {
|
|
const raw = await issueFor(signingKey, { accountId: 'acct-A' })
|
|
const tok = await verifyCapabilityToken(raw, AUD, NOW)
|
|
expect(tok.sub).toBe('acct-A')
|
|
})
|
|
|
|
it('issues and verifies the new enroll right — issue.ts consumes it via CapabilityRightSchema (FIX C-native-1)', async () => {
|
|
const cnfJkt = await jwkThumbprint((await makeEphemeral()).publicRaw)
|
|
const raw = await issueCapabilityToken(
|
|
{
|
|
principal: principal('acct-A'),
|
|
aud: AUD,
|
|
host: uuid(),
|
|
rights: ['enroll'],
|
|
ttlSeconds: 45,
|
|
cnfJkt,
|
|
},
|
|
signingKey,
|
|
NOW,
|
|
)
|
|
const tok = await verifyCapabilityToken(raw, AUD, NOW + 1)
|
|
expect(tok.rights).toEqual(['enroll'])
|
|
expect(hasRight(tok, 'enroll')).toBe(true)
|
|
})
|
|
|
|
it('rejects an expired token', async () => {
|
|
const raw = await issueFor(signingKey, { ttl: 30 })
|
|
await expect(verifyCapabilityToken(raw, AUD, NOW + 31)).rejects.toMatchObject({ reason: 'expired' })
|
|
})
|
|
|
|
it('rejects a not-yet-valid token (iat in the future beyond skew)', async () => {
|
|
const raw = await issueFor(signingKey, {})
|
|
await expect(verifyCapabilityToken(raw, AUD, NOW - 100)).rejects.toMatchObject({
|
|
reason: 'not_yet_valid',
|
|
})
|
|
})
|
|
|
|
it('refuses an over-long TTL at issue (no long-lived reusable token)', async () => {
|
|
await expect(issueFor(signingKey, { ttl: 61 })).rejects.toMatchObject({ reason: 'ttl_too_long' })
|
|
})
|
|
|
|
it('clamps a too-short TTL up to the 30s floor', async () => {
|
|
const raw = await issueFor(signingKey, { ttl: 5 })
|
|
const tok = await verifyCapabilityToken(raw, AUD, NOW)
|
|
expect(tok.exp - tok.iat).toBe(30)
|
|
})
|
|
|
|
it('rejects a wildcard host at issue', async () => {
|
|
await expect(issueFor(signingKey, { host: '*' })).rejects.toMatchObject({ reason: 'wildcard_host' })
|
|
})
|
|
|
|
it('rejects wrong aud (Host-confusion, INV1)', async () => {
|
|
const raw = await issueFor(signingKey, {})
|
|
await expect(verifyCapabilityToken(raw, 'bob.term.example.com', NOW)).rejects.toMatchObject({
|
|
reason: 'aud_mismatch',
|
|
})
|
|
})
|
|
|
|
it('rejects a tampered payload (signature fails)', async () => {
|
|
const raw = await issueFor(signingKey, {})
|
|
const tampered = raw.slice(0, -4) + (raw.endsWith('AAAA') ? 'BBBB' : 'AAAA')
|
|
await expect(verifyCapabilityToken(tampered, AUD, NOW)).rejects.toBeInstanceOf(CapabilityError)
|
|
})
|
|
|
|
it('rejects a token signed by a different key', async () => {
|
|
const other = await setupP5SigningKey() // reconfigures verify key to a DIFFERENT pair
|
|
const raw = await issueFor(other.signingKey, {})
|
|
// reconfigure back to the original key so the verifier uses the wrong public key
|
|
await setupP5SigningKey()
|
|
await expect(verifyCapabilityToken(raw, AUD, NOW)).rejects.toMatchObject({ reason: 'bad_signature' })
|
|
})
|
|
|
|
it('enforces least-privilege rights (INV15)', async () => {
|
|
const raw = await issueFor(signingKey, { rights: ['attach'] })
|
|
const tok = await verifyCapabilityToken(raw, AUD, NOW)
|
|
expect(hasRight(tok, 'attach')).toBe(true)
|
|
expect(hasRight(tok, 'kill')).toBe(false)
|
|
})
|
|
|
|
describe('DPoP proof-of-possession', () => {
|
|
it('accepts a proof from the bound ephemeral key and rejects a different key', async () => {
|
|
const eph = await makeEphemeral()
|
|
const cnfJkt = await jwkThumbprint(eph.publicRaw)
|
|
const raw = await issueFor(signingKey, { cnfJkt })
|
|
const tok = await verifyCapabilityToken(raw, AUD, NOW)
|
|
expect(readCnfJkt(tok)).toBe(cnfJkt)
|
|
|
|
const htu = 'https://alice.term.example.com/ws'
|
|
const good = await buildDpopProof(eph.privateKey, eph.publicRaw, {
|
|
htu,
|
|
htm: 'GET',
|
|
jti: uuid(),
|
|
iat: NOW,
|
|
})
|
|
expect(await verifyDpopProof(tok, { proofJws: good, htu, htm: 'GET' }, NOW)).toBe(true)
|
|
|
|
const wrong = await makeEphemeral()
|
|
const bad = await buildDpopProof(wrong.privateKey, wrong.publicRaw, {
|
|
htu,
|
|
htm: 'GET',
|
|
jti: uuid(),
|
|
iat: NOW,
|
|
})
|
|
expect(await verifyDpopProof(tok, { proofJws: bad, htu, htm: 'GET' }, NOW)).toBe(false)
|
|
})
|
|
|
|
it('rejects a replayed DPoP proof (same jti reused)', async () => {
|
|
const eph = await makeEphemeral()
|
|
const cnfJkt = await jwkThumbprint(eph.publicRaw)
|
|
const raw = await issueFor(signingKey, { cnfJkt })
|
|
const tok = await verifyCapabilityToken(raw, AUD, NOW)
|
|
const htu = 'https://alice.term.example.com/ws'
|
|
const jti = uuid()
|
|
const proof = await buildDpopProof(eph.privateKey, eph.publicRaw, { htu, htm: 'GET', jti, iat: NOW })
|
|
expect(await verifyDpopProof(tok, { proofJws: proof, htu, htm: 'GET' }, NOW)).toBe(true)
|
|
expect(await verifyDpopProof(tok, { proofJws: proof, htu, htm: 'GET' }, NOW)).toBe(false)
|
|
})
|
|
|
|
// F4: a proof whose jwk.x decodes to a NON-32-byte blob makes importEd25519PublicRaw throw.
|
|
// verifyDpopProof must be TOTALLY fail-safe and RESOLVE to false, never reject.
|
|
it('resolves false (never throws) when the proof key is not a valid 32-byte Ed25519 key', async () => {
|
|
// A 16-byte "key" — importEd25519PublicRaw will reject this raw length.
|
|
const shortBlob = new Uint8Array(16).fill(7)
|
|
// cnf.jkt is computed over the SAME 16-byte blob so the thumbprint check passes and
|
|
// execution reaches the risky importEd25519PublicRaw call.
|
|
const cnfJkt = await jwkThumbprint(shortBlob)
|
|
const raw = await issueFor(signingKey, { cnfJkt })
|
|
const tok = await verifyCapabilityToken(raw, AUD, NOW)
|
|
|
|
const htu = 'https://alice.term.example.com/ws'
|
|
const enc = (o: unknown) => encodeBase64UrlBytes(new TextEncoder().encode(JSON.stringify(o)))
|
|
const h = enc({ typ: 'dpop+ed25519', jwk: { crv: 'Ed25519', kty: 'OKP', x: encodeBase64UrlBytes(shortBlob) } })
|
|
const p = enc({ htu, htm: 'GET', jti: uuid(), iat: NOW })
|
|
const s = encodeBase64UrlBytes(new Uint8Array(64)) // any signature bytes
|
|
const proofJws = `${h}.${p}.${s}`
|
|
|
|
const verify = verifyDpopProof(tok, { proofJws, htu, htm: 'GET' }, NOW)
|
|
await expect(verify).resolves.toBe(false)
|
|
})
|
|
})
|
|
|
|
it('rejects a malformed cnf.jkt at issue (not a 43-char base64url thumbprint)', async () => {
|
|
await expect(issueFor(signingKey, { cnfJkt: 'short' })).rejects.toMatchObject({ reason: 'bad_cnf' })
|
|
})
|
|
})
|