Files
web-terminal/control-panel/src/manage-token.ts
Yaojia Wang 675de771c7 feat(control-panel): web admin UI for the zero-touch tunnel
Loopback Fastify auth-broker + esbuild SPA. Operator password login (constant-time,
signed HttpOnly session cookie, per-forwarded-IP rate-limit) → session-gated proxy
that mints a fresh 60s manage capability token per call to the control-plane admin
API: list hosts, mint pairing codes (with QR + pair command), revoke hosts. Security
headers + CSP, CP_URL pinned loopback (anti-SSRF), hostId dot-segment guard. 55 tests
pass; security-reviewed. Deployed behind nginx panel.terminal.yaojia.wang.
2026-07-19 19:47:51 +02:00

107 lines
4.2 KiB
TypeScript

/**
* 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<string>
}
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<CryptoKey> {
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<ArrayBuffer>
try {
// Copy into a fresh ArrayBuffer-backed view: WebCrypto's BufferSource requires Uint8Array<ArrayBuffer>,
// 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<CryptoKey> | undefined
const signingKey = (): Promise<CryptoKey> => {
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<string> {
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),
)
},
}
}