Files
web-terminal/agent/src/enroll/pair.ts
Yaojia Wang 1e398c7561 fix(agent): make native cert auto-renew actually work end-to-end
Two real-deploy renew bugs (found running the live /renew):
- the /renew mTLS request pinned the enroll caChain as the server CA → TLS
  'unable to get local issuer certificate' (the LE-fronted CP is publicly
  trusted). Verify the server against SYSTEM roots (drop ca), keep the client
  cert + rejectUnauthorized:true. (TlsClientOptions.ca now optional.)
- renewCert parsed {cert, caChain:string}, but the CP returns cert=base64(DER)
  + caChain=base64(DER)[]; normalize to PEM (shared certs/pem.ts, reused by
  native enroll). Verified live: cert rotated 13:41→next-day, frpc restarted,
  tunnel stayed up. 281 tests pass.
2026-07-19 07:56:01 +02:00

170 lines
6.1 KiB
TypeScript

/**
* §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<EnrollResponseJson>
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<EnrollResult> {
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
}