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.
170 lines
6.1 KiB
TypeScript
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
|
|
}
|