From fc78b743c991d7a2e4b74c83b6c96fd05d11e9f2 Mon Sep 17 00:00:00 2001 From: jochen Date: Sat, 3 Oct 2026 16:05:58 +0200 Subject: [PATCH] claude-code: the sealed hand-over, the credentials write with the lineage rule, the identity read (hq to-be 40 WP2, in progress) The parts of the agent module that hold whichever way the console is registered: X25519 + HKDF + AES-GCM from Node's own library so the bundle carries no dependency; the predecessor's lineage rule (rotation only if newer, a re-issue adopted, a switch regardless) with its incidents as tests; an atomic 0600 write that strips any refresh token and keeps keys it does not know; the account read from the agent's own state file. Manifest and renderer follow. --- modules/claude-code/grant.ts | 100 ++++++++++++++++++++++ modules/claude-code/identity.ts | 25 ++++++ modules/claude-code/seal.ts | 77 +++++++++++++++++ modules/claude-code/test/grant.test.ts | 54 ++++++++++++ modules/claude-code/test/identity.test.ts | 19 ++++ modules/claude-code/test/seal.test.ts | 31 +++++++ 6 files changed, 306 insertions(+) create mode 100644 modules/claude-code/grant.ts create mode 100644 modules/claude-code/identity.ts create mode 100644 modules/claude-code/seal.ts create mode 100644 modules/claude-code/test/grant.test.ts create mode 100644 modules/claude-code/test/identity.test.ts create mode 100644 modules/claude-code/test/seal.test.ts diff --git a/modules/claude-code/grant.ts b/modules/claude-code/grant.ts new file mode 100644 index 0000000..d9c060e --- /dev/null +++ b/modules/claude-code/grant.ts @@ -0,0 +1,100 @@ +// The agent's credentials file, and whether an offered grant may replace what it holds (novox/hq +// ADR 0183, design 36 ยง5). Pure where it decides, so the rules are tested without a file. +// +// The file is the vendor's: `{ claudeAiOauth: { accessToken, expiresAt, refreshTokenExpiresAt?, +// scopes?, subscriptionType?, rateLimitTier? }, ... }`. A node never holds a refresh token, so the +// one this module writes never carries one, and a full grant a login left behind is stripped the +// moment the manager hands the node its own. +// +// The lineage rule is the predecessor's, with the incidents that earned it: a rotation of the same +// licence is applied only if newer; a grant re-issued by a login is adopted whatever its expiry; a +// switch to another licence is applied regardless, because across licences the expiries are +// unrelated numbers. + +import { readFileSync, renameSync, writeFileSync, mkdirSync } from "node:fs"; +import { dirname } from "node:path"; + +export interface Grant { + readonly accessToken: string; + readonly expiresAt: number; + readonly refreshTokenExpiresAt?: number | null; + readonly scopes?: readonly string[] | null; + readonly subscriptionType?: string | null; + readonly rateLimitTier?: string | null; +} + +export type ApplySource = "rotation" | "switch"; + +export type ApplyDecision = + | { apply: true; reissued?: boolean } + | { apply: false; reason: "already-current" } + | { apply: false; reason: "not-newer"; localExpiresAt: number }; + +/** Two refresh-token expiries within a day are one lineage; a login starts a fresh window weeks away. */ +export const GENERATION_TOLERANCE_MS = 24 * 60 * 60 * 1000; + +export function sameGeneration(a?: number | null, b?: number | null): boolean { + if (a == null || b == null) return true; + return Math.abs(Number(a) - Number(b)) <= GENERATION_TOLERANCE_MS; +} + +export function decideApply(local: Grant | null | undefined, offered: Grant, source: ApplySource): ApplyDecision { + if (!local?.accessToken) return { apply: true }; + if (local.accessToken === offered.accessToken) return { apply: false, reason: "already-current" }; + const reissued = !sameGeneration(local.refreshTokenExpiresAt, offered.refreshTokenExpiresAt); + if (source === "rotation" && !reissued && Number(local.expiresAt) >= Number(offered.expiresAt)) { + return { apply: false, reason: "not-newer", localExpiresAt: Number(local.expiresAt) }; + } + return reissued ? { apply: true, reissued: true } : { apply: true }; +} + +type Oauth = Record & { accessToken?: string; refreshToken?: string; expiresAt?: number }; +type Credentials = Record & { claudeAiOauth?: Oauth }; + +export function readCredentials(path: string): Credentials | null { + try { + const parsed = JSON.parse(readFileSync(path, "utf8")) as Credentials; + return parsed && typeof parsed === "object" ? parsed : null; + } catch { + return null; + } +} + +/** The grant the file holds, or null. */ +export function grantOf(creds: Credentials | null): Grant | null { + const o = creds?.claudeAiOauth; + if (!o?.accessToken) return null; + return { + accessToken: o.accessToken, + expiresAt: Number(o.expiresAt ?? 0), + refreshTokenExpiresAt: o.refreshTokenExpiresAt == null ? null : Number(o.refreshTokenExpiresAt), + }; +} + +/** Does the file hold a full grant โ€” a refresh token this module never writes, so a person's login? */ +export function holdsLogin(creds: Credentials | null): boolean { + return typeof creds?.claudeAiOauth?.refreshToken === "string" && creds.claudeAiOauth.refreshToken.length > 0; +} + +/** Overlay the handed grant on what is there, and delete any refresh token. */ +export function withGrant(local: Credentials | null, grant: Grant): Credentials { + const next: Credentials = { ...(local ?? {}) }; + const oauth: Oauth = { ...(local?.claudeAiOauth ?? {}) }; + oauth.accessToken = grant.accessToken; + oauth.expiresAt = grant.expiresAt; + for (const k of ["refreshTokenExpiresAt", "scopes", "subscriptionType", "rateLimitTier"] as const) { + const v = grant[k]; + if (v != null) oauth[k] = v as unknown; + } + delete oauth.refreshToken; + next.claudeAiOauth = oauth; + return next; +} + +/** Write atomically 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, mode: 0o700 }); + const tmp = `${path}.mesh-tmp`; + writeFileSync(tmp, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 }); + renameSync(tmp, path); +} diff --git a/modules/claude-code/identity.ts b/modules/claude-code/identity.ts new file mode 100644 index 0000000..d9405b0 --- /dev/null +++ b/modules/claude-code/identity.ts @@ -0,0 +1,25 @@ +// Which account the agent is logged in as (novox/hq ADR 0183): not in the token, but in the agent's +// own state file beside the home, `~/.claude.json` โ†’ `oauthAccount`. Read, never written. + +import { readFileSync } from "node:fs"; + +export interface Identity { + readonly accountUuid: string; + readonly emailAddress?: string; + readonly organizationUuid?: string; +} + +export function readIdentity(stateFile: string): Identity | null { + try { + const raw = JSON.parse(readFileSync(stateFile, "utf8")) as { oauthAccount?: Record }; + const a = raw.oauthAccount; + if (!a || typeof a.accountUuid !== "string") return null; + return { + accountUuid: a.accountUuid, + emailAddress: typeof a.emailAddress === "string" ? a.emailAddress : undefined, + organizationUuid: typeof a.organizationUuid === "string" ? a.organizationUuid : undefined, + }; + } catch { + return null; + } +} diff --git a/modules/claude-code/seal.ts b/modules/claude-code/seal.ts new file mode 100644 index 0000000..8c467c0 --- /dev/null +++ b/modules/claude-code/seal.ts @@ -0,0 +1,77 @@ +// Sealing a token to one recipient (novox/hq ADR 0183): the manager seals what it hands a node to that +// node's agent module key, and a node seals a waiting login to the key the manager names. X25519 for +// the agreement, HKDF-SHA256 for the key, AES-256-GCM for the box โ€” all from Node's own library, so a +// bundle carries no dependency and no secret ever crosses the bus in the clear. +// +// A sealed box is `{ v: 1, eph, iv, tag, ct }`, every field base64. `eph` is a one-time public key, so +// two boxes of one value to one recipient share nothing, and only the recipient's private key opens it. + +import { + createCipheriv, createDecipheriv, createPrivateKey, createPublicKey, diffieHellman, + generateKeyPairSync, hkdfSync, randomBytes, type KeyObject, +} from "node:crypto"; + +export interface SealedBox { + readonly v: 1; + readonly eph: string; + readonly iv: string; + readonly tag: string; + readonly ct: string; +} + +/** A recipient's keypair, as the two PEM strings it is kept and published as. */ +export interface KeyPairPem { + readonly publicKey: string; + readonly privateKey: string; +} + +const INFO = Buffer.from("novox-mesh sealed box v1"); + +export function generateKeyPair(): KeyPairPem { + const { publicKey, privateKey } = generateKeyPairSync("x25519"); + return { + publicKey: publicKey.export({ type: "spki", format: "pem" }).toString(), + privateKey: privateKey.export({ type: "pkcs8", format: "pem" }).toString(), + }; +} + +function keyFor(secret: Buffer, eph: Buffer, recipient: Buffer): Buffer { + // The ephemeral and the recipient's public halves are bound into the key, so a box cannot be + // re-addressed to another recipient by swapping its `eph`. + return Buffer.from(hkdfSync("sha256", secret, Buffer.concat([eph, recipient]), INFO, 32)); +} + +function rawPublic(key: KeyObject): Buffer { + return key.export({ type: "spki", format: "der" }).subarray(-32); +} + +export function seal(plaintext: string, recipientPublicPem: string): SealedBox { + const recipient = createPublicKey(recipientPublicPem); + const eph = generateKeyPairSync("x25519"); + const secret = diffieHellman({ privateKey: eph.privateKey, publicKey: recipient }); + const ephRaw = eph.publicKey.export({ type: "spki", format: "der" }); + const key = keyFor(secret, ephRaw, rawPublic(recipient)); + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + return { + v: 1, + eph: ephRaw.toString("base64"), + iv: iv.toString("base64"), + tag: cipher.getAuthTag().toString("base64"), + ct: ct.toString("base64"), + }; +} + +/** Open a box with the recipient's private key. Throws on a box for another key or one tampered with. */ +export function open(box: SealedBox, privateKeyPem: string): string { + if (!box || box.v !== 1) throw new Error("not a sealed box this module can open"); + const priv = createPrivateKey(privateKeyPem); + const ephRaw = Buffer.from(box.eph, "base64"); + const eph = createPublicKey({ key: ephRaw, format: "der", type: "spki" }); + const secret = diffieHellman({ privateKey: priv, publicKey: eph }); + const key = keyFor(secret, ephRaw, rawPublic(createPublicKey(priv))); + const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(box.iv, "base64")); + decipher.setAuthTag(Buffer.from(box.tag, "base64")); + return Buffer.concat([decipher.update(Buffer.from(box.ct, "base64")), decipher.final()]).toString("utf8"); +} diff --git a/modules/claude-code/test/grant.test.ts b/modules/claude-code/test/grant.test.ts new file mode 100644 index 0000000..1b10895 --- /dev/null +++ b/modules/claude-code/test/grant.test.ts @@ -0,0 +1,54 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + decideApply, grantOf, holdsLogin, readCredentials, withGrant, writeCredentials, type Grant, +} from "../dist/grant.js"; + +const NOW = 1_700_000_000_000; +const HOUR = 3_600_000; +const g = (over: Partial = {}): Grant => ({ + accessToken: "tok-A", expiresAt: NOW + HOUR, refreshTokenExpiresAt: NOW + 30 * 24 * HOUR, ...over, +}); + +test("a rotation applies a newer grant of the same licence", () => { + assert.deepEqual(decideApply(g(), g({ accessToken: "tok-B", expiresAt: NOW + 2 * HOUR }), "rotation"), { apply: true }); +}); + +test("a rotation refuses a grant that arrived late and is older", () => { + const d = decideApply(g({ accessToken: "new", expiresAt: NOW + 2 * HOUR }), g({ accessToken: "old" }), "rotation"); + assert.equal(d.apply === false && d.reason, "not-newer"); +}); + +test("a grant re-issued by a login is adopted even though it expires sooner (2026-09-05)", () => { + const local = g({ expiresAt: NOW + 8 * HOUR, refreshTokenExpiresAt: NOW + 30 * 24 * HOUR }); + const offered = g({ accessToken: "reissued", expiresAt: NOW + HOUR, refreshTokenExpiresAt: NOW + 5 * 24 * HOUR }); + assert.deepEqual(decideApply(local, offered, "rotation"), { apply: true, reissued: true }); +}); + +test("a switch to another licence applies whatever the expiries say", () => { + const local = g({ expiresAt: NOW + 8 * HOUR }); + assert.equal(decideApply(local, g({ accessToken: "other", expiresAt: NOW + HOUR }), "switch").apply, true); +}); + +test("the same token is not rewritten", () => { + assert.deepEqual(decideApply(g(), g(), "switch"), { apply: false, reason: "already-current" }); +}); + +test("a full grant left by a login is seen as a login, and stripped when the node's own is written", () => { + const dir = mkdtempSync(join(tmpdir(), "claude-code-")); + const path = join(dir, ".claude", ".credentials.json"); + writeFileSync(join(dir, "x"), ""); + const login = { claudeAiOauth: { accessToken: "at-login", refreshToken: "rt-login", expiresAt: NOW }, other: 1 }; + assert.equal(holdsLogin(login), true); + writeCredentials(path, withGrant(login, g({ accessToken: "at-mesh", scopes: ["user:inference"] }))); + const back = readCredentials(path)!; + assert.equal(holdsLogin(back), false); + assert.equal(grantOf(back)!.accessToken, "at-mesh"); + assert.deepEqual(back.claudeAiOauth!.scopes, ["user:inference"]); + assert.equal(back.other, 1, "a key the module does not know was lost"); + assert.ok(!readFileSync(path, "utf8").includes("rt-login")); + assert.equal(statSync(path).mode & 0o777, 0o600); +}); diff --git a/modules/claude-code/test/identity.test.ts b/modules/claude-code/test/identity.test.ts new file mode 100644 index 0000000..31f8c6b --- /dev/null +++ b/modules/claude-code/test/identity.test.ts @@ -0,0 +1,19 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { readIdentity } from "../dist/identity.js"; + +test("the account is read from the agent's state file", () => { + const p = join(mkdtempSync(join(tmpdir(), "cc-id-")), ".claude.json"); + writeFileSync(p, JSON.stringify({ oauthAccount: { accountUuid: "u-1", emailAddress: "a@example.org" }, other: 2 })); + assert.deepEqual(readIdentity(p), { accountUuid: "u-1", emailAddress: "a@example.org", organizationUuid: undefined }); +}); + +test("no state file, or no account in it, is no identity rather than a guess", () => { + assert.equal(readIdentity("/nonexistent/.claude.json"), null); + const p = join(mkdtempSync(join(tmpdir(), "cc-id-")), ".claude.json"); + writeFileSync(p, "{}"); + assert.equal(readIdentity(p), null); +}); diff --git a/modules/claude-code/test/seal.test.ts b/modules/claude-code/test/seal.test.ts new file mode 100644 index 0000000..9685d4e --- /dev/null +++ b/modules/claude-code/test/seal.test.ts @@ -0,0 +1,31 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { generateKeyPair, open, seal } from "../dist/seal.js"; + +test("a box opens with its recipient's key and yields the value", () => { + const k = generateKeyPair(); + assert.equal(open(seal("at-secret", k.publicKey), k.privateKey), "at-secret"); +}); + +test("a box sealed for one node does not open with another node's key", () => { + const a = generateKeyPair(); + const b = generateKeyPair(); + assert.throws(() => open(seal("at-secret", a.publicKey), b.privateKey)); +}); + +test("a tampered box is refused, not opened to garbage", () => { + const k = generateKeyPair(); + const box = seal("at-secret", k.publicKey); + const ct = Buffer.from(box.ct, "base64"); + ct[0] ^= 0xff; + assert.throws(() => open({ ...box, ct: ct.toString("base64") }, k.privateKey)); +}); + +test("two boxes of one value share nothing a reader could compare", () => { + const k = generateKeyPair(); + const x = seal("at-secret", k.publicKey); + const y = seal("at-secret", k.publicKey); + assert.notEqual(x.ct, y.ct); + assert.notEqual(x.eph, y.eph); + assert.ok(!JSON.stringify(x).includes("at-secret")); +});