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.
|
// 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
|
// The refresh token is read here, on the MANAGER NODE, sealed to that node's PUBLIC sealing key, and
|
||||||
// only the sealed envelope leaves this process (novox/hq ADR 0050, Phase C). The control plane stores
|
// only the sealed box leaves this process (novox/hq ADR 0050). The control plane stores that box via
|
||||||
// that envelope via `licence set-grant` without ever seeing the refresh token in the clear — the same
|
// `licence set-grant` without ever seeing the refresh token in the clear — the same bound every
|
||||||
// bound every refresh keeps. This is the counterpart to `refresh/index.js`: adoption seals the first
|
// delivery keeps. This is the counterpart to `refresh/index.js`: adoption seals the first box, refresh
|
||||||
// envelope, refresh opens and re-seals it.
|
// 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_ANTHROPIC_ADOPT_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_MODEL_ACCESS_BIND_FILE the manager holder's bound facts, carrying manager_public_key
|
||||||
// MESH_ANTHROPIC_GRANT_OUT where the sealed envelope is written, for `licence set-grant`
|
// MESH_ANTHROPIC_GRANT_OUT where the sealed box is written, for `licence set-grant`
|
||||||
|
|
||||||
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { dirname } from "node:path";
|
|
||||||
|
|
||||||
import { sealAtRest } from "../atrest.js";
|
import { seal } from "../sealedbox.js";
|
||||||
|
import { managerPublicKey, writeSealedGrant } from "../grantfile.js";
|
||||||
|
|
||||||
function required(name: string): string {
|
function required(name: string): string {
|
||||||
const v = process.env[name];
|
const v = process.env[name];
|
||||||
@@ -21,19 +23,13 @@ function required(name: string): string {
|
|||||||
return v;
|
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");
|
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();
|
// The node's PUBLIC sealing key, delivered by the mesh in the manager holder's bound facts. Public,
|
||||||
const envelope = sealAtRest(refreshToken, nodePub);
|
// 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");
|
const sealed = seal(new Uint8Array(Buffer.from(refreshToken, "utf8")), nodePub);
|
||||||
mkdirSync(dirname(out), { recursive: true });
|
writeSealedGrant(required("MESH_ANTHROPIC_GRANT_OUT"), sealed, nodePub);
|
||||||
const tmp = `${out}.tmp`;
|
console.error("[anthropic-manager] sealed the refresh token to this node's key; only the host opens it");
|
||||||
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");
|
|
||||||
|
|||||||
@@ -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()]);
|
|
||||||
}
|
|
||||||
@@ -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<string, unknown> };
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -4,6 +4,15 @@
|
|||||||
"capabilities": [
|
"capabilities": [
|
||||||
"container-runtime"
|
"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": {
|
"own-secrets": {
|
||||||
"broker": "/var/lib/mesh/anthropic-manager/broker"
|
"broker": "/var/lib/mesh/anthropic-manager/broker"
|
||||||
},
|
},
|
||||||
@@ -17,26 +26,12 @@
|
|||||||
"path": "/var/lib/mesh/anthropic-manager",
|
"path": "/var/lib/mesh/anthropic-manager",
|
||||||
"mode": "0700"
|
"mode": "0700"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "keys",
|
|
||||||
"type": "directory",
|
|
||||||
"path": "/var/lib/mesh/anthropic-manager/keys",
|
|
||||||
"mode": "0700"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "out",
|
"id": "out",
|
||||||
"type": "directory",
|
"type": "directory",
|
||||||
"path": "/var/lib/mesh/anthropic-manager/out",
|
"path": "/var/lib/mesh/anthropic-manager/out",
|
||||||
"mode": "0700"
|
"mode": "0700"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "config",
|
|
||||||
"type": "file",
|
|
||||||
"path": "/var/lib/mesh/anthropic-manager/config.json",
|
|
||||||
"merge": "json",
|
|
||||||
"content": "{}",
|
|
||||||
"mode": "0600"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "refresh",
|
"id": "refresh",
|
||||||
"type": "container",
|
"type": "container",
|
||||||
@@ -55,9 +50,8 @@
|
|||||||
"env": {
|
"env": {
|
||||||
"MESH_BROKER_FILE": "/run/secrets/broker",
|
"MESH_BROKER_FILE": "/run/secrets/broker",
|
||||||
"MESH_ANTHROPIC_LICENCE": "personal",
|
"MESH_ANTHROPIC_LICENCE": "personal",
|
||||||
"MESH_ANTHROPIC_GRANT_FILE": "/run/state/grant.json",
|
"MESH_MODEL_ACCESS_SECRET_FILE": "/run/state/refresh-token",
|
||||||
"MESH_NODE_SEALING_PUBLIC_FILE": "/run/state/keys/sealing.pub",
|
"MESH_MODEL_ACCESS_BIND_FILE": "/run/state/model.json",
|
||||||
"MESH_NODE_SEALING_PRIVATE_FILE": "/run/state/keys/sealing.priv",
|
|
||||||
"MESH_ANTHROPIC_ACCESS_OUT": "/run/state/out/access-token",
|
"MESH_ANTHROPIC_ACCESS_OUT": "/run/state/out/access-token",
|
||||||
"MESH_ANTHROPIC_GRANT_OUT": "/run/state/out/grant.json",
|
"MESH_ANTHROPIC_GRANT_OUT": "/run/state/out/grant.json",
|
||||||
"MESH_ANTHROPIC_USAGE_OUT": "/run/state/out/usage.json",
|
"MESH_ANTHROPIC_USAGE_OUT": "/run/state/out/usage.json",
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
// The manager's scheduled run (novox/hq ADR 0050/0053). It is the whole of the carve-out in one
|
// 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:
|
// 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);
|
// 1. read the refresh token as CLEARTEXT — the host unsealed the stored box with THIS node's private
|
||||||
// 2. open it HERE with the node's own sealing key — the one moment a refresh token is in the clear,
|
// key and mounted it at the module's bound secret path, exactly as it delivers any credential.
|
||||||
// on the one node the ADR permits it;
|
// This module holds no node key and opens nothing itself;
|
||||||
// 3. call the vendor's OAuth token endpoint to mint a fresh access token (and maybe a rotated
|
// 2. call the vendor's OAuth token endpoint to mint a fresh access token (and maybe a rotated
|
||||||
// refresh token);
|
// refresh token);
|
||||||
// 4. re-seal the rotated refresh token at rest (still openable by this node alone);
|
// 3. if the vendor rotated the refresh token, SEAL the new one to this node's PUBLIC sealing key
|
||||||
// 5. hand the control plane back ONLY the access token in the clear + the opaque re-sealed
|
// (delivered in the bound facts) with the same anonymous box the mesh seals every credential with;
|
||||||
// envelope — never the refresh token — which it seals per holder and stores;
|
// 4. hand the control plane back ONLY the access token in the clear + the opaque re-sealed box —
|
||||||
// 6. poll usage with the fresh access token and record the licence-grain reading.
|
// 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 +
|
// mesh-control receives the products of steps 3–4 through `licence submit-refresh` (access token +
|
||||||
// opaque envelope). The refresh token never leaves this process except as ciphertext.
|
// 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
|
// 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
|
// 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 { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
||||||
import { dirname } from "node:path";
|
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";
|
import { refreshGrant, grantFromRefresh, readUsage, flattenUsage } from "../client.js";
|
||||||
|
|
||||||
function required(name: string): string {
|
function required(name: string): string {
|
||||||
@@ -32,30 +35,6 @@ function required(name: string): string {
|
|||||||
return v;
|
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 {
|
function atomicWrite(path: string, content: string): void {
|
||||||
mkdirSync(dirname(path), { recursive: true });
|
mkdirSync(dirname(path), { recursive: true });
|
||||||
const tmp = `${path}.tmp`;
|
const tmp = `${path}.tmp`;
|
||||||
@@ -66,14 +45,18 @@ function atomicWrite(path: string, content: string): void {
|
|||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const licence = process.env.MESH_ANTHROPIC_LICENCE ?? "unknown";
|
const licence = process.env.MESH_ANTHROPIC_LICENCE ?? "unknown";
|
||||||
|
|
||||||
const envelope = readEnvelope(required("MESH_ANTHROPIC_GRANT_FILE"));
|
// Step 1: the refresh token as cleartext, unsealed and mounted by the HOST. No open here.
|
||||||
const nodePub = readTrimmed(required("MESH_NODE_SEALING_PUBLIC_FILE"));
|
const refreshToken = readFileSync(required("MESH_MODEL_ACCESS_SECRET_FILE"), "utf8").trim();
|
||||||
const nodePriv = readTrimmed(required("MESH_NODE_SEALING_PRIVATE_FILE"));
|
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.
|
// The node's PUBLIC sealing key, to re-seal a rotated refresh token. Public, delivered in the facts.
|
||||||
const refreshToken = openAtRest(envelope, nodePub, nodePriv);
|
const nodePub = managerPublicKey(required("MESH_MODEL_ACCESS_BIND_FILE"));
|
||||||
|
|
||||||
// Step 3: the vendor call.
|
// Step 2: the vendor call.
|
||||||
const refreshed = await refreshGrant(refreshToken);
|
const refreshed = await refreshGrant(refreshToken);
|
||||||
if (!refreshed) {
|
if (!refreshed) {
|
||||||
// A dead endpoint or a rejected token: nothing to publish, and we do not clobber a good grant.
|
// 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`);
|
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) {
|
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) {
|
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
|
// Step 4: the access token in the clear, for the control plane to seal per consumer holder. This is
|
||||||
// ever receives that is not ciphertext.
|
// all it ever receives that is not ciphertext.
|
||||||
atomicWrite(required("MESH_ANTHROPIC_ACCESS_OUT"), grant.access.accessToken);
|
atomicWrite(required("MESH_ANTHROPIC_ACCESS_OUT"), grant.access.accessToken);
|
||||||
|
|
||||||
console.error(
|
console.error(
|
||||||
@@ -101,7 +84,7 @@ async function main(): Promise<void> {
|
|||||||
(grant.rotatedRefresh ? ", refresh token rotated and re-sealed" : ", refresh token unchanged"),
|
(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 {
|
try {
|
||||||
const usage = await readUsage(grant.access.accessToken);
|
const usage = await readUsage(grant.access.accessToken);
|
||||||
if (usage) {
|
if (usage) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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")));
|
||||||
|
});
|
||||||
@@ -9,7 +9,8 @@
|
|||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"atrest.ts",
|
"sealedbox.ts",
|
||||||
|
"grantfile.ts",
|
||||||
"client.ts",
|
"client.ts",
|
||||||
"adopt/index.ts",
|
"adopt/index.ts",
|
||||||
"refresh/index.ts"
|
"refresh/index.ts"
|
||||||
|
|||||||
Reference in New Issue
Block a user