anthropic model-access modules: manager (refreshable-grant) and consumer

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
This commit is contained in:
2026-09-07 01:00:12 +02:00
parent cd46d53464
commit c206e2e11e
19 changed files with 1293 additions and 0 deletions
+174
View File
@@ -0,0 +1,174 @@
// 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()]);
}