/** * §4.5 pairing-code redemption (agent side) — PLAN_RELAY_AGENT T4. * * REDEEM: POST /enroll { code, agentPubkey, csr } (raw code NEVER persisted — INV5). * RETURN: the FROZEN EnrollResult (imported from relay-contracts, never redeclared), validated * with EnrollResultSchema at the boundary (untrusted external data). On success the FIX 3 * `hostContentSecret` (wrapped to this agent's Ed25519 identity by P3) is UNWRAPPED in-process * and persisted 0600 via the keystore; the WRAPPED bytes are never stored, the unwrapped secret * is never logged/re-sent (INV5/INV9). * * Only the PUBLIC key + CSR leave the host (INV4). */ import { EnrollResultSchema, decodeBase64UrlBytes, encodeBase64UrlBytes } from 'relay-contracts' import type { EnrollResult } from 'relay-contracts' import type { AgentIdentity } from '../keys/identity.js' import type { Keystore } from '../keys/keystore.js' import { buildCsr } from './csr.js' import { derBase64ToPem } from '../certs/pem.js' /** v0.8 shared-token gate vs v0.9+ per-host Ed25519. Default is `'ed25519'` from v0.9. */ export type EnrollMode = 'token' | 'ed25519' export const DEFAULT_ENROLL_MODE: EnrollMode = 'ed25519' export class EnrollError extends Error { constructor(message: string) { super(message) this.name = 'EnrollError' } } export class PairingCodeSpentError extends EnrollError { constructor() { super('pairing code already redeemed (single-use)') this.name = 'PairingCodeSpentError' } } export class PairingCodeExpiredError extends EnrollError { constructor() { super('pairing code expired') this.name = 'PairingCodeExpiredError' } } /** * Unwrap the P3-wrapped `hostContentSecret` using the agent's enrollment identity (in-process). * P3's exact wrap scheme is not yet frozen (cross-plan integration point); this seam is injected * so the real unwrap drops in without touching the redeem flow. Default = passthrough. */ export type UnwrapContentSecret = (wrapped: Uint8Array, id: AgentIdentity) => Uint8Array const passthroughUnwrap: UnwrapContentSecret = (wrapped) => wrapped export interface RedeemOptions { readonly fetchImpl?: typeof fetch readonly mode?: EnrollMode readonly agentToken?: string readonly unwrapContentSecret?: UnwrapContentSecret readonly subject?: string /** * Native frp-client enroll has NO E2E content secret — the control-plane returns * `hostContentSecret: null`. When true, tolerate its absence (skip unwrap + storage); the plain * frpc byte tunnel needs no content key. Defaults false so the legacy relay path still requires it. */ readonly allowMissingContentSecret?: boolean } interface EnrollResponseJson { hostId: string subdomain: string cert: string caChain: string hostContentSecret: string // base64url over the wire } function parseEnrollResult(json: unknown, allowMissingContentSecret = false): EnrollResult { const j = json as Partial if (typeof j.hostContentSecret !== 'string') { if (!allowMissingContentSecret) { throw new EnrollError('enroll response missing hostContentSecret') } // Native frp-client enroll: cert = base64(DER) string, caChain = base64(DER) string[], no content // key. The keystore + frpc need PEM, so convert here. Empty secret sentinel is never stored. const caChain: unknown = j.caChain if ( typeof j.hostId !== 'string' || typeof j.subdomain !== 'string' || typeof j.cert !== 'string' || !Array.isArray(caChain) || caChain.length === 0 || !caChain.every((c) => typeof c === 'string') ) { throw new EnrollError('enroll response missing required fields') } return { hostId: j.hostId, subdomain: j.subdomain, cert: derBase64ToPem(j.cert), caChain: (caChain as string[]).map((c) => derBase64ToPem(c)).join(''), hostContentSecret: new Uint8Array(0), } } const candidate = { hostId: j.hostId, subdomain: j.subdomain, cert: j.cert, caChain: j.caChain, hostContentSecret: decodeBase64UrlBytes(j.hostContentSecret), } const result = EnrollResultSchema.safeParse(candidate) if (!result.success) { throw new EnrollError(`enroll response failed schema validation: ${result.error.message}`) } return result.data } /** * Redeem `code` at `enrollUrl`. Sends only pubkey + CSR (INV4); never persists the raw code * (INV5). Stores the returned cert + CA chain and the unwrapped hostContentSecret 0600. */ export async function redeemPairingCode( enrollUrl: string, code: string, id: AgentIdentity, ks: Keystore, opts: RedeemOptions = {}, ): Promise { const doFetch = opts.fetchImpl ?? fetch const mode = opts.mode ?? DEFAULT_ENROLL_MODE const unwrap = opts.unwrapContentSecret ?? passthroughUnwrap const body = mode === 'token' ? { code, agentToken: opts.agentToken ?? '' } : { code, agentPubkey: encodeBase64UrlBytes(id.publicKey), csr: buildCsr(id, opts.subject ?? 'web-terminal-agent'), } let res: Response try { res = await doFetch(enrollUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }) } catch (err) { throw new EnrollError(`enroll request failed: ${(err as Error).message}`) } if (res.status === 409) throw new PairingCodeSpentError() if (res.status === 410) throw new PairingCodeExpiredError() if (!res.ok) throw new EnrollError(`enroll returned HTTP ${res.status}`) let json: unknown try { json = await res.json() } catch (err) { throw new EnrollError(`enroll response was not JSON: ${(err as Error).message}`) } const enroll = parseEnrollResult(json, opts.allowMissingContentSecret ?? false) ks.saveCert(enroll.cert, enroll.caChain) // FIX 3: unwrap in-process, persist ONLY the unwrapped secret (wrapped bytes never stored). // Native frp-client enroll has no content secret (empty sentinel) → nothing to unwrap/store. if (enroll.hostContentSecret.length > 0) { const unwrapped = unwrap(enroll.hostContentSecret, id) ks.saveContentSecret(unwrapped) } return enroll }