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:
@@ -1,19 +1,21 @@
|
||||
// Adoption: the ONE time an operator's refresh token enters the mesh, and it enters already sealed.
|
||||
//
|
||||
// The refresh token is read here, on the MANAGER NODE, sealed at rest to that node's own key, and
|
||||
// only the sealed envelope leaves this process (novox/hq ADR 0050, Phase C). The control plane stores
|
||||
// that envelope via `licence set-grant` without ever seeing the refresh token in the clear — the same
|
||||
// bound every refresh keeps. This is the counterpart to `refresh/index.js`: adoption seals the first
|
||||
// envelope, refresh opens and re-seals it.
|
||||
// The refresh token is read here, on the MANAGER NODE, sealed to that node's PUBLIC sealing key, and
|
||||
// only the sealed box leaves this process (novox/hq ADR 0050). The control plane stores that box via
|
||||
// `licence set-grant` without ever seeing the refresh token in the clear — the same bound every
|
||||
// delivery keeps. This is the counterpart to `refresh/index.js`: adoption seals the first box, refresh
|
||||
// re-seals a rotated one; both use the very anonymous box (`crypto_box_seal`) the mesh seals every
|
||||
// credential with, so the HOST unseals the stored box to mount the cleartext back — this module is
|
||||
// never given a private key and opens nothing.
|
||||
//
|
||||
// MESH_ANTHROPIC_REFRESH_TOKEN_FILE the operator's refresh token, read once and never written out
|
||||
// MESH_NODE_SEALING_PUBLIC_FILE the manager node's public sealing key (base64 raw X25519)
|
||||
// MESH_ANTHROPIC_GRANT_OUT where the sealed envelope is written, for `licence set-grant`
|
||||
// MESH_ANTHROPIC_ADOPT_TOKEN_FILE the operator's refresh token, read once and never written out
|
||||
// MESH_MODEL_ACCESS_BIND_FILE the manager holder's bound facts, carrying manager_public_key
|
||||
// MESH_ANTHROPIC_GRANT_OUT where the sealed box is written, for `licence set-grant`
|
||||
|
||||
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { sealAtRest } from "../atrest.js";
|
||||
import { seal } from "../sealedbox.js";
|
||||
import { managerPublicKey, writeSealedGrant } from "../grantfile.js";
|
||||
|
||||
function required(name: string): string {
|
||||
const v = process.env[name];
|
||||
@@ -21,19 +23,13 @@ function required(name: string): string {
|
||||
return v;
|
||||
}
|
||||
|
||||
const refreshToken = readFileSync(required("MESH_ANTHROPIC_REFRESH_TOKEN_FILE"), "utf8").trim();
|
||||
const refreshToken = readFileSync(required("MESH_ANTHROPIC_ADOPT_TOKEN_FILE"), "utf8").trim();
|
||||
if (!refreshToken) throw new Error("[anthropic-manager] there is no refresh token to adopt");
|
||||
|
||||
const nodePub = readFileSync(required("MESH_NODE_SEALING_PUBLIC_FILE"), "utf8").trim();
|
||||
const envelope = sealAtRest(refreshToken, nodePub);
|
||||
// The node's PUBLIC sealing key, delivered by the mesh in the manager holder's bound facts. Public,
|
||||
// so it is safe to hand a module; the private half stays with the host, which is what opens the box.
|
||||
const nodePub = managerPublicKey(required("MESH_MODEL_ACCESS_BIND_FILE"));
|
||||
|
||||
const out = required("MESH_ANTHROPIC_GRANT_OUT");
|
||||
mkdirSync(dirname(out), { recursive: true });
|
||||
const tmp = `${out}.tmp`;
|
||||
writeFileSync(
|
||||
tmp,
|
||||
JSON.stringify({ token: envelope.token, wrapped_key: envelope.wrappedKey, manager_key: envelope.managerKey }),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
renameSync(tmp, out);
|
||||
console.error("[anthropic-manager] sealed the refresh token at rest; only this node's key opens it");
|
||||
const sealed = seal(new Uint8Array(Buffer.from(refreshToken, "utf8")), nodePub);
|
||||
writeSealedGrant(required("MESH_ANTHROPIC_GRANT_OUT"), sealed, nodePub);
|
||||
console.error("[anthropic-manager] sealed the refresh token to this node's key; only the host opens it");
|
||||
|
||||
Reference in New Issue
Block a user