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.
107 lines
4.2 KiB
TypeScript
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),
|
|
)
|
|
},
|
|
}
|
|
}
|