/** * In-process `manage` capability-token minter โ€” the in-code equivalent of * relay-run/scripts/mint-manage-token.ts. Every proxied admin call mints a FRESH short-TTL token so * a leaked token's blast radius is ~60s. * * The token is a ยง4.3 PASETO v4.public capability token signed by the P5 PRIVATE Ed25519 key * (PKCS#8 PEM at CAPABILITY_SIGN_KEY_PATH): `aud = BASE_DOMAIN`, `sub = OPERATOR_ACCOUNT_ID`, * `rights = ['manage']`, `ttl = 60s`. `issueCapabilityToken` mandates a well-formed DPoP `cnf.jkt`, * so we stamp one from a throwaway ephemeral key (the CP admin API verifies signature+aud+rights but * does NOT require a live DPoP proof โ€” see the mint script's header note). * * SECURITY: the signing-key material and the minted token are NEVER logged. */ import { readFile } from 'node:fs/promises' import { issueCapabilityToken } from 'relay-auth' import { generateEd25519KeyPair, exportEd25519PublicRaw } from 'relay-auth/src/crypto/ed25519.js' import { jwkThumbprint } from 'relay-auth/src/crypto/thumbprint.js' /** Manage tokens are `manage`-scoped, single-account, 60s TTL. `host` is a placeholder (issue() forbids '*'/''). */ const MANAGE_TOKEN_TTL_SEC = 60 const MANAGE_HOST_PLACEHOLDER = '_manage_' /** Raised when the signing key cannot be loaded/imported. Message is safe (no key material). */ export class ManageTokenError extends Error { constructor(message: string) { super(message) this.name = 'ManageTokenError' } } export interface ManageTokenMinter { /** Mint a fresh manage token for the configured operator account. */ mint(): Promise } export interface ManageTokenConfig { readonly capabilitySignKeyPath: string readonly baseDomain: string readonly operatorAccountId: string } /** Import a PKCS#8 PEM Ed25519 private key into a non-extractable signing CryptoKey. */ async function importSigningKeyFromPem(pemPath: string): Promise { let pem: string try { pem = await readFile(pemPath, 'utf8') } catch { throw new ManageTokenError(`capability signing key not readable at ${pemPath}`) } const b64 = pem.replace(/-----[^-]+-----/g, '').replace(/\s+/g, '') if (b64.length === 0) throw new ManageTokenError('capability signing key PEM is empty') let der: Uint8Array try { // Copy into a fresh ArrayBuffer-backed view: WebCrypto's BufferSource requires Uint8Array, // which Buffer (ArrayBufferLike) does not satisfy under strict lib types. const raw = Buffer.from(b64, 'base64') der = new Uint8Array(new ArrayBuffer(raw.byteLength)) der.set(raw) } catch { throw new ManageTokenError('capability signing key PEM is not valid base64') } try { return await globalThis.crypto.subtle.importKey('pkcs8', der, { name: 'Ed25519' }, false, ['sign']) } catch { throw new ManageTokenError('capability signing key is not a valid PKCS#8 Ed25519 key') } } /** * Build a minter that lazily loads + memoizes the signing key (so the panel boots even if the key * file is briefly unavailable โ€” a missing key surfaces as an upstream error on the first proxy call, * not a boot crash). Clock is injectable for tests. */ export function createManageTokenMinter(config: ManageTokenConfig, now: () => number = () => Date.now()): ManageTokenMinter { let keyPromise: Promise | undefined const signingKey = (): Promise => { if (keyPromise === undefined) { keyPromise = importSigningKeyFromPem(config.capabilitySignKeyPath).catch((err: unknown) => { keyPromise = undefined // allow a later retry after the operator fixes the key throw err }) } return keyPromise } return { async mint(): Promise { const key = await signingKey() const eph = await generateEd25519KeyPair() const cnfJkt = await jwkThumbprint(await exportEd25519PublicRaw(eph.publicKey)) return issueCapabilityToken( { principal: { accountId: config.operatorAccountId } as never, // runtime reads only accountId aud: config.baseDomain, host: MANAGE_HOST_PLACEHOLDER, rights: ['manage'], ttlSeconds: MANAGE_TOKEN_TTL_SEC, cnfJkt, }, key, Math.floor(now() / 1000), ) }, } }