diff --git a/mcp-server/src/__tests__/wire-validator.test.ts b/mcp-server/src/__tests__/wire-validator.test.ts new file mode 100644 index 0000000..1ae2a50 --- /dev/null +++ b/mcp-server/src/__tests__/wire-validator.test.ts @@ -0,0 +1,419 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { generateKeyPairSync } from "node:crypto"; + +import { + validateWireRecord, + type ValidationResult, +} from "../services/wire-validator.js"; +import { sign } from "../services/signature.js"; +import { computeNodeId } from "../services/node-identity.js"; +import { WIRE_SPEC_VERSION } from "../services/wire-types.js"; + +// --------------------------------------------------------------------------- +// Test fixtures +// +// Every negative test starts from a fully-valid record and breaks ONE +// invariant. That keeps the assertion surface clean: if a test fails on +// rule N but really tripped over rule M, the helper would mask it; we +// build up the valid baseline once and mutate from there. +// --------------------------------------------------------------------------- + +interface Identity { + pem: string; + pubkeyRaw: Uint8Array; + nodeId: string; +} + +function freshIdentity(): Identity { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); + const spki = publicKey.export({ type: "spki", format: "der" }); + // Last 32 bytes of the SPKI DER encoding are the raw Ed25519 pubkey. + const pubkeyRaw = new Uint8Array(spki.subarray(spki.length - 32)); + const pem = privateKey.export({ type: "pkcs8", format: "pem" }) as string; + const nodeId = computeNodeId(Buffer.from(pubkeyRaw)); + return { pem, pubkeyRaw, nodeId }; +} + +function full768Embedding(seed = 0): number[] { + const out = new Array(768); + for (let i = 0; i < 768; i++) out[i] = (seed + i) / 1000; + return out; +} + +// Anchor "now" so rule 7/8 windows are deterministic. +const FIXED_NOW = new Date("2026-04-28T12:00:00.000Z"); + +function makePubkeyResolver(known: Map) { + return (nodeId: string): Uint8Array | null => known.get(nodeId) ?? null; +} + +function attachSignature( + record: Record, + pem: string +): Record { + return { ...record, signature: sign(record, pem) }; +} + +// --------------------------------------------------------------------------- +// Valid examples — one per kind +// --------------------------------------------------------------------------- + +function buildValidLesson(producer: Identity): Record { + const unsigned = { + id: "11111111-2222-3333-4444-555555555555", + content: "Lessons must be generalized before they leave a node.", + embedding: full768Embedding(), + synthesized_from_cluster_size: 4, + origin_node_id: producer.nodeId, + signed_at: "2026-04-28T11:00:00.000Z", + created_at: "2026-04-27T10:00:00.000Z", + tags: ["test", "swarm"], + spec_version: WIRE_SPEC_VERSION, + }; + return attachSignature(unsigned, producer.pem); +} + +function buildValidHubAnchor(producer: Identity): Record { + const unsigned = { + embedding: full768Embedding(7), + hub_score: 0.82, + local_memory_count: 17, + topic_label: "test-cluster", + origin_node_id: producer.nodeId, + signed_at: "2026-04-28T11:00:00.000Z", + spec_version: WIRE_SPEC_VERSION, + }; + return attachSignature(unsigned, producer.pem); +} + +function buildValidAdvertisement(self: Identity): Record { + // Pubkey on the wire is unpadded base64url per SWARM_SPEC §3.3. + const pubkeyB64Url = Buffer.from(self.pubkeyRaw).toString("base64url"); + const unsigned = { + node_id: self.nodeId, + pubkey: pubkeyB64Url, + display_name: "test-node", + endpoint_url: "https://node.example.com", + spec_version: WIRE_SPEC_VERSION, + signed_at: "2026-04-28T11:00:00.000Z", + }; + return attachSignature(unsigned, self.pem); +} + +function expectErr(result: ValidationResult, rule: number): void { + assert.equal(result.ok, false, `expected rejection on rule ${rule}, got ok=true`); + if (result.ok) return; // narrowing + assert.equal( + result.rule, + rule, + `expected rule ${rule}, got rule ${result.rule} (reason: ${result.reason})` + ); +} + +// --------------------------------------------------------------------------- +// Happy paths +// --------------------------------------------------------------------------- + +test("valid Lesson is accepted", async () => { + const producer = freshIdentity(); + const record = buildValidLesson(producer); + const result = await validateWireRecord(record, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: makePubkeyResolver( + new Map([[producer.nodeId, producer.pubkeyRaw]]) + ), + }); + assert.equal(result.ok, true); +}); + +test("valid HubAnchor is accepted", async () => { + const producer = freshIdentity(); + const record = buildValidHubAnchor(producer); + const result = await validateWireRecord(record, "hub_anchor", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: makePubkeyResolver( + new Map([[producer.nodeId, producer.pubkeyRaw]]) + ), + }); + assert.equal(result.ok, true); +}); + +test("valid NodeAdvertisement is accepted (self-signed)", async () => { + const self = freshIdentity(); + const record = buildValidAdvertisement(self); + const result = await validateWireRecord(record, "node_advertisement", { + ourSpecMajor: 1, + now: FIXED_NOW, + // Self-signed: getPubkeyForNode is not consulted; resolver returns null. + getPubkeyForNode: () => null, + }); + assert.equal(result.ok, true); +}); + +// --------------------------------------------------------------------------- +// Negative cases — one per rule (1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 13) +// --------------------------------------------------------------------------- + +test("rule 1: spec_version major mismatch is rejected", async () => { + const producer = freshIdentity(); + const valid = buildValidLesson(producer); + // Re-sign with the wrong spec_version so rule 5 wouldn't also fire. + const { signature: _omit, ...unsigned } = valid; + const tampered = attachSignature( + { ...unsigned, spec_version: "2.0" }, + producer.pem + ); + const result = await validateWireRecord(tampered, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: makePubkeyResolver( + new Map([[producer.nodeId, producer.pubkeyRaw]]) + ), + }); + expectErr(result, 1); +}); + +test("rule 2: missing required field is rejected", async () => { + const producer = freshIdentity(); + const record = buildValidLesson(producer) as Record; + delete record.content; + const result = await validateWireRecord(record, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: makePubkeyResolver( + new Map([[producer.nodeId, producer.pubkeyRaw]]) + ), + }); + expectErr(result, 2); +}); + +test("rule 3: type mismatch on a typed field is rejected", async () => { + const producer = freshIdentity(); + const record = buildValidLesson(producer); + // synthesized_from_cluster_size is typed as number; flip to string. + const broken = { ...record, synthesized_from_cluster_size: "four" }; + const result = await validateWireRecord(broken, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: makePubkeyResolver( + new Map([[producer.nodeId, producer.pubkeyRaw]]) + ), + }); + expectErr(result, 3); +}); + +test("rule 4: embedding wrong length is rejected", async () => { + const producer = freshIdentity(); + const valid = buildValidLesson(producer); + const { signature: _omit, ...unsigned } = valid; + const broken = attachSignature( + { ...unsigned, embedding: [0.1, 0.2, 0.3] }, + producer.pem + ); + const result = await validateWireRecord(broken, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: makePubkeyResolver( + new Map([[producer.nodeId, producer.pubkeyRaw]]) + ), + }); + expectErr(result, 4); +}); + +test("rule 4: embedding containing non-finite is rejected", async () => { + const producer = freshIdentity(); + const valid = buildValidLesson(producer); + const { signature: _omit, embedding: _e, ...rest } = valid; + const emb = full768Embedding(); + emb[42] = Number.NaN; + // We cannot re-sign a record with NaN inside (canonicalize refuses); + // attach a placeholder signature that the validator will never reach. + const broken = { ...rest, embedding: emb, signature: "AAAA" }; + const result = await validateWireRecord(broken, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: makePubkeyResolver( + new Map([[producer.nodeId, producer.pubkeyRaw]]) + ), + }); + expectErr(result, 4); +}); + +test("rule 5: bad signature is rejected (content mutated post-sign)", async () => { + const producer = freshIdentity(); + const valid = buildValidLesson(producer); + // Mutate content AFTER signing so JCS-recompute produces different bytes. + const tampered = { ...valid, content: "Mutated after signing." }; + const result = await validateWireRecord(tampered, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: makePubkeyResolver( + new Map([[producer.nodeId, producer.pubkeyRaw]]) + ), + }); + expectErr(result, 5); +}); + +test("rule 6: NodeAdvertisement node_id != multihash(pubkey) is rejected", async () => { + const self = freshIdentity(); + const other = freshIdentity(); + const valid = buildValidAdvertisement(self); + const { signature: _omit, ...unsigned } = valid; + // Substitute a foreign node_id and re-sign with self's key — rule 5 still + // verifies (self signed it) but rule 6 must trip first. + const broken = attachSignature( + { ...unsigned, node_id: other.nodeId }, + self.pem + ); + const result = await validateWireRecord(broken, "node_advertisement", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: () => null, + }); + expectErr(result, 6); +}); + +test("rule 7: signed_at more than 5 minutes in the future is rejected", async () => { + const producer = freshIdentity(); + const valid = buildValidLesson(producer); + const { signature: _omit, ...unsigned } = valid; + // FIXED_NOW + 1 hour — well past the 5-min skew tolerance. + const broken = attachSignature( + { ...unsigned, signed_at: "2026-04-28T13:00:00.000Z" }, + producer.pem + ); + const result = await validateWireRecord(broken, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: makePubkeyResolver( + new Map([[producer.nodeId, producer.pubkeyRaw]]) + ), + }); + expectErr(result, 7); +}); + +test("rule 8: Lesson signed_at older than 90 days is rejected", async () => { + const producer = freshIdentity(); + const valid = buildValidLesson(producer); + const { signature: _omit, ...unsigned } = valid; + // 100 days before FIXED_NOW. created_at must precede signed_at (rule 9), + // so push it earlier too. + const broken = attachSignature( + { + ...unsigned, + signed_at: "2026-01-18T12:00:00.000Z", + created_at: "2026-01-17T12:00:00.000Z", + }, + producer.pem + ); + const result = await validateWireRecord(broken, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: makePubkeyResolver( + new Map([[producer.nodeId, producer.pubkeyRaw]]) + ), + }); + expectErr(result, 8); +}); + +test("rule 9: Lesson signed_at < created_at is rejected", async () => { + const producer = freshIdentity(); + const valid = buildValidLesson(producer); + const { signature: _omit, ...unsigned } = valid; + const broken = attachSignature( + { + ...unsigned, + signed_at: "2026-04-28T10:00:00.000Z", + created_at: "2026-04-28T11:00:00.000Z", + }, + producer.pem + ); + const result = await validateWireRecord(broken, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: makePubkeyResolver( + new Map([[producer.nodeId, producer.pubkeyRaw]]) + ), + }); + expectErr(result, 9); +}); + +test("rule 11: Lesson synthesized_from_cluster_size < 2 is rejected", async () => { + const producer = freshIdentity(); + const valid = buildValidLesson(producer); + const { signature: _omit, ...unsigned } = valid; + const broken = attachSignature( + { ...unsigned, synthesized_from_cluster_size: 1 }, + producer.pem + ); + const result = await validateWireRecord(broken, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: makePubkeyResolver( + new Map([[producer.nodeId, producer.pubkeyRaw]]) + ), + }); + expectErr(result, 11); +}); + +test("rule 12: Lesson.content over 8 KiB is rejected", async () => { + const producer = freshIdentity(); + const valid = buildValidLesson(producer); + const { signature: _omit, ...unsigned } = valid; + const broken = attachSignature( + { ...unsigned, content: "A".repeat(8 * 1024 + 1) }, + producer.pem + ); + const result = await validateWireRecord(broken, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: makePubkeyResolver( + new Map([[producer.nodeId, producer.pubkeyRaw]]) + ), + }); + expectErr(result, 12); +}); + +test("rule 13: NodeAdvertisement endpoint_url not https is rejected", async () => { + const self = freshIdentity(); + const valid = buildValidAdvertisement(self); + const { signature: _omit, ...unsigned } = valid; + const broken = attachSignature( + { ...unsigned, endpoint_url: "http://node.example.com" }, + self.pem + ); + const result = await validateWireRecord(broken, "node_advertisement", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: () => null, + }); + expectErr(result, 13); +}); + +// --------------------------------------------------------------------------- +// Defensive extras (not required by the issue, cheap to keep) +// --------------------------------------------------------------------------- + +test("non-object input is rejected (no exception thrown)", async () => { + const result = await validateWireRecord("not a record" as unknown, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + getPubkeyForNode: () => null, + }); + assert.equal(result.ok, false); +}); + +test("rule 5: unknown origin_node_id (no pubkey resolver hit) is rejected", async () => { + const producer = freshIdentity(); + const record = buildValidLesson(producer); + const result = await validateWireRecord(record, "lesson", { + ourSpecMajor: 1, + now: FIXED_NOW, + // Resolver knows nobody — a swarm without trust state for this node. + getPubkeyForNode: () => null, + }); + expectErr(result, 5); +}); diff --git a/mcp-server/src/services/wire-validator.ts b/mcp-server/src/services/wire-validator.ts new file mode 100644 index 0000000..740eef9 --- /dev/null +++ b/mcp-server/src/services/wire-validator.ts @@ -0,0 +1,403 @@ +/** + * Wire validator — Swarm Phase 3b (issue #86). + * + * Pure runtime validator for incoming wire records. Implements rejection + * rules 1-13 from docs/SWARM_SPEC.md §5; rules 14 (local trust — caller's + * job) and 15 (body cap — transport-layer concern) are intentionally + * out of scope here. Rule 10 (duplicate id+signed_at with conflicting + * signature) is also caller-side: it requires a database read against + * already-ingested records and cannot be answered by a pure function. + * + * Design discipline (issue #86 Hard constraints): + * - Pure: no DB, no HTTP, no file I/O, no caching/memoization. + * - All side-channel data (current time, pubkey-by-node lookup) is + * injected via `opts` so this function is trivially testable and + * deterministic for a given input. + * - Composes the existing JCS+Ed25519 primitives in `signature.ts` and + * the `computeNodeId` helper in `node-identity.ts`. Re-implementing + * either would create a divergence surface — the trust premise of + * the swarm is that two honest implementations agree on the + * bytes-under-signature and on the node_id derivation. + */ +import { verify } from "./signature.js"; +import { computeNodeId } from "./node-identity.js"; + +// --------------------------------------------------------------------------- +// Public surface +// --------------------------------------------------------------------------- + +export type ValidationOk = { ok: true }; +export type ValidationErr = { ok: false; rule: number; reason: string }; +export type ValidationResult = ValidationOk | ValidationErr; + +export type WireKind = "lesson" | "hub_anchor" | "node_advertisement"; + +export interface ValidateOptions { + /** Major component of the receiver's spec version (e.g. 1 for v1.x). */ + ourSpecMajor: number; + /** Current time. Injected so tests are clock-independent. */ + now: Date; + /** + * Lookup the raw 32-byte Ed25519 pubkey for a non-self node, or `null` + * if unknown. Used for rule 5 on Lesson and HubAnchor (signature + * verification against the producer's key). NodeAdvertisement is + * self-signed: the pubkey is in the record itself, so this callback + * is not consulted for that kind. + */ + getPubkeyForNode: (nodeId: string) => Uint8Array | null; +} + +// --------------------------------------------------------------------------- +// Constants from SWARM_SPEC §5 +// --------------------------------------------------------------------------- + +const FUTURE_TOLERANCE_MS = 5 * 60 * 1000; // rule 7 +const STALE_LIMIT_MS = 90 * 24 * 60 * 60 * 1000; // rule 8 +const LESSON_CONTENT_BYTE_LIMIT = 8 * 1024; // rule 12 +const HUB_TOPIC_LABEL_CHAR_LIMIT = 256; // rule 12 +const ADV_DISPLAY_NAME_CHAR_LIMIT = 64; // rule 12 +const EMBEDDING_DIM = 768; // rule 4 +const ED25519_PUBKEY_LEN = 32; + +// --------------------------------------------------------------------------- +// Field schemas — exhaustive required-field lists per kind +// +// The required-field set is the chokepoint for rules 2 and 3: a field +// that's missing is rule 2, a field present-but-wrong-type is rule 3. +// Optional fields (Lesson.tags, HubAnchor.topic_label, +// NodeAdvertisement.display_name) are validated separately because their +// rejection rules differ (rule 12 size limits, not rule 2/3 presence). +// --------------------------------------------------------------------------- + +type FieldType = "string" | "number" | "array"; + +interface FieldSpec { + name: string; + type: FieldType; +} + +const LESSON_REQUIRED: readonly FieldSpec[] = [ + { name: "id", type: "string" }, + { name: "content", type: "string" }, + { name: "embedding", type: "array" }, + { name: "synthesized_from_cluster_size", type: "number" }, + { name: "origin_node_id", type: "string" }, + { name: "signed_at", type: "string" }, + { name: "signature", type: "string" }, + { name: "created_at", type: "string" }, + { name: "spec_version", type: "string" }, +]; + +const HUB_REQUIRED: readonly FieldSpec[] = [ + { name: "embedding", type: "array" }, + { name: "hub_score", type: "number" }, + { name: "local_memory_count", type: "number" }, + { name: "origin_node_id", type: "string" }, + { name: "signed_at", type: "string" }, + { name: "signature", type: "string" }, + { name: "spec_version", type: "string" }, +]; + +const ADV_REQUIRED: readonly FieldSpec[] = [ + { name: "node_id", type: "string" }, + { name: "pubkey", type: "string" }, + { name: "endpoint_url", type: "string" }, + { name: "spec_version", type: "string" }, + { name: "signed_at", type: "string" }, + { name: "signature", type: "string" }, +]; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function fail(rule: number, reason: string): ValidationErr { + return { ok: false, rule, reason }; +} + +function isObject(v: unknown): v is Record { + return v !== null && typeof v === "object" && !Array.isArray(v); +} + +function checkPresentAndTyped( + record: Record, + specs: readonly FieldSpec[] +): ValidationErr | null { + for (const f of specs) { + if (!(f.name in record) || record[f.name] === null || record[f.name] === undefined) { + return fail(2, `missing required field: ${f.name}`); + } + } + for (const f of specs) { + const v = record[f.name]; + if (f.type === "string" && typeof v !== "string") { + return fail(3, `field ${f.name} must be string, got ${typeof v}`); + } + if (f.type === "number" && (typeof v !== "number" || !Number.isFinite(v))) { + return fail( + 3, + `field ${f.name} must be a finite number, got ${typeof v}` + ); + } + if (f.type === "array" && !Array.isArray(v)) { + return fail(3, `field ${f.name} must be array, got ${typeof v}`); + } + } + return null; +} + +function checkEmbedding(value: unknown): ValidationErr | null { + if (!Array.isArray(value)) { + // Defensive — type pass already enforced array, this is belt-and-suspenders. + return fail(4, "embedding must be an array"); + } + if (value.length !== EMBEDDING_DIM) { + return fail( + 4, + `embedding must have ${EMBEDDING_DIM} elements, got ${value.length}` + ); + } + for (let i = 0; i < value.length; i++) { + const x = value[i]; + if (typeof x !== "number" || !Number.isFinite(x)) { + return fail(4, `embedding[${i}] is not a finite number`); + } + } + return null; +} + +/** Parse `.` per SWARM_SPEC §1. Returns null if malformed. */ +function parseSpecMajor(specVersion: string): number | null { + // Spec §1: decimal integers, no leading zeros, no whitespace. Single-digit + // majors are common ("1.0"), multi-digit minors are valid ("1.10"); we + // accept any non-leading-zero decimal pair. + const m = specVersion.match(/^(0|[1-9]\d*)\.(0|[1-9]\d*)$/); + if (!m) return null; + return Number(m[1]); +} + +/** Parse an ISO-8601 timestamp into ms-epoch, or null if unparseable. */ +function parseTimestamp(s: string): number | null { + const t = Date.parse(s); + return Number.isFinite(t) ? t : null; +} + +/** + * Decode `pubkey` from a NodeAdvertisement. SWARM_SPEC §3 / §3.3 fix the + * encoding to unpadded base64url. We refuse padded base64 and any + * non-alphabet character so a producer that picked the wrong variant + * fails loudly here, not later inside Ed25519. + */ +function decodeAdvertisementPubkey(s: string): Uint8Array | null { + if (!/^[A-Za-z0-9_-]+$/.test(s)) return null; + const buf = Buffer.from(s, "base64url"); + return new Uint8Array(buf); +} + +// --------------------------------------------------------------------------- +// validateWireRecord — entry point +// +// Returns Promise per the issue contract; the body is +// synchronous today, but keeping the async surface lets a future receive +// path block on e.g. an async pubkey lookup without re-fitting every +// caller. (Synchronous in v1, async-shaped for v2 — same trade-off the +// rest of the swarm services took.) +// --------------------------------------------------------------------------- + +export async function validateWireRecord( + record: unknown, + kind: WireKind, + opts: ValidateOptions +): Promise { + if (!isObject(record)) { + return fail( + 3, + `record must be a JSON object, got ${ + Array.isArray(record) ? "array" : typeof record + }` + ); + } + + const specs = + kind === "lesson" + ? LESSON_REQUIRED + : kind === "hub_anchor" + ? HUB_REQUIRED + : ADV_REQUIRED; + + // Rules 2 + 3: required-field presence and JSON-type match. + const presenceErr = checkPresentAndTyped(record, specs); + if (presenceErr) return presenceErr; + + // Rule 1: spec_version major. Done after presence/type so we know the + // field is at least a string before we try to parse it. + const specVersion = record.spec_version as string; + const major = parseSpecMajor(specVersion); + if (major === null) { + // A string that isn't a valid . is a type-shape problem + // for that field — it can't pass spec-version negotiation but it isn't + // a major mismatch in the rule-1 sense either. Surface as rule 3. + return fail(3, `spec_version "${specVersion}" is not a valid .`); + } + if (major !== opts.ourSpecMajor) { + return fail( + 1, + `spec_version major ${major} != receiver's major ${opts.ourSpecMajor}` + ); + } + + // Rule 4: embedding shape (Lesson and HubAnchor only). + if (kind === "lesson" || kind === "hub_anchor") { + const e = checkEmbedding(record.embedding); + if (e) return e; + } + + // Rule 12: kind-specific size limits. Optional fields are also typed + // here because their type was not exercised by checkPresentAndTyped. + if (kind === "lesson") { + const content = record.content as string; + if (Buffer.byteLength(content, "utf8") > LESSON_CONTENT_BYTE_LIMIT) { + return fail( + 12, + `Lesson.content > ${LESSON_CONTENT_BYTE_LIMIT} bytes UTF-8` + ); + } + } else if (kind === "hub_anchor") { + const topic = record.topic_label; + if (topic !== undefined && topic !== null) { + if (typeof topic !== "string") { + return fail(3, `topic_label must be string, got ${typeof topic}`); + } + if (topic.length > HUB_TOPIC_LABEL_CHAR_LIMIT) { + return fail( + 12, + `HubAnchor.topic_label > ${HUB_TOPIC_LABEL_CHAR_LIMIT} chars` + ); + } + } + } else if (kind === "node_advertisement") { + const dn = record.display_name; + if (dn !== undefined && dn !== null) { + if (typeof dn !== "string") { + return fail(3, `display_name must be string, got ${typeof dn}`); + } + if (dn.length > ADV_DISPLAY_NAME_CHAR_LIMIT) { + return fail( + 12, + `NodeAdvertisement.display_name > ${ADV_DISPLAY_NAME_CHAR_LIMIT} chars` + ); + } + } + } + + // Rule 13: NodeAdvertisement.endpoint_url MUST be https. + if (kind === "node_advertisement") { + const url = record.endpoint_url as string; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return fail(13, `endpoint_url is not a valid URL: ${url}`); + } + if (parsed.protocol !== "https:") { + return fail( + 13, + `endpoint_url must use https, got ${parsed.protocol}//` + ); + } + } + + // Rule 11: Lesson.synthesized_from_cluster_size >= 2 (Generalization rule + // enforced on the wire, SWARM_SPEC §3.1). + if (kind === "lesson") { + const n = record.synthesized_from_cluster_size as number; + if (n < 2) { + return fail( + 11, + `Lesson.synthesized_from_cluster_size must be >= 2, got ${n}` + ); + } + } + + // Rule 7 + 8: timestamp bounds. signed_at is required for all kinds; + // rule 8 (90-day staleness) is Lesson/HubAnchor only — NodeAdvertisement + // is expected to be re-fetched live. + const signedAtMs = parseTimestamp(record.signed_at as string); + if (signedAtMs === null) { + return fail(3, `signed_at is not a parseable ISO-8601 timestamp`); + } + const nowMs = opts.now.getTime(); + if (signedAtMs > nowMs + FUTURE_TOLERANCE_MS) { + return fail( + 7, + `signed_at ${record.signed_at} is more than 5 minutes in the future` + ); + } + if (kind === "lesson" || kind === "hub_anchor") { + if (signedAtMs < nowMs - STALE_LIMIT_MS) { + return fail( + 8, + `signed_at ${record.signed_at} is older than 90 days` + ); + } + } + + // Rule 9: Lesson.signed_at MUST NOT precede created_at — a producer + // cannot sign a lesson before having synthesized it. + if (kind === "lesson") { + const createdAtMs = parseTimestamp(record.created_at as string); + if (createdAtMs === null) { + return fail(3, `created_at is not a parseable ISO-8601 timestamp`); + } + if (signedAtMs < createdAtMs) { + return fail(9, `Lesson.signed_at (${record.signed_at}) < created_at (${record.created_at})`); + } + } + + // Rule 6: NodeAdvertisement.node_id MUST equal multihash(pubkey). This is + // also where we obtain the verifier pubkey for rule 5 — for self-signed + // advertisements, no out-of-band lookup is needed. + let verifierPubkey: Uint8Array | null; + if (kind === "node_advertisement") { + const pubkeyB64Url = record.pubkey as string; + const pubkeyBytes = decodeAdvertisementPubkey(pubkeyB64Url); + if (!pubkeyBytes || pubkeyBytes.length !== ED25519_PUBKEY_LEN) { + return fail( + 6, + `pubkey is not a 32-byte unpadded base64url Ed25519 key` + ); + } + let derivedNodeId: string; + try { + derivedNodeId = computeNodeId(Buffer.from(pubkeyBytes)); + } catch (e) { + return fail(6, `cannot derive node_id from pubkey: ${(e as Error).message}`); + } + if (derivedNodeId !== record.node_id) { + return fail( + 6, + `node_id ${record.node_id} != multihash(pubkey) ${derivedNodeId}` + ); + } + verifierPubkey = pubkeyBytes; + } else { + const originId = record.origin_node_id as string; + verifierPubkey = opts.getPubkeyForNode(originId); + if (!verifierPubkey) { + // We can't verify a signature against a key we don't hold — collapse + // this to a rule 5 rejection. Trust establishment (how the receiver + // ever obtains the pubkey for `originId`) is rule 14's territory. + return fail(5, `no pubkey available for origin_node_id ${originId}`); + } + } + + // Rule 5: Ed25519 signature verification over JCS-canonical bytes. + // `verify` strips the on-wire `signature` field internally before + // recomputing — see signature.ts. + const sig = record.signature as string; + if (!verify(record, sig, verifierPubkey)) { + return fail(5, `Ed25519 signature verification failed`); + } + + return { ok: true }; +}