From a7997fc857aea43266bc56cf096de8a4031b2c9f Mon Sep 17 00:00:00 2001 From: mesh-admin Date: Tue, 13 Oct 2026 23:48:02 +0000 Subject: [PATCH] Add the TypeScript twin of the sealed box, proven by the Go package's fixtures (issue 650) (Also replaces the placeholder package.json the previous commit carried by mistake.) Co-Authored-By: Claude Opus 5.5 --- package.json | 31 +++++++++++++++- src/index.ts | 1 + src/seal/box.ts | 90 +++++++++++++++++++++++++++++++++++++++++++++++ src/seal/index.ts | 29 +++++++++++++++ test/seal.test.ts | 71 +++++++++++++++++++++++++++++++++++++ 5 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 src/seal/box.ts create mode 100644 src/seal/index.ts create mode 100644 test/seal.test.ts diff --git a/package.json b/package.json index e30efcb..5abcf7b 100644 --- a/package.json +++ b/package.json @@ -1 +1,30 @@ -PKGJSON \ No newline at end of file +{ + "name": "@novox/mesh-sdk", + "version": "0.1.14", + "description": "The stable spine a Novox Mesh module's own code builds against.", + "type": "module", + "exports": { + ".": "./dist/index.js", + "./contracts": "./dist/contracts/index.js", + "./provisioner": "./dist/provisioner/index.js", + "./tools": "./dist/tools/index.js", + "./messaging": "./dist/messaging/index.js", + "./events": "./dist/events/index.js", + "./primitives": "./dist/primitives/index.js", + "./stdio": "./dist/stdio/index.js", + "./state": "./dist/state/index.js", + "./seal": "./dist/seal/index.js" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "node --test --experimental-strip-types 'test/*.test.ts'", + "prepare": "tsc" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.6.0" + } +} diff --git a/src/index.ts b/src/index.ts index 39a0633..22382e5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,3 +11,4 @@ export * as messaging from "./messaging/index.js"; export * as events from "./events/index.js"; export * as primitives from "./primitives/index.js"; export * as state from "./state/index.js"; +export * as seal from "./seal/index.js"; diff --git a/src/seal/box.ts b/src/seal/box.ts new file mode 100644 index 0000000..30ea274 --- /dev/null +++ b/src/seal/box.ts @@ -0,0 +1,90 @@ +// The sealed box itself (novox/hq ADR 0183, ADR 0206), with its two sources of randomness as arguments so +// the tests can prove it byte for byte against the Go package's vectors. Modules import `./index.js`. + +import { + createCipheriv, + createDecipheriv, + createPrivateKey, + createPublicKey, + diffieHellman, + hkdfSync, + type KeyObject, +} from "node:crypto"; + +/** A value sealed to one recipient, as it travels in JSON; every field but `v` is standard base64. */ +export interface SealedBox { + readonly v: number; + /** The one-time public key, SPKI DER. */ + readonly eph: string; + readonly iv: string; + readonly tag: string; + readonly ct: string; +} + +/** The only box version `open` opens. */ +export const VERSION = 1; + +const INFO = "novox-mesh sealed box v1"; +const IV_BYTES = 12; +const TAG_BYTES = 16; + +function x25519(key: KeyObject, what: string): KeyObject { + if (key.asymmetricKeyType !== "x25519") throw new Error(`not an X25519 ${what} key`); + return key; +} + +export function publicFromPem(pem: string): KeyObject { + return x25519(createPublicKey({ key: pem, format: "pem" }), "public"); +} + +export function privateFromPem(pem: string): KeyObject { + return x25519(createPrivateKey({ key: pem, format: "pem" }), "private"); +} + +/** The recipient's raw 32-byte public key: the last 32 bytes of its SPKI DER. */ +function raw(pub: KeyObject): Buffer { + const der = pub.export({ format: "der", type: "spki" }); + return der.subarray(der.length - 32); +} + +function boxKey(secret: Buffer, ephDer: Buffer, recipientRaw: Buffer): Buffer { + return Buffer.from(hkdfSync("sha256", secret, Buffer.concat([ephDer, recipientRaw]), INFO, 32)); +} + +/** `seal` with the one-time private key and the iv given. */ +export function sealWith(plaintext: string, recipientPem: string, eph: KeyObject, iv: Buffer): SealedBox { + const recipient = publicFromPem(recipientPem); + x25519(eph, "one-time"); + if (iv.length !== IV_BYTES) throw new Error(`an iv of ${iv.length} bytes, not ${IV_BYTES}`); + const secret = diffieHellman({ privateKey: eph, publicKey: recipient }); + const ephDer = createPublicKey(eph).export({ format: "der", type: "spki" }); + const cipher = createCipheriv("aes-256-gcm", boxKey(secret, ephDer, raw(recipient)), iv); + const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + return { + v: VERSION, + eph: ephDer.toString("base64"), + iv: iv.toString("base64"), + tag: cipher.getAuthTag().toString("base64"), + ct: ct.toString("base64"), + }; +} + +/** Open a box with the recipient's PEM private key; throws for a box to another key, changed, or of another version. */ +export function open(box: SealedBox, privatePem: string): string { + if (box.v !== VERSION) throw new Error(`a sealed box of version ${box.v}, not ${VERSION}`); + const priv = privateFromPem(privatePem); + const ephDer = Buffer.from(box.eph, "base64"); + const eph = x25519(createPublicKey({ key: ephDer, format: "der", type: "spki" }), "one-time"); + const iv = Buffer.from(box.iv, "base64"); + const tag = Buffer.from(box.tag, "base64"); + // node accepts a shorter tag than 16 bytes; the box never has one, so a short one is refused here. + if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) throw new Error("the box does not open with this key"); + const secret = diffieHellman({ privateKey: priv, publicKey: eph }); + const decipher = createDecipheriv("aes-256-gcm", boxKey(secret, ephDer, raw(createPublicKey(priv))), iv); + decipher.setAuthTag(tag); + try { + return Buffer.concat([decipher.update(Buffer.from(box.ct, "base64")), decipher.final()]).toString("utf8"); + } catch { + throw new Error("the box does not open with this key"); + } +} diff --git a/src/seal/index.ts b/src/seal/index.ts new file mode 100644 index 0000000..d3b1751 --- /dev/null +++ b/src/seal/index.ts @@ -0,0 +1,29 @@ +// Sealing to one recipient (novox/hq ADR 0183, ADR 0206): X25519 for the agreement, HKDF-SHA256 for the +// key, AES-256-GCM for the box. The TypeScript twin of the Go package `go/seal`, byte for byte: the same +// fixtures prove both. A box is `{ v: 1, eph, iv, tag, ct }`; keys travel as PEM (SPKI public, PKCS #8 +// private). + +import { generateKeyPairSync, randomBytes } from "node:crypto"; +import { sealWith, open, VERSION, type SealedBox } from "./box.js"; + +export { open, VERSION, type SealedBox }; + +/** A recipient's keypair as the two PEM strings it is kept and sent as. */ +export interface KeyPair { + readonly publicKey: string; + readonly privateKey: string; +} + +/** A fresh X25519 keypair, PEM-encoded. */ +export function generateKeyPair(): KeyPair { + const { publicKey, privateKey } = generateKeyPairSync("x25519"); + return { + publicKey: publicKey.export({ format: "pem", type: "spki" }) as string, + privateKey: privateKey.export({ format: "pem", type: "pkcs8" }) as string, + }; +} + +/** Seal plaintext to the recipient's PEM public key, with a fresh one-time key and iv. */ +export function seal(plaintext: string, recipientPem: string): SealedBox { + return sealWith(plaintext, recipientPem, generateKeyPairSync("x25519").privateKey, randomBytes(12)); +} diff --git a/test/seal.test.ts b/test/seal.test.ts new file mode 100644 index 0000000..9eb840b --- /dev/null +++ b/test/seal.test.ts @@ -0,0 +1,71 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createPrivateKey } from "node:crypto"; + +import { seal, open, generateKeyPair, type SealedBox } from "../dist/seal/index.js"; +import { sealWith } from "../dist/seal/box.js"; + +// The fixtures the Go package proves itself against: boxes the licence manager's own seal.go made, and +// the one the agent module's TypeScript made. One format, three implementations. +const fixtures = new URL("../go/seal/testdata/", import.meta.url); +const lm = JSON.parse(readFileSync(new URL("sealed-by-licence-manager.json", fixtures), "utf8")) as { + recipient: { publicKey: string; privateKey: string }; + sealed: { plaintext: string; box: SealedBox }[]; + vectors: { plaintext: string; ephPrivate: string; iv: string; box: SealedBox }[]; +}; + +// PKCS #8 for an X25519 private key is this prefix and the 32 raw bytes. +const pkcs8 = (raw: Buffer) => + createPrivateKey({ key: Buffer.concat([Buffer.from("302e020100300506032b656e04220420", "hex"), raw]), format: "der", type: "pkcs8" }); + +test("seal: every box the licence manager sealed opens to its plaintext", () => { + assert.ok(lm.sealed.length > 0); + for (const s of lm.sealed) assert.equal(open(s.box, lm.recipient.privateKey), s.plaintext); +}); + +test("seal: given the same one-time key and iv, the box is byte for byte the licence manager's", () => { + assert.ok(lm.vectors.length > 0); + for (const v of lm.vectors) { + const box = sealWith(v.plaintext, lm.recipient.publicKey, pkcs8(Buffer.from(v.ephPrivate, "base64")), Buffer.from(v.iv, "base64")); + assert.deepEqual(box, v.box); + assert.equal(open(box, lm.recipient.privateKey), v.plaintext); + } +}); + +test("seal: the agent module's TypeScript box opens", () => { + const f = JSON.parse(readFileSync(new URL("sealed-by-typescript.json", fixtures), "utf8")); + assert.equal(open(f.box, f.privateKey), f.plaintext); +}); + +test("seal: a box opens for its recipient only, and two boxes share no one-time key or iv", () => { + const a = generateKeyPair(); + const b = generateKeyPair(); + const box = seal("a token", a.publicKey); + assert.equal(open(box, a.privateKey), "a token"); + assert.throws(() => open(box, b.privateKey)); + const again = seal("a token", a.publicKey); + assert.notEqual(again.eph, box.eph); + assert.notEqual(again.iv, box.iv); +}); + +test("seal: a box changed in any field, or of another version, does not open", () => { + const good = lm.sealed[1].box; + const flip = (s: string) => { + const b = Buffer.from(s, "base64"); + b[b.length - 1] ^= 1; + return b.toString("base64"); + }; + for (const box of [ + { ...good, ct: flip(good.ct) }, + { ...good, tag: flip(good.tag) }, + { ...good, iv: flip(good.iv) }, + { ...good, eph: flip(good.eph) }, + { ...good, v: 2 }, + ]) assert.throws(() => open(box as SealedBox, lm.recipient.privateKey)); +}); + +test("seal: a key that is not X25519 PEM is refused", () => { + assert.throws(() => seal("x", "not a key")); + assert.throws(() => open(lm.sealed[0].box, lm.recipient.publicKey)); +});