/** * Agent identity — PLAN_RELAY_AGENT T3 (INV4: the private key NEVER leaves the host). * * Two key algorithms share one `AgentIdentity` shape (discriminated by `alg`): * - `ed25519` — the relay E2E rendezvous path (unchanged). * - `p256` — the native-tunnel HOST frp-client key (FIX H-host-2): the `frp-client-CA` is P-256, * so the host key, its CSR (ECDSA-with-SHA256), and its leaf are all P-256. * `AgentIdentity` exposes ONLY the public key, the §4.2 enroll fingerprint, and an in-process * `sign()`; there is NO API that returns or serializes the private key. The raw private key material * is held in a module-private closure and, for P-256, NEVER leaves the host (only the pubkey + CSR do). */ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from 'node:crypto' import type { KeyObject } from 'node:crypto' import { encodeBase64UrlBytes } from 'relay-contracts' /** Which key algorithm an identity carries. Drives CSR SPKI + signatureAlgorithm encoding. */ export type KeyAlg = 'ed25519' | 'p256' export interface AgentIdentity { /** Which key algorithm this identity uses (`ed25519` = relay path, `p256` = native frp-client). */ readonly alg: KeyAlg /** * The registry-stored public key bytes (§4.2 agent_pubkey): * - `ed25519`: the raw 32-byte public key; * - `p256`: the EC SubjectPublicKeyInfo DER (what the control-plane P-256 gate compares). */ readonly publicKey: Uint8Array /** §4.2 enroll_fpr: base64url(SHA-256(publicKey)) — pinned by the browser E2E TOFU (§4.4). */ readonly enrollFpr: string /** * Signature over `message` using the in-process private key (never returns the key): * - `ed25519`: a raw 64-byte Ed25519 signature; * - `p256`: a DER `ECDSA-Sig-Value` (ecdsa-with-SHA256) — the PKCS#10 signatureValue shape. */ sign(message: Uint8Array): Uint8Array /** Export the PRIVATE key as PKCS#8 PEM — for on-disk 0600 persistence ONLY (keystore). */ exportPrivatePkcs8Pem(): string /** The underlying private KeyObject — for in-process crypto (CSR/mTLS) ONLY, never serialized to the wire. */ privateKeyObject(): KeyObject } /** SHA-256 fingerprint of a raw Ed25519 public key, base64url — §4.2 enroll_fpr. */ export function computeEnrollFpr(publicKey: Uint8Array): string { const digest = createHash('sha256').update(publicKey).digest() return encodeBase64UrlBytes(new Uint8Array(digest)) } /** Raw 32-byte Ed25519 public key from a KeyObject (strips the SPKI DER prefix). */ function rawPublicKey(pub: KeyObject): Uint8Array { const der = pub.export({ type: 'spki', format: 'der' }) // Ed25519 SPKI is a fixed 44-byte structure; the raw key is the trailing 32 bytes. return new Uint8Array(der.subarray(der.length - 32)) } function buildIdentity(privateKey: KeyObject, publicKey: KeyObject): AgentIdentity { const rawPub = rawPublicKey(publicKey) const enrollFpr = computeEnrollFpr(rawPub) return { alg: 'ed25519', publicKey: rawPub, enrollFpr, sign(message: Uint8Array): Uint8Array { return new Uint8Array(sign(null, message, privateKey)) }, exportPrivatePkcs8Pem(): string { return privateKey.export({ type: 'pkcs8', format: 'pem' }).toString() }, privateKeyObject(): KeyObject { return privateKey }, } } /** * P-256 identity (FIX H-host-2). `publicKey` is the EC SubjectPublicKeyInfo DER (the exact bytes the * control-plane frp-client gate compares against the registry); `sign` produces a DER `ECDSA-Sig-Value` * over the SHA-256 digest — the PKCS#10 / X.509 signatureValue shape. The private key never leaves * this closure (INV4); only the SPKI + CSR are emitted off-host. */ function buildP256Identity(privateKey: KeyObject, publicKey: KeyObject): AgentIdentity { const spkiDer = new Uint8Array(publicKey.export({ type: 'spki', format: 'der' })) const enrollFpr = computeEnrollFpr(spkiDer) return { alg: 'p256', publicKey: spkiDer, enrollFpr, sign(message: Uint8Array): Uint8Array { // ecdsa-with-SHA256 → DER ECDSA-Sig-Value (node's default dsaEncoding is 'der'). return new Uint8Array(sign('sha256', message, privateKey)) }, exportPrivatePkcs8Pem(): string { return privateKey.export({ type: 'pkcs8', format: 'pem' }).toString() }, privateKeyObject(): KeyObject { return privateKey }, } } /** Generate a fresh Ed25519 identity; the private key is held in-memory only (INV4). */ export function generateIdentity(): AgentIdentity { const { privateKey, publicKey } = generateKeyPairSync('ed25519') return buildIdentity(privateKey, publicKey) } /** * Generate a fresh EC P-256 identity for the native-tunnel host frp-client key (FIX H-host-2). The * private key is held in-memory only (INV4) and NEVER serialized off-host; only the pubkey + CSR leave. */ export function generateP256Identity(): AgentIdentity { const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' }) return buildP256Identity(privateKey, publicKey) } /** Reconstruct an Ed25519 identity from a stored PKCS#8 PEM private key (keystore load path). */ export function identityFromPrivatePem(pem: string): AgentIdentity { const privateKey = createPrivateKey(pem) const publicKey = createPublicKey(privateKey) return buildIdentity(privateKey, publicKey) } /** Reconstruct a P-256 identity from a stored PKCS#8 PEM private key (keystore load path). */ export function p256IdentityFromPrivatePem(pem: string): AgentIdentity { const privateKey = createPrivateKey(pem) const publicKey = createPublicKey(privateKey) return buildP256Identity(privateKey, publicKey) } /** Verify an Ed25519 signature against a raw public key — helper for tests/handshake checks. */ export function verifySignature( publicKey: Uint8Array, message: Uint8Array, signature: Uint8Array, ): boolean { const spkiPrefix = Uint8Array.from([ 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, ]) const der = new Uint8Array(spkiPrefix.length + publicKey.length) der.set(spkiPrefix, 0) der.set(publicKey, spkiPrefix.length) const pub = createPublicKey({ key: Buffer.from(der), format: 'der', type: 'spki' }) return verify(null, message, pub, signature) }