Phase C of vendor-agnostic model-access (ADR 0050/0054). Two TypeScript runtime modules: - anthropic-manager: the refresh token is sealed at rest to the manager node's own key (atrest.ts, envelope encryption over X25519) and opened ONLY on the manager node. adopt seals the first envelope; refresh opens it, calls the Anthropic OAuth token endpoint, re-seals a rotated refresh token, and hands the control plane only the access token plus the opaque envelope. Also polls licence-grain usage (ADR 0054). - anthropic-consumer: writes the delivered access token to ~/.claude/.credentials.json, access-token-only, atomically (the refresh token is never delivered); reports session-grain usage from the CLI transcripts; a fail-closed identity guard (expected-uuid plumbing is a flagged TODO). Both run as scheduled containers (ADR 0053). Pure logic covered by node --test fixtures (at-rest round-trip, credential strip, transcript sum, refresh merge). Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
175 lines
7.8 KiB
TypeScript
175 lines
7.8 KiB
TypeScript
// 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()]);
|
|
}
|