anthropic-manager: seal the refresh token to the node key, do no crypto to open

The manager module drops its bespoke ECIES at-rest envelope and the node-private-key mount.
A module is never given a node's private key, so it cannot open an envelope -- the refresh
token is now delivered to it as cleartext by the host, unsealed from an ordinary sealed box.

  - sealedbox.ts: a dependency-free NaCl crypto_box_seal (node:crypto for X25519, transcribed
    XSalsa20-Poly1305 and BLAKE2b-24), byte-compatible with Go's box.SealAnonymous. It SEALS
    only -- opening is the host's job. Proven by a cross-language test in mesh-control.
  - adopt: reads the node's PUBLIC key from the delivered bound facts and seals the operator's
    refresh token to it, handing out only the box.
  - refresh: reads the refresh token as cleartext the host mounted, calls the vendor, re-seals
    a rotated token to the node's public key, submits only { access token, box }.
  - module.json: a model-access holder now -- binds the facts, binds the refresh token as a
    sealed secret; no keys dir, no MESH_NODE_SEALING_* mount.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
2026-09-07 01:55:19 +02:00
parent c206e2e11e
commit 4c98bee043
9 changed files with 611 additions and 310 deletions
+30 -47
View File
@@ -1,18 +1,20 @@
// The manager's scheduled run (novox/hq ADR 0050/0053). It is the whole of the carve-out in one
// place, and it runs on the MANAGER NODE, never in the control plane:
//
// 1. read the opaque refresh-token envelope the control plane forwarded (it cannot open it);
// 2. open it HERE with the node's own sealing key — the one moment a refresh token is in the clear,
// on the one node the ADR permits it;
// 3. call the vendor's OAuth token endpoint to mint a fresh access token (and maybe a rotated
// 1. read the refresh token as CLEARTEXT — the host unsealed the stored box with THIS node's private
// key and mounted it at the module's bound secret path, exactly as it delivers any credential.
// This module holds no node key and opens nothing itself;
// 2. call the vendor's OAuth token endpoint to mint a fresh access token (and maybe a rotated
// refresh token);
// 4. re-seal the rotated refresh token at rest (still openable by this node alone);
// 5. hand the control plane back ONLY the access token in the clear + the opaque re-sealed
// envelope — never the refresh token — which it seals per holder and stores;
// 6. poll usage with the fresh access token and record the licence-grain reading.
// 3. if the vendor rotated the refresh token, SEAL the new one to this node's PUBLIC sealing key
// (delivered in the bound facts) with the same anonymous box the mesh seals every credential with;
// 4. hand the control plane back ONLY the access token in the clear + the opaque re-sealed box —
// never the refresh token — which it seals per consumer holder and stores;
// 5. poll usage with the fresh access token and record the licence-grain reading.
//
// mesh-control receives the products of steps 5–6 through `licence submit-refresh` (access token +
// opaque envelope). The refresh token never leaves this process except as ciphertext.
// mesh-control receives the products of steps 3–4 through `licence submit-refresh` (access token +
// sealed box). The refresh token never leaves this process except as ciphertext, and it never had to
// be opened here at all — the host did that.
//
// This runs as `mesh-tools run`, which connects no broker, so the outputs are written to files the
// host mounts; the submit itself (the transport to mesh-control) is done by the caller invoking
@@ -23,7 +25,8 @@
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { openAtRest, sealAtRest, type Envelope } from "../atrest.js";
import { seal } from "../sealedbox.js";
import { managerPublicKey, writeSealedGrant } from "../grantfile.js";
import { refreshGrant, grantFromRefresh, readUsage, flattenUsage } from "../client.js";
function required(name: string): string {
@@ -32,30 +35,6 @@ function required(name: string): string {
return v;
}
function readTrimmed(path: string): string {
return readFileSync(path, "utf8").trim();
}
/** Accept an envelope in either the wire (snake_case) or internal (camelCase) shape. */
function readEnvelope(path: string): Envelope {
const raw = JSON.parse(readFileSync(path, "utf8")) as Record<string, string>;
const token = raw.token ?? "";
const wrappedKey = raw.wrappedKey ?? raw.wrapped_key ?? "";
const managerKey = raw.managerKey ?? raw.manager_key ?? "";
if (!token || !wrappedKey || !managerKey) {
throw new Error("the refresh-token envelope is missing one of token/wrapped_key/manager_key");
}
return { token, wrappedKey, managerKey };
}
/** Write the envelope in the wire (snake_case) shape mesh-control's `submit-refresh` reads. */
function writeEnvelope(path: string, env: Envelope): void {
atomicWrite(
path,
JSON.stringify({ token: env.token, wrapped_key: env.wrappedKey, manager_key: env.managerKey }),
);
}
function atomicWrite(path: string, content: string): void {
mkdirSync(dirname(path), { recursive: true });
const tmp = `${path}.tmp`;
@@ -66,14 +45,18 @@ function atomicWrite(path: string, content: string): void {
async function main(): Promise<void> {
const licence = process.env.MESH_ANTHROPIC_LICENCE ?? "unknown";
const envelope = readEnvelope(required("MESH_ANTHROPIC_GRANT_FILE"));
const nodePub = readTrimmed(required("MESH_NODE_SEALING_PUBLIC_FILE"));
const nodePriv = readTrimmed(required("MESH_NODE_SEALING_PRIVATE_FILE"));
// Step 1: the refresh token as cleartext, unsealed and mounted by the HOST. No open here.
const refreshToken = readFileSync(required("MESH_MODEL_ACCESS_SECRET_FILE"), "utf8").trim();
if (!refreshToken) {
// Nothing was delivered — the manager has not adopted a refresh token yet, or the push has not
// landed. Said rather than treated as an empty token the vendor would reject obscurely.
throw new Error("[anthropic-manager] no refresh token was delivered; adopt one first");
}
// Step 2: the one open, on the manager node.
const refreshToken = openAtRest(envelope, nodePub, nodePriv);
// The node's PUBLIC sealing key, to re-seal a rotated refresh token. Public, delivered in the facts.
const nodePub = managerPublicKey(required("MESH_MODEL_ACCESS_BIND_FILE"));
// Step 3: the vendor call.
// Step 2: the vendor call.
const refreshed = await refreshGrant(refreshToken);
if (!refreshed) {
// A dead endpoint or a rejected token: nothing to publish, and we do not clobber a good grant.
@@ -84,16 +67,16 @@ async function main(): Promise<void> {
throw new Error(`[anthropic-manager] the refresh of ${licence} returned no access token`);
}
// Step 4: re-seal the rotated refresh token, if the vendor rotated it. Nothing to store otherwise.
// Step 3: re-seal the rotated refresh token, if the vendor rotated it. Nothing to store otherwise.
if (grant.rotatedRefresh) {
const rotated = sealAtRest(grant.rotatedRefresh, nodePub);
const sealed = seal(new Uint8Array(Buffer.from(grant.rotatedRefresh, "utf8")), nodePub);
if (process.env.MESH_ANTHROPIC_GRANT_OUT) {
writeEnvelope(process.env.MESH_ANTHROPIC_GRANT_OUT, rotated);
writeSealedGrant(process.env.MESH_ANTHROPIC_GRANT_OUT, sealed, nodePub);
}
}
// Step 5: the access token in the clear, for the control plane to seal per holder. This is all it
// ever receives that is not ciphertext.
// Step 4: the access token in the clear, for the control plane to seal per consumer holder. This is
// all it ever receives that is not ciphertext.
atomicWrite(required("MESH_ANTHROPIC_ACCESS_OUT"), grant.access.accessToken);
console.error(
@@ -101,7 +84,7 @@ async function main(): Promise<void> {
(grant.rotatedRefresh ? ", refresh token rotated and re-sealed" : ", refresh token unchanged"),
);
// Step 6: licence-grain usage, best-effort — a usage read failing must not fail the refresh.
// Step 5: licence-grain usage, best-effort — a usage read failing must not fail the refresh.
try {
const usage = await readUsage(grant.access.accessToken);
if (usage) {