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,47 +0,0 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { generateKeyPairSync } from "node:crypto";
|
||||
|
||||
import { sealAtRest, openAtRest, type Envelope } from "../atrest.ts";
|
||||
|
||||
/** A node key pair as the mesh records it: raw 32-byte X25519 keys, standard base64. */
|
||||
function nodeKeys(): { pub: string; priv: string } {
|
||||
const kp = generateKeyPairSync("x25519");
|
||||
const pub = (kp.publicKey.export({ format: "jwk" }) as { x: string }).x;
|
||||
const priv = (kp.privateKey.export({ format: "jwk" }) as { d: string }).d;
|
||||
// JWK is base64url; the mesh records standard base64 of the same 32 bytes.
|
||||
const std = (b64url: string) => Buffer.from(b64url, "base64url").toString("base64");
|
||||
return { pub: std(pub), priv: std(priv) };
|
||||
}
|
||||
|
||||
test("the manager seals a refresh token and reads it back with its own key", () => {
|
||||
const { pub, priv } = nodeKeys();
|
||||
const env = sealAtRest("rt-the-refresh-token", pub);
|
||||
assert.equal(env.managerKey, pub);
|
||||
// Nothing in the envelope is the refresh token in the clear.
|
||||
assert.doesNotMatch(env.token, /rt-the-refresh-token/);
|
||||
assert.doesNotMatch(env.wrappedKey, /rt-the-refresh-token/);
|
||||
assert.equal(openAtRest(env, pub, priv), "rt-the-refresh-token");
|
||||
});
|
||||
|
||||
test("a node that is not the manager cannot open the envelope", () => {
|
||||
const manager = nodeKeys();
|
||||
const other = nodeKeys();
|
||||
const env = sealAtRest("rt-secret", manager.pub);
|
||||
assert.throws(() => openAtRest(env, other.pub, other.priv));
|
||||
});
|
||||
|
||||
test("two seals of the same token look nothing alike", () => {
|
||||
const { pub } = nodeKeys();
|
||||
const a = sealAtRest("rt-secret", pub);
|
||||
const b = sealAtRest("rt-secret", pub);
|
||||
assert.notEqual(a.token, b.token);
|
||||
assert.notEqual(a.wrappedKey, b.wrappedKey);
|
||||
});
|
||||
|
||||
test("a tampered envelope is refused, not silently mis-opened", () => {
|
||||
const { pub, priv } = nodeKeys();
|
||||
const env = sealAtRest("rt-secret", pub);
|
||||
const flipped: Envelope = { ...env, token: Buffer.from(env.token, "base64").reverse().toString("base64") };
|
||||
assert.throws(() => openAtRest(flipped, pub, priv));
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { generateKeyPairSync } from "node:crypto";
|
||||
|
||||
import { seal } from "../sealedbox.ts";
|
||||
|
||||
// The definitive proof that this seal interoperates with Go's box.OpenAnonymous (the host's Unseal
|
||||
// and mesh-control's secrets.Seal/Open) is a cross-language test in mesh-control
|
||||
// (internal/secrets/sealedbox_xcheck_test.go), which opens a fixture this module's seal() produced.
|
||||
// These tests hold the TypeScript side: the output has the crypto_box_seal shape, and it is
|
||||
// randomised so a rotation that changed nothing looks nothing like one that changed everything.
|
||||
|
||||
/** A node public key as the mesh records it: raw 32-byte X25519, standard base64. */
|
||||
function aNodePublicKey(): string {
|
||||
const kp = generateKeyPairSync("x25519");
|
||||
const x = (kp.publicKey.export({ format: "jwk" }) as { x: string }).x;
|
||||
return Buffer.from(x, "base64url").toString("base64");
|
||||
}
|
||||
|
||||
test("a seal has the crypto_box_seal shape: ephemeralPub(32) + tag(16) + ciphertext(len)", () => {
|
||||
const pub = aNodePublicKey();
|
||||
const msg = Buffer.from("rt-a-refresh-token", "utf8");
|
||||
const blob = Buffer.from(seal(new Uint8Array(msg), pub), "base64");
|
||||
// 32 (ephemeral public key) + 16 (Poly1305 tag) + message length.
|
||||
assert.equal(blob.length, 32 + 16 + msg.length);
|
||||
});
|
||||
|
||||
test("two seals of the same value differ — a fresh ephemeral key each time", () => {
|
||||
const pub = aNodePublicKey();
|
||||
const msg = new Uint8Array(Buffer.from("rt-a-refresh-token", "utf8"));
|
||||
assert.notEqual(seal(msg, pub), seal(msg, pub));
|
||||
});
|
||||
|
||||
test("a public key that is not 32 bytes is refused before anything is sealed", () => {
|
||||
assert.throws(() => seal(new Uint8Array([1, 2, 3]), Buffer.from("short").toString("base64")));
|
||||
});
|
||||
Reference in New Issue
Block a user