From c206e2e11edec95ad76f14bc5195c84ef99f2cc8 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 7 Sep 2026 01:00:12 +0200 Subject: [PATCH 1/3] anthropic model-access modules: manager (refreshable-grant) and consumer Phase C of vendor-agnostic model-access (ADR 0050/0054). Two TypeScript runtime modules: - anthropic-manager: the refresh token is sealed at rest to the manager node's own key (atrest.ts, envelope encryption over X25519) and opened ONLY on the manager node. adopt seals the first envelope; refresh opens it, calls the Anthropic OAuth token endpoint, re-seals a rotated refresh token, and hands the control plane only the access token plus the opaque envelope. Also polls licence-grain usage (ADR 0054). - anthropic-consumer: writes the delivered access token to ~/.claude/.credentials.json, access-token-only, atomically (the refresh token is never delivered); reports session-grain usage from the CLI transcripts; a fail-closed identity guard (expected-uuid plumbing is a flagged TODO). Both run as scheduled containers (ADR 0053). Pure logic covered by node --test fixtures (at-rest round-trip, credential strip, transcript sum, refresh merge). Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- modules/anthropic-consumer/apply/index.ts | 75 ++++++++ modules/anthropic-consumer/credentials.ts | 82 +++++++++ modules/anthropic-consumer/identity.ts | 49 +++++ modules/anthropic-consumer/module.json | 91 +++++++++ modules/anthropic-consumer/package.json | 14 ++ .../test/credentials.test.ts | 32 ++++ .../test/transcript.test.ts | 48 +++++ modules/anthropic-consumer/transcript.ts | 113 ++++++++++++ modules/anthropic-consumer/tsconfig.json | 18 ++ modules/anthropic-consumer/usage/index.ts | 92 +++++++++ modules/anthropic-manager/adopt/index.ts | 39 ++++ modules/anthropic-manager/atrest.ts | 174 ++++++++++++++++++ modules/anthropic-manager/client.ts | 135 ++++++++++++++ modules/anthropic-manager/module.json | 68 +++++++ modules/anthropic-manager/package.json | 14 ++ modules/anthropic-manager/refresh/index.ts | 142 ++++++++++++++ modules/anthropic-manager/test/atrest.test.ts | 47 +++++ modules/anthropic-manager/test/client.test.ts | 43 +++++ modules/anthropic-manager/tsconfig.json | 17 ++ 19 files changed, 1293 insertions(+) create mode 100644 modules/anthropic-consumer/apply/index.ts create mode 100644 modules/anthropic-consumer/credentials.ts create mode 100644 modules/anthropic-consumer/identity.ts create mode 100644 modules/anthropic-consumer/module.json create mode 100644 modules/anthropic-consumer/package.json create mode 100644 modules/anthropic-consumer/test/credentials.test.ts create mode 100644 modules/anthropic-consumer/test/transcript.test.ts create mode 100644 modules/anthropic-consumer/transcript.ts create mode 100644 modules/anthropic-consumer/tsconfig.json create mode 100644 modules/anthropic-consumer/usage/index.ts create mode 100644 modules/anthropic-manager/adopt/index.ts create mode 100644 modules/anthropic-manager/atrest.ts create mode 100644 modules/anthropic-manager/client.ts create mode 100644 modules/anthropic-manager/module.json create mode 100644 modules/anthropic-manager/package.json create mode 100644 modules/anthropic-manager/refresh/index.ts create mode 100644 modules/anthropic-manager/test/atrest.test.ts create mode 100644 modules/anthropic-manager/test/client.test.ts create mode 100644 modules/anthropic-manager/tsconfig.json diff --git a/modules/anthropic-consumer/apply/index.ts b/modules/anthropic-consumer/apply/index.ts new file mode 100644 index 0000000..c0ab37a --- /dev/null +++ b/modules/anthropic-consumer/apply/index.ts @@ -0,0 +1,75 @@ +// The consumer's scheduled run: take the ACCESS token the mesh delivered and write it where the +// Claude CLI reads it, access-token-only (novox/hq ADR 0050). The refresh token is never here to +// strip — the manager holds it, and a holder's delivery has only ever been the access token. +// +// What the host delivers, per the manifest: +// secrets.model-access -> a file holding the sealed-then-unsealed ACCESS token (the host opened it +// with this node's private key; this process reads plaintext). +// binds.model-access -> a JSON file of the non-secret facts the licence serves (which licence, +// model, and — when the control plane carries them — grant expiry/scopes). +// +// Runs as `mesh-tools run` (no broker) on a schedule, so it is idempotent: same token in, same file +// out. + +import { readFileSync } from "node:fs"; + +import { deliver, type DeliveredGrant } from "../credentials.js"; +import { readAccountUuid, check } from "../identity.js"; + +function required(name: string): string { + const v = process.env[name]; + if (!v) throw new Error(`${name} is not set — the consumer runtime was deployed without it`); + return v; +} + +/** Read optional non-secret grant metadata (expiry, scopes, subscription) from the bound facts file. */ +function readBoundMeta(path: string | undefined): Partial { + if (!path) return {}; + try { + const raw = JSON.parse(readFileSync(path, "utf8")) as Record; + return { + expiresAt: typeof raw.expiresAt === "number" ? raw.expiresAt : null, + refreshTokenExpiresAt: typeof raw.refreshTokenExpiresAt === "number" ? raw.refreshTokenExpiresAt : null, + scopes: Array.isArray(raw.scopes) ? (raw.scopes as string[]) : null, + subscriptionType: typeof raw.subscriptionType === "string" ? raw.subscriptionType : null, + }; + } catch { + return {}; + } +} + +function main(): void { + const accessToken = readFileSync(required("MESH_MODEL_ACCESS_SECRET_FILE"), "utf8").trim(); + if (!accessToken) { + // Nothing was delivered — which reads exactly like a credential that never arrived, so it is + // said rather than written as an empty file the CLI would take for a login it should not do. + throw new Error("[anthropic-consumer] the delivered access token is empty; nothing was written"); + } + + const meta = readBoundMeta(process.env.MESH_MODEL_ACCESS_BIND_FILE); + const grant: DeliveredGrant = { accessToken, ...meta }; + + const target = process.env.MESH_CLAUDE_CREDENTIALS_FILE ?? `${homedir()}/.claude/.credentials.json`; + deliver(target, grant); + console.error(`[anthropic-consumer] wrote an access-token-only credential to ${target}`); + + // The mis-binding guard, best-effort and fail-closed. The expected account uuid is not yet plumbed + // (identity.ts TODO), so this reports what it can see rather than acting on it — it never delivers + // to a wrong account because it never learns one to deliver to. + const identityFile = process.env.MESH_CLAUDE_IDENTITY_FILE ?? `${homedir()}/.claude.json`; + const found = readAccountUuid(identityFile); + const expected = process.env.MESH_MODEL_ACCESS_ACCOUNT_UUID ?? null; + const verdict = check(found, expected); + if (verdict.state === "wrong-account") { + throw new Error( + `[anthropic-consumer] the CLI is logged in as ${verdict.found}, not the licensed ${verdict.expected}; refusing`, + ); + } + console.error(`[anthropic-consumer] identity check: ${verdict.state}`); +} + +function homedir(): string { + return process.env.HOME ?? "/root"; +} + +main(); diff --git a/modules/anthropic-consumer/credentials.ts b/modules/anthropic-consumer/credentials.ts new file mode 100644 index 0000000..574c9f5 --- /dev/null +++ b/modules/anthropic-consumer/credentials.ts @@ -0,0 +1,82 @@ +// Writing the access token where the Claude CLI reads it — the consumer half of model-access +// (novox/hq ADR 0050). A node holds an ACCESS token and nothing else: it cannot rotate, so it is +// never given a refresh token, and this enforces that on every write. +// +// The file shape and the strip are ported byte-exact from the mature implementation (see the port +// map): `~/.claude/.credentials.json` → `{ claudeAiOauth: { accessToken, expiresAt, +// refreshTokenExpiresAt?, scopes?, subscriptionType? } }`, and the refresh token is deleted, not +// merely omitted, so a full grant left by an interactive login is stripped back to access-only. + +import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +/** The access-token-only grant the mesh delivered — what the manager submitted, minus the refresh. */ +export interface DeliveredGrant { + readonly accessToken: string; + readonly expiresAt?: number | null; + readonly refreshTokenExpiresAt?: number | null; + readonly scopes?: string[] | null; + readonly subscriptionType?: string | null; +} + +interface ClaudeOauth { + accessToken?: string; + expiresAt?: number; + refreshTokenExpiresAt?: number; + scopes?: string[]; + subscriptionType?: string; + refreshToken?: string; +} + +interface Credentials { + claudeAiOauth?: ClaudeOauth; + [key: string]: unknown; +} + +/** Read the existing credentials file, or an empty object if there is none or it is unreadable. */ +function readLocal(path: string): Credentials { + try { + return JSON.parse(readFileSync(path, "utf8")) as Credentials; + } catch { + return {}; + } +} + +/** + * Overlay the delivered grant onto whatever is on disk, then STRIP the refresh token — the node + * carve-out. Returns the object to write, so the strip is testable without touching a file. + */ +export function applyGrant(local: Credentials, grant: DeliveredGrant): Credentials { + const oauth = local.claudeAiOauth ?? {}; + const next: Credentials = { + ...local, + claudeAiOauth: { + ...oauth, + accessToken: grant.accessToken, + ...(grant.expiresAt != null ? { expiresAt: grant.expiresAt } : {}), + ...(grant.refreshTokenExpiresAt != null + ? { refreshTokenExpiresAt: grant.refreshTokenExpiresAt } + : {}), + ...(grant.scopes ? { scopes: grant.scopes } : {}), + ...(grant.subscriptionType ? { subscriptionType: grant.subscriptionType } : {}), + }, + }; + // A node NEVER holds a refresh token: delete it, so a full grant on disk is reduced to access-only. + delete next.claudeAiOauth!.refreshToken; + return next; +} + +/** Atomic write-then-rename at 0600 — a partial credentials file must never be read as a whole one. */ +export function writeCredentials(path: string, creds: Credentials): void { + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.tmp`; + writeFileSync(tmp, JSON.stringify(creds, null, 2), { mode: 0o600 }); + renameSync(tmp, path); +} + +/** Read, overlay, strip, write — the whole consumer credential update, in one call. */ +export function deliver(path: string, grant: DeliveredGrant): Credentials { + const next = applyGrant(readLocal(path), grant); + writeCredentials(path, next); + return next; +} diff --git a/modules/anthropic-consumer/identity.ts b/modules/anthropic-consumer/identity.ts new file mode 100644 index 0000000..e3af1c0 --- /dev/null +++ b/modules/anthropic-consumer/identity.ts @@ -0,0 +1,49 @@ +// The mis-binding guard (novox/hq ADR 0050, port map §identity). Account identity is NOT in the +// token or any API — it lives in a sibling CLI state file, `~/.claude.json` → +// `oauthAccount.accountUuid`. The guard compares the account the CLI is actually logged in as to the +// account the licence was recorded against, and FAILS CLOSED: an absent file or an unrecorded licence +// account refuses rather than guesses, because delivering an access token to the wrong account is the +// exact fault this exists to catch. +// +// **Partial first cut, FLAGGED.** Reading the sibling file is implemented; the licence's recorded +// account uuid is not yet plumbed from the control plane to the consumer (the bound `model.json` does +// not carry it today). So `check` returns `licence-not-adopted` when no expected uuid is supplied, +// which is the fail-closed answer, and the wiring of the expected uuid is a TODO below. + +import { readFileSync } from "node:fs"; + +export type IdentityVerdict = + | { state: "verified"; accountUuid: string } + | { state: "no-identity-file" } + | { state: "licence-not-adopted" } + | { state: "wrong-account"; found: string; expected: string }; + +interface ClaudeJson { + oauthAccount?: { accountUuid?: string; emailAddress?: string; organizationUuid?: string }; +} + +/** Read `oauthAccount.accountUuid` from `~/.claude.json`, or null if the file or field is absent. */ +export function readAccountUuid(path: string): string | null { + try { + const raw = JSON.parse(readFileSync(path, "utf8")) as ClaudeJson; + return raw.oauthAccount?.accountUuid ?? null; + } catch { + return null; + } +} + +/** + * Compare the CLI's logged-in account to the one the licence was recorded against. Pure over its + * inputs so the fail-closed logic is tested without a filesystem. + * + * TODO(novox/hq ADR 0050, Phase C): plumb `expected` — the licence's recorded account uuid — from the + * control plane into the consumer's bound `model.json`, then adopt-on-first-sight or refuse per the + * port map's five states. Until then only the two safe verdicts are reachable: verified when an + * expected uuid is provided and matches, refuse otherwise. + */ +export function check(found: string | null, expected: string | null): IdentityVerdict { + if (found === null) return { state: "no-identity-file" }; + if (!expected) return { state: "licence-not-adopted" }; + if (found === expected) return { state: "verified", accountUuid: found }; + return { state: "wrong-account", found, expected }; +} diff --git a/modules/anthropic-consumer/module.json b/modules/anthropic-consumer/module.json new file mode 100644 index 0000000..0b5e52c --- /dev/null +++ b/modules/anthropic-consumer/module.json @@ -0,0 +1,91 @@ +{ + "module": "anthropic-consumer", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "requires": [ + "model-access" + ], + "binds": { + "model-access": "/var/lib/anthropic-consumer/model.json" + }, + "secrets": { + "model-access": "/var/lib/anthropic-consumer/access-token" + }, + "own-secrets": { + "broker": "/var/lib/mesh/anthropic-consumer/broker" + }, + "emits": [ + "module.anthropic-consumer.usage.session" + ], + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "path": "/var/lib/mesh/anthropic-consumer", + "mode": "0700" + }, + { + "id": "state", + "type": "directory", + "path": "/var/lib/anthropic-consumer", + "mode": "0700" + }, + { + "id": "claude-home", + "type": "directory", + "path": "/var/lib/anthropic-consumer/claude", + "mode": "0700" + }, + { + "id": "out", + "type": "directory", + "path": "/var/lib/anthropic-consumer/out", + "mode": "0700" + }, + { + "id": "apply", + "type": "container", + "name": "mesh-anthropic-consumer-apply", + "image": "mesh-runtime-anthropic-consumer@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "schedule": "*/5 * * * *", + "args": [ + "run", + "/app/modules/anthropic-consumer/dist/apply/index.js" + ], + "volumes": [ + "/var/lib/anthropic-consumer:/run/state" + ], + "env": { + "MESH_MODEL_ACCESS_SECRET_FILE": "/run/state/access-token", + "MESH_MODEL_ACCESS_BIND_FILE": "/run/state/model.json", + "MESH_CLAUDE_CREDENTIALS_FILE": "/run/state/claude/.credentials.json", + "MESH_CLAUDE_IDENTITY_FILE": "/run/state/claude/.claude.json" + } + }, + { + "id": "usage", + "type": "container", + "name": "mesh-anthropic-consumer-usage", + "image": "mesh-runtime-anthropic-consumer@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "schedule": "*/5 * * * *", + "args": [ + "run", + "/app/modules/anthropic-consumer/dist/usage/index.js" + ], + "volumes": [ + "/var/lib/mesh/anthropic-consumer/broker:/run/secrets/broker:ro", + "/var/lib/anthropic-consumer:/run/state" + ], + "env": { + "MESH_BROKER_FILE": "/run/secrets/broker", + "MESH_CLAUDE_PROJECTS_DIR": "/run/state/claude/projects", + "MESH_ANTHROPIC_USAGE_OUT": "/run/state/out/session-usage.json", + "MESH_TOOLS_MAIN": "/app/dist/main.js" + } + } + ] +} diff --git a/modules/anthropic-consumer/package.json b/modules/anthropic-consumer/package.json new file mode 100644 index 0000000..ef3f418 --- /dev/null +++ b/modules/anthropic-consumer/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-anthropic-consumer", + "version": "0.1.0", + "description": "anthropic-consumer — the consumer side of model-access (ADR 0050): writes the delivered access token to ~/.claude/.credentials.json (access-token-only) and reports session-grain usage from the CLI transcripts (ADR 0054).", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/anthropic-consumer/test/credentials.test.ts b/modules/anthropic-consumer/test/credentials.test.ts new file mode 100644 index 0000000..72ddcd7 --- /dev/null +++ b/modules/anthropic-consumer/test/credentials.test.ts @@ -0,0 +1,32 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { applyGrant, deliver } from "../credentials.ts"; + +test("applyGrant strips the refresh token a full grant on disk left behind", () => { + const local = { claudeAiOauth: { accessToken: "at-old", refreshToken: "rt-must-not-survive" } }; + const next = applyGrant(local, { accessToken: "at-new", expiresAt: 123 }); + assert.equal(next.claudeAiOauth!.accessToken, "at-new"); + assert.equal(next.claudeAiOauth!.expiresAt, 123); + assert.ok(!("refreshToken" in next.claudeAiOauth!), "a node held onto a refresh token"); +}); + +test("deliver writes the port-map shape, access-token-only, and never a refresh token", () => { + const dir = mkdtempSync(join(tmpdir(), "anthropic-consumer-")); + const path = join(dir, ".credentials.json"); + // A prior interactive login left a full grant on disk. + writeFileSync(path, JSON.stringify({ claudeAiOauth: { accessToken: "at-old", refreshToken: "rt-login" } })); + + deliver(path, { accessToken: "at-delivered", expiresAt: 999, subscriptionType: "max" }); + + const raw = readFileSync(path, "utf8"); + const creds = JSON.parse(raw); + assert.equal(creds.claudeAiOauth.accessToken, "at-delivered"); + assert.equal(creds.claudeAiOauth.expiresAt, 999); + assert.equal(creds.claudeAiOauth.subscriptionType, "max"); + assert.doesNotMatch(raw, /rt-login/, "the refresh token is still on disk"); + assert.ok(!("refreshToken" in creds.claudeAiOauth)); +}); diff --git a/modules/anthropic-consumer/test/transcript.test.ts b/modules/anthropic-consumer/test/transcript.test.ts new file mode 100644 index 0000000..5048b5a --- /dev/null +++ b/modules/anthropic-consumer/test/transcript.test.ts @@ -0,0 +1,48 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { foldTranscript } from "../transcript.ts"; + +// A captured-shape transcript: two assistant turns and a user line, exactly the fields the port map +// names. Not imagined — the field names match the mature implementation's parse. +const TRANSCRIPT = [ + JSON.stringify({ type: "user", timestamp: "2026-01-01T00:00:00Z", cwd: "/work/app", gitBranch: "main" }), + JSON.stringify({ + type: "assistant", + timestamp: "2026-01-01T00:00:01Z", + costUSD: 0.01, + message: { + model: "claude-opus-4-8", + usage: { input_tokens: 100, cache_creation_input_tokens: 20, cache_read_input_tokens: 5, output_tokens: 40 }, + }, + }), + JSON.stringify({ + type: "assistant", + timestamp: "2026-01-01T00:00:02Z", + costUSD: 0.02, + message: { model: "claude-opus-4-8", usage: { input_tokens: 200, output_tokens: 60 } }, + }), + "", // a half-written trailing line is ordinary and must not be fatal. +].join("\n"); + +test("a transcript sums per-session token counts, cost, and metadata", () => { + const s = foldTranscript("session-abc", TRANSCRIPT); + assert.equal(s.sessionId, "session-abc"); + assert.equal(s.turns, 2); + assert.equal(s.inputTokens, 300); + assert.equal(s.cacheCreationTokens, 20); + assert.equal(s.cacheReadTokens, 5); + assert.equal(s.outputTokens, 100); + assert.equal(Math.round(s.costUSD * 100) / 100, 0.03); + assert.equal(s.model, "claude-opus-4-8"); + assert.equal(s.gitBranch, "main"); + assert.equal(s.cwd, "/work/app"); + assert.equal(s.startedAt, "2026-01-01T00:00:00Z"); + assert.equal(s.lastActive, "2026-01-01T00:00:02Z"); +}); + +test("a malformed line is skipped, not fatal", () => { + const s = foldTranscript("s", 'not json\n{"type":"assistant","message":{"usage":{"output_tokens":7}}}'); + assert.equal(s.outputTokens, 7); + assert.equal(s.turns, 1); +}); diff --git a/modules/anthropic-consumer/transcript.ts b/modules/anthropic-consumer/transcript.ts new file mode 100644 index 0000000..bb6149b --- /dev/null +++ b/modules/anthropic-consumer/transcript.ts @@ -0,0 +1,113 @@ +// Session-grain usage from the CLI's own transcripts (novox/hq ADR 0054). The mature implementation +// reads `~/.claude/projects//.jsonl` and sums the token counts each assistant +// message reports; this ports the token extraction and DROPS the per-message account-attribution +// timeline — the nox (node,module) session has a fixed licence binding (port map "don't-map" #3), so +// there is nothing to attribute per message. +// +// The fields are ported from the port map: assistant lines carry +// `message.usage.{input_tokens,cache_creation_input_tokens,cache_read_input_tokens,output_tokens}`, +// `costUSD`, `message.model`, `timestamp`; user lines carry `cwd`, `gitBranch`. + +import { createInterface } from "node:readline"; +import { createReadStream } from "node:fs"; + +/** One session's totals — the session-grain usage row ADR 0054 fixes. */ +export interface SessionUsage { + sessionId: string; + model: string | null; + gitBranch: string | null; + cwd: string | null; + turns: number; + inputTokens: number; + cacheCreationTokens: number; + cacheReadTokens: number; + outputTokens: number; + costUSD: number; + startedAt: string | null; + lastActive: string | null; +} + +interface Line { + type?: string; + timestamp?: string; + cwd?: string; + gitBranch?: string; + costUSD?: number; + message?: { + model?: string; + usage?: { + input_tokens?: number; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; + output_tokens?: number; + }; + }; +} + +function empty(sessionId: string): SessionUsage { + return { + sessionId, + model: null, + gitBranch: null, + cwd: null, + turns: 0, + inputTokens: 0, + cacheCreationTokens: 0, + cacheReadTokens: 0, + outputTokens: 0, + costUSD: 0, + startedAt: null, + lastActive: null, + }; +} + +/** Fold one transcript line into a session's running totals. Pure, so it is tested on fixtures. */ +export function foldLine(acc: SessionUsage, raw: string): SessionUsage { + const line = parse(raw); + if (!line) return acc; + + if (line.timestamp) { + if (!acc.startedAt || line.timestamp < acc.startedAt) acc.startedAt = line.timestamp; + if (!acc.lastActive || line.timestamp > acc.lastActive) acc.lastActive = line.timestamp; + } + if (line.type === "user") { + if (line.cwd) acc.cwd = line.cwd; + if (line.gitBranch) acc.gitBranch = line.gitBranch; + } + if (line.type === "assistant") { + acc.turns += 1; + const u = line.message?.usage ?? {}; + acc.inputTokens += u.input_tokens ?? 0; + acc.cacheCreationTokens += u.cache_creation_input_tokens ?? 0; + acc.cacheReadTokens += u.cache_read_input_tokens ?? 0; + acc.outputTokens += u.output_tokens ?? 0; + acc.costUSD += line.costUSD ?? 0; + if (!acc.model && line.message?.model) acc.model = line.message.model; + } + return acc; +} + +function parse(raw: string): Line | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + try { + return JSON.parse(trimmed) as Line; + } catch { + // A malformed line is skipped, never fatal: a transcript is an append-only log the CLI owns, and + // a half-written last line is ordinary. + return null; + } +} + +/** Sum a whole transcript string into one session's usage — the tested core of the streaming read. */ +export function foldTranscript(sessionId: string, text: string): SessionUsage { + return text.split("\n").reduce(foldLine, empty(sessionId)); +} + +/** Stream one `.jsonl` file line by line, so a large transcript never loads whole. */ +export async function readSessionFile(path: string, sessionId: string): Promise { + const acc = empty(sessionId); + const rl = createInterface({ input: createReadStream(path), crlfDelay: Infinity }); + for await (const line of rl) foldLine(acc, line); + return acc; +} diff --git a/modules/anthropic-consumer/tsconfig.json b/modules/anthropic-consumer/tsconfig.json new file mode 100644 index 0000000..ab08067 --- /dev/null +++ b/modules/anthropic-consumer/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": [ + "credentials.ts", + "transcript.ts", + "identity.ts", + "apply/index.ts", + "usage/index.ts" + ] +} diff --git a/modules/anthropic-consumer/usage/index.ts b/modules/anthropic-consumer/usage/index.ts new file mode 100644 index 0000000..7f31b11 --- /dev/null +++ b/modules/anthropic-consumer/usage/index.ts @@ -0,0 +1,92 @@ +// Session-grain usage emission (novox/hq ADR 0054). On a schedule, read every transcript under +// `~/.claude/projects/*/.jsonl`, sum its tokens, and emit one session-grain usage event +// per session. The consumer IS the (node,module) session's fixed binding, so no per-message account +// attribution is done — just the totals (port map "don't-map" #3). +// +// Runs as `mesh-tools run` (no broker), so events are emitted best-effort via the sibling mesh-tools +// `emit` primitive; the totals are also written to a file so the reading is observable without one. + +import { readdirSync, statSync, writeFileSync, renameSync, mkdirSync } from "node:fs"; +import { join, dirname } from "node:path"; + +import { readSessionFile, type SessionUsage } from "../transcript.js"; + +function projectsDir(): string { + return process.env.MESH_CLAUDE_PROJECTS_DIR ?? `${process.env.HOME ?? "/root"}/.claude/projects`; +} + +/** Every `.jsonl` under the projects tree, with the project directory it sits in. */ +function transcripts(root: string): { path: string; sessionId: string }[] { + const found: { path: string; sessionId: string }[] = []; + let projects: string[]; + try { + projects = readdirSync(root); + } catch { + return found; // no projects yet is not a failure — there is simply nothing to report. + } + for (const proj of projects) { + const dir = join(root, proj); + let entries: string[]; + try { + if (!statSync(dir).isDirectory()) continue; + entries = readdirSync(dir); + } catch { + continue; + } + for (const file of entries) { + if (!file.endsWith(".jsonl")) continue; + found.push({ path: join(dir, file), sessionId: file.replace(/\.jsonl$/, "") }); + } + } + return found; +} + +async function main(): Promise { + const module = process.env.MESH_MODULE ?? "anthropic-consumer"; + const node = process.env.MESH_NODE ?? "unknown"; + + const readings: SessionUsage[] = []; + for (const t of transcripts(projectsDir())) { + try { + readings.push(await readSessionFile(t.path, t.sessionId)); + } catch (err) { + console.error(`[anthropic-consumer] could not read ${t.path}: ${err}`); + } + } + + for (const r of readings) { + await emitUsage({ grain: "session", node, module, ...r }); + } + + if (process.env.MESH_ANTHROPIC_USAGE_OUT) { + atomicWrite(process.env.MESH_ANTHROPIC_USAGE_OUT, JSON.stringify(readings, null, 2)); + } + console.error(`[anthropic-consumer] reported ${readings.length} session(s)`); +} + +function atomicWrite(path: string, content: string): void { + mkdirSync(dirname(path), { recursive: true }); + const tmp = `${path}.tmp`; + writeFileSync(tmp, content, { mode: 0o600 }); + renameSync(tmp, path); +} + +/** Emit best-effort via the sibling mesh-tools `emit`, which wires a broker a run step has none. */ +async function emitUsage(body: Record): Promise { + const main = process.env.MESH_TOOLS_MAIN ?? "/app/dist/main.js"; + const { spawn } = await import("node:child_process"); + await new Promise((resolve) => { + const child = spawn( + process.execPath, + [main, "emit", "module.anthropic-consumer.usage.session", JSON.stringify(body)], + { stdio: "inherit" }, + ); + child.on("exit", () => resolve()); + child.on("error", (err) => { + console.error(`[anthropic-consumer] could not emit usage: ${err}`); + resolve(); + }); + }); +} + +await main(); diff --git a/modules/anthropic-manager/adopt/index.ts b/modules/anthropic-manager/adopt/index.ts new file mode 100644 index 0000000..794cd7f --- /dev/null +++ b/modules/anthropic-manager/adopt/index.ts @@ -0,0 +1,39 @@ +// 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. +// +// 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` + +import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +import { sealAtRest } from "../atrest.js"; + +function required(name: string): string { + const v = process.env[name]; + if (!v) throw new Error(`${name} is not set — adoption needs it`); + return v; +} + +const refreshToken = readFileSync(required("MESH_ANTHROPIC_REFRESH_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); + +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"); diff --git a/modules/anthropic-manager/atrest.ts b/modules/anthropic-manager/atrest.ts new file mode 100644 index 0000000..759ab6c --- /dev/null +++ b/modules/anthropic-manager/atrest.ts @@ -0,0 +1,174 @@ +// 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/client.ts b/modules/anthropic-manager/client.ts new file mode 100644 index 0000000..5b37387 --- /dev/null +++ b/modules/anthropic-manager/client.ts @@ -0,0 +1,135 @@ +// The only file that talks to Anthropic — the vendor half of the refreshable-grant adapter +// (novox/hq ADR 0050). Isolated exactly as cloudflare-dns isolates its registrar call, so the +// vendor is swappable and the one place a token endpoint is reached is auditable. +// +// Two endpoints, and they are different hosts (port-map "don't-map" #1): the TOKEN host mints a new +// access token from the refresh token; the USAGE host reports utilisation against an access token. + +/** The token endpoint, overridable so the lab can point the whole flow at a stub without a vendor. */ +export function tokenEndpoint(env = process.env): string { + return env.MESH_ANTHROPIC_TOKEN_ENDPOINT ?? "https://platform.claude.com/v1/oauth/token"; +} + +/** The usage endpoint, likewise overridable for the lab. */ +export function usageEndpoint(env = process.env): string { + return env.MESH_ANTHROPIC_USAGE_ENDPOINT ?? "https://api.anthropic.com/api/oauth/usage"; +} + +// The OAuth client id is a hard-won constant, ported byte-exact from the mature implementation: a +// metadata URL in its place yields 400. It is not a secret (it identifies the public Claude Code +// client), so it lives in code. +const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; + +/** The vendor's token response, snake_case as the wire has it. */ +export interface RefreshedGrant { + readonly access_token?: string; + readonly refresh_token?: string; + readonly expires_in?: number; + readonly refresh_token_expires_in?: number; + readonly scopes?: string[]; + readonly subscription_type?: string; +} + +/** + * Exchange a refresh token for a fresh grant. Returns null on any non-ok response, surfacing the + * OAuth error body (invalid_grant/invalid_client/…) — the difference between "the token is dead" and + * "the endpoint was unreachable", which a bare status hides. + */ +export async function refreshGrant( + refreshToken: string, + env = process.env, +): Promise { + const resp = await fetch(tokenEndpoint(env), { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLIENT_ID, + }), + }); + if (!resp.ok) { + const body = await resp.text().catch(() => ""); + console.error( + `[anthropic-manager] token refresh failed: ${resp.status} ${resp.statusText} — ${body.slice(0, 400)}`, + ); + return null; + } + return (await resp.json()) as RefreshedGrant; +} + +/** The vendor's usage response — utilisation percentages against several windows. */ +export interface UsageLimits { + readonly five_hour?: { utilization: number; resets_at?: string }; + readonly seven_day?: { utilization: number; resets_at?: string }; + readonly seven_day_sonnet?: { utilization: number; resets_at?: string }; + readonly seven_day_opus?: { utilization: number; resets_at?: string }; + readonly extra_usage?: { utilization: number }; + readonly [key: string]: unknown; +} + +/** + * Read utilisation for an access token. Never refreshes here (a 401 is just reported): a second + * refresh source racing the first is the fault the mature implementation warns against. + */ +export async function readUsage(accessToken: string, env = process.env): Promise { + const resp = await fetch(usageEndpoint(env), { + headers: { authorization: `Bearer ${accessToken}` }, + }); + if (!resp.ok) { + const body = await resp.text().catch(() => ""); + console.error(`[anthropic-manager] usage endpoint returned ${resp.status}: ${body.slice(0, 200)}`); + return null; + } + return (await resp.json()) as UsageLimits; +} + +/** The licence-grain reading ADR 0054 fixes, flattened from the vendor's windows. */ +export interface UsageReading { + readonly sessionPct: number | null; + readonly sessionResetsAt: string | null; + readonly weeklyPct: number | null; + readonly sonnetPct: number | null; + readonly extraPct: number | null; + readonly raw: UsageLimits; +} + +export function flattenUsage(u: UsageLimits): UsageReading { + return { + sessionPct: u.five_hour?.utilization ?? null, + sessionResetsAt: u.five_hour?.resets_at ?? null, + weeklyPct: u.seven_day?.utilization ?? null, + sonnetPct: u.seven_day_sonnet?.utilization ?? null, + extraPct: u.extra_usage?.utilization ?? null, + raw: u, + }; +} + +/** The access-token-only grant a holder is delivered — the port-map credential-file shape's fields. */ +export interface AccessGrant { + readonly accessToken: string; + readonly expiresAt: number | null; + readonly refreshTokenExpiresAt: number | null; + readonly scopes: string[] | null; + readonly subscriptionType: string | null; +} + +/** + * Turn a vendor refresh into what the manager submits: the access-token-only grant for holders, and + * the rotated refresh token if the vendor sent one. Never clobbers a good grant from an empty + * response — no access_token means the caller keeps what it had. + */ +export function grantFromRefresh(r: RefreshedGrant, nowMs: number): { access: AccessGrant; rotatedRefresh: string | null } | null { + if (!r.access_token) return null; + return { + access: { + accessToken: r.access_token, + expiresAt: typeof r.expires_in === "number" ? nowMs + r.expires_in * 1000 : null, + refreshTokenExpiresAt: + typeof r.refresh_token_expires_in === "number" ? nowMs + r.refresh_token_expires_in * 1000 : null, + scopes: r.scopes ?? null, + subscriptionType: r.subscription_type ?? null, + }, + rotatedRefresh: r.refresh_token ?? null, + }; +} diff --git a/modules/anthropic-manager/module.json b/modules/anthropic-manager/module.json new file mode 100644 index 0000000..b33c78c --- /dev/null +++ b/modules/anthropic-manager/module.json @@ -0,0 +1,68 @@ +{ + "module": "anthropic-manager", + "version": "1", + "capabilities": [ + "container-runtime" + ], + "own-secrets": { + "broker": "/var/lib/mesh/anthropic-manager/broker" + }, + "emits": [ + "module.anthropic-manager.usage.read" + ], + "resources": [ + { + "id": "mesh-state", + "type": "directory", + "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", + "name": "mesh-anthropic-manager-refresh", + "image": "mesh-runtime-anthropic-manager@sha256:0000000000000000000000000000000000000000000000000000000000000000", + "network": "host", + "schedule": "*/5 * * * *", + "args": [ + "run", + "/app/modules/anthropic-manager/dist/refresh/index.js" + ], + "volumes": [ + "/var/lib/mesh/anthropic-manager/broker:/run/secrets/broker:ro", + "/var/lib/mesh/anthropic-manager:/run/state" + ], + "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_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", + "MESH_TOOLS_MAIN": "/app/dist/main.js" + } + } + ] +} diff --git a/modules/anthropic-manager/package.json b/modules/anthropic-manager/package.json new file mode 100644 index 0000000..90d518a --- /dev/null +++ b/modules/anthropic-manager/package.json @@ -0,0 +1,14 @@ +{ + "name": "@novox/module-anthropic-manager", + "version": "0.1.0", + "description": "anthropic-manager — the manager side of the model-access refreshable-grant (ADR 0050): opens the refresh token on the manager node alone, refreshes it against Anthropic's OAuth endpoint, and submits back only the access token and the re-sealed refresh envelope.", + "type": "module", + "private": true, + "dependencies": { + "@novox/mesh-sdk": "^0.1.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/modules/anthropic-manager/refresh/index.ts b/modules/anthropic-manager/refresh/index.ts new file mode 100644 index 0000000..245057f --- /dev/null +++ b/modules/anthropic-manager/refresh/index.ts @@ -0,0 +1,142 @@ +// 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 +// 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. +// +// 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. +// +// 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 +// `mesh-control licence submit-refresh`. In the lab that caller is the scenario; in production it is +// an authenticated call the manager node makes. The transport is the one part stubbed here — FLAGGED +// — because a cross-node authenticated command surface is out of this module's scope. + +import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +import { openAtRest, sealAtRest, type Envelope } from "../atrest.js"; +import { refreshGrant, grantFromRefresh, readUsage, flattenUsage } from "../client.js"; + +function required(name: string): string { + const v = process.env[name]; + if (!v) throw new Error(`${name} is not set — the manager runtime was deployed without it`); + 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`; + writeFileSync(tmp, content, { mode: 0o600 }); + renameSync(tmp, path); +} + +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 2: the one open, on the manager node. + const refreshToken = openAtRest(envelope, nodePub, nodePriv); + + // Step 3: 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. + throw new Error(`[anthropic-manager] the refresh of ${licence} produced no grant`); + } + const grant = grantFromRefresh(refreshed, Date.now()); + if (!grant) { + 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. + if (grant.rotatedRefresh) { + const rotated = sealAtRest(grant.rotatedRefresh, nodePub); + if (process.env.MESH_ANTHROPIC_GRANT_OUT) { + writeEnvelope(process.env.MESH_ANTHROPIC_GRANT_OUT, rotated); + } + } + + // 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. + atomicWrite(required("MESH_ANTHROPIC_ACCESS_OUT"), grant.access.accessToken); + + console.error( + `[anthropic-manager] refreshed ${licence}: access token minted` + + (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. + try { + const usage = await readUsage(grant.access.accessToken); + if (usage) { + const reading = flattenUsage(usage); + if (process.env.MESH_ANTHROPIC_USAGE_OUT) { + atomicWrite( + process.env.MESH_ANTHROPIC_USAGE_OUT, + JSON.stringify({ licence, grain: "licence", ...reading }), + ); + } + await emitUsage({ licence, grain: "licence", ...reading }); + } + } catch (err) { + console.error(`[anthropic-manager] usage poll for ${licence} failed: ${err}`); + } +} + +/** + * Emit a usage event best-effort by shelling out to the sibling mesh-tools `emit` primitive, which + * is the one path that wires a broker from a run-once/scheduled step (which itself connects none). + * A broker hiccup must never fail a refresh that already happened. + */ +async function emitUsage(body: Record): Promise { + const main = process.env.MESH_TOOLS_MAIN ?? "/app/dist/main.js"; + const { spawn } = await import("node:child_process"); + await new Promise((resolve) => { + const child = spawn(process.execPath, [main, "emit", "module.anthropic-manager.usage.read", JSON.stringify(body)], { + stdio: "inherit", + }); + child.on("exit", () => resolve()); + child.on("error", (err) => { + console.error(`[anthropic-manager] could not emit usage: ${err}`); + resolve(); + }); + }); +} + +await main(); diff --git a/modules/anthropic-manager/test/atrest.test.ts b/modules/anthropic-manager/test/atrest.test.ts new file mode 100644 index 0000000..595bf63 --- /dev/null +++ b/modules/anthropic-manager/test/atrest.test.ts @@ -0,0 +1,47 @@ +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/client.test.ts b/modules/anthropic-manager/test/client.test.ts new file mode 100644 index 0000000..a28cd5f --- /dev/null +++ b/modules/anthropic-manager/test/client.test.ts @@ -0,0 +1,43 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { grantFromRefresh, flattenUsage } from "../client.ts"; + +test("an empty refresh response never clobbers a good grant", () => { + assert.equal(grantFromRefresh({}, 1000), null); +}); + +test("a refresh with an access token yields an access-token-only grant and epoch expiry", () => { + const out = grantFromRefresh( + { access_token: "at-new", expires_in: 3600, refresh_token: "rt-rotated", subscription_type: "pro" }, + 1_000_000, + ); + assert.ok(out); + assert.equal(out!.access.accessToken, "at-new"); + assert.equal(out!.access.expiresAt, 1_000_000 + 3600 * 1000); + assert.equal(out!.access.subscriptionType, "pro"); + // The rotated refresh token is reported separately, for the manager to re-seal — never put in the + // holder grant. + assert.equal(out!.rotatedRefresh, "rt-rotated"); + assert.ok(!("refreshToken" in (out!.access as object))); +}); + +test("a refresh that did not rotate the refresh token reports none to re-seal", () => { + const out = grantFromRefresh({ access_token: "at-new" }, 0); + assert.ok(out); + assert.equal(out!.rotatedRefresh, null); +}); + +test("usage flattens the vendor windows to the ADR 0054 grain", () => { + const r = flattenUsage({ + five_hour: { utilization: 42, resets_at: "2026-01-01T00:00:00Z" }, + seven_day: { utilization: 10 }, + seven_day_sonnet: { utilization: 5 }, + extra_usage: { utilization: 1 }, + }); + assert.equal(r.sessionPct, 42); + assert.equal(r.sessionResetsAt, "2026-01-01T00:00:00Z"); + assert.equal(r.weeklyPct, 10); + assert.equal(r.sonnetPct, 5); + assert.equal(r.extraPct, 1); +}); diff --git a/modules/anthropic-manager/tsconfig.json b/modules/anthropic-manager/tsconfig.json new file mode 100644 index 0000000..3a6da13 --- /dev/null +++ b/modules/anthropic-manager/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": [ + "atrest.ts", + "client.ts", + "adopt/index.ts", + "refresh/index.ts" + ] +} -- 2.54.0 From 4c98bee0432abf2ed0f700901b24a9ddeb1d22e4 Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 7 Sep 2026 01:55:19 +0200 Subject: [PATCH 2/3] 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" -- 2.54.0 From 19666ff054a200937b8685d80a43740cd9298a4d Mon Sep 17 00:00:00 2001 From: jochen Date: Mon, 7 Sep 2026 02:17:37 +0200 Subject: [PATCH 3/3] anthropic-manager: seal over audited tweetnacl-sealedbox-js, not a hand-transcribed NaCl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The manager reseals a rotated refresh token to the node key with crypto_box_seal. That seal was a full inline transcription of TweetNaCl's XSalsa20-Poly1305 and blakejs' BLAKE2b (dependency-free, ~440 lines). Replace the internals with the audited tweetnacl-sealedbox-js library — the same crypto_box_seal, on the same tweetnacl and blakejs the mesh used to validate the seal during Phase C. The exported API is unchanged: seal(value, recipientPublicB64) -> base64. The wire format is unchanged too — ephemeralPub(32) followed by the box, nonce = blake2b(ephemeralPub + recipientPub, 24) — so the host's Go box.OpenAnonymous still opens it. The mesh-control cross-check fixture is regenerated from this seal(). The library and tweetnacl are added to the module's package.json dependencies so the runtime image bundles them (blakejs arrives transitively). A local ambient .d.ts types the untyped CJS bundle; it is imported as a default import because Node's ESM loader cannot see a UMD bundle's named exports. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF --- modules/anthropic-manager/package.json | 4 +- modules/anthropic-manager/sealedbox.ts | 459 +----------------- modules/anthropic-manager/tsconfig.json | 1 + .../tweetnacl-sealedbox-js.d.ts | 13 + 4 files changed, 35 insertions(+), 442 deletions(-) create mode 100644 modules/anthropic-manager/tweetnacl-sealedbox-js.d.ts diff --git a/modules/anthropic-manager/package.json b/modules/anthropic-manager/package.json index 90d518a..df1bea1 100644 --- a/modules/anthropic-manager/package.json +++ b/modules/anthropic-manager/package.json @@ -5,7 +5,9 @@ "type": "module", "private": true, "dependencies": { - "@novox/mesh-sdk": "^0.1.0" + "@novox/mesh-sdk": "^0.1.0", + "tweetnacl": "^1.0.3", + "tweetnacl-sealedbox-js": "^1.2.0" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/modules/anthropic-manager/sealedbox.ts b/modules/anthropic-manager/sealedbox.ts index e0251b8..a8cb1ae 100644 --- a/modules/anthropic-manager/sealedbox.ts +++ b/modules/anthropic-manager/sealedbox.ts @@ -1,4 +1,5 @@ -// A NaCl `crypto_box_seal`, in TypeScript, byte-compatible with Go's `box.SealAnonymous`. +// A NaCl `crypto_box_seal`, byte-compatible with Go's `box.SealAnonymous`, over the audited +// `tweetnacl-sealedbox-js`. // // **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 @@ -16,33 +17,28 @@ // 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. +// **The crypto is not ours.** `tweetnacl-sealedbox-js` is `crypto_box_seal` built on the audited +// TweetNaCl (`tweetnacl`) and blakejs — the same construction, and the same libraries, the mesh used +// to validate this seal during Phase C. It generates the ephemeral X25519 key pair, derives the +// nonce as `blake2b(ephemeralPub ‖ recipientPub, 24)`, and produces `ephemeralPub ‖ box`. That is +// exactly what Go's `box.OpenAnonymous` opens: the wire format is unchanged from the hand-transcribed +// version this replaces — only the implementation is now a maintained, reviewed dependency rather +// than a copy of TweetNaCl and blakejs carried inline. The module runtime image bundles it +// (package.json dependencies; novox/hq ADR 0052). // // **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. +// (mesh-control internal/secrets/sealedbox_xcheck_test.go); the fixture is regenerated from this +// `seal()`. A drift between this seal and Go's box 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"; +// A default import, not `{ seal }`: the library is a CommonJS UMD bundle, and Node's ESM loader +// cannot statically see its named exports — only its default, which is the whole module object. +import sealedbox from "tweetnacl-sealedbox-js"; 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. @@ -56,425 +52,6 @@ export function seal(value: Uint8Array, recipientPublicB64: string): string { 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; + const sealed = sealedbox.seal(value, new Uint8Array(recipientPub)); + return Buffer.from(sealed).toString("base64"); } diff --git a/modules/anthropic-manager/tsconfig.json b/modules/anthropic-manager/tsconfig.json index c1ebce4..acd0018 100644 --- a/modules/anthropic-manager/tsconfig.json +++ b/modules/anthropic-manager/tsconfig.json @@ -9,6 +9,7 @@ "noEmit": true }, "include": [ + "tweetnacl-sealedbox-js.d.ts", "sealedbox.ts", "grantfile.ts", "client.ts", diff --git a/modules/anthropic-manager/tweetnacl-sealedbox-js.d.ts b/modules/anthropic-manager/tweetnacl-sealedbox-js.d.ts new file mode 100644 index 0000000..756dfba --- /dev/null +++ b/modules/anthropic-manager/tweetnacl-sealedbox-js.d.ts @@ -0,0 +1,13 @@ +// Ambient types for `tweetnacl-sealedbox-js` (crypto_box_seal), which ships without its own. +// The library is a small UMD bundle over `tweetnacl` and `blakejs`; only `seal` is used here. +declare module "tweetnacl-sealedbox-js" { + /** crypto_box_seal: returns ephemeralPub(32) ‖ box, sealed to `recipientPublicKey`. */ + export function seal(message: Uint8Array, recipientPublicKey: Uint8Array): Uint8Array; + /** crypto_box_seal_open: returns the plaintext, or null if it does not open. */ + export function open( + sealed: Uint8Array, + recipientPublicKey: Uint8Array, + recipientSecretKey: Uint8Array, + ): Uint8Array | null; + export const overheadLength: number; +} -- 2.54.0