/** * On-disk keystore — PLAN_RELAY_AGENT T3 (INV4/INV5). Persists ONLY to `stateDir`, every secret * file mode `0600`. Stores: the Ed25519 private key (PKCS#8 PEM), the mTLS cert + CA chain, and * the FIX 3 unwrapped `hostContentSecret`. The raw pairing code is NEVER written here (T4). */ import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync, } from 'node:fs' import { join } from 'node:path' import { createPrivateKey } from 'node:crypto' import type { AgentIdentity } from './identity.js' import { identityFromPrivatePem, p256IdentityFromPrivatePem } from './identity.js' const SECRET_MODE = 0o600 const DIR_MODE = 0o700 export interface Keystore { saveIdentity(id: AgentIdentity): void loadIdentity(): AgentIdentity | null saveCert(certPem: string, caChainPem: string): void loadCert(): { certPem: string; caChainPem: string } | null saveContentSecret(secret: Uint8Array): void loadContentSecret(): Uint8Array | null } const KEY_FILE = 'agent.key.pem' const CERT_FILE = 'agent.cert.pem' const CA_FILE = 'agent.ca.pem' const CONTENT_SECRET_FILE = 'content.secret' /** Corrupt/unreadable key material surfaces as a typed error (never a silent empty read). */ export class KeystoreError extends Error { constructor(message: string) { super(message) this.name = 'KeystoreError' } } function ensureDir(stateDir: string): void { if (!existsSync(stateDir)) { mkdirSync(stateDir, { recursive: true, mode: DIR_MODE }) return } // Refuse to write secrets into a world-/group-accessible directory (INV5 hard error). const mode = statSync(stateDir).mode & 0o777 if ((mode & 0o077) !== 0) { throw new KeystoreError( `stateDir ${stateDir} is group/world accessible (mode ${mode.toString(8)}); refusing to write secrets`, ) } } /** * Reconstruct an `AgentIdentity` from a stored PKCS#8 PEM, branching on the key's algorithm * discriminant (the PKCS#8 AlgorithmIdentifier OID, surfaced as `asymmetricKeyType`): an Ed25519 * key → the relay-path builder; an EC P-256 key → the native frp-client builder (EC SPKI publicKey). * A saved P-256 identity therefore round-trips as `alg: 'p256'` with a usable signing key, while * Ed25519 stays byte-identical. Any other algorithm is a hard error (never silently mislabeled). */ function identityFromStoredPem(pem: string): AgentIdentity { const key = createPrivateKey(pem) const alg = key.asymmetricKeyType if (alg === 'ed25519') return identityFromPrivatePem(pem) if (alg === 'ec') { // An `ec` key alone is not proof of P-256 — a P-384/secp256k1 key also reports `ec`. The native // frp-client path is P-256 ONLY, so assert the named curve before treating it as such; any other // curve is a hard error (never silently mislabeled as a usable P-256 identity). const curve = key.asymmetricKeyDetails?.namedCurve if (curve !== 'prime256v1') { throw new Error( `unsupported stored EC identity curve: ${curve ?? 'unknown'} (only prime256v1/P-256 is supported)`, ) } return p256IdentityFromPrivatePem(pem) } throw new Error(`unsupported stored identity key algorithm: ${alg ?? 'unknown'}`) } function writeSecret(path: string, data: string | Uint8Array): void { writeFileSync(path, data, { mode: SECRET_MODE }) // Enforce 0600 even if a prior umask/file left it wider. chmodSync(path, SECRET_MODE) } export function openKeystore(stateDir: string): Keystore { const keyPath = join(stateDir, KEY_FILE) const certPath = join(stateDir, CERT_FILE) const caPath = join(stateDir, CA_FILE) const secretPath = join(stateDir, CONTENT_SECRET_FILE) return { saveIdentity(id: AgentIdentity): void { ensureDir(stateDir) writeSecret(keyPath, id.exportPrivatePkcs8Pem()) }, loadIdentity(): AgentIdentity | null { if (!existsSync(keyPath)) return null let pem: string try { pem = readFileSync(keyPath, 'utf8') } catch (err) { throw new KeystoreError(`failed to read identity key: ${(err as Error).message}`) } try { return identityFromStoredPem(pem) } catch (err) { throw new KeystoreError(`corrupt identity key file: ${(err as Error).message}`) } }, saveCert(certPem: string, caChainPem: string): void { ensureDir(stateDir) writeSecret(certPath, certPem) writeSecret(caPath, caChainPem) }, loadCert(): { certPem: string; caChainPem: string } | null { if (!existsSync(certPath) || !existsSync(caPath)) return null return { certPem: readFileSync(certPath, 'utf8'), caChainPem: readFileSync(caPath, 'utf8'), } }, saveContentSecret(secret: Uint8Array): void { ensureDir(stateDir) writeSecret(secretPath, secret) }, loadContentSecret(): Uint8Array | null { if (!existsSync(secretPath)) return null return new Uint8Array(readFileSync(secretPath)) }, } }