From 4c98bee0432abf2ed0f700901b24a9ddeb1d22e4 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 7 Sep 2026 01:55:19 +0200 Subject: [PATCH] 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 --- modules/anthropic-manager/adopt/index.ts | 44 +- modules/anthropic-manager/atrest.ts | 174 ------- modules/anthropic-manager/grantfile.ts | 32 ++ modules/anthropic-manager/module.json | 28 +- modules/anthropic-manager/refresh/index.ts | 77 ++- modules/anthropic-manager/sealedbox.ts | 480 ++++++++++++++++++ modules/anthropic-manager/test/atrest.test.ts | 47 -- .../anthropic-manager/test/sealedbox.test.ts | 36 ++ modules/anthropic-manager/tsconfig.json | 3 +- 9 files changed, 611 insertions(+), 310 deletions(-) delete mode 100644 modules/anthropic-manager/atrest.ts create mode 100644 modules/anthropic-manager/grantfile.ts create mode 100644 modules/anthropic-manager/sealedbox.ts delete mode 100644 modules/anthropic-manager/test/atrest.test.ts create mode 100644 modules/anthropic-manager/test/sealedbox.test.ts diff --git a/modules/anthropic-manager/adopt/index.ts b/modules/anthropic-manager/adopt/index.ts index 794cd7f..792ef1a 100644 --- a/modules/anthropic-manager/adopt/index.ts +++ b/modules/anthropic-manager/adopt/index.ts @@ -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"); diff --git a/modules/anthropic-manager/atrest.ts b/modules/anthropic-manager/atrest.ts deleted file mode 100644 index 759ab6c..0000000 --- a/modules/anthropic-manager/atrest.ts +++ /dev/null @@ -1,174 +0,0 @@ -// 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()]); -} diff --git a/modules/anthropic-manager/grantfile.ts b/modules/anthropic-manager/grantfile.ts new file mode 100644 index 0000000..2b0ceda --- /dev/null +++ b/modules/anthropic-manager/grantfile.ts @@ -0,0 +1,32 @@ +// Reading the manager node's PUBLIC sealing key out of the bound facts the mesh delivers, and +// writing a sealed refresh token in the wire shape mesh-control reads. +// +// **The public key is delivered, not derived.** The manager module holds no node key of its own +// (novox/hq ADR 0050) — it is deliberately never given one. To seal a refresh token to this node it +// needs the node's PUBLIC sealing key, and mesh-control puts that in the manager holder's bound facts +// (`serves.manager_public_key`), safe to disclose because it is public. Both adoption and every +// rotation read it from there. + +import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +/** The manager node's public sealing key, from the bound facts file the mesh delivers. */ +export function managerPublicKey(boundFile: string): string { + const raw = JSON.parse(readFileSync(boundFile, "utf8")) as { serves?: Record }; + const key = raw.serves?.["manager_public_key"]; + if (typeof key !== "string" || key === "") { + throw new Error( + "the bound facts carry no manager_public_key — this node is not the licence's manager, or " + + "the manager holder has not been delivered yet", + ); + } + return key; +} + +/** Write a sealed refresh token in the {sealed, manager_key} wire shape mesh-control reads. */ +export function writeSealedGrant(path: string, sealed: string, managerKey: string): void { + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.tmp`; + writeFileSync(tmp, JSON.stringify({ sealed, manager_key: managerKey }), { mode: 0o600 }); + renameSync(tmp, path); +} diff --git a/modules/anthropic-manager/module.json b/modules/anthropic-manager/module.json index b33c78c..90b60ec 100644 --- a/modules/anthropic-manager/module.json +++ b/modules/anthropic-manager/module.json @@ -4,6 +4,15 @@ "capabilities": [ "container-runtime" ], + "requires": [ + "model-access" + ], + "binds": { + "model-access": "/var/lib/mesh/anthropic-manager/model.json" + }, + "secrets": { + "model-access": "/var/lib/mesh/anthropic-manager/refresh-token" + }, "own-secrets": { "broker": "/var/lib/mesh/anthropic-manager/broker" }, @@ -17,26 +26,12 @@ "path": "/var/lib/mesh/anthropic-manager", "mode": "0700" }, - { - "id": "keys", - "type": "directory", - "path": "/var/lib/mesh/anthropic-manager/keys", - "mode": "0700" - }, { "id": "out", "type": "directory", "path": "/var/lib/mesh/anthropic-manager/out", "mode": "0700" }, - { - "id": "config", - "type": "file", - "path": "/var/lib/mesh/anthropic-manager/config.json", - "merge": "json", - "content": "{}", - "mode": "0600" - }, { "id": "refresh", "type": "container", @@ -55,9 +50,8 @@ "env": { "MESH_BROKER_FILE": "/run/secrets/broker", "MESH_ANTHROPIC_LICENCE": "personal", - "MESH_ANTHROPIC_GRANT_FILE": "/run/state/grant.json", - "MESH_NODE_SEALING_PUBLIC_FILE": "/run/state/keys/sealing.pub", - "MESH_NODE_SEALING_PRIVATE_FILE": "/run/state/keys/sealing.priv", + "MESH_MODEL_ACCESS_SECRET_FILE": "/run/state/refresh-token", + "MESH_MODEL_ACCESS_BIND_FILE": "/run/state/model.json", "MESH_ANTHROPIC_ACCESS_OUT": "/run/state/out/access-token", "MESH_ANTHROPIC_GRANT_OUT": "/run/state/out/grant.json", "MESH_ANTHROPIC_USAGE_OUT": "/run/state/out/usage.json", diff --git a/modules/anthropic-manager/refresh/index.ts b/modules/anthropic-manager/refresh/index.ts index 245057f..25df864 100644 --- a/modules/anthropic-manager/refresh/index.ts +++ b/modules/anthropic-manager/refresh/index.ts @@ -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; - 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 { 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 { 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 { (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) { diff --git a/modules/anthropic-manager/sealedbox.ts b/modules/anthropic-manager/sealedbox.ts new file mode 100644 index 0000000..e0251b8 --- /dev/null +++ b/modules/anthropic-manager/sealedbox.ts @@ -0,0 +1,480 @@ +// A NaCl `crypto_box_seal`, in TypeScript, byte-compatible with Go's `box.SealAnonymous`. +// +// **Why this file exists, and why it is exactly this.** novox/hq ADR 0050's refreshable-grant +// carve-out delivers the refresh token to the manager module the way the mesh delivers every other +// credential: sealed to the node's key, and unsealed by the *host* — never by the module. The host +// unseals with Go's `golang.org/x/crypto/nacl/box.OpenAnonymous` (mesh-host +// internal/identity/sealing.go), and mesh-control seals with `box.SealAnonymous` +// (mesh-control internal/secrets/seal.go). Both are NaCl `crypto_box_seal`: +// +// sealed = ephemeralPub(32) ‖ crypto_box(msg, nonce, recipientPub, ephemeralSecret) +// nonce = blake2b( ephemeralPub ‖ recipientPub , 24 bytes, unkeyed ) +// +// When the vendor rotates the refresh token, the manager module must store the new one back the +// same way — sealed to the manager node's own sealing key — so mesh-control keeps it without ever +// reading it and the host can later unseal it to deliver the cleartext again. That reseal happens +// here, on the manager node, in TypeScript. It therefore has to produce the *identical* byte format +// Go's `Open` accepts, or the host would refuse the delivery. +// +// **Dependency-free on purpose.** The module runtime image carries only mesh-tools' node_modules +// (novox/hq ADR 0052), so a module cannot pull `tweetnacl` at runtime. node:crypto gives X25519 but +// not XSalsa20-Poly1305 or a 24-byte BLAKE2b, so the `crypto_box` and the nonce hash are transcribed +// here from the public-domain TweetNaCl (Chestnykh/Mandiri, 2014) and blakejs (dcposch, RFC 7693). +// X25519 (ephemeral key generation and the Diffie-Hellman) is left to node:crypto, which is +// standards-conformant and interoperates with Go's curve25519 regardless of who generated a key. +// +// **How it is kept honest.** A cross-language test seals a fixture here and opens it in Go +// (mesh-control internal/secrets/sealedbox_xcheck_test.go), and this module's own test round-trips +// it against a second decrypt. A transcription slip surfaces there as a seal Go cannot open, not as +// a refresh token silently mangled in production. +// +// This module SEALS only. It never opens — opening is the host's job, with the node private key the +// module is deliberately never given. + +import { + createPublicKey, + diffieHellman, + generateKeyPairSync, + type KeyObject, +} from "node:crypto"; + +const RAW_KEY_LEN = 32; +// "expand 32-byte k", the Salsa20 constant. +const SIGMA = new Uint8Array([ + 101, 120, 112, 97, 110, 100, 32, 51, 50, 45, 98, 121, 116, 101, 32, 107, +]); + +/** + * Seal a value to a node's public sealing key, producing what Go's `box.OpenAnonymous` opens. + * + * @param value the plaintext (e.g. a rotated refresh token) + * @param recipientPublicB64 the node's raw 32-byte X25519 public key, standard base64 + * @returns standard-base64( ephemeralPub ‖ box ) + */ +export function seal(value: Uint8Array, recipientPublicB64: string): string { + const recipientPub = Buffer.from(recipientPublicB64, "base64"); + if (recipientPub.length !== RAW_KEY_LEN) { + throw new Error(`a sealing public key is 32 bytes, not ${recipientPub.length}`); + } + const recipientKey = importRawX25519Public(recipientPub); + + // Ephemeral-static ECDH: a throwaway X25519 key pair whose public half rides in front, and the + // raw Diffie-Hellman point shared with the recipient. node:crypto does both. + const eph = generateKeyPairSync("x25519"); + const ephemeralPub = rawX25519Public(eph.publicKey); + const dh = new Uint8Array(diffieHellman({ privateKey: eph.privateKey, publicKey: recipientKey })); + + // The crypto_box shared key is HSalsa20 of the DH point (crypto_box_beforenm). + const boxKey = new Uint8Array(32); + cryptoCoreHsalsa20(boxKey, new Uint8Array(16), dh, SIGMA); + + // nonce = blake2b(ephemeralPub ‖ recipientPub, 24), unkeyed — exactly Go's sealNonce. + const nonce = blake2b24(concat(ephemeralPub, recipientPub)); + + const boxed = cryptoBox(value, nonce, boxKey); + + return Buffer.from(concat(ephemeralPub, boxed)).toString("base64"); +} + +// --- X25519 via node:crypto ------------------------------------------------------------------ + +function importRawX25519Public(raw: Uint8Array): KeyObject { + return createPublicKey({ + key: { kty: "OKP", crv: "X25519", x: Buffer.from(raw).toString("base64url") }, + format: "jwk", + }); +} + +function rawX25519Public(key: KeyObject): Uint8Array { + const jwk = key.export({ format: "jwk" }) as { x?: string }; + if (!jwk.x) throw new Error("a public key had no point"); + return new Uint8Array(Buffer.from(jwk.x, "base64url")); +} + +// --- crypto_box / crypto_secretbox (XSalsa20-Poly1305) --------------------------------------- +// +// Transcribed from TweetNaCl (public domain). crypto_box after the DH/HSalsa20 above is exactly +// crypto_secretbox: XSalsa20 keystream XOR, then a Poly1305 tag over the ciphertext. + +/** crypto_box_afternm: secretbox(msg, nonce, key), returning tag(16) ‖ ciphertext. */ +function cryptoBox(msg: Uint8Array, nonce: Uint8Array, key: Uint8Array): Uint8Array { + // secretbox operates on a 32-byte-zero-prefixed message; its output's first 16 bytes are zero, + // and the useful box is everything from byte 16 (the Poly1305 tag, then the ciphertext). + const m = new Uint8Array(32 + msg.length); + m.set(msg, 32); + const c = new Uint8Array(m.length); + cryptoSecretbox(c, m, m.length, nonce, key); + return c.subarray(16); +} + +function cryptoSecretbox( + c: Uint8Array, + m: Uint8Array, + d: number, + n: Uint8Array, + k: Uint8Array, +): void { + if (d < 32) throw new Error("secretbox message underflow"); + cryptoStreamXor(c, 0, m, 0, d, n, k); + cryptoOnetimeauth(c, 16, c, 32, d - 32, c); + for (let i = 0; i < 16; i++) c[i] = 0; +} + +function L32(x: number, c: number): number { + return (x << c) | (x >>> (32 - c)); +} + +function ld32(x: Uint8Array, i: number): number { + let u = x[i + 3] & 0xff; + u = (u << 8) | (x[i + 2] & 0xff); + u = (u << 8) | (x[i + 1] & 0xff); + return (u << 8) | (x[i + 0] & 0xff); +} + +function st32(x: Uint8Array, j: number, u: number): void { + for (let i = 0; i < 4; i++) { + x[j + i] = u & 255; + u >>>= 8; + } +} + +function core(out: Uint8Array, inp: Uint8Array, k: Uint8Array, c: Uint8Array, h: boolean): void { + const w = new Uint32Array(16); + const x = new Uint32Array(16); + const y = new Uint32Array(16); + const t = new Uint32Array(4); + + for (let i = 0; i < 4; i++) { + x[5 * i] = ld32(c, 4 * i); + x[1 + i] = ld32(k, 4 * i); + x[6 + i] = ld32(inp, 4 * i); + x[11 + i] = ld32(k, 16 + 4 * i); + } + + for (let i = 0; i < 16; i++) y[i] = x[i]; + + for (let i = 0; i < 20; i++) { + for (let j = 0; j < 4; j++) { + for (let m = 0; m < 4; m++) t[m] = x[(5 * j + 4 * m) % 16]; + t[1] ^= L32((t[0] + t[3]) | 0, 7); + t[2] ^= L32((t[1] + t[0]) | 0, 9); + t[3] ^= L32((t[2] + t[1]) | 0, 13); + t[0] ^= L32((t[3] + t[2]) | 0, 18); + for (let m = 0; m < 4; m++) w[4 * j + ((j + m) % 4)] = t[m]; + } + for (let m = 0; m < 16; m++) x[m] = w[m]; + } + + if (h) { + for (let i = 0; i < 16; i++) x[i] = (x[i] + y[i]) | 0; + for (let i = 0; i < 4; i++) { + x[5 * i] = (x[5 * i] - ld32(c, 4 * i)) | 0; + x[6 + i] = (x[6 + i] - ld32(inp, 4 * i)) | 0; + } + for (let i = 0; i < 4; i++) { + st32(out, 4 * i, x[5 * i]); + st32(out, 16 + 4 * i, x[6 + i]); + } + } else { + for (let i = 0; i < 16; i++) st32(out, 4 * i, (x[i] + y[i]) | 0); + } +} + +function cryptoCoreHsalsa20(out: Uint8Array, inp: Uint8Array, k: Uint8Array, c: Uint8Array): void { + core(out, inp, k, c, true); +} + +function cryptoStreamSalsa20Xor( + c: Uint8Array, + cpos: number, + m: Uint8Array, + mpos: number, + b: number, + n: Uint8Array, + k: Uint8Array, +): void { + const z = new Uint8Array(16); + const x = new Uint8Array(64); + if (!b) return; + for (let i = 0; i < 8; i++) z[i] = n[i]; + while (b >= 64) { + coreSalsa20(x, z, k, SIGMA); + for (let i = 0; i < 64; i++) c[cpos + i] = m[mpos + i] ^ x[i]; + let u = 1; + for (let i = 8; i < 16; i++) { + u = (u + (z[i] & 0xff)) | 0; + z[i] = u & 0xff; + u >>>= 8; + } + b -= 64; + cpos += 64; + mpos += 64; + } + if (b > 0) { + coreSalsa20(x, z, k, SIGMA); + for (let i = 0; i < b; i++) c[cpos + i] = m[mpos + i] ^ x[i]; + } +} + +function coreSalsa20(out: Uint8Array, inp: Uint8Array, k: Uint8Array, c: Uint8Array): void { + core(out, inp, k, c, false); +} + +function cryptoStreamXor( + c: Uint8Array, + cpos: number, + m: Uint8Array, + mpos: number, + d: number, + n: Uint8Array, + k: Uint8Array, +): void { + const s = new Uint8Array(32); + cryptoCoreHsalsa20(s, n, k, SIGMA); + cryptoStreamSalsa20Xor(c, cpos, m, mpos, d, n.subarray(16), s); +} + +const MINUSP = new Uint32Array([5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 252]); + +function add1305(h: Uint32Array, c: Uint32Array): void { + let u = 0; + for (let j = 0; j < 17; j++) { + u = (u + ((h[j] + c[j]) | 0)) | 0; + h[j] = u & 255; + u >>>= 8; + } +} + +function cryptoOnetimeauth( + out: Uint8Array, + outpos: number, + m: Uint8Array, + mpos: number, + n: number, + k: Uint8Array, +): void { + const x = new Uint32Array(17); + const r = new Uint32Array(17); + const h = new Uint32Array(17); + const c = new Uint32Array(17); + const g = new Uint32Array(17); + for (let j = 0; j < 16; j++) r[j] = k[j]; + r[3] &= 15; + r[4] &= 252; + r[7] &= 15; + r[8] &= 252; + r[11] &= 15; + r[12] &= 252; + r[15] &= 15; + + let j: number; + while (n > 0) { + for (j = 0; j < 17; j++) c[j] = 0; + for (j = 0; j < 16 && j < n; ++j) c[j] = m[mpos + j]; + c[j] = 1; + mpos += j; + n -= j; + add1305(h, c); + for (let i = 0; i < 17; i++) { + x[i] = 0; + for (j = 0; j < 17; j++) { + x[i] = + (x[i] + (h[j] * (j <= i ? r[i - j] : (320 * r[i + 17 - j]) | 0)) | 0) | 0; + } + } + for (let i = 0; i < 17; i++) h[i] = x[i]; + let u = 0; + for (j = 0; j < 16; j++) { + u = (u + h[j]) | 0; + h[j] = u & 255; + u >>>= 8; + } + u = (u + h[16]) | 0; + h[16] = u & 3; + u = (5 * (u >>> 2)) | 0; + for (j = 0; j < 16; j++) { + u = (u + h[j]) | 0; + h[j] = u & 255; + u >>>= 8; + } + u = (u + h[16]) | 0; + h[16] = u; + } + + for (j = 0; j < 17; j++) g[j] = h[j]; + add1305(h, MINUSP); + const s = -(h[16] >>> 7) | 0; + for (j = 0; j < 17; j++) h[j] ^= s & (g[j] ^ h[j]); + + for (j = 0; j < 16; j++) c[j] = k[j + 16]; + c[16] = 0; + add1305(h, c); + for (j = 0; j < 16; j++) out[outpos + j] = h[j]; +} + +// --- BLAKE2b (24-byte, unkeyed) — the sealed-box nonce hash ---------------------------------- +// +// Transcribed from blakejs (RFC 7693 reference). Only the fixed path this needs: no key, no salt, +// no personalisation, a single ≤128-byte input. + +const BLAKE2B_IV32 = new Uint32Array([ + 0xf3bcc908, 0x6a09e667, 0x84caa73b, 0xbb67ae85, 0xfe94f82b, 0x3c6ef372, 0x5f1d36f1, 0xa54ff53a, + 0xade682d1, 0x510e527f, 0x2b3e6c1f, 0x9b05688c, 0xfb41bd6b, 0x1f83d9ab, 0x137e2179, 0x5be0cd19, +]); + +const SIGMA82 = new Uint8Array( + [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, + 11, 7, 5, 3, 11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4, 7, 9, 3, 1, 13, 12, 11, 14, + 2, 6, 5, 10, 4, 0, 15, 8, 9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13, 2, 12, 6, 10, 0, + 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9, 12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11, 13, + 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10, 6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, + 10, 5, 10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, + 11, 12, 13, 14, 15, 14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3, + ].map((n) => n * 2), +); + +interface Blake2bCtx { + b: Uint8Array; + h: Uint32Array; + t: number; + c: number; + outlen: number; +} + +function b2bGet32(arr: Uint8Array, i: number): number { + return (arr[i] ^ (arr[i + 1] << 8) ^ (arr[i + 2] << 16) ^ (arr[i + 3] << 24)) >>> 0; +} + +function add64aa(v: Uint32Array, a: number, b: number): void { + const o0 = v[a] + v[b]; + let o1 = v[a + 1] + v[b + 1]; + if (o0 >= 0x100000000) o1++; + v[a] = o0; + v[a + 1] = o1; +} + +function add64ac(v: Uint32Array, a: number, b0: number, b1: number): void { + let o0 = v[a] + b0; + if (b0 < 0) o0 += 0x100000000; + let o1 = v[a + 1] + b1; + if (o0 >= 0x100000000) o1++; + v[a] = o0; + v[a + 1] = o1; +} + +function b2bG( + v: Uint32Array, + m: Uint32Array, + a: number, + b: number, + c: number, + d: number, + ix: number, + iy: number, +): void { + const x0 = m[ix]; + const x1 = m[ix + 1]; + const y0 = m[iy]; + const y1 = m[iy + 1]; + + add64aa(v, a, b); + add64ac(v, a, x0, x1); + + let xor0 = v[d] ^ v[a]; + let xor1 = v[d + 1] ^ v[a + 1]; + v[d] = xor1; + v[d + 1] = xor0; + + add64aa(v, c, d); + + xor0 = v[b] ^ v[c]; + xor1 = v[b + 1] ^ v[c + 1]; + v[b] = (xor0 >>> 24) ^ (xor1 << 8); + v[b + 1] = (xor1 >>> 24) ^ (xor0 << 8); + + add64aa(v, a, b); + add64ac(v, a, y0, y1); + + xor0 = v[d] ^ v[a]; + xor1 = v[d + 1] ^ v[a + 1]; + v[d] = (xor0 >>> 16) ^ (xor1 << 16); + v[d + 1] = (xor1 >>> 16) ^ (xor0 << 16); + + add64aa(v, c, d); + + xor0 = v[b] ^ v[c]; + xor1 = v[b + 1] ^ v[c + 1]; + v[b] = (xor1 >>> 31) ^ (xor0 << 1); + v[b + 1] = (xor0 >>> 31) ^ (xor1 << 1); +} + +function blake2bCompress(ctx: Blake2bCtx, last: boolean): void { + const v = new Uint32Array(32); + const m = new Uint32Array(32); + for (let i = 0; i < 16; i++) { + v[i] = ctx.h[i]; + v[i + 16] = BLAKE2B_IV32[i]; + } + v[24] = v[24] ^ ctx.t; + v[25] = v[25] ^ (ctx.t / 0x100000000); + if (last) { + v[28] = ~v[28]; + v[29] = ~v[29]; + } + for (let i = 0; i < 32; i++) m[i] = b2bGet32(ctx.b, 4 * i); + for (let i = 0; i < 12; i++) { + b2bG(v, m, 0, 8, 16, 24, SIGMA82[i * 16 + 0], SIGMA82[i * 16 + 1]); + b2bG(v, m, 2, 10, 18, 26, SIGMA82[i * 16 + 2], SIGMA82[i * 16 + 3]); + b2bG(v, m, 4, 12, 20, 28, SIGMA82[i * 16 + 4], SIGMA82[i * 16 + 5]); + b2bG(v, m, 6, 14, 22, 30, SIGMA82[i * 16 + 6], SIGMA82[i * 16 + 7]); + b2bG(v, m, 0, 10, 20, 30, SIGMA82[i * 16 + 8], SIGMA82[i * 16 + 9]); + b2bG(v, m, 2, 12, 22, 24, SIGMA82[i * 16 + 10], SIGMA82[i * 16 + 11]); + b2bG(v, m, 4, 14, 16, 26, SIGMA82[i * 16 + 12], SIGMA82[i * 16 + 13]); + b2bG(v, m, 6, 8, 18, 28, SIGMA82[i * 16 + 14], SIGMA82[i * 16 + 15]); + } + for (let i = 0; i < 16; i++) ctx.h[i] = ctx.h[i] ^ v[i] ^ v[i + 16]; +} + +function blake2b24(input: Uint8Array): Uint8Array { + const outlen = 24; + const ctx: Blake2bCtx = { + b: new Uint8Array(128), + h: new Uint32Array(16), + t: 0, + c: 0, + outlen, + }; + // Parameter block: outlen, keylen=0, fanout=1, depth=1; the rest zero. + const param = new Uint8Array(64); + param[0] = outlen; + param[2] = 1; + param[3] = 1; + for (let i = 0; i < 16; i++) ctx.h[i] = BLAKE2B_IV32[i] ^ b2bGet32(param, i * 4); + + for (let i = 0; i < input.length; i++) { + if (ctx.c === 128) { + ctx.t += ctx.c; + blake2bCompress(ctx, false); + ctx.c = 0; + } + ctx.b[ctx.c++] = input[i]; + } + + ctx.t += ctx.c; + while (ctx.c < 128) ctx.b[ctx.c++] = 0; + blake2bCompress(ctx, true); + + const out = new Uint8Array(outlen); + for (let i = 0; i < outlen; i++) out[i] = ctx.h[i >> 2] >> (8 * (i & 3)); + return out; +} + +// --- small helpers --------------------------------------------------------------------------- + +function concat(a: Uint8Array, b: Uint8Array): Uint8Array { + const out = new Uint8Array(a.length + b.length); + out.set(a, 0); + out.set(b, a.length); + return out; +} diff --git a/modules/anthropic-manager/test/atrest.test.ts b/modules/anthropic-manager/test/atrest.test.ts deleted file mode 100644 index 595bf63..0000000 --- a/modules/anthropic-manager/test/atrest.test.ts +++ /dev/null @@ -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)); -}); diff --git a/modules/anthropic-manager/test/sealedbox.test.ts b/modules/anthropic-manager/test/sealedbox.test.ts new file mode 100644 index 0000000..a30bb30 --- /dev/null +++ b/modules/anthropic-manager/test/sealedbox.test.ts @@ -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"))); +}); diff --git a/modules/anthropic-manager/tsconfig.json b/modules/anthropic-manager/tsconfig.json index 3a6da13..c1ebce4 100644 --- a/modules/anthropic-manager/tsconfig.json +++ b/modules/anthropic-manager/tsconfig.json @@ -9,7 +9,8 @@ "noEmit": true }, "include": [ - "atrest.ts", + "sealedbox.ts", + "grantfile.ts", "client.ts", "adopt/index.ts", "refresh/index.ts"