diff --git a/mcp-server/src/__tests__/signature.test.ts b/mcp-server/src/__tests__/signature.test.ts new file mode 100644 index 0000000..90792d4 --- /dev/null +++ b/mcp-server/src/__tests__/signature.test.ts @@ -0,0 +1,308 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + generateKeyPairSync, + createPrivateKey, + sign as nodeSign, +} from "node:crypto"; + +import { + canonicalize, + sign, + verify, + signWithSelfKey, + defaultKeyFile, +} from "../services/signature.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function freshKeypair(): { pem: string; pubkeyRaw: Uint8Array } { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const spki = publicKey.export({ type: "spki", format: "der" }); + // Last 32 bytes of the SPKI DER are the raw Ed25519 pubkey. + const pubkeyRaw = new Uint8Array(spki.subarray(spki.length - 32)); + const pem = privateKey.export({ type: "pkcs8", format: "pem" }) as string; + return { pem, pubkeyRaw }; +} + +// --------------------------------------------------------------------------- +// canonicalize — RFC 8785 conformance vectors +// +// The wire-format trust premise breaks the moment two implementations +// disagree on the bytes-under-signature, so we pin the small but +// load-bearing rules here rather than only relying on round-trip tests. +// --------------------------------------------------------------------------- + +test("canonicalize: primitives", () => { + assert.equal(canonicalize(null), "null"); + assert.equal(canonicalize(true), "true"); + assert.equal(canonicalize(false), "false"); + assert.equal(canonicalize(0), "0"); + assert.equal(canonicalize(-1), "-1"); + assert.equal(canonicalize(1.5), "1.5"); + assert.equal(canonicalize("hello"), '"hello"'); + assert.equal(canonicalize(""), '""'); +}); + +test("canonicalize: empty containers", () => { + assert.equal(canonicalize([]), "[]"); + assert.equal(canonicalize({}), "{}"); +}); + +test("canonicalize: object keys are sorted by UTF-16 code unit (RFC 8785 §3.2.3)", () => { + // The pillar property: any reordering at construction time produces + // the same canonical bytes. Without this, two honest senders writing + // the same record in different orders would produce different + // signatures and the swarm trust premise breaks. + assert.equal(canonicalize({ a: 1, b: 2 }), '{"a":1,"b":2}'); + assert.equal(canonicalize({ b: 2, a: 1 }), '{"a":1,"b":2}'); + // Mixed casing matters — uppercase letters precede lowercase in UTF-16. + assert.equal(canonicalize({ b: 1, A: 2 }), '{"A":2,"b":1}'); + // Sorting recurses into nested objects. + assert.equal( + canonicalize({ outer: { z: 1, a: 2 }, alpha: 1 }), + '{"alpha":1,"outer":{"a":2,"z":1}}' + ); +}); + +test("canonicalize: arrays preserve order (only object keys are sorted)", () => { + // RFC 8785 §3.2.4: arrays are not reordered. Reordering would change + // the meaning of e.g. an embedding vector silently. + assert.equal(canonicalize([3, 1, 2]), "[3,1,2]"); + assert.equal(canonicalize(["b", "a", "c"]), '["b","a","c"]'); +}); + +test("canonicalize: numbers via ECMAScript Number-to-String", () => { + // RFC 8785 §3.2.2.3 mandates exactly the JS Number toString algorithm. + // JSON.stringify on a finite Number returns that representation, + // including the `1e+21` / `-0 → 0` edge cases worth pinning. + assert.equal(canonicalize(1e21), "1e+21"); + assert.equal(canonicalize(-0), "0"); + assert.equal(canonicalize(0.1), "0.1"); + assert.equal(canonicalize(100), "100"); +}); + +test("canonicalize: rejects non-finite numbers (I-JSON constraint)", () => { + // SWARM_SPEC §2 / RFC 7493: NaN and ±Infinity are not valid JSON. A + // silent coercion here would let one node produce bytes that no other + // node can reproduce, so the verifier MUST refuse the input loudly. + assert.throws(() => canonicalize(NaN), /non-finite/); + assert.throws(() => canonicalize(Infinity), /non-finite/); + assert.throws(() => canonicalize(-Infinity), /non-finite/); +}); + +test("canonicalize: rejects undefined", () => { + // Symbols, functions, undefined — none are JSON-representable. Falling + // back to JSON.stringify behaviour (silently dropping object keys with + // undefined values, returning undefined for top-level undefined) would + // make the canonical form context-dependent. + assert.throws(() => canonicalize(undefined)); +}); + +test("canonicalize: stringifies common JSON-escaped characters", () => { + assert.equal(canonicalize('a"b'), '"a\\"b"'); + assert.equal(canonicalize("a\\b"), '"a\\\\b"'); + assert.equal(canonicalize("\n\r\t"), '"\\n\\r\\t"'); +}); + +test("canonicalize: the RFC 8785 §3.2.3 example", () => { + // From the RFC text (Appendix B example, abridged): demonstrates that + // both key sorting and number normalization act in the same pass. + assert.equal( + canonicalize({ "1": "one", "10": "ten", "2": "two" }), + '{"1":"one","10":"ten","2":"two"}' + ); +}); + +// --------------------------------------------------------------------------- +// sign / verify — round-trip and tamper-resistance +// --------------------------------------------------------------------------- + +test("sign+verify: round-trip succeeds with the matching public key", () => { + // The most basic property — without it nothing else matters. If this + // ever regresses the swarm cannot exchange a single signed record. + const { pem, pubkeyRaw } = freshKeypair(); + const record = { id: "abc", content: "hello swarm", n: 7 }; + const signature = sign(record, pem); + assert.equal(verify(record, signature, pubkeyRaw), true); +}); + +test("verify: a single-byte tampering of the record fails verification", () => { + // Verifier MUST detect mutation of any signed field. We mutate one + // byte of a string field — this is the canonical detection surface + // for malicious record edits in transit. + const { pem, pubkeyRaw } = freshKeypair(); + const record = { id: "abc", content: "hello swarm", n: 7 }; + const signature = sign(record, pem); + const tampered = { ...record, content: "Hello swarm" }; // one-byte case flip + assert.equal(verify(tampered, signature, pubkeyRaw), false); +}); + +test("verify: a different field added to the record fails verification", () => { + // Adding a field changes the JCS bytes, so the signature must be + // rejected even if the original fields are untouched. Otherwise an + // attacker could append claims to a signed record. + const { pem, pubkeyRaw } = freshKeypair(); + const record = { id: "abc", content: "hello swarm" }; + const signature = sign(record, pem); + const tampered = { ...record, extra: "claim" }; + assert.equal(verify(tampered, signature, pubkeyRaw), false); +}); + +test("verify: returns false when the public key does not match the signer", () => { + // Cross-key verification must fail. This is what makes node identity + // load-bearing in the first place — anyone can sign anything, but + // only the holder of the private key matching `origin_node_id`'s + // pubkey produces a signature that verifies against that pubkey. + const a = freshKeypair(); + const b = freshKeypair(); + const record = { id: "abc", content: "hello swarm" }; + const signature = sign(record, a.pem); + assert.equal(verify(record, signature, b.pubkeyRaw), false); +}); + +test("sign: signature is deterministic (Ed25519 RFC 8032 property)", () => { + // Ed25519 is deterministic — same key + same message → same signature. + // This isn't strictly required for security, but it lets us reason + // about test stability and rules out a non-deterministic regression + // (e.g. someone swapping in ECDSA without RFC 6979). + const { pem } = freshKeypair(); + const record = { id: "abc", content: "hello swarm" }; + assert.equal(sign(record, pem), sign(record, pem)); +}); + +test("sign: identical signature regardless of key insertion order", () => { + // The acceptance criterion in issue #77: `{a:1, b:2}` and `{b:2, a:1}` + // produce the same signature. This is the bytes-stability promise of + // JCS made into an executable contract. + const { pem } = freshKeypair(); + assert.equal( + sign({ a: 1, b: 2 } as Record, pem), + sign({ b: 2, a: 1 } as Record, pem) + ); +}); + +test("sign: signature is identical with or without a placeholder signature field", () => { + // SWARM_SPEC §2.2 step 1: strip `signature` before canonicalizing. + // Without this rule, attaching the signature to the record would + // change the bytes-under-signature on the next sign — verifier would + // have to know to remove it, and any "re-sign in place" path would + // be broken. + const { pem } = freshKeypair(); + const a = sign({ id: "abc", n: 1 }, pem); + const b = sign({ id: "abc", n: 1, signature: "placeholder" }, pem); + const c = sign({ id: "abc", n: 1, signature: "" }, pem); + assert.equal(a, b); + assert.equal(a, c); +}); + +test("verify: ignores the on-wire `signature` field when recomputing canonical bytes", () => { + // Mirror of the previous test on the verify side. The transported + // record has the signature attached; the verifier must strip it + // before JCS-recomputing or every honest verification would fail. + const { pem, pubkeyRaw } = freshKeypair(); + const record = { id: "abc", content: "hello swarm" }; + const signature = sign(record, pem); + const onWire = { ...record, signature }; + assert.equal(verify(onWire, signature, pubkeyRaw), true); +}); + +test("verify: rejects malformed base64 signatures without throwing", () => { + // Defense-in-depth: a verifier on the network edge must not throw on + // garbage from a peer, only return false. Otherwise a single bad + // record could DoS the receive path. + const { pubkeyRaw } = freshKeypair(); + assert.equal(verify({ x: 1 }, "not!!!base64@@@", pubkeyRaw), false); +}); + +test("verify: rejects wrong-length signatures (must be 64 bytes)", () => { + // Ed25519 signatures are exactly 64 bytes. Anything else is by + // definition forged or corrupt — refuse without invoking OpenSSL. + const { pubkeyRaw } = freshKeypair(); + const tooShort = Buffer.alloc(32, 0).toString("base64"); + const tooLong = Buffer.alloc(128, 0).toString("base64"); + assert.equal(verify({ x: 1 }, tooShort, pubkeyRaw), false); + assert.equal(verify({ x: 1 }, tooLong, pubkeyRaw), false); +}); + +test("verify: rejects wrong-length public keys (must be 32 bytes)", () => { + // Same defensive rule for the pubkey side — a 31- or 33-byte key + // means the caller built the SPKI wrapper wrong, and we'd rather + // surface that as `false` than as an OpenSSL error. + const { pem } = freshKeypair(); + const signature = sign({ x: 1 }, pem); + assert.equal(verify({ x: 1 }, signature, new Uint8Array(31)), false); + assert.equal(verify({ x: 1 }, signature, new Uint8Array(33)), false); + assert.equal(verify({ x: 1 }, signature, new Uint8Array(0)), false); +}); + +test("sign: wire-format anchor — independent re-derivation matches", () => { + // Independent reference: build the canonical bytes by hand, sign with + // the raw node:crypto API, base64-encode, and assert against `sign`. + // If this ever fails, the wire-format service has drifted from the + // primitives it composes — the moment to halt. + const { pem, pubkeyRaw } = freshKeypair(); + const record = { z: 3, a: 1, m: 2 }; + const expectedBytes = Buffer.from('{"a":1,"m":2,"z":3}', "utf8"); + const expectedSig = nodeSign(null, expectedBytes, createPrivateKey(pem)) + .toString("base64"); + assert.equal(sign(record, pem), expectedSig); + assert.equal(verify(record, expectedSig, pubkeyRaw), true); +}); + +// --------------------------------------------------------------------------- +// signWithSelfKey — convenience wrapper, dependency-injected for tests +// --------------------------------------------------------------------------- + +test("signWithSelfKey: attaches signed_at and produces a verifying signature", () => { + // The wrapper has to (a) inject signed_at INTO the signed payload so + // receivers can reject stale records, (b) return that augmented + // payload alongside the signature, (c) keep `signature` OUT of the + // returned `record` (the caller decides whether to attach it before + // transport — see SWARM_SPEC §2.2 step 4). + const { pem, pubkeyRaw } = freshKeypair(); + const fixedNow = new Date("2026-04-27T20:01:26.605Z"); + const out = signWithSelfKey( + { id: "abc", content: "hi" }, + { + keyFile: "irrelevant — loadPem overrides this", + loadPem: () => pem, + now: () => fixedNow, + } + ); + assert.equal(out.signed_at, "2026-04-27T20:01:26.605Z"); + assert.equal(out.record.signed_at, out.signed_at); + assert.equal( + Object.prototype.hasOwnProperty.call(out.record, "signature"), + false, + "record must not carry the signature field — caller decides on attachment" + ); + assert.equal(verify(out.record, out.signature, pubkeyRaw), true); +}); + +test("signWithSelfKey: signed_at is part of the signed bytes (mutation detected)", () => { + // Belt-and-suspenders: if a refactor ever puts signed_at OUTSIDE the + // signed bytes, an attacker could replay an old record under a fresh + // timestamp. Catch that by mutating signed_at and asserting verify + // breaks. + const { pem, pubkeyRaw } = freshKeypair(); + const out = signWithSelfKey( + { id: "abc", content: "hi" }, + { loadPem: () => pem, now: () => new Date("2026-04-27T20:01:26.605Z") } + ); + const tampered = { ...out.record, signed_at: "2099-01-01T00:00:00.000Z" }; + assert.equal(verify(tampered, out.signature, pubkeyRaw), false); +}); + +test("defaultKeyFile: honors the MYCELIUM_NODE_KEY env override", () => { + const prev = process.env.MYCELIUM_NODE_KEY; + process.env.MYCELIUM_NODE_KEY = "/tmp/test-mycelium-node.key"; + try { + assert.equal(defaultKeyFile(), "/tmp/test-mycelium-node.key"); + } finally { + if (prev === undefined) delete process.env.MYCELIUM_NODE_KEY; + else process.env.MYCELIUM_NODE_KEY = prev; + } +}); diff --git a/mcp-server/src/services/signature.ts b/mcp-server/src/services/signature.ts new file mode 100644 index 0000000..e9ead33 --- /dev/null +++ b/mcp-server/src/services/signature.ts @@ -0,0 +1,261 @@ +/** + * Signature service — Swarm Phase 2 (issue #77). + * + * Reusable Ed25519 sign / verify primitives over JCS-canonicalized JSON, + * per docs/SWARM_SPEC.md §2 (RFC 8785, RFC 8032). + * + * Wire-format contract (SWARM_SPEC §2.2): + * + * 1. Strip the `signature` field from the record. + * 2. JCS-canonicalize the remainder (RFC 8785). + * 3. Sign the resulting UTF-8 bytes with Ed25519. + * 4. base64-encode the 64-byte signature; re-attach to the record. + * + * Verification reverses this. The canonical bytes are recomputed from the + * received JSON — we never trust the producer's serialization. That is + * what makes JCS load-bearing for the swarm trust premise (Verfassung + * pillar 6, Cyber security): two honest implementations must always + * agree on the bytes-under-signature, even if they re-order keys, round + * floats differently in memory, or print whitespace differently on + * transport. + * + * Scope discipline (issue #77 Hard constraints): + * - No networking, HTTP, or libp2p in this file. + * - This file is NOT yet integrated into existing record-creation + * paths (lesson synthesis, hub anchors, …). That is a follow-up. + * - Read-only dependency on the node identity bootstrapped by + * scripts/init-node-identity.mjs (issue #76); no migration touches. + */ +import { + createPrivateKey, + createPublicKey, + KeyObject, + sign as nodeSign, + verify as nodeVerify, +} from "node:crypto"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +// --------------------------------------------------------------------------- +// JCS canonicalization (RFC 8785) — pure, no external deps +// --------------------------------------------------------------------------- + +/** + * Serialize a value to its RFC 8785 JSON Canonical Form (JCS). + * + * Compliance summary: + * - Object keys sorted by UTF-16 code-unit order (RFC 8785 §3.2.3). + * `Array.prototype.sort` without a comparator is exactly that order + * in ECMAScript, so a plain `.sort()` is correct here. + * - No whitespace, no insignificant separators (`{"a":1,"b":2}`). + * - Strings escaped per RFC 8259 §7 minimal-escape rules. Node's + * `JSON.stringify` follows those rules for valid UTF-16, including + * the named escapes `\b \f \n \r \t` and lowercase `\uXXXX` for + * other C0 control characters. + * - Numbers serialized via the ECMAScript Number-to-String algorithm + * (RFC 8785 §3.2.2.3 mandates this exact algorithm). `JSON.stringify` + * on a finite Number produces that representation. + * - Non-finite numbers (`NaN`, `±Infinity`) and `undefined` are + * rejected — JCS is defined only over I-JSON (RFC 7493) and silent + * coercion would let two implementations disagree. + * + * Edge case worth knowing: float-array fields (e.g. embeddings) inherit + * the ECMAScript number serialization. Producers in other languages MUST + * use a JCS-conformant serializer there too — see SWARM_SPEC §2.1. + */ +export function canonicalize(value: unknown): string { + return jcs(value); +} + +function jcs(v: unknown): string { + if (v === null) return "null"; + if (typeof v === "boolean") return v ? "true" : "false"; + if (typeof v === "number") { + if (!Number.isFinite(v)) { + throw new Error( + `canonicalize: non-finite number not allowed by RFC 8785 / I-JSON (got ${v})` + ); + } + return JSON.stringify(v); + } + if (typeof v === "string") return JSON.stringify(v); + if (Array.isArray(v)) return "[" + v.map(jcs).join(",") + "]"; + if (typeof v === "object") { + const obj = v as Record; + const keys = Object.keys(obj).sort(); + return ( + "{" + + keys + .map((k) => JSON.stringify(k) + ":" + jcs(obj[k])) + .join(",") + + "}" + ); + } + throw new Error( + `canonicalize: unsupported value type ${typeof v} (only JSON-representable values allowed)` + ); +} + +// --------------------------------------------------------------------------- +// Ed25519 sign / verify +// --------------------------------------------------------------------------- + +/** + * Sign a JSON record using the Ed25519 private key supplied as a PEM + * (PKCS#8) string. Returns the 64-byte signature, base64-padded per + * RFC 4648 §4 (matching the `signature` field encoding in SWARM_SPEC §2.3). + * + * The `signature` field of the record, if present, is stripped before + * canonicalization. This mirrors the verify side and lets callers + * re-sign records without manually deleting the field. + * + * Uses `crypto.sign(null, data, key)` — the Node 14+ Ed25519 API. The + * legacy `createSign('sha256')` path does NOT work for Ed25519 because + * Ed25519 prehashes internally and rejects an external hash algorithm. + */ +export function sign(record: object, privateKeyPem: string): string { + const key = createPrivateKey(privateKeyPem); + const bytes = Buffer.from(canonicalize(stripSignature(record)), "utf8"); + const sig = nodeSign(null, bytes, key); + return sig.toString("base64"); +} + +/** + * Verify the base64 Ed25519 signature on a JSON record against a raw + * 32-byte public key (the on-wire `pubkey` shape from SWARM_SPEC §2.3). + * + * Returns true iff the signature is valid for `JCS(record − signature)` + * under the supplied key. All other failure modes — malformed base64, + * wrong-length signature, wrong-length pubkey, OpenSSL verify error — + * collapse to `false`. This is intentional: a verifier MUST NOT throw on + * bad input from the network, only refuse to trust it. + */ +export function verify( + record: object, + signature: string, + publicKey: Uint8Array +): boolean { + if (!(publicKey instanceof Uint8Array) || publicKey.length !== 32) { + return false; + } + let sigBytes: Buffer; + try { + sigBytes = Buffer.from(signature, "base64"); + } catch { + return false; + } + // Ed25519 signatures are exactly 64 bytes. Accepting other lengths + // would let malformed records sneak past with a Node-internal error. + if (sigBytes.length !== 64) return false; + + let pubKeyObj: KeyObject; + try { + pubKeyObj = pubkeyFromRaw(Buffer.from(publicKey)); + } catch { + return false; + } + + const bytes = Buffer.from(canonicalize(stripSignature(record)), "utf8"); + try { + return nodeVerify(null, bytes, pubKeyObj, sigBytes); + } catch { + return false; + } +} + +/** Return a copy of `record` with the top-level `signature` field removed. */ +function stripSignature(record: object): object { + if (record === null || typeof record !== "object" || Array.isArray(record)) { + return record; + } + const { signature: _omit, ...rest } = record as Record; + return rest; +} + +/** + * Build a Node `KeyObject` from a raw 32-byte Ed25519 public key by + * prepending the fixed SPKI DER header. `createPublicKey` requires + * SPKI-formatted input; the raw bytes alone are not enough. + * + * SPKI prefix is fixed for Ed25519 — see RFC 8410 §4. + */ +function pubkeyFromRaw(raw: Buffer): KeyObject { + if (raw.length !== 32) { + throw new Error(`pubkeyFromRaw: expected 32 bytes, got ${raw.length}`); + } + const SPKI_HEADER = Buffer.from([ + 0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00, + ]); + return createPublicKey({ + key: Buffer.concat([SPKI_HEADER, raw]), + format: "der", + type: "spki", + }); +} + +// --------------------------------------------------------------------------- +// Convenience wrapper: sign with the node's persistent self-key +// --------------------------------------------------------------------------- + +export interface SignedRecord { + /** + * The signed payload — the original record with `signed_at` attached. + * `signed_at` is part of the signed bytes (so receivers can reject + * stale records); `signature` is NOT mutated into this object — the + * caller decides whether to attach it before transport. + */ + record: T & { signed_at: string }; + /** base64 Ed25519 signature over `JCS(record)`. */ + signature: string; + /** ISO 8601 UTC timestamp identical to `record.signed_at`. */ + signed_at: string; +} + +export interface SignWithSelfOptions { + /** Override the privkey path. Defaults to `MYCELIUM_NODE_KEY` env var or `~/.mycelium/node.key`. */ + keyFile?: string; + /** Inject a clock — handy for tests. Defaults to `Date.now()`. */ + now?: () => Date; + /** Inject a PEM loader — handy for tests that don't want to touch disk. */ + loadPem?: (keyFile: string) => string; +} + +/** + * Sign a record with the node's persistent Ed25519 private key — the one + * bootstrapped by `scripts/init-node-identity.mjs` (issue #76) at + * `~/.mycelium/node.key` (chmod 0600). + * + * Attaches `signed_at` to the record BEFORE signing so it ends up inside + * the canonical bytes (SWARM_SPEC §3 — `signed_at` is a required field + * on every signed wire type). Returns the augmented record, the + * signature, and the timestamp; the caller decides whether to attach + * `signature` to the on-wire object. + */ +export function signWithSelfKey( + record: T, + options: SignWithSelfOptions = {} +): SignedRecord { + const keyFile = options.keyFile ?? defaultKeyFile(); + const loadPem = options.loadPem ?? readPemSync; + const pem = loadPem(keyFile); + const now = options.now ? options.now() : new Date(); + const signed_at = now.toISOString(); + const payload = { ...(record as object), signed_at } as T & { + signed_at: string; + }; + const signature = sign(payload, pem); + return { record: payload, signature, signed_at }; +} + +/** Default privkey path. Honors `MYCELIUM_NODE_KEY` for ops overrides. */ +export function defaultKeyFile(): string { + return ( + process.env.MYCELIUM_NODE_KEY ?? + path.join(os.homedir(), ".mycelium", "node.key") + ); +} + +function readPemSync(keyFile: string): string { + return fs.readFileSync(keyFile, "utf8"); +}