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.
This commit is contained in:
106
control-panel/src/manage-token.ts
Normal file
106
control-panel/src/manage-token.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* 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),
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user