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; +}