// The refresh token, encrypted at rest so ONE node — the manager — can read it back, and nothing // else can: not the control plane, not a copy of its database, not another node. // // **Why this file exists at all.** novox/hq ADR 0050 draws one bounded carve-out in the mesh's "the // control plane cannot read what it stores" guarantee: a refreshable-grant credential (Anthropic's // subscription OAuth) must be rotated centrally, and rotating it means SOME node reads the refresh // token back, every cycle. The ADR names exactly one such node — the *manager* — and this is the // mechanism by which it, and only it, reads that token. mesh-control (the control plane) holds the // output of this as three opaque strings and never runs the open: it has no key that could. // // **The construction (ECIES over the node's own sealing key).** Envelope encryption: // - a fresh random 32-byte data key encrypts the refresh token with AES-256-GCM (`token`); // - that data key is wrapped to the manager node's X25519 sealing key — the same key pair the // host already holds for the node — via an ephemeral-static ECDH → HKDF-SHA256 → AES-256-GCM // (`wrappedKey`, carrying the ephemeral public key in front); // - `managerKey` is the node public key the data key was wrapped to, kept so a node that has since // rotated its key learns it can no longer open this, rather than discovering it as a decrypt // that fails. // Recovering the refresh token needs the node's X25519 *private* half, which never leaves that // machine. A copy of the control plane's database is a directory of ciphertexts and wrapped keys // with nothing to open either. // // **On format.** This is the manager module's own at-rest format, distinct from mesh-control's Go // `secrets.AtRest` (which is NaCl secretbox + sealed box). That is deliberate and safe: on the // Anthropic path the manager module is the ONLY component that seals or opens the envelope — it // seals at adoption, it opens and re-seals every refresh — and mesh-control stores the three parts // as opaque strings it never interprets. The two never have to agree byte-for-byte because the // bytes never cross the language boundary in an opened form. (If an operator-facing adopt path in // mesh-control ever needed to produce the first envelope, the two would have to be unified — a NaCl // port in TS, or a Go manager runtime. Flagged, not silently assumed.) import { createCipheriv, createDecipheriv, createPrivateKey, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes, type KeyObject, } from "node:crypto"; /** The three opaque parts mesh-control stores and forwards, and nothing else. */ export interface Envelope { /** base64( iv ‖ tag ‖ AES-256-GCM(dataKey, refreshToken) ). */ readonly token: string; /** base64( ephemeralPub(32) ‖ iv ‖ tag ‖ AES-256-GCM(kek, dataKey) ). */ readonly wrappedKey: string; /** base64 of the node's raw 32-byte X25519 public key the data key was wrapped to. */ readonly managerKey: string; } const INFO = Buffer.from("mesh-atrest-v1"); const IV_LEN = 12; const TAG_LEN = 16; const RAW_KEY_LEN = 32; /** Import a node's raw 32-byte X25519 public key (standard base64, as the mesh records it). */ function importPublic(rawBase64: string): KeyObject { const raw = Buffer.from(rawBase64, "base64"); if (raw.length !== RAW_KEY_LEN) { throw new Error(`a sealing public key is 32 bytes, not ${raw.length}`); } return createPublicKey({ key: { kty: "OKP", crv: "X25519", x: raw.toString("base64url") }, format: "jwk", }); } /** Import a node's raw 32-byte X25519 private key together with its public half. */ function importPrivate(rawPrivB64: string, rawPubB64: string): KeyObject { const priv = Buffer.from(rawPrivB64, "base64"); const pub = Buffer.from(rawPubB64, "base64"); if (priv.length !== RAW_KEY_LEN) { throw new Error(`a sealing private key is 32 bytes, not ${priv.length}`); } return createPrivateKey({ key: { kty: "OKP", crv: "X25519", x: pub.toString("base64url"), d: priv.toString("base64url") }, format: "jwk", }); } /** The raw 32-byte public key of an X25519 KeyObject. */ function rawPublic(key: KeyObject): Buffer { const jwk = key.export({ format: "jwk" }) as { x?: string }; if (!jwk.x) throw new Error("a public key had no point"); return Buffer.from(jwk.x, "base64url"); } /** Bind the wrapping key to both the ephemeral and the recipient public key, as a sealed box does. */ function deriveKek(shared: Buffer, ephemeralPub: Buffer, recipientPub: Buffer): Buffer { const salt = Buffer.concat([ephemeralPub, recipientPub]); return Buffer.from(hkdfSync("sha256", shared, salt, INFO, 32)); } /** * Seal a refresh token so only the holder of managerPublicKey's private half can read it. * * A fresh data key and ephemeral key each time, so two envelopes of the same token look nothing * alike — a rotation that changed nothing is indistinguishable from one that changed everything. */ export function sealAtRest(refreshToken: string, managerPublicKeyB64: string): Envelope { if (!refreshToken) throw new Error("there is nothing to seal"); const recipient = importPublic(managerPublicKeyB64); const recipientPub = rawPublic(recipient); // Ephemeral-static ECDH: a throwaway key pair whose public half rides in the envelope. const eph = generateKeyPairSync("x25519"); const ephemeralPub = rawPublic(eph.publicKey); const shared = diffieHellman({ privateKey: eph.privateKey, publicKey: recipient }); const kek = deriveKek(shared, ephemeralPub, recipientPub); const dataKey = randomBytes(32); const wrappedKey = Buffer.concat([ephemeralPub, aesSeal(kek, dataKey)]); const token = aesSeal(dataKey, Buffer.from(refreshToken, "utf8")); return { token: token.toString("base64"), wrappedKey: wrappedKey.toString("base64"), managerKey: managerPublicKeyB64, }; } /** * Recover the refresh token, given the manager node's own key pair. This is the one place a refresh * token is in the clear, and it runs only on the manager node. */ export function openAtRest(env: Envelope, managerPublicKeyB64: string, managerPrivateKeyB64: string): string { const priv = importPrivate(managerPrivateKeyB64, managerPublicKeyB64); const recipientPub = Buffer.from(managerPublicKeyB64, "base64"); const wrapped = Buffer.from(env.wrappedKey, "base64"); if (wrapped.length < RAW_KEY_LEN + IV_LEN + TAG_LEN) { throw new Error("the wrapped key is too short to hold what it must"); } const ephemeralPub = wrapped.subarray(0, RAW_KEY_LEN); const wrappedRest = wrapped.subarray(RAW_KEY_LEN); const ephemeralKey = importPublic(ephemeralPub.toString("base64")); const shared = diffieHellman({ privateKey: priv, publicKey: ephemeralKey }); const kek = deriveKek(shared, ephemeralPub, recipientPub); let dataKey: Buffer; try { dataKey = aesOpen(kek, wrappedRest); } catch { throw new Error("this refresh token was not wrapped to this manager's key"); } if (dataKey.length !== 32) throw new Error("the wrapped data key is the wrong length"); const refresh = aesOpen(dataKey, Buffer.from(env.token, "base64")); return refresh.toString("utf8"); } // --- AES-256-GCM helpers: output/consume iv ‖ tag ‖ ciphertext --- function aesSeal(key: Buffer, plaintext: Buffer): Buffer { const iv = randomBytes(IV_LEN); const cipher = createCipheriv("aes-256-gcm", key, iv); const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]); const tag = cipher.getAuthTag(); return Buffer.concat([iv, tag, ct]); } function aesOpen(key: Buffer, blob: Buffer): Buffer { const iv = blob.subarray(0, IV_LEN); const tag = blob.subarray(IV_LEN, IV_LEN + TAG_LEN); const ct = blob.subarray(IV_LEN + TAG_LEN); const decipher = createDecipheriv("aes-256-gcm", key, iv); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(ct), decipher.final()]); }