From af8fb701483aa530f07c9c2444075857e0cbb7bd Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 2 Sep 2026 14:57:23 -0700 Subject: [PATCH 1/7] Contest rotation ties: key selection on (document, generation) Tie-collapse at the top serial deduplicated on document alone, so two records naming the same document with different g= keys read as agreeing duplicates and selection picked a generation arbitrarily. A rotation caught mid-flight is a contest: the one-shot RRset rule has no lineage evidence to order the generations, so it must refuse rather than pick. This matches the reference verifier (best_of_document treats a non-unique undominated set as contested) and the canonical classifyRecords export will inherit exactly this behaviour, so adopting it now prevents a verdict flip reading as a regression later. HostnameBinding gains an optional contested flag; the directory renders it as the contested status. Found by the demo app fixing the same gap in its own copy after the onomancy session confirmed the reference semantics; the new test is a candidate conformance vector. --- src/onomancy/runtime.ts | 43 +++++++++++++++++++++++------- src/onomancy/verified-directory.ts | 10 ++++++- tests/verified-directory.test.mjs | 26 ++++++++++++++++++ 3 files changed, 68 insertions(+), 11 deletions(-) diff --git a/src/onomancy/runtime.ts b/src/onomancy/runtime.ts index 2d51d0e..38db162 100644 --- a/src/onomancy/runtime.ts +++ b/src/onomancy/runtime.ts @@ -118,6 +118,12 @@ export interface HostnameBinding { * across two queries — never two records in one answer. */ serial?: bigint; + /** + * Records of equal top precedence disagree on `(document, generation)`. + * The zone has failed to say what it designates; nothing here can be + * treated as the binding. + */ + contested?: boolean; /** * How many records were set aside for reading too far in the future. * @@ -204,6 +210,7 @@ export function createOnomancyRuntime( const binding: HostnameBinding = { hostname, ids: selection.ids }; if (selection.serial !== undefined) binding.serial = selection.serial; + if (selection.contested) binding.contested = true; if (selection.deferredSerials > 0) { binding.deferredSerials = selection.deferredSerials; } @@ -318,6 +325,8 @@ interface RecordSelection { ids: string[]; serial?: bigint; deferredSerials: number; + /** Records of equal top precedence disagree on (document, generation). */ + contested?: boolean; } /** @@ -374,18 +383,21 @@ function boundIdsOf(outcome: unknown, nowMs: bigint): RecordSelection { const leaders = eligible.filter((record) => record.serial === top); const distinct = [...new Set(leaders.map((record) => record.docIdHex))]; + // Contested-ness is keyed on the pair (document, generation), matching the + // reference verifier. Two records naming the same document with different + // generation keys at a tied serial are a rotation caught mid-flight; the + // one-shot RRset rule has no lineage evidence to order them, so it refuses + // rather than picking a generation arbitrarily. + const pairs = new Set( + leaders.map((record) => record.docIdHex + " " + record.generation) + ); // Agreement at the top serial, including the ordinary single-record case. - if (distinct.length === 1) { + if (pairs.size === 1) { return { ids: distinct, serial: top, deferredSerials }; } - // Disagreement at the same serial: two records claim to be equally current - // and name different documents. There is no ground here for preferring - // either — that is the definition of the tie. Returning all of them lets - // the caller see a contested binding and refuse it; returning one would - // manufacture a verdict the zone does not support. - return { ids: distinct, serial: top, deferredSerials }; + return { ids: distinct, serial: top, deferredSerials, contested: true }; } /** @@ -407,6 +419,17 @@ function freshnessOf(outcome: unknown): ChainFreshness | undefined { export interface Ono0Record { /** The hex-encoded root document id from `p=`. */ readonly docIdHex: string; + /** + * The `g=` generation key, as spelled in the record. + * + * Part of the zone-state key: contested-ness is decided on the pair + * `(document, generation)`, so same-document-different-generation records + * tied at the top serial are a rotation caught mid-flight — a contest, not + * agreeing duplicates. This matches the reference verifier's + * `best_of_document`, where a non-unique undominated set is contested by + * construction. + */ + readonly generation: string; /** * The `n=` serial, as a `BigInt`. * @@ -432,7 +455,7 @@ export interface Ono0Record { * we only need the same verdict, not the same diagnosis. */ const ONO0 = - /^v=ONO0;k=ed25519;n=(0|[1-9][0-9]{0,19});g=[A-Za-z0-9+/]+={0,2};p=([A-Za-z0-9+/]+={0,2})$/; + /^v=ONO0;k=ed25519;n=(0|[1-9][0-9]{0,19});g=([A-Za-z0-9+/]+={0,2});p=([A-Za-z0-9+/]+={0,2})$/; const U64_MAX = 18446744073709551615n; @@ -458,10 +481,10 @@ export function parseRecord(record: string): Ono0Record | undefined { const serial = BigInt(match[1]); if (serial > U64_MAX) return undefined; - const bytes = base64ToBytes(match[2]); + const bytes = base64ToBytes(match[3]); if (bytes === undefined || bytes.length !== 32) return undefined; - return { docIdHex: bytesToHex(bytes), serial }; + return { docIdHex: bytesToHex(bytes), generation: match[2]!, serial }; } /** The hex-encoded root document id of one TXT record. See {@link parseRecord}. */ diff --git a/src/onomancy/verified-directory.ts b/src/onomancy/verified-directory.ts index d610a88..3b9a574 100644 --- a/src/onomancy/verified-directory.ts +++ b/src/onomancy/verified-directory.ts @@ -19,6 +19,8 @@ type Resolution = | { phase: "resolved"; ids: string[]; + /** Equal-precedence records disagree on (document, generation). */ + contested?: boolean; freshness?: ChainFreshness; lapsedSeconds?: number; } @@ -304,6 +306,7 @@ export function createOnomancyDirectory( phase: "resolved", ids: binding.ids.map(bareId), }; + if (binding.contested) resolved.contested = true; if (binding.freshness !== undefined) { resolved.freshness = binding.freshness; } @@ -418,7 +421,12 @@ export function createOnomancyDirectory( // an answer about *this entry*, and the zone has not made one. The // remedy belongs to whoever controls the DNS records, so the status must // not read as "wait". - if (resolution.ids.length > 1) { + // Two triggers: distinct documents at the tied top serial (visible in + // ids), or same document with different generation keys (visible only in + // the contested flag the selection set). The second check also keeps + // custom OnomancyRuntime implementations honest that report multiple ids + // without the flag. + if (resolution.contested || resolution.ids.length > 1) { return { ...entry, dnsNameStatus: "contested" }; } diff --git a/tests/verified-directory.test.mjs b/tests/verified-directory.test.mjs index 59b8f99..a90b0a7 100644 --- a/tests/verified-directory.test.mjs +++ b/tests/verified-directory.test.mjs @@ -7,6 +7,7 @@ // reverted — these were verified to fail against pre-fix builds. import { test } from "node:test"; import assert from "node:assert/strict"; +import { Buffer } from "node:buffer"; const { createOnomancyDirectory, @@ -95,3 +96,28 @@ test("two subscriptions sharing one callback survive one unsubscribe", async () for (const fn of baseListeners) fn(); assert.equal(hits, 1, "the surviving subscription still fires"); }); + +test("contests a rotation tie: same document, different generation keys", async () => { + // Candidate vector for classifyRecords: the old selection keyed ties on + // document alone, so this case read as agreeing duplicates and picked a + // generation arbitrarily. The reference verifier refuses it. + const { createOnomancyRuntime } = await import("../dist/onomancy/index.js"); + const A = "aa".repeat(32); + const b64 = (h) => Buffer.from(h, "hex").toString("base64"); + const rec = (g) => `v=ONO0;k=ed25519;n=5;g=${g};p=${b64(A)}`; + const runtime = createOnomancyRuntime( + { + resolveHostname: async () => ({ + records: [rec(b64("11".repeat(32))), rec(b64("22".repeat(32)))], + }), + Name: class { + constructor() {} + }, + }, + { now: () => 1788000000000 } + ); + + const binding = await runtime.resolveBoundIds("a.example"); + assert.equal(binding.contested, true, "rotation tie must be contested"); + assert.equal(binding.ids.length, 1, "one document, still reported"); +}); From 98ef3b7ceb590a7a7947a3717f6dd753705f4835 Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 2 Sep 2026 15:02:54 -0700 Subject: [PATCH 2/7] Adopt canonical grammar strictness; vendor shared conformance vectors parseRecord now refuses what the canonical parser refuses: a g= that does not decode to exactly 32 bytes (the same lax bug class the demo fixed writer-side), and records over 255 characters. parseRecord is exported so vectors can drive it. tests/ vendors the shared ONO0 conformance vectors (rev 2, authored by keyhive-todo-app-demo; provenance sha in the header): all 36 applicable vectors pass, including the three deferral vectors added after running rev 1 against canonical semantics exposed that the demo's resolver had no skew deferral at all - a live vulnerability their 37/37 self- agreement could never have found, since both TS copies shared the gap. The u64-adjacent vector now carries an explicit nowMs; the boundary vector pins <= as selecting. nextSerial vectors are publisher-side and out of scope here. --- src/onomancy/index.ts | 6 ++- src/onomancy/runtime.ts | 12 +++++ src/onomancy/verified-directory.ts | 4 +- tests/conformance-vectors.test.mjs | 67 ++++++++++++++++++++++++++++ tests/ono0-conformance-vectors.jsonl | 41 +++++++++++++++++ 5 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 tests/conformance-vectors.test.mjs create mode 100644 tests/ono0-conformance-vectors.jsonl diff --git a/src/onomancy/index.ts b/src/onomancy/index.ts index ee2d38b..fee9496 100644 --- a/src/onomancy/index.ts +++ b/src/onomancy/index.ts @@ -19,7 +19,11 @@ * still owns the only instance. */ -export { createOnomancyRuntime, parseRecordDocId } from "./runtime.js"; +export { + createOnomancyRuntime, + parseRecord, + parseRecordDocId, +} from "./runtime.js"; export type { HostnameBinding, OnomancyModule, diff --git a/src/onomancy/runtime.ts b/src/onomancy/runtime.ts index 38db162..143ae5c 100644 --- a/src/onomancy/runtime.ts +++ b/src/onomancy/runtime.ts @@ -475,12 +475,24 @@ const U64_MAX = 18446744073709551615n; * maintained. */ export function parseRecord(record: string): Ono0Record | undefined { + // A TXT record longer than 255 characters cannot have come from a single + // conformant character-string; the canonical grammar rejects it outright. + if (record.length > 255) return undefined; + const match = record.match(ONO0); if (!match) return undefined; const serial = BigInt(match[1]); if (serial > U64_MAX) return undefined; + // g= is constrained identically to p=: it must decode to exactly 32 bytes. + // A generation key of any other length is malformed, not lenient-parseable + // — the canonical grammar routes both fields through the same decoder. + const generationBytes = base64ToBytes(match[2]!); + if (generationBytes === undefined || generationBytes.length !== 32) { + return undefined; + } + const bytes = base64ToBytes(match[3]); if (bytes === undefined || bytes.length !== 32) return undefined; diff --git a/src/onomancy/verified-directory.ts b/src/onomancy/verified-directory.ts index 3b9a574..c2f5b4b 100644 --- a/src/onomancy/verified-directory.ts +++ b/src/onomancy/verified-directory.ts @@ -421,8 +421,8 @@ export function createOnomancyDirectory( // an answer about *this entry*, and the zone has not made one. The // remedy belongs to whoever controls the DNS records, so the status must // not read as "wait". - // Two triggers: distinct documents at the tied top serial (visible in - // ids), or same document with different generation keys (visible only in + // Two triggers: distinct documents at the tied top serial (shows up in + // ids), or same document with different generation keys (carried only by // the contested flag the selection set). The second check also keeps // custom OnomancyRuntime implementations honest that report multiple ids // without the flag. diff --git a/tests/conformance-vectors.test.mjs b/tests/conformance-vectors.test.mjs new file mode 100644 index 0000000..1586b8e --- /dev/null +++ b/tests/conformance-vectors.test.mjs @@ -0,0 +1,67 @@ +// Shared ONO0 conformance vectors, authored by keyhive-todo-app-demo from +// their 34 record tests plus the 2026-09-02 canonical rulings, and destined +// for onomancy's Rust conformance table. Vendored (not read from the bridge) +// so the suite is hermetic; provenance sha of the bridged original (rev 2): +// f35db62be31faaa0ace91724722dbf1c444fad0a02b9b8b8ad96ec3c8fe6d0a2 +// +// Rev 2 fixed the u64-adjacent vector (now carries nowMs) and added three +// deferral vectors, including the exact-boundary pin (<= bound selects). +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import { URL } from "node:url"; + +const { createOnomancyRuntime, parseRecord } = + await import("../dist/onomancy/index.js"); + +const vectors = fs + .readFileSync(new URL("./ono0-conformance-vectors.jsonl", import.meta.url)) + .toString() + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + +const classify = async (records, nowMs) => + createOnomancyRuntime( + { + resolveHostname: async () => ({ records }), + Name: class { + constructor() {} + }, + }, + { now: () => Number(nowMs ?? 1788000000000n) } + ).resolveBoundIds("x.example"); + +for (const v of vectors) { + if (v.kind === "parse") { + test(`parse: ${v.name}`, () => { + const r = parseRecord(v.input); + // Resolver granularity: every non-parsed disposition is one skip. + assert.equal(!!r, v.expected === "parsed"); + if (r && v.serial !== undefined) { + assert.equal(String(r.serial), String(v.serial)); + } + }); + } else if (v.kind === "classify") { + test(`classify: ${v.name}`, async () => { + const b = await classify(v.input, v.nowMs ? BigInt(v.nowMs) : undefined); + const status = b.contested + ? "contested" + : b.ids.length + ? "bound" + : "unbound"; + assert.equal(status, v.expected.status); + if (v.expected.serial !== undefined) { + assert.equal(String(b.serial), String(v.expected.serial)); + } + if (v.expected.status === "bound") assert.equal(b.ids.length, 1); + if (typeof v.expected.documents === "number") { + assert.equal(b.ids.length, v.expected.documents); + } + if (typeof v.expected.deferred === "number") { + assert.equal(b.deferredSerials ?? 0, v.expected.deferred); + } + }); + } + // kind === "nextSerial": publisher-side; this library has no publisher. +} diff --git a/tests/ono0-conformance-vectors.jsonl b/tests/ono0-conformance-vectors.jsonl new file mode 100644 index 0000000..58c8dcc --- /dev/null +++ b/tests/ono0-conformance-vectors.jsonl @@ -0,0 +1,41 @@ +{"kind":"meta","source":"keyhive-todo-app-demo src/record.test.ts (35 tests) + 2026-09-02 canonical rulings","date":"2026-09-02","revision":2,"revisionNote":"rev 2: 'u64-adjacent serials stay distinct' gained an explicit nowMs (the rev-1 vector was unsatisfiable under canonical deferral-before-selection and pinned this repo's pre-deferral bug); three deferral vectors added, one marked overturned against this repo's own old behaviour.","format":{"serials":"decimal strings everywhere (JSON numbers cannot hold u64)","nowMs":"decimal-string clock for classify vectors; deferral rule is serial > nowMs + skewBoundMs, applied BEFORE selection. Absent nowMs means every serial in the vector is far in the past: the expectation must hold at any realistic present-day clock.","skewBoundMs":"300000","documents":"a COUNT of distinct documents, not a list; 'bound' implies exactly one","document":"doc1/doc2 name the p= values fill(32,1)/fill(32,2)","deferred":"count of records set aside by the skew rule; 0 when omitted from input conditions but stated explicitly in unbound expectations"},"notes":["Dispositions use the canonical vocabulary (parsed/malformed/foreign/unknownVersion); this repo's parser collapses foreign+unknownVersion+malformed into one skip, which is fine for a resolver and insufficient for the export.","Overturned entries carry the OLD expectation and the ruling, so the table records the settled question rather than silently flipping."]} +{"kind":"parse","name":"valid record round-trips","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"parsed","serial":"7","gBytes":32,"pBytes":32} +{"kind":"parse","name":"live record: brooklynzelenka.com","input":"v=ONO0;k=ed25519;n=1787792719795;g=8XXKlRi7D9msSp8U4TZo0AzQ99InBDquIYprrW7NoI4=;p=nJ8I/xDYHbttOOpAzRaYFgGpvdtYmlGuXNsaNKWz+Us=","expected":"parsed","serial":"1787792719795","note":"captured from live DoH+DNSSEC; pins the format production already serves"} +{"kind":"parse","name":"serial at u64 ceiling","input":"v=ONO0;k=ed25519;n=18446744073709551615;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"parsed","serial":"18446744073709551615"} +{"kind":"parse","name":"serial beyond u64","input":"v=ONO0;k=ed25519;n=18446744073709551616;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"malformed","note":"canonical: Overflow"} +{"kind":"parse","name":"leading-zero serial","input":"v=ONO0;k=ed25519;n=01;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"malformed","overturned":{"old":"parsed as 1 (regex \\d+ then BigInt)","ruling":"canonical decimal only: no leading zero"}} +{"kind":"parse","name":"zero serial","input":"v=ONO0;k=ed25519;n=0;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"parsed","serial":"0"} +{"kind":"parse","name":"non-integer serial","input":"v=ONO0;k=ed25519;n=1.5;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"malformed"} +{"kind":"parse","name":"non-numeric serial","input":"v=ONO0;k=ed25519;n=abc;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"malformed"} +{"kind":"parse","name":"short g= (1 byte)","input":"v=ONO0;k=ed25519;n=7;g=AA==;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"malformed","overturned":{"old":"parsed: g= length was unconstrained here, and a test asserted it","ruling":"g= decodes via the same key-field rule as p=: exactly 32 bytes"}} +{"kind":"parse","name":"empty g=","input":"v=ONO0;k=ed25519;n=7;g=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"malformed"} +{"kind":"parse","name":"short p= (1 byte)","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AA==","expected":"malformed"} +{"kind":"parse","name":"33-byte p=","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMD","expected":"malformed"} +{"kind":"parse","name":"fields out of order","input":"v=ONO0;k=ed25519;n=1;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=","expected":"malformed","note":"fixed order, exactly 5 fields"} +{"kind":"parse","name":"unknown field appended","input":"v=ONO0;k=ed25519;n=1;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=;x=1","expected":"malformed"} +{"kind":"parse","name":"unknown algorithm","input":"v=ONO0;k=x25519;n=1;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"malformed","note":"ruling: malformed, NOT foreign"} +{"kind":"parse","name":"future version","input":"v=ONO1;k=ed25519;n=1;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"unknownVersion","note":"skip-as-future; this repo's parser collapses to skip"} +{"kind":"parse","name":"foreign TXT","input":"v=SPF1 include:example.com","expected":"foreign"} +{"kind":"parse","name":"empty string","input":"","expected":"foreign"} +{"kind":"parse","name":"over 255 chars","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE= ","expected":"malformed","note":"TXT character-string ceiling"} +{"kind":"classify","name":"highest serial wins regardless of wire order","input":["v=ONO0;k=ed25519;n=3;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=","v=ONO0;k=ed25519;n=5;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="],"expected":{"status":"bound","serial":"7","document":"doc2"}} +{"kind":"classify","name":"rotation invariance","input":["v=ONO0;k=ed25519;n=5;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=","v=ONO0;k=ed25519;n=3;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="],"expected":{"status":"bound","serial":"7","document":"doc2"},"note":"answer must be invariant under any permutation of the set"} +{"kind":"classify","name":"contested: two documents at tied top serial","input":["v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI="],"expected":{"status":"contested","serial":"7","documents":2},"note":"refuse to choose; contested is distinct from unbound"} +{"kind":"classify","name":"rotation tie: same document, different g= keys","input":["v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=7;g=BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="],"expected":{"status":"contested","serial":"7"},"overturned":{"old":"bound, arbitrary g= won: dedup keyed on document alone in both TS copies","ruling":"canonical contested shape carries generation, so a mid-rotation tie is visible and refused"}} +{"kind":"classify","name":"duplicate record is agreement","input":["v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="],"expected":{"status":"bound","serial":"7"}} +{"kind":"classify","name":"older record loses without contesting","input":["v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=6;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI="],"expected":{"status":"bound","serial":"7","document":"doc1"}} +{"kind":"classify","name":"contest at a lower serial is ignored","input":["v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=6;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=","v=ONO0;k=ed25519;n=6;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=BAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQ="],"expected":{"status":"bound","serial":"7","document":"doc1"},"note":"added on review: our property tested rotation, not this"} +{"kind":"classify","name":"unparseable records do not deny the set","input":["v=SPF1 include:example.com","","v=ONO0;k=ed25519;n=3;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=x25519;n=9;g=AA==;p=AA=="],"expected":{"status":"bound","serial":"3"}} +{"kind":"classify","name":"u64-adjacent serials stay distinct","nowMs":"18446744073709551615","input":["v=ONO0;k=ed25519;n=18446744073709551614;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=18446744073709551615;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI="],"expected":{"status":"bound","serial":"18446744073709551615","document":"doc2"},"note":"REVISED: previous revision carried no nowMs, so canonical semantics deferred both records and the vector pinned this repo's pre-deferral behaviour. Explicit ceiling clock keeps the BigInt-distinctness point: Number coercion equates these two serials."} +{"kind":"classify","name":"far-future serial is deferred, honest record wins","nowMs":"1800000000000","input":["v=ONO0;k=ed25519;n=1800000000000;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=1800000300001;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI="],"expected":{"status":"bound","serial":"1800000000000","document":"doc1","deferred":1},"overturned":{"old":"bound to the forged far-future serial: this repo's selectBinding had no skew deferral, so a planted serial won selection while the README described the defence","ruling":"deferral precedes selection (verifier/state.rs); serial > nowMs + 300000 is set aside before the max is taken"},"note":"THE poisoning-bound vector: the forged record is exactly 1ms past the bound"} +{"kind":"classify","name":"only deferred records is nothing-usable-yet, not unbound-silent","nowMs":"1800000000000","input":["v=ONO0;k=ed25519;n=1800000300001;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=1800000300002;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI="],"expected":{"status":"unbound","deferred":2},"note":"deferred count distinguishes a silent domain from a jammed one"} +{"kind":"classify","name":"serial exactly at the skew bound is legitimate","nowMs":"1800000000000","input":["v=ONO0;k=ed25519;n=1800000300000;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="],"expected":{"status":"bound","serial":"1800000300000","document":"doc1"},"note":"boundary: <= nowMs + 300000 selects; deferral starts strictly past it"} +{"kind":"classify","name":"empty set","input":[],"expected":{"status":"unbound","deferred":0}} +{"kind":"classify","name":"nothing usable","input":["v=SPF1","junk"],"expected":{"status":"unbound","deferred":0}} +{"kind":"nextSerial","name":"first mint takes the clock","last":null,"now":"1000","expected":"1000"} +{"kind":"nextSerial","name":"clock ahead: track it","last":"1000","now":"9000","expected":"9000"} +{"kind":"nextSerial","name":"same-ms collision: bump","last":"1000","now":"1000","expected":"1001"} +{"kind":"nextSerial","name":"backwards clock: supersede anyway","last":"5000","now":"4000","expected":"5001"} +{"kind":"nextSerial","name":"one below ceiling mints the ceiling","last":"18446744073709551614","now":"1000","expected":"18446744073709551615"} +{"kind":"nextSerial","name":"at the ceiling: refuse","last":"18446744073709551615","now":"1000","expected":"refuse","overturned":{"old":"returned 2^64, writer then emitted a record every parser rejects","ruling":"refuse (throw): saturating mints an unsupersedable record, wrapping mints one that loses to everything"}} +{"kind":"nextSerial","name":"clock beyond ceiling: refuse","last":null,"now":"18446744073709551616","expected":"refuse"} From 7ea4036eb10cc47bddb6ccf175b0e9756d3dff2e Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 2 Sep 2026 15:25:44 -0700 Subject: [PATCH 3/7] Reject g= and p= keys that are not ed25519 curve points The ratified grammar (specs/serialization.md) requires decoders to reject a key field that does not decompress, even where it is never verified against: a 32-byte string that cannot denote a key is not the canonical encoding of anything, and parsers that disagree about whether such a record exists diverge on every selection it feeds. Both downstream TS parsers had the gap - 29/29 vector agreement was agreement on it - found by onomancy's reference harness rejecting the shared fixtures, whose fill(32,k) keys were mostly not points. The check is RFC 8032 decompression in bare BigInt, because this library imports only React: no crypto dependency, and WebCrypto import is async where this parser is sync. Verified against the reference validity table for all seventeen fill fixtures (now a permanent test), and the live brooklynzelenka.com record still parses. Vendors conformance vectors rev 3 (point-valid fixtures, two non-point parse vectors, nowSeconds boundary note): 39/39, no skips. Rev history: rev 1 caught this parser's lax g= and 255 limit; rev 2's canonical referee caught the demo's missing deferral; rev 3's reference harness caught point validity in both parsers and in the fixtures themselves. Each round's bug was invisible to the previous round's process. --- src/onomancy/runtime.ts | 67 ++++++++++++++++++++++++++++ tests/conformance-vectors.test.mjs | 11 +++-- tests/ono0-conformance-vectors.jsonl | 22 ++++----- tests/verified-directory.test.mjs | 28 ++++++++++++ 4 files changed, 114 insertions(+), 14 deletions(-) diff --git a/src/onomancy/runtime.ts b/src/onomancy/runtime.ts index 143ae5c..eaf2930 100644 --- a/src/onomancy/runtime.ts +++ b/src/onomancy/runtime.ts @@ -474,6 +474,71 @@ const U64_MAX = 18446744073709551615n; * are exposed to JS this whole function should be deleted rather than * maintained. */ +// ---- ed25519 point validity (RFC 8032 §5.1.3 decompression) ---------------- +// +// The grammar requires g= and p= to decode to VALID curve points, not merely +// 32 bytes: "decoders MUST reject a unit whose key field does not decompress, +// even where that field is never verified against" (specs/serialization.md). +// A 32-byte string that cannot denote a key is not the canonical encoding of +// anything, and parsers that disagree about whether such a record exists +// diverge on every selection it feeds. +// +// Implemented with bare BigInt because this library imports only React: no +// crypto dependency is available, WebCrypto key import is async (this parser +// is sync) and its point validation is implementation-defined anyway. This is +// validity only — no key material is used for anything. + +const ED_P = (1n << 255n) - 19n; +/** -121665/121666 mod p, the curve constant d. */ +const ED_D = + 37095705934669439343138083508754565189542113879843219016388785533085940283555n; + +function modPow(base: bigint, exp: bigint, mod: bigint): bigint { + let b = base % mod; + if (b < 0n) b += mod; + let result = 1n; + let e = exp; + while (e > 0n) { + if (e & 1n) result = (result * b) % mod; + b = (b * b) % mod; + e >>= 1n; + } + return result; +} + +/** Whether 32 bytes decompress to a point on the edwards25519 curve. */ +function isCurvePoint(bytes: Uint8Array): boolean { + // Little-endian y with the top bit as the x-parity flag. + let y = 0n; + for (let i = 31; i >= 0; i--) y = (y << 8n) | BigInt(bytes[i]!); + const xParity = (y >> 255n) & 1n; + y &= (1n << 255n) - 1n; + if (y >= ED_P) return false; + + // Solve x^2 = (y^2 - 1) / (d*y^2 + 1). + const y2 = (y * y) % ED_P; + const u = (y2 - 1n + ED_P) % ED_P; + const v = (ED_D * y2 + 1n) % ED_P; + + // Candidate root: x = u * v^3 * (u * v^7)^((p minus 5)/8). + const v3 = (v * v * v) % ED_P; + const v7 = (v3 * v3 * v) % ED_P; + let x = (u * v3 * modPow((u * v7) % ED_P, (ED_P - 5n) / 8n, ED_P)) % ED_P; + + const vx2 = (v * x * x) % ED_P; + if (vx2 === u) { + // x is the root. + } else if (vx2 === (ED_P - u) % ED_P) { + x = (x * modPow(2n, (ED_P - 1n) / 4n, ED_P)) % ED_P; + } else { + return false; + } + + // x = 0 cannot carry a sign bit. + if (x === 0n && xParity === 1n) return false; + return true; +} + export function parseRecord(record: string): Ono0Record | undefined { // A TXT record longer than 255 characters cannot have come from a single // conformant character-string; the canonical grammar rejects it outright. @@ -492,9 +557,11 @@ export function parseRecord(record: string): Ono0Record | undefined { if (generationBytes === undefined || generationBytes.length !== 32) { return undefined; } + if (!isCurvePoint(generationBytes)) return undefined; const bytes = base64ToBytes(match[3]); if (bytes === undefined || bytes.length !== 32) return undefined; + if (!isCurvePoint(bytes)) return undefined; return { docIdHex: bytesToHex(bytes), generation: match[2]!, serial }; } diff --git a/tests/conformance-vectors.test.mjs b/tests/conformance-vectors.test.mjs index 1586b8e..28a085a 100644 --- a/tests/conformance-vectors.test.mjs +++ b/tests/conformance-vectors.test.mjs @@ -1,11 +1,14 @@ // Shared ONO0 conformance vectors, authored by keyhive-todo-app-demo from // their 34 record tests plus the 2026-09-02 canonical rulings, and destined // for onomancy's Rust conformance table. Vendored (not read from the bridge) -// so the suite is hermetic; provenance sha of the bridged original (rev 2): -// f35db62be31faaa0ace91724722dbf1c444fad0a02b9b8b8ad96ec3c8fe6d0a2 +// so the suite is hermetic; provenance sha of the bridged original (rev 3): +// f7ebdb439780e2b1d3d0372d2cb077ed4be2204ccc011e590f3f6caccd0ab3cf // -// Rev 2 fixed the u64-adjacent vector (now carries nowMs) and added three -// deferral vectors, including the exact-boundary pin (<= bound selects). +// Rev history: rev 1 caught this parser's lax g= and missing 255-char limit; +// rev 2 (canonical referee) caught the demo's missing skew deferral; rev 3 +// (reference harness) caught missing point-validity in BOTH parsers and +// non-point fixture keys in the vectors themselves. Each round's bug was +// invisible to the previous round's process. import { test } from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; diff --git a/tests/ono0-conformance-vectors.jsonl b/tests/ono0-conformance-vectors.jsonl index 58c8dcc..17f12da 100644 --- a/tests/ono0-conformance-vectors.jsonl +++ b/tests/ono0-conformance-vectors.jsonl @@ -1,4 +1,4 @@ -{"kind":"meta","source":"keyhive-todo-app-demo src/record.test.ts (35 tests) + 2026-09-02 canonical rulings","date":"2026-09-02","revision":2,"revisionNote":"rev 2: 'u64-adjacent serials stay distinct' gained an explicit nowMs (the rev-1 vector was unsatisfiable under canonical deferral-before-selection and pinned this repo's pre-deferral bug); three deferral vectors added, one marked overturned against this repo's own old behaviour.","format":{"serials":"decimal strings everywhere (JSON numbers cannot hold u64)","nowMs":"decimal-string clock for classify vectors; deferral rule is serial > nowMs + skewBoundMs, applied BEFORE selection. Absent nowMs means every serial in the vector is far in the past: the expectation must hold at any realistic present-day clock.","skewBoundMs":"300000","documents":"a COUNT of distinct documents, not a list; 'bound' implies exactly one","document":"doc1/doc2 name the p= values fill(32,1)/fill(32,2)","deferred":"count of records set aside by the skew rule; 0 when omitted from input conditions but stated explicitly in unbound expectations"},"notes":["Dispositions use the canonical vocabulary (parsed/malformed/foreign/unknownVersion); this repo's parser collapses foreign+unknownVersion+malformed into one skip, which is fine for a resolver and insufficient for the export.","Overturned entries carry the OLD expectation and the ruling, so the table records the settled question rather than silently flipping."]} +{"kind":"meta","source":"keyhive-todo-app-demo src/record.test.ts (35 tests) + 2026-09-02 canonical rulings","date":"2026-09-02","revision":3,"revisionNote":"rev 3: all key/document fixtures moved to point-valid fills (docs 1/3/11, keys 6/9) - rev-2 fixtures fill(2)/fill(7) are not ed25519 curve points, so seven rev-2 classify expectations were canonically unsatisfiable; two non-point parse vectors added; point-validity reference table pinned in the source suite. Earlier, rev 2: 'u64-adjacent serials stay distinct' gained an explicit nowMs (the rev-1 vector was unsatisfiable under canonical deferral-before-selection and pinned this repo's pre-deferral bug); three deferral vectors added, one marked overturned against this repo's own old behaviour.","format":{"serials":"decimal strings everywhere (JSON numbers cannot hold u64)","nowMs":"decimal-string clock for classify vectors; deferral rule is serial > nowMs + skewBoundMs, applied BEFORE selection. Absent nowMs means every serial in the vector is far in the past: the expectation must hold at any realistic present-day clock.","skewBoundMs":"300000","nowSecondsCaveat":"the JS classifyRecords surface takes nowSeconds and refuses clocks past ~year 5138, so the u64-adjacent vector is exercised via the internal Rust rule only - by design (per ono)","documents":"a COUNT of distinct documents, not a list; 'bound' implies exactly one","document":"doc1/doc2 name the p= values fill(32,1)/fill(32,3); all key/doc fixtures are verified curve points except NONPOINT = fill(32,2)","deferred":"count of records set aside by the skew rule; 0 when omitted from input conditions but stated explicitly in unbound expectations"},"notes":["Dispositions use the canonical vocabulary (parsed/malformed/foreign/unknownVersion); this repo's parser collapses foreign+unknownVersion+malformed into one skip, which is fine for a resolver and insufficient for the export.","Overturned entries carry the OLD expectation and the ruling, so the table records the settled question rather than silently flipping."]} {"kind":"parse","name":"valid record round-trips","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"parsed","serial":"7","gBytes":32,"pBytes":32} {"kind":"parse","name":"live record: brooklynzelenka.com","input":"v=ONO0;k=ed25519;n=1787792719795;g=8XXKlRi7D9msSp8U4TZo0AzQ99InBDquIYprrW7NoI4=;p=nJ8I/xDYHbttOOpAzRaYFgGpvdtYmlGuXNsaNKWz+Us=","expected":"parsed","serial":"1787792719795","note":"captured from live DoH+DNSSEC; pins the format production already serves"} {"kind":"parse","name":"serial at u64 ceiling","input":"v=ONO0;k=ed25519;n=18446744073709551615;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"parsed","serial":"18446744073709551615"} @@ -11,6 +11,8 @@ {"kind":"parse","name":"empty g=","input":"v=ONO0;k=ed25519;n=7;g=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"malformed"} {"kind":"parse","name":"short p= (1 byte)","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AA==","expected":"malformed"} {"kind":"parse","name":"33-byte p=","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMD","expected":"malformed"} +{"kind":"parse","name":"32-byte non-point p=","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=","expected":"malformed","note":"ratified grammar (specs/serialization.md, 2026-08-19): key fields MUST decompress to curve points; fill(2) is the reference non-point. Added rev 3: BOTH TS parsers accepted this until ono's harness refused the fixtures."} +{"kind":"parse","name":"32-byte non-point g=","input":"v=ONO0;k=ed25519;n=7;g=AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"malformed","note":"same rule, and the sharper half: g= is never verified against during resolution, and the record is malformed anyway - parsers must agree on whether a record EXISTS."} {"kind":"parse","name":"fields out of order","input":"v=ONO0;k=ed25519;n=1;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=","expected":"malformed","note":"fixed order, exactly 5 fields"} {"kind":"parse","name":"unknown field appended","input":"v=ONO0;k=ed25519;n=1;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=;x=1","expected":"malformed"} {"kind":"parse","name":"unknown algorithm","input":"v=ONO0;k=x25519;n=1;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"malformed","note":"ruling: malformed, NOT foreign"} @@ -18,17 +20,17 @@ {"kind":"parse","name":"foreign TXT","input":"v=SPF1 include:example.com","expected":"foreign"} {"kind":"parse","name":"empty string","input":"","expected":"foreign"} {"kind":"parse","name":"over 255 chars","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE= ","expected":"malformed","note":"TXT character-string ceiling"} -{"kind":"classify","name":"highest serial wins regardless of wire order","input":["v=ONO0;k=ed25519;n=3;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=","v=ONO0;k=ed25519;n=5;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="],"expected":{"status":"bound","serial":"7","document":"doc2"}} -{"kind":"classify","name":"rotation invariance","input":["v=ONO0;k=ed25519;n=5;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=","v=ONO0;k=ed25519;n=3;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="],"expected":{"status":"bound","serial":"7","document":"doc2"},"note":"answer must be invariant under any permutation of the set"} -{"kind":"classify","name":"contested: two documents at tied top serial","input":["v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI="],"expected":{"status":"contested","serial":"7","documents":2},"note":"refuse to choose; contested is distinct from unbound"} -{"kind":"classify","name":"rotation tie: same document, different g= keys","input":["v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=7;g=BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="],"expected":{"status":"contested","serial":"7"},"overturned":{"old":"bound, arbitrary g= won: dedup keyed on document alone in both TS copies","ruling":"canonical contested shape carries generation, so a mid-rotation tie is visible and refused"}} +{"kind":"classify","name":"highest serial wins regardless of wire order","input":["v=ONO0;k=ed25519;n=3;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM=","v=ONO0;k=ed25519;n=5;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="],"expected":{"status":"bound","serial":"7","document":"doc2"}} +{"kind":"classify","name":"rotation invariance","input":["v=ONO0;k=ed25519;n=5;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM=","v=ONO0;k=ed25519;n=3;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="],"expected":{"status":"bound","serial":"7","document":"doc2"},"note":"answer must be invariant under any permutation of the set"} +{"kind":"classify","name":"contested: two documents at tied top serial","input":["v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM="],"expected":{"status":"contested","serial":"7","documents":2},"note":"refuse to choose; contested is distinct from unbound"} +{"kind":"classify","name":"rotation tie: same document, different g= keys","input":["v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=7;g=BgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgY=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="],"expected":{"status":"contested","serial":"7"},"overturned":{"old":"bound, arbitrary g= won: dedup keyed on document alone in both TS copies","ruling":"canonical contested shape carries generation, so a mid-rotation tie is visible and refused"}} {"kind":"classify","name":"duplicate record is agreement","input":["v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="],"expected":{"status":"bound","serial":"7"}} -{"kind":"classify","name":"older record loses without contesting","input":["v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=6;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI="],"expected":{"status":"bound","serial":"7","document":"doc1"}} -{"kind":"classify","name":"contest at a lower serial is ignored","input":["v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=6;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=","v=ONO0;k=ed25519;n=6;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=BAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQ="],"expected":{"status":"bound","serial":"7","document":"doc1"},"note":"added on review: our property tested rotation, not this"} +{"kind":"classify","name":"older record loses without contesting","input":["v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=6;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM="],"expected":{"status":"bound","serial":"7","document":"doc1"}} +{"kind":"classify","name":"contest at a lower serial is ignored","input":["v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=6;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM=","v=ONO0;k=ed25519;n=6;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCws="],"expected":{"status":"bound","serial":"7","document":"doc1"},"note":"added on review: our property tested rotation, not this"} {"kind":"classify","name":"unparseable records do not deny the set","input":["v=SPF1 include:example.com","","v=ONO0;k=ed25519;n=3;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=x25519;n=9;g=AA==;p=AA=="],"expected":{"status":"bound","serial":"3"}} -{"kind":"classify","name":"u64-adjacent serials stay distinct","nowMs":"18446744073709551615","input":["v=ONO0;k=ed25519;n=18446744073709551614;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=18446744073709551615;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI="],"expected":{"status":"bound","serial":"18446744073709551615","document":"doc2"},"note":"REVISED: previous revision carried no nowMs, so canonical semantics deferred both records and the vector pinned this repo's pre-deferral behaviour. Explicit ceiling clock keeps the BigInt-distinctness point: Number coercion equates these two serials."} -{"kind":"classify","name":"far-future serial is deferred, honest record wins","nowMs":"1800000000000","input":["v=ONO0;k=ed25519;n=1800000000000;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=1800000300001;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI="],"expected":{"status":"bound","serial":"1800000000000","document":"doc1","deferred":1},"overturned":{"old":"bound to the forged far-future serial: this repo's selectBinding had no skew deferral, so a planted serial won selection while the README described the defence","ruling":"deferral precedes selection (verifier/state.rs); serial > nowMs + 300000 is set aside before the max is taken"},"note":"THE poisoning-bound vector: the forged record is exactly 1ms past the bound"} -{"kind":"classify","name":"only deferred records is nothing-usable-yet, not unbound-silent","nowMs":"1800000000000","input":["v=ONO0;k=ed25519;n=1800000300001;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=1800000300002;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI="],"expected":{"status":"unbound","deferred":2},"note":"deferred count distinguishes a silent domain from a jammed one"} +{"kind":"classify","name":"u64-adjacent serials stay distinct","nowMs":"18446744073709551615","input":["v=ONO0;k=ed25519;n=18446744073709551614;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=18446744073709551615;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM="],"expected":{"status":"bound","serial":"18446744073709551615","document":"doc2"},"note":"REVISED: previous revision carried no nowMs, so canonical semantics deferred both records and the vector pinned this repo's pre-deferral behaviour. Explicit ceiling clock keeps the BigInt-distinctness point: Number coercion equates these two serials."} +{"kind":"classify","name":"far-future serial is deferred, honest record wins","nowMs":"1800000000000","input":["v=ONO0;k=ed25519;n=1800000000000;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=1800000300001;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM="],"expected":{"status":"bound","serial":"1800000000000","document":"doc1","deferred":1},"overturned":{"old":"bound to the forged far-future serial: this repo's selectBinding had no skew deferral, so a planted serial won selection while the README described the defence","ruling":"deferral precedes selection (verifier/state.rs); serial > nowMs + 300000 is set aside before the max is taken"},"note":"THE poisoning-bound vector: the forged record is exactly 1ms past the bound"} +{"kind":"classify","name":"only deferred records is nothing-usable-yet, not unbound-silent","nowMs":"1800000000000","input":["v=ONO0;k=ed25519;n=1800000300001;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=1800000300002;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM="],"expected":{"status":"unbound","deferred":2},"note":"deferred count distinguishes a silent domain from a jammed one"} {"kind":"classify","name":"serial exactly at the skew bound is legitimate","nowMs":"1800000000000","input":["v=ONO0;k=ed25519;n=1800000300000;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="],"expected":{"status":"bound","serial":"1800000300000","document":"doc1"},"note":"boundary: <= nowMs + 300000 selects; deferral starts strictly past it"} {"kind":"classify","name":"empty set","input":[],"expected":{"status":"unbound","deferred":0}} {"kind":"classify","name":"nothing usable","input":["v=SPF1","junk"],"expected":{"status":"unbound","deferred":0}} diff --git a/tests/verified-directory.test.mjs b/tests/verified-directory.test.mjs index a90b0a7..e822a26 100644 --- a/tests/verified-directory.test.mjs +++ b/tests/verified-directory.test.mjs @@ -121,3 +121,31 @@ test("contests a rotation tie: same document, different generation keys", async assert.equal(binding.contested, true, "rotation tie must be contested"); assert.equal(binding.ids.length, 1, "one document, still reported"); }); + +test("g= and p= must decompress to ed25519 curve points", async () => { + // The ratified grammar: "decoders MUST reject a unit whose key field does + // not decompress" (specs/serialization.md). Reference validity table for + // fill(32, k) fixtures, from onomancy's harness. + const { parseRecord } = await import("../dist/onomancy/index.js"); + const b64 = (k) => Buffer.from(new Uint8Array(32).fill(k)).toString("base64"); + const valid = [0, 1, 3, 6, 9, 10, 11, 12, 16]; + const invalid = [2, 4, 5, 7, 8, 13, 14, 15]; + for (const k of valid) { + assert.ok( + parseRecord(`v=ONO0;k=ed25519;n=5;g=${b64(1)};p=${b64(k)}`), + `fill(${k}) is a point and must parse` + ); + } + for (const k of invalid) { + assert.equal( + parseRecord(`v=ONO0;k=ed25519;n=5;g=${b64(1)};p=${b64(k)}`), + undefined, + `fill(${k}) is not a point and must be malformed` + ); + assert.equal( + parseRecord(`v=ONO0;k=ed25519;n=5;g=${b64(k)};p=${b64(1)}`), + undefined, + `non-point g= fill(${k}) must be malformed too` + ); + } +}); From 8a37e6a87c3cabdbb551b248a2c235ee31aa1166 Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 2 Sep 2026 15:58:19 -0700 Subject: [PATCH 4/7] Enforce canonical base64; keep contested serials out of the ratchet Adversarial review round two, against the point-validity batch. atob is forgiving - unpadded input and nonzero trailing bits in the final character decode identically - so one record had many spellings: a parser differential against the reference decoder (which requires canonical padding and rejects set trailing bits), and a phantom-contest vector inside this library, since the tie-set keys on the g= spelling and two spellings of one key read as a rotation. base64ToBytes now round-trips through btoa and rejects non-canonical spellings, making key and spelling bijective, which fixes both at one site. The ratchet remembers the highest serial ACCEPTED; a contested set was refused. Admitting its serial made a zone that heals a rotation without bumping the serial read replayed forever on stale chains. Gated. Also: the sqrt(-1) adjustment constant is hoisted (was ~175us recomputed on half of valid points), isCurvePoint returns false on wrong-length input instead of throwing, Ono0Record is exported beside parseRecord, OnomancyRuntime documents that custom implementations MUST set contested (it is the only carrier of same-document rotation ties), and the replay-preempts-contested ordering is documented as deliberate. The mathematics itself survived review: 6318-case differential fuzz against an independently written checker, zero divergences. --- src/onomancy/index.ts | 1 + src/onomancy/runtime.ts | 30 ++++++++++++++++++++++++++---- src/onomancy/verified-directory.ts | 9 ++++++++- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/onomancy/index.ts b/src/onomancy/index.ts index fee9496..de48029 100644 --- a/src/onomancy/index.ts +++ b/src/onomancy/index.ts @@ -24,6 +24,7 @@ export { parseRecord, parseRecordDocId, } from "./runtime.js"; +export type { Ono0Record } from "./runtime.js"; export type { HostnameBinding, OnomancyModule, diff --git a/src/onomancy/runtime.ts b/src/onomancy/runtime.ts index eaf2930..165ed86 100644 --- a/src/onomancy/runtime.ts +++ b/src/onomancy/runtime.ts @@ -49,7 +49,7 @@ export interface OnomancyRuntimeOptions { * a test that cannot name the instant can only assert whatever behaviour * it happens to observe. */ - now?: () => number; + now?: () => number | bigint; } /** @@ -164,6 +164,14 @@ export interface HostnameBinding { clockSkewSeconds?: number; } +/** + * Custom implementations note: `resolveBoundIds` MUST set `contested` when + * records of equal top precedence disagree on `(document, generation)` — + * including the same-document/different-generation case, which is carried + * ONLY by the flag (the ids list has one entry, so a consumer cannot infer + * the contest from it). An implementation that omits the flag silently + * verifies mid-rotation zones. + */ export interface OnomancyRuntime { /** * The DNSSEC-verified root document ids bound to `hostname`. @@ -205,7 +213,9 @@ export function createOnomancyRuntime( options.dohUrl ?? null ); const freshness = freshnessOf(outcome); - const nowMs = BigInt(Math.floor((options.now ?? Date.now)())); + const rawNow = (options.now ?? Date.now)(); + const nowMs = + typeof rawNow === "bigint" ? rawNow : BigInt(Math.floor(rawNow)); const selection = boundIdsOf(outcome, nowMs); const binding: HostnameBinding = { hostname, ids: selection.ids }; @@ -506,8 +516,12 @@ function modPow(base: bigint, exp: bigint, mod: bigint): bigint { return result; } +/** Precomputed 2^((p-1)/4), the square-root adjustment factor. */ +const ED_SQRT_ADJ = modPow(2n, (ED_P - 1n) / 4n, ED_P); + /** Whether 32 bytes decompress to a point on the edwards25519 curve. */ function isCurvePoint(bytes: Uint8Array): boolean { + if (bytes.length !== 32) return false; // Little-endian y with the top bit as the x-parity flag. let y = 0n; for (let i = 31; i >= 0; i--) y = (y << 8n) | BigInt(bytes[i]!); @@ -529,7 +543,7 @@ function isCurvePoint(bytes: Uint8Array): boolean { if (vx2 === u) { // x is the root. } else if (vx2 === (ED_P - u) % ED_P) { - x = (x * modPow(2n, (ED_P - 1n) / 4n, ED_P)) % ED_P; + x = (x * ED_SQRT_ADJ) % ED_P; } else { return false; } @@ -573,7 +587,15 @@ export function parseRecordDocId(record: string): string | undefined { function base64ToBytes(base64: string): Uint8Array | undefined { try { - return Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); + const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); + // Canonical spellings only. atob is forgiving - it accepts unpadded + // input and ignores nonzero trailing bits in the final character - so + // without the round-trip, one key has many spellings and parsers + // disagree about which records exist (the differential class the + // grammar's strict-decoding rule exists to kill; the reference decoder + // "requires canonical padding and rejects set trailing bits"). + if (btoa(String.fromCharCode(...bytes)) !== base64) return undefined; + return bytes; } catch { return undefined; } diff --git a/src/onomancy/verified-directory.ts b/src/onomancy/verified-directory.ts index c2f5b4b..3ea6a57 100644 --- a/src/onomancy/verified-directory.ts +++ b/src/onomancy/verified-directory.ts @@ -295,12 +295,19 @@ export function createOnomancyDirectory( : { phase: "no-claim" } ); } else if (isReplay(hostname, binding)) { + // Deliberate ordering: a replayed serial reads `replayed` even if + // the replayed set is also contested — the replay is the stronger + // statement (this exact data is known superseded). // A stale chain bearing a serial no higher than one already // accepted for this name. The zone — or something on the path — // is serving a record we know to be superseded. resolutions.set(hostname, { phase: "replayed" }); } else { - admitToRatchet(hostname, binding); + // The ratchet remembers the highest serial ACCEPTED. A contested + // set was refused, not accepted: admitting its serial would make a + // zone that heals the rotation without bumping the serial read + // `replayed` forever on stale chains. + if (!binding.contested) admitToRatchet(hostname, binding); const resolved: Resolution = { phase: "resolved", From 37c9e9f0224be8e6267d3fd7b38b9fb0c7695b4d Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 2 Sep 2026 15:58:19 -0700 Subject: [PATCH 5/7] Close the test-runner holes the mutants walked through Mutation testing proved two blind spots: a wire-order-first document selection passed all 39 tests (expected.document was never asserted), and a truncated vector file passed vacuously. The runner now asserts the selected document identity via the meta's alias map, pins the rev 3 per-kind vector counts, asserts deferred against its documented default-zero rather than only when present, no longer drops a '0' clock to a falsy check, and passes the clock as BigInt end to end (OnomancyRuntimeOptions.now may now return bigint) instead of losing precision near 2^64. New pins, each verified to fail against a mutant dist: non-canonical base64 spellings are malformed not aliases; x=0 with the sign bit set is rejected (the one RFC 8032 rule the fill(k) table cannot reach); a contested serial does not enter the ratchet. Also a stale-dist warning for local runs, and the test header no longer claims dist is React-free (it is not; the isolation gate allows exactly React). --- src/onomancy/runtime.ts | 2 +- tests/conformance-vectors.test.mjs | 40 +++++++-- tests/verified-directory.test.mjs | 134 ++++++++++++++++++++++++++++- 3 files changed, 167 insertions(+), 9 deletions(-) diff --git a/src/onomancy/runtime.ts b/src/onomancy/runtime.ts index 165ed86..6c16c44 100644 --- a/src/onomancy/runtime.ts +++ b/src/onomancy/runtime.ts @@ -516,7 +516,7 @@ function modPow(base: bigint, exp: bigint, mod: bigint): bigint { return result; } -/** Precomputed 2^((p-1)/4), the square-root adjustment factor. */ +/** Precomputed 2^((p minus 1)/4), the square-root adjustment factor. */ const ED_SQRT_ADJ = modPow(2n, (ED_P - 1n) / 4n, ED_P); /** Whether 32 bytes decompress to a point on the edwards25519 curve. */ diff --git a/tests/conformance-vectors.test.mjs b/tests/conformance-vectors.test.mjs index 28a085a..13ada03 100644 --- a/tests/conformance-vectors.test.mjs +++ b/tests/conformance-vectors.test.mjs @@ -13,6 +13,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import { URL } from "node:url"; +import { Buffer } from "node:buffer"; const { createOnomancyRuntime, parseRecord } = await import("../dist/onomancy/index.js"); @@ -24,6 +25,22 @@ const vectors = fs .split("\n") .map((line) => JSON.parse(line)); +// Guard against silent degradation: a truncated or partially-unparsed vector +// file must fail loudly, not pass vacuously. Counts pinned to rev 3. +test("vector file carries the full rev 3 corpus", () => { + const byKind = {}; + for (const v of vectors) + byKind[v.kind ?? "meta"] = (byKind[v.kind ?? "meta"] ?? 0) + 1; + assert.deepEqual(byKind, { meta: 1, parse: 21, classify: 14, nextSerial: 7 }); + assert.equal(vectors.find((v) => v.kind === "meta")?.revision, 3); +}); + +// The meta's document aliases, resolved to the hex ids our API reports. +const DOC_ALIAS = { + doc1: Buffer.from(new Uint8Array(32).fill(1)).toString("hex"), + doc2: Buffer.from(new Uint8Array(32).fill(3)).toString("hex"), +}; + const classify = async (records, nowMs) => createOnomancyRuntime( { @@ -47,7 +64,10 @@ for (const v of vectors) { }); } else if (v.kind === "classify") { test(`classify: ${v.name}`, async () => { - const b = await classify(v.input, v.nowMs ? BigInt(v.nowMs) : undefined); + const b = await classify( + v.input, + v.nowMs !== undefined ? BigInt(v.nowMs) : undefined + ); const status = b.contested ? "contested" : b.ids.length @@ -57,13 +77,23 @@ for (const v of vectors) { if (v.expected.serial !== undefined) { assert.equal(String(b.serial), String(v.expected.serial)); } - if (v.expected.status === "bound") assert.equal(b.ids.length, 1); + if (v.expected.status === "bound") { + assert.equal(b.ids.length, 1); + // The document identity, not just the count: a wire-order-dependent + // selection with the right serial passed this suite before this + // assertion existed. + if (v.expected.document) { + const want = DOC_ALIAS[v.expected.document]; + assert.ok(want, `unknown document alias ${v.expected.document}`); + assert.equal(b.ids[0], want); + } + } if (typeof v.expected.documents === "number") { assert.equal(b.ids.length, v.expected.documents); } - if (typeof v.expected.deferred === "number") { - assert.equal(b.deferredSerials ?? 0, v.expected.deferred); - } + // Meta: deferred is 0 when omitted — assert always, not only when set, + // so a fabricated deferredSerials on a bound answer cannot survive. + assert.equal(b.deferredSerials ?? 0, v.expected.deferred ?? 0); }); } // kind === "nextSerial": publisher-side; this library has no publisher. diff --git a/tests/verified-directory.test.mjs b/tests/verified-directory.test.mjs index e822a26..d8c9eac 100644 --- a/tests/verified-directory.test.mjs +++ b/tests/verified-directory.test.mjs @@ -1,13 +1,18 @@ // Behaviour pins for the onomancy directory wrapper, run against the built -// library (`pnpm build` first; CI builds before testing). Bare `node --test`: -// dist/ imports only React-free modules on these paths, which -// check-isolation.mjs guarantees. +// library (`pnpm build` first; CI builds before testing). Bare `node --test` +// works because the dist modules these paths load keep their dependencies +// injectable; note dist DOES import react (check-isolation.mjs allows +// exactly that), so a copied-out dist without node_modules will not load. // // Each pin exists because every other gate passes with its behaviour // reverted — these were verified to fail against pre-fix builds. import { test } from "node:test"; import assert from "node:assert/strict"; import { Buffer } from "node:buffer"; +import fs from "node:fs"; +import { setTimeout } from "node:timers"; +import console from "node:console"; +import { URL } from "node:url"; const { createOnomancyDirectory, @@ -149,3 +154,126 @@ test("g= and p= must decompress to ed25519 curve points", async () => { ); } }); + +// Local runs can test a stale dist; CI rebuilds first. Warn, do not fail. +{ + const newest = (dir) => { + let max = 0; + for (const e of fs.readdirSync(dir, { withFileTypes: true })) { + const p = dir + "/" + e.name; + max = Math.max(max, e.isDirectory() ? newest(p) : fs.statSync(p).mtimeMs); + } + return max; + }; + const src = new URL("../src", import.meta.url).pathname; + const dist = new URL("../dist/index.js", import.meta.url).pathname; + if (newest(src) > fs.statSync(dist).mtimeMs) { + console.warn( + "WARN: dist/ is older than src/ - run pnpm build before trusting these results" + ); + } +} + +test("x = 0 with the sign bit set is not a point", async () => { + // RFC 8032 §5.1.3 final rule: x = 0 cannot carry a sign bit. y = 1 gives + // x = 0 (the neutral element) and is valid; the same y with the sign bit + // set is the one encoding class the fill(k) table cannot reach. + const { parseRecord } = await import("../dist/onomancy/index.js"); + const y1 = new Uint8Array(32); + y1[0] = 1; + const signed = Uint8Array.from(y1); + signed[31] |= 0x80; + const b64 = (u8) => Buffer.from(u8).toString("base64"); + const rec = (p) => + `v=ONO0;k=ed25519;n=5;g=${b64(new Uint8Array(32).fill(1))};p=${p}`; + assert.ok(parseRecord(rec(b64(y1))), "y=1, x=0 unsigned is a valid point"); + assert.equal( + parseRecord(rec(b64(signed))), + undefined, + "x=0 + sign bit is not" + ); +}); + +test("non-canonical base64 spellings are malformed, not aliases", async () => { + // The grammar is strict: one record has one spelling. atob is forgiving, + // so without the canonical round-trip, unpadded and trailing-bit variants + // of one key parse as the same record - the parser-differential class - + // and two spellings of one generation key manufacture a phantom contest. + const { parseRecord } = await import("../dist/onomancy/index.js"); + const key = Buffer.from(new Uint8Array(32).fill(1)).toString("base64"); // canonical, ends "=" + const unpadded = key.slice(0, -1); + // Flip a low bit in the final character: same decoded bytes under atob. + const chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const last = key[42]; + const trailing = key.slice(0, 42) + chars[chars.indexOf(last) + 1] + "="; + const rec = (g) => `v=ONO0;k=ed25519;n=5;g=${g};p=${key}`; + assert.ok(parseRecord(rec(key)), "canonical spelling parses"); + assert.equal(parseRecord(rec(unpadded)), undefined, "unpadded is malformed"); + assert.equal( + parseRecord(rec(trailing)), + undefined, + "trailing-bit variant is malformed" + ); +}); + +test("a contested serial does not enter the ratchet", async () => { + // The ratchet remembers the highest serial ACCEPTED. Pin: contested@10 + // (refused), then the zone heals to a single record at the SAME serial on + // a stale chain - must verify, not read as replayed. + const { + createOnomancyDirectory, + createOnomancyRuntime, + createVerificationCache, + clearVerificationCache, + } = await import("../dist/onomancy/index.js"); + const lib = await import("../dist/index.js"); + const A = "aa".repeat(32); + const b64h = (h) => Buffer.from(h, "hex").toString("base64"); + const G1 = Buffer.from(new Uint8Array(32).fill(1)).toString("base64"); + const G2 = Buffer.from(new Uint8Array(32).fill(6)).toString("base64"); + const rec = (g) => `v=ONO0;k=ed25519;n=10;g=${g};p=${b64h(A)}`; + let script = [ + { records: [rec(G1), rec(G2)], freshness: "stale" }, // contested @10 + { records: [rec(G1)], freshness: "stale" }, // healed @10, stale chain + ]; + const rt = createOnomancyRuntime( + { + resolveHostname: async () => script.shift(), + Name: class { + constructor(raw) { + const bare = raw.startsWith("@") ? raw.slice(1) : raw; + this.anchor = "@" + bare.toLowerCase(); + this.anchorKind = "dns"; + this.segments = []; + } + free() {} + }, + }, + { now: () => 1788000000000 } + ); + const cache = createVerificationCache(); + const base = lib.createAutomergeDocDirectory( + { [A]: { name: "A", dnsName: "a.example" } }, + undefined + ); + const d = createOnomancyDirectory(base, rt, { + designation: (await import("../dist/onomancy/index.js")) + .idEqualityDesignation, + cache, + }); + const settle = async () => { + for (let i = 0; i < 6; i++) { + d.lookup(A); + await new Promise((r) => setTimeout(r, 25)); + } + return d.lookup(A).dnsNameStatus; + }; + assert.equal(await settle(), "contested"); + clearVerificationCache(cache); + assert.equal( + await settle(), + "verified", + "healed same-serial zone must not read replayed" + ); +}); From c33935cf216917ee8658eeeb72204b8e1b4435e0 Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 2 Sep 2026 23:06:17 -0700 Subject: [PATCH 6/7] Use pushed down onomancy APIs --- apps/component-test-app/package.json | 2 +- apps/component-test-app/src/App.tsx | 65 +-- apps/component-test-app/src/localDirectory.ts | 145 ------- apps/component-test-app/src/nameResolution.ts | 138 ++++--- apps/component-test-app/src/onomancyStub.ts | 14 +- package.json | 9 +- pnpm-lock.yaml | 27 +- src/directory/automerge-directory.ts | 67 ++- .../directory/compose.ts | 45 +- src/directory/namestore.ts | 114 ++++++ src/index.ts | 10 +- src/onomancy/index.ts | 15 +- src/onomancy/reverse-binding.ts | 88 ++++ src/onomancy/runtime.ts | 387 +++++------------- src/onomancy/verified-directory.ts | 13 +- tests/automerge-directory.test.mjs | 63 +++ tests/conformance-vectors.test.mjs | 115 ++++-- tests/namestore.test.mjs | 139 +++++++ tests/ono0-conformance-vectors.jsonl | 15 +- tests/verified-directory.test.mjs | 125 ++---- 20 files changed, 906 insertions(+), 690 deletions(-) delete mode 100644 apps/component-test-app/src/localDirectory.ts rename apps/component-test-app/src/composeDirectories.ts => src/directory/compose.ts (50%) create mode 100644 src/directory/namestore.ts create mode 100644 src/onomancy/reverse-binding.ts create mode 100644 tests/automerge-directory.test.mjs create mode 100644 tests/namestore.test.mjs diff --git a/apps/component-test-app/package.json b/apps/component-test-app/package.json index c0dd116..d42272d 100644 --- a/apps/component-test-app/package.json +++ b/apps/component-test-app/package.json @@ -16,7 +16,7 @@ "@automerge/automerge-subduction": "0.16.1", "@inkandswitch/onomancy-react": "workspace:*", "@automerge/react": "2.6.0-subduction.48", - "@inkandswitch/onomancy": "0.2.0", + "@inkandswitch/onomancy": "0.3.0", "@keyhive/keyhive": "0.1.0-alpha.8", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/apps/component-test-app/src/App.tsx b/apps/component-test-app/src/App.tsx index 1df98b7..411f630 100644 --- a/apps/component-test-app/src/App.tsx +++ b/apps/component-test-app/src/App.tsx @@ -6,6 +6,7 @@ import { useSyncExternalStore, } from "react"; import { + ImmutableString, isValidAutomergeUrl, useDocument, type AutomergeUrl, @@ -20,6 +21,7 @@ import { bytesToHex, CopyableField, createDocumentTarget, + bindEdge, createGroupTarget, DirectoryProvider, type DirectoryDoc, @@ -36,16 +38,14 @@ import { useOnomancyDirectory, type DnsDesignation, } from "@inkandswitch/onomancy-react/onomancy"; -import { composeDirectories } from "./composeDirectories"; + import { DocumentPanel, LoadDocument } from "./DocumentPanel"; import { - checkSegments, hostnameRoot, parseLookup, resolveLookup, type Resolution, } from "./nameResolution"; -import { createLocalDirectory } from "./localDirectory"; import { createStubOnomancy } from "./onomancyStub"; import { keyhiveRuntime } from "./keyhiveRuntime"; @@ -75,9 +75,6 @@ interface AppProps { * A test app for the onomancy-react components. */ export default function App({ hive, repo }: AppProps) { - // The localStorage copy: always available, never shared. - const localDirectory = useMemo(() => createLocalDirectory(), []); - // The shared directory document: the root doc a domain can bind (its id // goes in the TXT record's p= field), created on first run or loaded from // another profile. @@ -89,11 +86,13 @@ export default function App({ hive, repo }: AppProps) { if (directoryUrl) return; let cancelled = false; void (async () => { - // Seeded with the reserved namestore map: a completely empty initial - // document never reaches the ready state in the current stack. - const handle = await repo.create2< - DirectoryDoc & { onomancy?: Record } - >({ onomancy: {} }); + // Seeded with an empty certificate list: a completely empty initial + // document never reaches the ready state in the current stack, and + // the list is the flat layout's own protocol key — a non-reference + // value, absent from name matching and from the directory's entries. + const handle = await repo.create2>({ + ".well-known/onomancy/certificates": [], + }); await hive.addSyncServerRelayToDoc(handle.url); if (!cancelled) { localStorage.setItem(DIRECTORY_URL_KEY, handle.url); @@ -155,14 +154,12 @@ export default function App({ hive, repo }: AppProps) { } ); - // Reads prefer the shared document, writes go to both. - const directory: NameDirectory = useMemo( - () => - directoryDoc - ? composeDirectories(docDirectory, localDirectory) - : localDirectory, - [directoryDoc, docDirectory, localDirectory] - ); + // The shared document is the only name store: its local Automerge replica + // is already the offline copy, so a second cache would only shadow it. + // Before the document is ready, the directory reads empty and reports + // non-writable, and the profile editor says so — an honest window, where + // a fallback accepted writes nobody else would ever see. + const directory: NameDirectory = docDirectory; // A real app imports @inkandswitch/onomancy here instead of the stub. const onomancyRuntime = useMemo( @@ -188,17 +185,19 @@ export default function App({ hive, repo }: AppProps) { designation, }); - // Namestore edges: path keys mapping to bare automerge: references under - // the reserved key, per the path-resolution spec's namestore layout. - // The bind path's anchor selects WHICH namestore the edge is written into: - // `~`/bare into our own directory, `@hostname` into whatever root document - // the domain designates, `automerge:` into that document directly. Once the - // anchor picks the document, the write is identical — anchors only decide - // where a walk (or a bind) starts. + // Namestore edges: path keys mapping to bare automerge: references in the + // document's own top-level map, per the path-resolution spec's namestore + // layout (flat, multi-segment keys, shared with protocol and directory + // data). The bind path's anchor selects WHICH namestore the edge is + // written into: `~`/bare into our own directory, `@hostname` into whatever + // root document the domain designates, `automerge:` into that document + // directly. Once the anchor picks the document, the write is identical — + // anchors only decide where a walk (or a bind) starts. const bindName = useCallback( async (rawPath: string, url: AutomergeUrl): Promise => { + // Segment hygiene comes with the parse: `parseLookup` goes through + // the onomancy grammar, so what is bound is exactly what resolves. const { root, segments } = parseLookup(rawPath); - checkSegments(segments); if (segments.length === 0) { throw new Error("Nothing to bind: add at least one path segment."); } @@ -220,17 +219,19 @@ export default function App({ hive, repo }: AppProps) { let handle; try { - handle = await repo.find< - DirectoryDoc & { onomancy?: Record } - >(targetUrl); + handle = await repo.find>(targetUrl); } catch { throw new Error( `The target namestore is not available locally and could not be fetched from the sync server: ${targetUrl}` ); } handle.change((doc) => { - doc.onomancy ??= {}; - doc.onomancy[key] = url; + // The library helper carries the layout rules — reserved-path + // refusal, the flat top-level write, the legacy-container cleanup. + // The scalar-string encoding is ours to inject because it is the + // substrate's: a plain JS string assigned into an Automerge map + // becomes `Text`, which a conforming reader refuses. + bindEdge(doc, key, url, (target) => new ImmutableString(target)); }); return spelling; }, diff --git a/apps/component-test-app/src/localDirectory.ts b/apps/component-test-app/src/localDirectory.ts deleted file mode 100644 index e12f9cd..0000000 --- a/apps/component-test-app/src/localDirectory.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { - DirectoryEntry, - DirectoryEntryKind, - NameDirectory, -} from "@inkandswitch/onomancy-react"; - -/** - * A name directory kept in localStorage. Its contents live outside React so it - * notifies through `subscribe`. - */ - -const STORAGE_KEY = "keyhive-test-app-directory"; - -/** Avatars are bytes and localStorage holds strings, so this uses base64. */ -interface StoredEntry { - name?: string; - peerId?: string; - avatarBase64?: string; - kind?: DirectoryEntryKind; - contactCard?: string; - dnsName?: string; -} - -type StoredDirectory = Record; - -function read(): StoredDirectory { - try { - const raw = localStorage.getItem(STORAGE_KEY); - return raw ? (JSON.parse(raw) as StoredDirectory) : {}; - } catch { - return {}; - } -} - -function bytesToBase64(bytes: Uint8Array): string { - let binary = ""; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary); -} - -function base64ToBytes(base64: string): Uint8Array { - const binary = atob(base64); - const bytes = new Uint8Array(binary.length); - for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - return bytes; -} - -export function createLocalDirectory(): NameDirectory { - const listeners = new Set<() => void>(); - let stored = read(); - - // Keyed by the base64 it came from, so lookup returns a stable reference. - const avatarCache = new Map(); - - function decodeAvatar(base64: string | undefined): Uint8Array | null { - if (!base64) return null; - const cached = avatarCache.get(base64); - if (cached) return cached; - const bytes = base64ToBytes(base64); - avatarCache.set(base64, bytes); - return bytes; - } - - function toEntry(id: string, record: StoredEntry): DirectoryEntry { - return { - id, - name: record.name, - peerId: record.peerId, - avatar: decodeAvatar(record.avatarBase64), - kind: record.kind, - contactCard: record.contactCard, - dnsName: record.dnsName, - }; - } - - function notify() { - for (const listener of listeners) listener(); - } - - // Does not fire in the tab that made the change, so publish notifies too. - function onStorage(event: StorageEvent) { - if (event.key !== STORAGE_KEY) return; - stored = read(); - notify(); - } - - return { - source: "localStorage", - trust: "unverified", - writable: true, - enumerable: true, - notice: - "Names are stored in this browser only. Nothing is shared, and nothing is verified.", - - lookup(id) { - const record = stored[id]; - return record ? toEntry(id, record) : undefined; - }, - - list() { - return Object.entries(stored).map(([id, record]) => toEntry(id, record)); - }, - - publish(entry) { - const existing = stored[entry.id] ?? {}; - const record: StoredEntry = { ...existing }; - if (entry.name !== undefined) record.name = entry.name; - if (entry.peerId !== undefined) record.peerId = entry.peerId; - if (entry.avatar !== undefined) { - record.avatarBase64 = entry.avatar - ? bytesToBase64(entry.avatar) - : undefined; - } - if (entry.kind !== undefined) record.kind = entry.kind; - if (entry.contactCard !== undefined) - record.contactCard = entry.contactCard; - // The empty string clears a claim; undefined leaves it alone. - if (entry.dnsName !== undefined) { - if (entry.dnsName === "") delete record.dnsName; - else record.dnsName = entry.dnsName; - } - stored = { ...stored, [entry.id]: record }; - localStorage.setItem(STORAGE_KEY, JSON.stringify(stored)); - notify(); - }, - - // The window listener is attached only while someone is subscribed, so a - // directory that is built and dropped leaves nothing behind. - subscribe(listener) { - listeners.add(listener); - if (listeners.size === 1) { - window.addEventListener("storage", onStorage); - // Another tab may have written while nothing was listening. - stored = read(); - notify(); - } - return () => { - listeners.delete(listener); - if (listeners.size === 0) { - window.removeEventListener("storage", onStorage); - } - }; - }, - }; -} diff --git a/apps/component-test-app/src/nameResolution.ts b/apps/component-test-app/src/nameResolution.ts index 46469ad..608c936 100644 --- a/apps/component-test-app/src/nameResolution.ts +++ b/apps/component-test-app/src/nameResolution.ts @@ -1,9 +1,11 @@ import { + isImmutableString, isValidAutomergeUrl, stringifyAutomergeUrl, type AutomergeUrl, type Repo, } from "@automerge/react/slim"; +import { Name } from "@inkandswitch/onomancy"; import { hexToBytes, RESERVED_ONOMANCY_KEY, @@ -12,10 +14,9 @@ import type { OnomancyRuntime } from "@inkandswitch/onomancy-react/onomancy"; /** * The path-resolution walk over locally held documents, per the onomancy - * path-resolution spec: greedy longest-key matching against the flat - * namestore map under the reserved key, one hop per matched edge, no - * backtracking. Partial outcomes are the designed norm under partition, - * not errors. + * path-resolution spec: greedy longest-key matching against the document's + * own flat top-level map, one hop per matched edge, no backtracking. + * Partial outcomes are the designed norm under partition, not errors. */ export type Resolution = @@ -43,29 +44,41 @@ export interface ParsedLookup { * the walk after it is identical. */ export function parseLookup(raw: string): ParsedLookup { - let rest = raw.trim(); - const root: ParsedLookup["root"] = "self"; + const trimmed = raw.trim(); + if (trimmed === "") return { root: "self", segments: [] }; - if (rest.startsWith("@")) { - const [hostname, ...segments] = rest.slice(1).split("/"); - if (!hostname || !hostname.includes(".")) { - throw new Error(`Not a DNS name: "@${hostname}"`); - } - return { root: { hostname: hostname.toLowerCase() }, segments }; - } + // A sigil-less string is read as a local name, as a typing convenience; + // everything after that convenience is the grammar's. Parsing through + // onomancy's own `Name` — the same code that decides names everywhere + // else — is what keeps this app from drifting: canonicalization, dotless + // names, IP literals, label and segment rules all come from one place. + // (The stub never fakes the grammar either; only resolution is faked.) + const spelled = + trimmed.startsWith("~") || + trimmed.startsWith("@") || + trimmed.startsWith("automerge:") + ? trimmed + : `~/${trimmed}`; - if (rest.startsWith("automerge:")) { - const [anchor, ...segments] = rest.split("/"); - if (!isValidAutomergeUrl(anchor)) { - throw new Error(`Not a document anchor: "${anchor}"`); + const name = new Name(spelled); + try { + const segments = [...name.segments]; + switch (name.anchorKind) { + case "local": + return { root: "self", segments }; + case "dns": + // Printed with its sigil; the hostname is what DNS is asked about. + return { root: { hostname: name.anchor.slice(1) }, segments }; + case "doc": + return { root: { url: name.anchor as AutomergeUrl }, segments }; + default: + // The grammar has exactly three anchor kinds. A fourth means this + // app and its onomancy disagree about the name grammar. + throw new Error(`Unknown anchor kind: "${name.anchorKind}"`); } - return { root: { url: anchor }, segments }; + } finally { + name.free(); } - - if (rest === "~") return { root, segments: [] }; - if (rest.startsWith("~/")) rest = rest.slice(2); - if (rest === "") return { root, segments: [] }; - return { root, segments: rest.split("/") }; } /** The Automerge URL for a hex-encoded 32-byte document id. */ @@ -75,19 +88,6 @@ export function urlFromDocIdHex(hex: string): AutomergeUrl { ); } -/** Segment hygiene per the name grammar: reject rather than normalize. */ -export function checkSegments(segments: string[]): void { - for (const segment of segments) { - if (segment === "") throw new Error("Empty segment."); - if (segment === "." || segment === "..") { - throw new Error("No traversal segments."); - } - if (/[#/\p{Cc}]/u.test(segment)) { - throw new Error(`Invalid segment: "${segment}"`); - } - } -} - /** The namestore edges of one held document, malformed values absent. */ async function namestoreOf( repo: Repo, @@ -101,20 +101,64 @@ async function namestoreOf( return undefined; } if (typeof doc !== "object" || doc === null) return undefined; - const map = (doc as Record)[RESERVED_ONOMANCY_KEY]; - if (typeof map !== "object" || map === null) return {}; + // The document's own top-level map IS the namestore (flat layout). Bare + // references only: anything else is absent (E5) — which is also what + // keeps directory entries, certificate lists, and other protocol data + // out of the walk without any key registry — and malformed keys never + // match already-valid segments (E6). const edges: Record = {}; - for (const [key, value] of Object.entries(map)) { - // Bare references only: anything else is absent (E5), and malformed - // keys never match already-valid segments (E6). - if (typeof value === "string" && isValidAutomergeUrl(value)) { - edges[key] = value; + for (const [key, value] of Object.entries(doc)) { + const target = edgeUrlOf(value); + if (target !== undefined) edges[key] = target; + } + + // The legacy nested layout, resolved as a fallback during the migration + // window: a flat edge shadows a nested one at the same path, rebinding + // migrates a path for free, and the branch's removal condition is that + // no namestore in use still holds a nested edge — the log keeps such + // documents visible. + const legacy = (doc as Record)[RESERVED_ONOMANCY_KEY]; + if (typeof legacy === "object" && legacy !== null) { + const inherited: string[] = []; + for (const [key, value] of Object.entries(legacy)) { + const target = edgeUrlOf(value); + if (target !== undefined && !(key in edges)) { + edges[key] = target; + inherited.push(key); + } + } + if (inherited.length > 0) { + console.warn( + `onomancy: ${url} still resolves ${inherited.length} edge(s) from the legacy nested layout (${inherited.join( + ", " + )}); rebind them or migrate — conforming resolvers cannot see them` + ); } } return edges; } +/** + * The reference a namestore value carries, or `undefined` when the value + * is not one (E5/E8). + * + * Scalar strings (`ImmutableString`, the only encoding a conforming + * reader matches) and plain JS strings — this app's own pre-migration + * writes, which Automerge stored as `Text`. The `Text` branch is a + * KNOWING leniency bounded by behaviour rather than structure (neither + * app splices edge values); it keeps old edges resolving through the + * migration window and shares the legacy branch's removal condition. + */ +function edgeUrlOf(value: unknown): AutomergeUrl | undefined { + const text = isImmutableString(value) + ? value.val + : typeof value === "string" + ? value + : undefined; + return text !== undefined && isValidAutomergeUrl(text) ? text : undefined; +} + /** Greedy longest-key match: the most segments, at segment boundaries. */ function longestMatch( edges: Record, @@ -132,14 +176,16 @@ function longestMatch( return best; } -/** Resolve segments from a root document. No backtracking, live reads. */ +/** + * Resolve segments from a root document. No backtracking, live reads. + * Segment hygiene is `parseLookup`'s (the grammar's): every caller's + * segments come out of a parsed name. + */ export async function resolvePath( repo: Repo, root: AutomergeUrl, segments: string[] ): Promise { - checkSegments(segments); - let current = root; let consumed = 0; const total = segments.length; diff --git a/apps/component-test-app/src/onomancyStub.ts b/apps/component-test-app/src/onomancyStub.ts index c44326c..956d96e 100644 --- a/apps/component-test-app/src/onomancyStub.ts +++ b/apps/component-test-app/src/onomancyStub.ts @@ -15,8 +15,13 @@ import type { OnomancyModule } from "@inkandswitch/onomancy-react/onomancy"; export function createStubOnomancy(selfIdHex: string): OnomancyModule { return { // The grammar is never stubbed: `.test` hostnames are ordinary DNS - // names, so parsing them is the real parser's job either way. + // names, so parsing them is the real parser's job either way. The same + // goes for the RRset rules and the anchor decoder — the fabricated + // `.test` records are grammatical, so the real classifier judges them, + // and only resolution itself is ever faked. Name: onomancy.Name, + classifyRecords: onomancy.classifyRecords, + docAnchorBytes: onomancy.docAnchorBytes, resolveHostname(hostname: string, dohUrl?: string | null) { if (!hostname.endsWith(".test")) { @@ -26,7 +31,12 @@ export function createStubOnomancy(selfIdHex: string): OnomancyModule { case "self.test": return Promise.resolve(outcome(hostname, selfIdHex)); case "other.test": - return Promise.resolve(outcome(hostname, "ab".repeat(32))); + // A valid curve point that is not the local identity: fill(0x03) + // is in the conformance vectors' point-validity table. fill bytes + // are NOT points in general (~half the byte space is not), and a + // non-point p= makes the whole record malformed — which reads as + // "publishes no usable record", not as a mismatch. + return Promise.resolve(outcome(hostname, "03".repeat(32))); default: // A plain Error with no `reason` property: the directory's // conservative fallback maps it to `offline`, and the e2e diff --git a/package.json b/package.json index 8edbec8..63545db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@inkandswitch/onomancy-react", - "version": "0.1.0", + "version": "0.2.0", "description": "React components and hooks for keyhive access control.", "license": "MIT", "repository": { @@ -70,11 +70,18 @@ "peerDependencies": { "@automerge/automerge-repo-keyhive": ">=0.5.0-alpha.6", "@automerge/react": "2.6.0-subduction.48", + "@inkandswitch/onomancy": ">=0.3.0", "react": "^18.3.1" }, + "peerDependenciesMeta": { + "@inkandswitch/onomancy": { + "optional": true + } + }, "devDependencies": { "@automerge/automerge-repo-keyhive": "0.5.0-alpha.6", "@automerge/react": "2.6.0-subduction.48", + "@inkandswitch/onomancy": "0.3.0", "@eslint/eslintrc": "^3.1.0", "@eslint/js": "^9.39.5", "@playwright/test": "1.61.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 509c03e..ccb40a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: '@eslint/js': specifier: ^9.39.5 version: 9.39.5 + '@inkandswitch/onomancy': + specifier: 0.3.0 + version: 0.3.0 '@playwright/test': specifier: 1.61.1 version: 1.61.1 @@ -82,8 +85,8 @@ importers: specifier: 2.6.0-subduction.48 version: 2.6.0-subduction.48(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@inkandswitch/onomancy': - specifier: 0.2.0 - version: 0.2.0 + specifier: 0.3.0 + version: 0.3.0 '@inkandswitch/onomancy-react': specifier: workspace:* version: link:../.. @@ -477,8 +480,8 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@inkandswitch/onomancy@0.2.0': - resolution: {integrity: sha512-keh5i80jwtoCK6+6w1vwvGpvskhlZW5XF2OyjxRLsvPFCFpqmPTtZ5c1h9SGReiZNoh3icGpdZ2wG0fuLNx8mQ==} + '@inkandswitch/onomancy@0.3.0': + resolution: {integrity: sha512-rl5PUDRenAquVlQWO3Wjjj18l15GdcXpo0irpypeWIKn9VD1xhgFFOVtL/D9KFzTtFKxuc1rSwXI7wa2klE3Nw==} '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -504,7 +507,6 @@ packages: engines: {node: ^22.20 || ^24.12 || >=25} cpu: [x64] os: [linux] - libc: [glibc] '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} @@ -568,79 +570,66 @@ packages: resolution: {integrity: sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.62.4': resolution: {integrity: sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.62.4': resolution: {integrity: sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.62.4': resolution: {integrity: sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.62.4': resolution: {integrity: sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.62.4': resolution: {integrity: sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.62.4': resolution: {integrity: sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.62.4': resolution: {integrity: sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.62.4': resolution: {integrity: sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.62.4': resolution: {integrity: sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.62.4': resolution: {integrity: sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.62.4': resolution: {integrity: sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.62.4': resolution: {integrity: sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.62.4': resolution: {integrity: sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==} @@ -1960,7 +1949,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@inkandswitch/onomancy@0.2.0': {} + '@inkandswitch/onomancy@0.3.0': {} '@jridgewell/gen-mapping@0.3.13': dependencies: diff --git a/src/directory/automerge-directory.ts b/src/directory/automerge-directory.ts index 4ce7f0e..5f5474a 100644 --- a/src/directory/automerge-directory.ts +++ b/src/directory/automerge-directory.ts @@ -47,6 +47,42 @@ export interface AutomergeDocDirectoryOptions { const DEFAULT_NOTICE = "Names come from a shared document that anyone with its id can edit. They are not verified."; +/** + * Whether a top-level key may name a directory entry. + * + * The flat namestore layout puts names, protocol data, and directory + * entries in one shared top-level map, so what is NOT an entry id is + * decided here once for the read paths and the publish guard together: + * the `.well-known/` prefix (a writers' convention with asserted owners), + * and the legacy `onomancy` container during its migration window. + */ +function isEntryId(id: string): boolean { + return ( + id !== RESERVED_ONOMANCY_KEY && + id !== ".well-known" && + !id.startsWith(".well-known/") + ); +} + +/** + * Whether a top-level value is a directory entry, by shape. + * + * The same shared map holds namestore edges (scalar-string wrappers, + * which arrive as objects carrying their text in `val`), certificate + * lists (arrays), and byte payloads — none of which is an entry, and a + * spread of one would mint a junk row. Shape, not key: names are bare + * keys, so no key list can enumerate what to skip. + */ +function isEntryRecord(value: unknown): value is DirectoryDoc[string] { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + !(value instanceof Uint8Array) && + !("val" in value) + ); +} + /** * A directory backed by a single Automerge document, where each peer writes its * own entry. Build it with `useAutomergeDocDirectory`. @@ -65,31 +101,38 @@ export function createAutomergeDocDirectory( notice: options.notice ?? DEFAULT_NOTICE, lookup(id) { - if (id === RESERVED_ONOMANCY_KEY) return undefined; + if (!isEntryId(id)) return undefined; const record = doc?.[id]; - return record ? { id, ...record } : undefined; + return isEntryRecord(record) ? { id, ...record } : undefined; }, list() { if (!doc) return []; return Object.entries(doc) - .filter(([id]) => id !== RESERVED_ONOMANCY_KEY) + .filter( + (pair): pair is [string, DirectoryDoc[string]] => + isEntryId(pair[0]) && isEntryRecord(pair[1]) + ) .map(([id, record]) => ({ id, ...record })); }, }; if (change) { directory.publish = (entry: DirectoryEntry) => { - // Refused loudly. `lookup` and `list` filter this key, so an unguarded - // write succeeds and then becomes unreadable — and it lands in the - // region onomancy uses for protocol data, where whoever can write can - // remove or replace certificates (a capability the spec reserves to - // admin-delegated keys; dns-anchor.md §In the Bound Document). Throwing - // rather than dropping, because a silent no-op is indistinguishable - // from a write that worked when the read paths hide it either way. - if (entry.id === RESERVED_ONOMANCY_KEY) { + // Refused loudly. `lookup` and `list` filter these keys, so an + // unguarded write succeeds and then becomes unreadable — and it lands + // in a region another owner defines: `.well-known//` carries + // protocol and application data by the writers' convention the + // path-resolution spec assigns (onomancy's certificate list among it, + // where whoever can write can remove or replace certificates — a + // capability the spec reserves to admin-delegated keys; dns-anchor.md + // §In the Bound Document). The legacy `onomancy` container is guarded + // for the same reason during its migration window. Throwing rather + // than dropping, because a silent no-op is indistinguishable from a + // write that worked when the read paths hide it either way. + if (!isEntryId(entry.id)) { throw new Error( - `"${RESERVED_ONOMANCY_KEY}" is reserved for onomancy protocol data and cannot be used as a directory entry id.` + `"${entry.id}" is reserved for protocol data and cannot be used as a directory entry id.` ); } diff --git a/apps/component-test-app/src/composeDirectories.ts b/src/directory/compose.ts similarity index 50% rename from apps/component-test-app/src/composeDirectories.ts rename to src/directory/compose.ts index 922382a..7fca9f0 100644 --- a/apps/component-test-app/src/composeDirectories.ts +++ b/src/directory/compose.ts @@ -1,7 +1,15 @@ -import type { - DirectoryEntry, - NameDirectory, -} from "@inkandswitch/onomancy-react"; +// Field-wise composition of two directories: layering by trust and +// provenance, one set of merge rules to reason about. The motivating +// consumer is the demo's naming trust ladder — petnames over verified +// self-profiles over a shared phonebook — where each layer is written by +// someone with a different right to it and the reader wants the most +// trusted value per FIELD, not per entry. +// +// Not a cache. A directory backed by an Automerge document already has an +// offline story — its local replica — and composing a second store over it +// only shadows the shared one with writes nobody else receives. + +import type { DirectoryEntry, NameDirectory } from "./types.js"; function definedFields(entry: DirectoryEntry): DirectoryEntry { const out = { ...entry }; @@ -21,9 +29,15 @@ function mergeEntries( } /** - * One directory over two: reads prefer `primary` field by field, writes go to - * both. Here the primary is the shared directory document and the fallback is - * the localStorage copy, so names survive offline and sync when they can. + * One directory over two: reads prefer `primary` field by field, writes go + * to both. A typical pairing puts the more trusted source first — your own + * labels over a shared document, a verified layer over an unverified one — + * so a missing field falls through without the whole entry losing its + * better name. + * + * Trust is the floor, not the ceiling: the composition reports + * `unverified`, because a merged entry may carry fields from either side + * and the read path cannot attribute them. */ export function composeDirectories( primary: NameDirectory, @@ -51,8 +65,21 @@ export function composeDirectories( }, async publish(entry) { - if (primary.publish) await primary.publish(entry); - if (fallback.publish) await fallback.publish(entry); + // Both writes are attempted regardless of the first's outcome, and + // failures surface together: losing the local copy because the shared + // write rejected would trade durability for tidiness. + const outcomes = await Promise.allSettled([ + primary.publish?.(entry), + fallback.publish?.(entry), + ]); + const failures = outcomes.filter( + (outcome): outcome is PromiseRejectedResult => + outcome.status === "rejected" + ); + // Rethrown only after both writes were attempted: the caller learns + // the publish did not fully land, without the surviving write having + // been skipped on the way. + if (failures.length > 0) throw failures[0]!.reason; }, subscribe(listener) { diff --git a/src/directory/namestore.ts b/src/directory/namestore.ts new file mode 100644 index 0000000..529ab16 --- /dev/null +++ b/src/directory/namestore.ts @@ -0,0 +1,114 @@ +// Namestore edge writes, promoted from the applications so the layout +// rules cannot drift between them. +// +// A namestore is a document's own flat top-level map (onomancy +// path-resolution spec, Namestore Layout): names are bare keys — possibly +// multi-segment, `todos/groceries` — whose values are bare `automerge:` +// references, sharing the map with protocol data, directory entries, and +// anything else whose value is not a reference (and is therefore absent +// from name matching by shape). Both consuming applications shipped a +// nested layout before reading that sentence carefully; these helpers +// carry the flat rules plus the migration-window cleanup of the legacy +// container. +// +// Deliberately pure over an already-open document: this package imports +// nothing but React, so finding the document and opening a change are the +// application's (`handle.change((doc) => bindEdge(doc, …))`), as is the +// substrate's scalar-string encoding, injected as `toReference`. + +import { RESERVED_ONOMANCY_KEY } from "./automerge-directory.js"; + +/** + * A change-proxied namestore document: the mutable view an Automerge + * `change` callback receives. Values are the substrate's; these helpers + * only ever assign what `toReference` returns and delete. + */ +export type NamestoreWriteDoc = Record; + +/** + * Encode a target url as the substrate's scalar-string reference. + * + * Injected because the encoding is load-bearing and substrate-owned: a + * plain JS string assigned into an Automerge map becomes a `Text` object, + * which a conforming reader refuses (spliced merges can form a third value + * nobody wrote), so an application passes its own wrapper — for Automerge, + * `(url) => new ImmutableString(url)`. + */ +export type ToReference = (url: string) => unknown; + +/** + * Write one edge: `path` (segments joined by `/`) names `target` from this + * document. + * + * Refuses reserved paths before touching the document, writes the flat + * top-level key, and migrates its own path out of the legacy nested + * container — the retired copy would otherwise linger and resurrect if the + * flat edge were later unbound. Write paths may migrate; read paths stay + * read paths. + * + * Segment hygiene is the caller's: parse the path through the onomancy + * grammar (`Name`) before binding, so what is bound is exactly what + * resolves. + */ +export function bindEdge( + doc: NamestoreWriteDoc, + path: string, + target: string, + toReference: ToReference +): void { + refuseReservedPath(path); + + doc[path] = toReference(target); + + const legacy = doc[RESERVED_ONOMANCY_KEY]; + if (isContainer(legacy) && path in legacy) delete legacy[path]; +} + +/** + * Remove one edge, which is how a name is unbound. + * + * Deletes from both layouts, so an unbind cannot resurrect a legacy edge + * the flat one was shadowing. Guarded like {@link bindEdge}: unbinding the + * certificate list's key would delete the certificate list. + */ +export function unbindEdge(doc: NamestoreWriteDoc, path: string): void { + refuseReservedPath(path); + + delete doc[path]; + + const legacy = doc[RESERVED_ONOMANCY_KEY]; + if (isContainer(legacy) && path in legacy) delete legacy[path]; +} + +/** Refusal to touch a name under a protocol-reserved prefix. */ +export class ReservedPathError extends Error { + constructor(path: string) { + super( + `"${path}" is reserved: paths under .well-known/ carry protocol data, not names` + ); + this.name = "ReservedPathError"; + } +} + +/** + * The gate every namestore write path shares, exported so it can be tested + * without a document: it must fire before anything is touched. + * + * The `.well-known//` prefix carries protocol and application data + * by the writers' convention the path-resolution spec assigns (onomancy's + * certificate and decision lists among it). Resolvers apply no special rule + * to the prefix — exclusion from matching is by value shape — but a WRITER + * binding a name there replaces data another owner defines, while looking + * like a successful bind. The whole prefix is refused, not just onomancy's + * segment: owners are asserted rather than inherited, so a writer cannot + * know which segments are claimed. + */ +export function refuseReservedPath(path: string): void { + if (path === ".well-known" || path.startsWith(".well-known/")) { + throw new ReservedPathError(path); + } +} + +function isContainer(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/index.ts b/src/index.ts index ce23d81..639a659 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,13 +28,21 @@ export type { DirectoryDocChange, } from "./directory/automerge-directory.js"; export { useAutomergeDocDirectory } from "./directory/useAutomergeDocDirectory.js"; +export { composeDirectories } from "./directory/compose.js"; +export { + bindEdge, + refuseReservedPath, + ReservedPathError, + unbindEdge, +} from "./directory/namestore.js"; +export type { NamestoreWriteDoc, ToReference } from "./directory/namestore.js"; // DNS name verification lives in `@inkandswitch/onomancy-react/onomancy`. This // entry point knows what a claim is and how to render one; it does not // resolve anything. `DnsNameStatus` carries the rules a status must follow, // whoever computes it. -export { documentDelegatesTo } from "./access/delegation.js"; +export { bareId, documentDelegatesTo } from "./access/delegation.js"; export type { DelegationVerdict, DocumentDelegationOptions, diff --git a/src/onomancy/index.ts b/src/onomancy/index.ts index de48029..efb8be0 100644 --- a/src/onomancy/index.ts +++ b/src/onomancy/index.ts @@ -19,16 +19,13 @@ * still owns the only instance. */ -export { - createOnomancyRuntime, - parseRecord, - parseRecordDocId, -} from "./runtime.js"; -export type { Ono0Record } from "./runtime.js"; +export { createOnomancyRuntime } from "./runtime.js"; export type { HostnameBinding, + OnomancyClassification, OnomancyModule, OnomancyName, + OnomancyRecordCandidate, OnomancyRuntime, OnomancyRuntimeOptions, } from "./runtime.js"; @@ -43,6 +40,12 @@ export type { KeyhiveDesignationOptions, } from "./designation.js"; +export { requireReverseBinding } from "./reverse-binding.js"; +export type { + ReverseBindingCheck, + ReverseBindingClaim, +} from "./reverse-binding.js"; + export { clearVerificationCache, clearVerificationVerdicts, diff --git a/src/onomancy/reverse-binding.ts b/src/onomancy/reverse-binding.ts new file mode 100644 index 0000000..d4108e5 --- /dev/null +++ b/src/onomancy/reverse-binding.ts @@ -0,0 +1,88 @@ +// The certificate half of DNS name verification, as a designation +// combinator. Promoted from the demo application once the flat-namestore +// migration landed; the verification itself stays injected, because it +// needs the bound documents (held replicas) and the onomancy Wasm, and +// this package imports nothing but React. + +import type { DnsDesignation } from "./designation.js"; + +/** + * What a bound document said about a hostname, when asked for its + * certificate. + * + * - `accepted` — a certificate held in the document verified for this + * hostname (the application's check decides what "verified" requires, + * including any mutuality rule it applies). + * - `rejected` — evidence arrived and failed: a certificate was held and + * did not verify. + * - `absent` — the document holds no certificate for this hostname, or is + * not held here. Says nothing, in either direction. + */ +export type ReverseBindingClaim = "accepted" | "rejected" | "absent"; + +/** + * Ask one designated document whether it accepts the hostname back. + * + * `documentId` is hex, as `HostnameBinding.ids` carries it. A typical + * implementation reads the certificate list at the document's + * `.well-known/onomancy/certificates` key and verifies through the + * onomancy module (`verifyCertificate`/`verifyBinding`). + */ +export type ReverseBindingCheck = ( + documentId: string, + hostname: string +) => Promise; + +/** + * Require the reverse half of a DNS binding: the zone names the document + * (`p=`, the forward half the DNS layer proves), and the document names + * the hostname back through an onomancy certificate signed by one of its + * admin-delegated keys. The spec is explicit that a verified binding needs + * both (dns-anchor, "A verified DNS binding proves exactly this"), and a + * conforming verifier refuses when the reverse half is absent. + * + * `createKeyhiveDesignation` alone checks only that the identity + * administers the designated document. That is necessary and not + * sufficient: it shows the identity *could have* signed a certificate, + * never that one exists. Reporting `designates` on it is not a weaker + * claim than the spec's — it is a different one. + * + * So this composes the two and takes the weaker verdict: + * + * | inner says | certificate | result | + * | --- | --- | --- | + * | designates | accepted | `designates` | + * | designates | absent | `unknown` — not proven, not disproven | + * | designates | rejected | `excludes` — evidence arrived and failed | + * | anything else | — | unchanged | + * + * Absence maps to `unknown` rather than `excludes` deliberately. A document + * that carries no certificate has not *denied* the domain; it has said + * nothing, and absence of evidence is not evidence of absence. A + * certificate that arrived and failed verification is different in kind, + * and that one does convict. + * + * Every bound document gets a chance: a domain mid-migration publishes + * several, and only one need carry the certificate. One consequence of the + * composition: an inner designation that grades a bare-key binding is + * wrapped into `unknown` here, because a bare key holds no certificate. + * That is correct — `p=` names a document. + */ +export function requireReverseBinding( + check: ReverseBindingCheck, + inner: DnsDesignation +): DnsDesignation { + return async (entry, boundIds, hostname) => { + const forward = await inner(entry, boundIds, hostname); + if (forward !== "designates") return forward; + + let sawRejection = false; + for (const id of boundIds) { + const claim = await check(id, hostname); + if (claim === "accepted") return "designates"; + if (claim === "rejected") sawRejection = true; + } + + return sawRejection ? "excludes" : "unknown"; + }; +} diff --git a/src/onomancy/runtime.ts b/src/onomancy/runtime.ts index 6c16c44..6668372 100644 --- a/src/onomancy/runtime.ts +++ b/src/onomancy/runtime.ts @@ -17,16 +17,56 @@ export interface OnomancyName { free?(): void; } +/** + * A binding candidate as `classifyRecords` reports it. Structurally + * `@inkandswitch/onomancy`'s `RecordCandidate`, declared here so this + * package needs no import of its own. + */ +export interface OnomancyRecordCandidate { + /** The bound document, as an `automerge:` anchor. */ + readonly document: string; + /** The attested generation key (`g=`), canonical base64. */ + readonly generation: string; + /** The serial as a decimal string: the space is u64, past `number`. */ + readonly serial: string; +} + +/** + * The `RRset` rules' outcome over one zone's TXT strings. Structurally + * `@inkandswitch/onomancy`'s `RecordClassification`. + */ +export interface OnomancyClassification { + /** The zone's word: the unique claim at the top serial. */ + readonly selected?: OnomancyRecordCandidate; + /** Distinct claims tied at the top serial: equivocation, none picked. */ + readonly contested?: OnomancyRecordCandidate[]; + /** Bindings set aside: serial past the skew bound at the given clock. */ + readonly deferred: number; + /** Records that are not `v=ONO` at all. */ + readonly foreign: number; + /** `v=ONO` records with a tag newer than the module implements. */ + readonly unknownVersion: number; + /** `v=ONO0` records that failed the strict grammar. */ + readonly malformed: number; +} + /** * The subset of `@inkandswitch/onomancy`'s exports this package needs supplied * by the application, so that the Wasm module is loaded once and only by the * host. See `KeyhiveRuntime` for the same pattern applied to keyhive. + * + * `classifyRecords` and `docAnchorBytes` first shipped in onomancy 0.3.0; + * older builds cannot satisfy this interface. That is deliberate: the TXT + * grammar and the selection rule are a trust root's parser, and this package + * carrying its own copy is how two implementations drift — which is exactly + * what happened, three security fixes at a time, before the rules moved into + * the module every consumer already loads. */ export interface OnomancyModule { /** * Resolve a hostname's onomancy binding live over DoH, validated from the * IANA trust anchors baked into the Wasm. Resolves to - * `{ hostname, links, freshness, records: string[] }`. + * `{ hostname, records: string[], freshness, window, checkedAt, … }`. */ resolveHostname(hostname: string, dohUrl?: string | null): Promise; @@ -37,6 +77,23 @@ export interface OnomancyModule { * code that decides them everywhere else. */ Name: new (raw: string) => OnomancyName; + + /** + * The `RRset` rules over one zone's TXT strings: strict `v=ONO0` parsing, + * deferral of far-future serials before selection, highest serial wins, + * and ties contested on `(document, generation)` rather than picked. + */ + classifyRecords( + records: string[], + nowSeconds?: number | null + ): OnomancyClassification; + + /** + * The 32 payload bytes of a doc anchor — the root document id. The + * bytes-side counterpart of the `automerge:` anchors `classifyRecords` + * emits, for consumers whose own vocabulary is raw ids. + */ + docAnchorBytes(anchor: string): Uint8Array; } export interface OnomancyRuntimeOptions { @@ -78,22 +135,6 @@ export type ChainFreshness = "fresh" | "stale" | "deferred"; */ const MAX_DNS_NAME_LENGTH = 254; -/** - * How far ahead of the local clock a serial may read before it is set aside. - * - * Serials are millisecond timestamps, so a record from a publisher whose - * clock runs slightly fast is ordinary and must not be punished. A record - * from *years* ahead is not a clock — it is an attempt to jam the ratchet at - * a value nothing honest will ever exceed. - * - * The bound is what makes a ratchet safe to have at all. With it, a transient - * attacker can push the ratchet at most five minutes past wall clock, and an - * honest publisher — minting `max(now_ms, last + 1)` — outgrows the poison - * within the window. Without it, one forged record locks the name forever, - * and the ratchet becomes the attack rather than the defence. - */ -const SERIAL_SKEW_BOUND_MS = 5n * 60n * 1000n; - /** * A DNSSEC-verified binding: the root document ids a hostname's * `_onomancy` TXT records designate. @@ -213,16 +254,45 @@ export function createOnomancyRuntime( options.dohUrl ?? null ); const freshness = freshnessOf(outcome); + + // The module's clock argument is epoch seconds (it refuses a + // milliseconds reading as implausible); the injectable option stays + // milliseconds because `Date.now` is the ordinary source. Flooring + // widens the deferral horizon by under a second, which the spec's + // five-minute skew bound dwarfs. const rawNow = (options.now ?? Date.now)(); - const nowMs = - typeof rawNow === "bigint" ? rawNow : BigInt(Math.floor(rawNow)); - const selection = boundIdsOf(outcome, nowMs); - - const binding: HostnameBinding = { hostname, ids: selection.ids }; - if (selection.serial !== undefined) binding.serial = selection.serial; - if (selection.contested) binding.contested = true; - if (selection.deferredSerials > 0) { - binding.deferredSerials = selection.deferredSerials; + const nowSeconds = + typeof rawNow === "bigint" + ? Number(rawNow / 1000n) + : Math.floor(rawNow / 1000); + + // The RRset rules live in the module — strict grammar, deferral + // before selection, highest serial wins, ties contested on the pair + // (document, generation) — so this package holds no parser of its + // own to drift. What remains here is the mapping to this package's + // id vocabulary (hex), via the module's own anchor decoder. + const classified = onomancy.classifyRecords( + recordsOf(outcome), + nowSeconds + ); + + const leaders = classified.selected + ? [classified.selected] + : (classified.contested ?? []); + const ids = [ + ...new Set( + leaders.map((claim) => + bytesToHex(onomancy.docAnchorBytes(claim.document)) + ) + ), + ]; + + const binding: HostnameBinding = { hostname, ids }; + const [first] = leaders; + if (first !== undefined) binding.serial = BigInt(first.serial); + if (classified.contested) binding.contested = true; + if (classified.deferred > 0) { + binding.deferredSerials = classified.deferred; } if (freshness !== undefined) binding.freshness = freshness; @@ -330,86 +400,6 @@ function validityWindowOf( return expiration >= inception ? { inception, expiration } : undefined; } -/** The outcome of choosing among a hostname's `v=ONO0` records. */ -interface RecordSelection { - ids: string[]; - serial?: bigint; - deferredSerials: number; - /** Records of equal top precedence disagree on (document, generation). */ - contested?: boolean; -} - -/** - * The `p=` document ids a `resolveHostname` outcome designates, with the - * serial that won and a count of records set aside as future-dated. - * - * Order of operations is load-bearing and comes from the spec: **deferral - * precedes movement.** A record reading too far ahead is set aside *before* - * selection, so it can never become the winner and therefore never reaches - * the ratchet. Reversing these two steps would let a forged far-future - * serial jam the ratchet at a value no honest publisher will ever exceed — - * turning the defence into the attack. - */ -function boundIdsOf(outcome: unknown, nowMs: bigint): RecordSelection { - const none: RecordSelection = { ids: [], deferredSerials: 0 }; - if (typeof outcome !== "object" || outcome === null) return none; - const records = (outcome as { records?: unknown }).records; - if (!Array.isArray(records)) return none; - - // Parse every record, keeping only those that are `v=ONO0` and well formed. - // A foreign or malformed TXT record beside a valid one is normal — a zone - // holds records for many purposes — so an unparseable neighbour must not - // fail the set. - const parsed: Ono0Record[] = []; - for (const record of records) { - if (typeof record !== "string") continue; - const ono0 = parseRecord(record); - if (ono0 !== undefined) parsed.push(ono0); - } - - // Set aside anything dated beyond the skew bound. Deferred, not rejected: - // these ripen as the clock advances, so a publisher whose clock runs a - // little fast is delayed rather than refused. - const horizon = nowMs + SERIAL_SKEW_BOUND_MS; - const eligible = parsed.filter((record) => record.serial <= horizon); - const deferredSerials = parsed.length - eligible.length; - - if (eligible.length === 0) return { ids: [], deferredSerials }; - - // Highest serial wins. RRset order is *not* significant — a resolver may - // return the same set in a different order on each query — so taking - // `records[0]` would make the answer depend on which shuffle arrived. - // The serial is the publisher's own statement of which record supersedes. - // - // Compared as `bigint` throughout. The serial space is u64, and `Number` - // silently equates neighbours near its top — which would turn a genuine - // supersession into a tie, and a tie is reported as a contested zone. A - // domain correctly superseding its own record would show to every visitor - // as misconfigured. `Math.max` is avoided for the same reason: it coerces - // back through `number` at precisely the comparison the bigint exists to - // protect. - let top = eligible[0]!.serial; - for (const record of eligible) if (record.serial > top) top = record.serial; - - const leaders = eligible.filter((record) => record.serial === top); - const distinct = [...new Set(leaders.map((record) => record.docIdHex))]; - // Contested-ness is keyed on the pair (document, generation), matching the - // reference verifier. Two records naming the same document with different - // generation keys at a tied serial are a rotation caught mid-flight; the - // one-shot RRset rule has no lineage evidence to order them, so it refuses - // rather than picking a generation arbitrarily. - const pairs = new Set( - leaders.map((record) => record.docIdHex + " " + record.generation) - ); - - // Agreement at the top serial, including the ordinary single-record case. - if (pairs.size === 1) { - return { ids: distinct, serial: top, deferredSerials }; - } - - return { ids: distinct, serial: top, deferredSerials, contested: true }; -} - /** * The chain grade in a `resolveHostname` outcome, when it reported one. * @@ -425,178 +415,19 @@ function freshnessOf(outcome: unknown): ChainFreshness | undefined { : undefined; } -/** One parsed `v=ONO0` TXT record. */ -export interface Ono0Record { - /** The hex-encoded root document id from `p=`. */ - readonly docIdHex: string; - /** - * The `g=` generation key, as spelled in the record. - * - * Part of the zone-state key: contested-ness is decided on the pair - * `(document, generation)`, so same-document-different-generation records - * tied at the top serial are a rotation caught mid-flight — a contest, not - * agreeing duplicates. This matches the reference verifier's - * `best_of_document`, where a non-unique undominated set is contested by - * construction. - */ - readonly generation: string; - /** - * The `n=` serial, as a `BigInt`. - * - * Not a `number`. The serial space is the full u64 range — - * `max(now_ms, last + 1)` is a publisher *recommendation*, not a bound, and - * verifiers must accept any u64. `u64::MAX` is 18446744073709551615 against - * `Number.MAX_SAFE_INTEGER` of 9007199254740991, so a conformant serial can - * exceed what a `number` represents exactly. - * - * Every serial in the wild today is a millisecond timestamp (~1.8e12) and - * would survive as a `number`, which is exactly why this would break - * silently and late. Do not "simplify" it back on the grounds that the - * grammar already caps the digit count — 20 digits is the u64 limit, not the - * safe-integer limit. - */ - readonly serial: bigint; -} - -/** - * Canonical decimal, per the DNS anchoring spec: no leading zeros, at most 20 - * digits, no sign, no whitespace. `Serial::parse` upstream rejects each of - * those with a distinct error (`LeadingZero`, `TooManyDigits`, `Overflow`); - * we only need the same verdict, not the same diagnosis. - */ -const ONO0 = - /^v=ONO0;k=ed25519;n=(0|[1-9][0-9]{0,19});g=([A-Za-z0-9+/]+={0,2});p=([A-Za-z0-9+/]+={0,2})$/; - -const U64_MAX = 18446744073709551615n; - /** - * One TXT record parsed, or `undefined` when it is not a well-formed `v=ONO0` - * record. Parsing is strict within the known tag, per the DNS anchoring spec: - * exact field order, known fields only, canonical integers. - * - * Strictness in this direction is the safe one. Accepting a record the - * protocol rejects means resolving a name a conformant verifier refuses — two - * users, same zone, different answers, no error anywhere. - * - * Hand-written on purpose: this is the TXT wire format, which `Name` does not - * parse. `Name` decides what a *name* is; this decides what a *record* is. - * Upstream has `TxtRecord::parse` and `classify()` already written; when they - * are exposed to JS this whole function should be deleted rather than - * maintained. + * The TXT strings in a `resolveHostname` outcome. Read structurally: the + * module is injected, so the build in play is whatever the consumer + * installed, and a missing or oddly-shaped field is an empty set rather + * than a throw. A foreign or malformed neighbour is the classifier's to + * tally, not ours to pre-filter — only non-strings are dropped, since the + * classifier refuses those wholesale. */ -// ---- ed25519 point validity (RFC 8032 §5.1.3 decompression) ---------------- -// -// The grammar requires g= and p= to decode to VALID curve points, not merely -// 32 bytes: "decoders MUST reject a unit whose key field does not decompress, -// even where that field is never verified against" (specs/serialization.md). -// A 32-byte string that cannot denote a key is not the canonical encoding of -// anything, and parsers that disagree about whether such a record exists -// diverge on every selection it feeds. -// -// Implemented with bare BigInt because this library imports only React: no -// crypto dependency is available, WebCrypto key import is async (this parser -// is sync) and its point validation is implementation-defined anyway. This is -// validity only — no key material is used for anything. - -const ED_P = (1n << 255n) - 19n; -/** -121665/121666 mod p, the curve constant d. */ -const ED_D = - 37095705934669439343138083508754565189542113879843219016388785533085940283555n; - -function modPow(base: bigint, exp: bigint, mod: bigint): bigint { - let b = base % mod; - if (b < 0n) b += mod; - let result = 1n; - let e = exp; - while (e > 0n) { - if (e & 1n) result = (result * b) % mod; - b = (b * b) % mod; - e >>= 1n; - } - return result; -} - -/** Precomputed 2^((p minus 1)/4), the square-root adjustment factor. */ -const ED_SQRT_ADJ = modPow(2n, (ED_P - 1n) / 4n, ED_P); - -/** Whether 32 bytes decompress to a point on the edwards25519 curve. */ -function isCurvePoint(bytes: Uint8Array): boolean { - if (bytes.length !== 32) return false; - // Little-endian y with the top bit as the x-parity flag. - let y = 0n; - for (let i = 31; i >= 0; i--) y = (y << 8n) | BigInt(bytes[i]!); - const xParity = (y >> 255n) & 1n; - y &= (1n << 255n) - 1n; - if (y >= ED_P) return false; - - // Solve x^2 = (y^2 - 1) / (d*y^2 + 1). - const y2 = (y * y) % ED_P; - const u = (y2 - 1n + ED_P) % ED_P; - const v = (ED_D * y2 + 1n) % ED_P; - - // Candidate root: x = u * v^3 * (u * v^7)^((p minus 5)/8). - const v3 = (v * v * v) % ED_P; - const v7 = (v3 * v3 * v) % ED_P; - let x = (u * v3 * modPow((u * v7) % ED_P, (ED_P - 5n) / 8n, ED_P)) % ED_P; - - const vx2 = (v * x * x) % ED_P; - if (vx2 === u) { - // x is the root. - } else if (vx2 === (ED_P - u) % ED_P) { - x = (x * ED_SQRT_ADJ) % ED_P; - } else { - return false; - } - - // x = 0 cannot carry a sign bit. - if (x === 0n && xParity === 1n) return false; - return true; -} - -export function parseRecord(record: string): Ono0Record | undefined { - // A TXT record longer than 255 characters cannot have come from a single - // conformant character-string; the canonical grammar rejects it outright. - if (record.length > 255) return undefined; - - const match = record.match(ONO0); - if (!match) return undefined; - - const serial = BigInt(match[1]); - if (serial > U64_MAX) return undefined; - - // g= is constrained identically to p=: it must decode to exactly 32 bytes. - // A generation key of any other length is malformed, not lenient-parseable - // — the canonical grammar routes both fields through the same decoder. - const generationBytes = base64ToBytes(match[2]!); - if (generationBytes === undefined || generationBytes.length !== 32) { - return undefined; - } - if (!isCurvePoint(generationBytes)) return undefined; - - const bytes = base64ToBytes(match[3]); - if (bytes === undefined || bytes.length !== 32) return undefined; - if (!isCurvePoint(bytes)) return undefined; - - return { docIdHex: bytesToHex(bytes), generation: match[2]!, serial }; -} - -/** The hex-encoded root document id of one TXT record. See {@link parseRecord}. */ -export function parseRecordDocId(record: string): string | undefined { - return parseRecord(record)?.docIdHex; -} - -function base64ToBytes(base64: string): Uint8Array | undefined { - try { - const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); - // Canonical spellings only. atob is forgiving - it accepts unpadded - // input and ignores nonzero trailing bits in the final character - so - // without the round-trip, one key has many spellings and parsers - // disagree about which records exist (the differential class the - // grammar's strict-decoding rule exists to kill; the reference decoder - // "requires canonical padding and rejects set trailing bits"). - if (btoa(String.fromCharCode(...bytes)) !== base64) return undefined; - return bytes; - } catch { - return undefined; - } +function recordsOf(outcome: unknown): string[] { + if (typeof outcome !== "object" || outcome === null) return []; + const records = (outcome as { records?: unknown }).records; + if (!Array.isArray(records)) return []; + return records.filter( + (record): record is string => typeof record === "string" + ); } diff --git a/src/onomancy/verified-directory.ts b/src/onomancy/verified-directory.ts index 3ea6a57..cb4cad1 100644 --- a/src/onomancy/verified-directory.ts +++ b/src/onomancy/verified-directory.ts @@ -1,3 +1,4 @@ +import { bareId } from "../access/delegation.js"; import type { DirectoryEntry, DnsNameStatus, @@ -45,9 +46,11 @@ type Verdict = * `resolutions` is the DNS layer: hostname to bound document ids. It is the * onomancy spec's *binding cache*, which requires entries to be re-verified * at use — a decision that depends on `now`, so memoizing it across time is - * memoizing a function of an argument that was dropped. Doing that properly - * needs certificate verification, which the Wasm binding does not expose, so - * this half stays memoized and the limitation is recorded rather than hidden. + * memoizing a function of an argument that was dropped. The Wasm module now + * exposes the verification entry points (`verifyCertificate`, + * `verifyBinding`), but re-verifying at use also needs the bound documents + * held locally, which this wrapper never sees — so this half stays memoized + * and the limitation is recorded rather than hidden. * * `verdicts` is the designation layer: does the bound document belong to this * identity? That is a question about local keyhive state and the DNS spec has @@ -516,10 +519,6 @@ export function createOnomancyDirectory( return directory; } -function bareId(id: string): string { - return (id.startsWith("0x") ? id.slice(2) : id).toLowerCase(); -} - /** * Why the DNS layer gave no answer, from the runtime's own `reason` when it * supplies one. diff --git a/tests/automerge-directory.test.mjs b/tests/automerge-directory.test.mjs new file mode 100644 index 0000000..fadc04e --- /dev/null +++ b/tests/automerge-directory.test.mjs @@ -0,0 +1,63 @@ +// Behaviour pins for the shared-document directory under the flat +// namestore layout, where directory entries, namestore edges (scalar +// strings), and protocol data (`.well-known/…`) share one top-level map. +// +// Each pin verified to go red against a build without its guard: an +// unguarded publish at a protocol key succeeds and then becomes +// unreadable, replacing data another owner defines — the certificate +// list among it, whose replacement silently degrades every verified +// `@host` badge for the document. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const { createAutomergeDocDirectory } = await import("../dist/index.js"); + +test("publish refuses protocol keys, loudly", () => { + const doc = {}; + const directory = createAutomergeDocDirectory(doc, (fn) => fn(doc)); + + for (const id of [ + "onomancy", // the legacy container, guarded through its migration window + ".well-known", + ".well-known/onomancy/certificates", + ".well-known/other-app/data", + ]) { + assert.throws( + () => directory.publish({ id, name: "Mallory" }), + /reserved/, + `${id} must refuse` + ); + } + + // A prefix rule, not a substring rule. + directory.publish({ id: ".well-knownish", name: "odd but allowed" }); + assert.equal(directory.lookup(".well-knownish")?.name, "odd but allowed"); +}); + +test("reads skip protocol keys and non-entry shapes, by key and by shape", () => { + const alice = "aa".repeat(32); + const doc = { + [alice]: { name: "Alice" }, + // A flat namestore edge: a scalar-string wrapper carrying its text in + // `val`, exactly how one reads back out of an Automerge document. + "todos/groceries": { val: "automerge:2AbCdEf" }, + // The certificate list: an array, not an entry. + ".well-known/onomancy/certificates": [new Uint8Array([1, 2, 3])], + // The legacy nested container. + onomancy: { "old/name": "automerge:2AbCdEf" }, + }; + const directory = createAutomergeDocDirectory(doc, undefined); + + assert.deepEqual( + directory.list().map((entry) => entry.id), + [alice], + "exactly the entries, nothing spread from an edge or a list" + ); + assert.equal(directory.lookup("todos/groceries"), undefined); + assert.equal( + directory.lookup(".well-known/onomancy/certificates"), + undefined + ); + assert.equal(directory.lookup("onomancy"), undefined); + assert.equal(directory.lookup(alice)?.name, "Alice"); +}); diff --git a/tests/conformance-vectors.test.mjs b/tests/conformance-vectors.test.mjs index 13ada03..5d1092c 100644 --- a/tests/conformance-vectors.test.mjs +++ b/tests/conformance-vectors.test.mjs @@ -1,22 +1,30 @@ // Shared ONO0 conformance vectors, authored by keyhive-todo-app-demo from -// their 34 record tests plus the 2026-09-02 canonical rulings, and destined -// for onomancy's Rust conformance table. Vendored (not read from the bridge) -// so the suite is hermetic; provenance sha of the bridged original (rev 3): -// f7ebdb439780e2b1d3d0372d2cb077ed4be2204ccc011e590f3f6caccd0ab3cf +// their record tests plus the 2026-09-02/03 canonical rulings; their home +// is onomancy's conformance table. Vendored (not read from the bridge) so +// the suite is hermetic. // // Rev history: rev 1 caught this parser's lax g= and missing 255-char limit; // rev 2 (canonical referee) caught the demo's missing skew deferral; rev 3 // (reference harness) caught missing point-validity in BOTH parsers and -// non-point fixture keys in the vectors themselves. Each round's bug was -// invisible to the previous round's process. +// non-point fixture keys in the vectors themselves; rev 4 pinned strict +// base64 canonicality and the two decompression edges no fill fixture can +// reach — which the verification run then caught the Rust decoder accepting. +// Each round's bug was invisible to the previous round's process. +// +// This library no longer carries the rules the vectors judge: parsing and +// selection moved into `@inkandswitch/onomancy` (`classifyRecords`), and +// what remains here is `createOnomancyRuntime`'s mapping onto +// `HostnameBinding`. The replay therefore runs at two levels — parse +// vectors against the module the app injects, classify vectors through the +// runtime — so a regression in either the rules or the mapping goes red. import { test } from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; import { URL } from "node:url"; import { Buffer } from "node:buffer"; -const { createOnomancyRuntime, parseRecord } = - await import("../dist/onomancy/index.js"); +const onomancy = await import("@inkandswitch/onomancy"); +const { createOnomancyRuntime } = await import("../dist/onomancy/index.js"); const vectors = fs .readFileSync(new URL("./ono0-conformance-vectors.jsonl", import.meta.url)) @@ -26,13 +34,13 @@ const vectors = fs .map((line) => JSON.parse(line)); // Guard against silent degradation: a truncated or partially-unparsed vector -// file must fail loudly, not pass vacuously. Counts pinned to rev 3. -test("vector file carries the full rev 3 corpus", () => { +// file must fail loudly, not pass vacuously. Counts pinned to rev 4. +test("vector file carries the full rev 4 corpus", () => { const byKind = {}; for (const v of vectors) byKind[v.kind ?? "meta"] = (byKind[v.kind ?? "meta"] ?? 0) + 1; - assert.deepEqual(byKind, { meta: 1, parse: 21, classify: 14, nextSerial: 7 }); - assert.equal(vectors.find((v) => v.kind === "meta")?.revision, 3); + assert.deepEqual(byKind, { meta: 1, parse: 25, classify: 15, nextSerial: 7 }); + assert.equal(vectors.find((v) => v.kind === "meta")?.revision, 4); }); // The meta's document aliases, resolved to the hex ids our API reports. @@ -41,28 +49,74 @@ const DOC_ALIAS = { doc2: Buffer.from(new Uint8Array(32).fill(3)).toString("hex"), }; +const SKEW_MS = 300000n; + +// A fixed, realistic instant where the vectors leave the clock open. +const FIXED_NOW_MS = 1788000000000n; + +// The module refuses implausible clocks (its seconds-vs-milliseconds +// validation), so a vector needing one is exercised against the internal +// Rust rule only — sanctioned by the vectors' meta. +const IMPLAUSIBLE_SECONDS = 100000000000n; + +/** A clock (ms) at which `serial` escapes deferral, or undefined. */ +const clockForMs = (serial) => { + const value = BigInt(serial); + const floor = value > SKEW_MS ? value - SKEW_MS : 0n; + if (floor / 1000n >= IMPLAUSIBLE_SECONDS) return undefined; + return floor > FIXED_NOW_MS ? floor : FIXED_NOW_MS; +}; + const classify = async (records, nowMs) => createOnomancyRuntime( - { - resolveHostname: async () => ({ records }), - Name: class { - constructor() {} - }, - }, - { now: () => Number(nowMs ?? 1788000000000n) } + { ...onomancy, resolveHostname: async () => ({ records }) }, + { now: () => Number(nowMs ?? FIXED_NOW_MS) } ).resolveBoundIds("x.example"); for (const v of vectors) { if (v.kind === "parse") { + // Module level: the rules themselves, disposition by disposition. test(`parse: ${v.name}`, () => { - const r = parseRecord(v.input); - // Resolver granularity: every non-parsed disposition is one skip. - assert.equal(!!r, v.expected === "parsed"); - if (r && v.serial !== undefined) { - assert.equal(String(r.serial), String(v.serial)); + if (v.expected === "parsed") { + const nowMs = + v.serial === undefined ? FIXED_NOW_MS : clockForMs(v.serial); + if (nowMs === undefined) { + // No plausible clock admits the serial (the u64 ceiling), but + // only a grammatical binding can land in `deferred`. + const out = onomancy.classifyRecords( + [v.input], + Number(FIXED_NOW_MS / 1000n) + ); + assert.equal(out.malformed + out.foreign + out.unknownVersion, 0); + assert.equal(out.deferred, 1); + return; + } + const out = onomancy.classifyRecords([v.input], Number(nowMs / 1000n)); + assert.equal(out.malformed + out.foreign + out.unknownVersion, 0); + assert.equal(out.selected?.serial, v.serial); + } else { + const out = onomancy.classifyRecords( + [v.input], + Number(FIXED_NOW_MS / 1000n) + ); + assert.equal(out[v.expected], 1, `expected one ${v.expected}`); + assert.equal(out.selected, undefined); + assert.equal(out.contested, undefined); } }); } else if (v.kind === "classify") { + // Runtime level: the same rules THROUGH resolveBoundIds, plus the + // mapping to hex ids, the contested flag, and the deferral count. + if ( + v.nowMs !== undefined && + BigInt(v.nowMs) / 1000n >= IMPLAUSIBLE_SECONDS + ) { + // The module refuses a clock this far out as implausible, so the + // vector is exercised against the internal Rust rule only (vectors' + // meta, `nowSecondsCaveat`). + test(`classify: ${v.name}`, { skip: "clock beyond the plausible bound" }); + continue; + } test(`classify: ${v.name}`, async () => { const b = await classify( v.input, @@ -95,6 +149,17 @@ for (const v of vectors) { // so a fabricated deferredSerials on a bound answer cannot survive. assert.equal(b.deferredSerials ?? 0, v.expected.deferred ?? 0); }); + } else if (v.kind === "nextSerial") { + // Module level: this library has no publisher, but the rule is one + // import away and the vectors are already in hand. + test(`nextSerial: ${v.name}`, () => { + const last = v.last === null ? undefined : v.last; + const now = Number(v.now); + if (v.expected === "refuse") { + assert.throws(() => onomancy.nextSerial(last, now)); + } else { + assert.equal(onomancy.nextSerial(last, now), v.expected); + } + }); } - // kind === "nextSerial": publisher-side; this library has no publisher. } diff --git a/tests/namestore.test.mjs b/tests/namestore.test.mjs new file mode 100644 index 0000000..627b755 --- /dev/null +++ b/tests/namestore.test.mjs @@ -0,0 +1,139 @@ +// Behaviour pins for the surfaces promoted out of the applications once +// the flat-namestore migration landed: the namestore write helpers, the +// reverse-binding designation combinator, and the directory composition. +// Promoted precisely so the rules cannot drift between consumers, which +// makes these pins the single place the rules are stated executable. +import { test } from "node:test"; +import assert from "node:assert/strict"; + +const { bindEdge, unbindEdge, composeDirectories } = + await import("../dist/index.js"); +const { requireReverseBinding } = await import("../dist/onomancy/index.js"); + +// A stand-in for the substrate's scalar-string wrapper: the helpers only +// ever assign what `toReference` returns, so any marker shape proves the +// injection is honored. +const toReference = (url) => ({ val: url }); + +test("bindEdge writes the flat key through the injected reference", () => { + const doc = {}; + bindEdge(doc, "todos/groceries", "automerge:2AbC", toReference); + assert.deepEqual(doc["todos/groceries"], { val: "automerge:2AbC" }); +}); + +test("a rebind migrates its own path out of the legacy container", () => { + const doc = { onomancy: { "todos/groceries": "automerge:2Old" } }; + bindEdge(doc, "todos/groceries", "automerge:2New", toReference); + assert.deepEqual(doc["todos/groceries"], { val: "automerge:2New" }); + assert.equal( + "todos/groceries" in doc.onomancy, + false, + "the retired copy must not linger to resurrect on a later unbind" + ); +}); + +test("unbindEdge deletes from both layouts", () => { + const doc = { + "todos/groceries": { val: "automerge:2New" }, + onomancy: { "todos/groceries": "automerge:2Old" }, + }; + unbindEdge(doc, "todos/groceries"); + assert.equal("todos/groceries" in doc, false); + assert.equal("todos/groceries" in doc.onomancy, false); +}); + +test("both write helpers refuse reserved paths before touching the document", () => { + const doc = {}; + for (const path of [ + ".well-known", + ".well-known/onomancy/certificates", + ".well-known/other-app/data", + ]) { + assert.throws( + () => bindEdge(doc, path, "automerge:2AbC", toReference), + /reserved/, + `bind at ${path}` + ); + assert.throws(() => unbindEdge(doc, path), /reserved/, `unbind at ${path}`); + } + assert.deepEqual(doc, {}, "nothing was written on any refusal"); + + // A prefix rule, not a substring rule. + bindEdge(doc, ".well-knownish", "automerge:2AbC", toReference); + assert.ok(doc[".well-knownish"]); +}); + +test("requireReverseBinding takes the weaker verdict", async () => { + const entry = { id: "aa".repeat(32), name: "Alice" }; + const inner = (verdict) => async () => verdict; + const check = (answers) => async (id) => answers[id] ?? "absent"; + + const designation = (innerVerdict, answers) => + requireReverseBinding(check(answers), inner(innerVerdict))( + entry, + Object.keys(answers), + "a.example" + ); + + // The decision table, row by row. + assert.equal( + await designation("designates", { doc1: "accepted" }), + "designates" + ); + assert.equal( + await designation("designates", { doc1: "absent" }), + "unknown", + "no certificate is silence, not denial" + ); + assert.equal( + await designation("designates", { doc1: "rejected" }), + "excludes", + "evidence that arrived and failed convicts" + ); + // One accepting document among several is enough (mid-migration + // dual-publish), and acceptance beats a sibling's rejection. + assert.equal( + await designation("designates", { doc1: "rejected", doc2: "accepted" }), + "designates" + ); + // Anything but designates passes through untouched: the reverse half is + // only asked about documents the forward half already granted. + assert.equal(await designation("excludes", { doc1: "accepted" }), "excludes"); + assert.equal(await designation("unknown", { doc1: "accepted" }), "unknown"); +}); + +test("composed publish attempts both writes even when one rejects", async () => { + const base = { + source: "x", + trust: "unverified", + writable: true, + enumerable: true, + notice: "", + lookup: () => undefined, + list: () => [], + }; + let fallbackSaw; + const primary = { + ...base, + publish: async () => { + throw new Error("shared write rejected"); + }, + }; + const fallback = { + ...base, + publish: async (entry) => { + fallbackSaw = entry; + }, + }; + + const composed = composeDirectories(primary, fallback); + await assert.rejects( + () => composed.publish({ id: "aa".repeat(32), name: "Alice" }), + /shared write rejected/ + ); + assert.equal( + fallbackSaw?.name, + "Alice", + "the local write must not be lost to the shared write's rejection" + ); +}); diff --git a/tests/ono0-conformance-vectors.jsonl b/tests/ono0-conformance-vectors.jsonl index 17f12da..4f5e0f9 100644 --- a/tests/ono0-conformance-vectors.jsonl +++ b/tests/ono0-conformance-vectors.jsonl @@ -1,4 +1,4 @@ -{"kind":"meta","source":"keyhive-todo-app-demo src/record.test.ts (35 tests) + 2026-09-02 canonical rulings","date":"2026-09-02","revision":3,"revisionNote":"rev 3: all key/document fixtures moved to point-valid fills (docs 1/3/11, keys 6/9) - rev-2 fixtures fill(2)/fill(7) are not ed25519 curve points, so seven rev-2 classify expectations were canonically unsatisfiable; two non-point parse vectors added; point-validity reference table pinned in the source suite. Earlier, rev 2: 'u64-adjacent serials stay distinct' gained an explicit nowMs (the rev-1 vector was unsatisfiable under canonical deferral-before-selection and pinned this repo's pre-deferral bug); three deferral vectors added, one marked overturned against this repo's own old behaviour.","format":{"serials":"decimal strings everywhere (JSON numbers cannot hold u64)","nowMs":"decimal-string clock for classify vectors; deferral rule is serial > nowMs + skewBoundMs, applied BEFORE selection. Absent nowMs means every serial in the vector is far in the past: the expectation must hold at any realistic present-day clock.","skewBoundMs":"300000","nowSecondsCaveat":"the JS classifyRecords surface takes nowSeconds and refuses clocks past ~year 5138, so the u64-adjacent vector is exercised via the internal Rust rule only - by design (per ono)","documents":"a COUNT of distinct documents, not a list; 'bound' implies exactly one","document":"doc1/doc2 name the p= values fill(32,1)/fill(32,3); all key/doc fixtures are verified curve points except NONPOINT = fill(32,2)","deferred":"count of records set aside by the skew rule; 0 when omitted from input conditions but stated explicitly in unbound expectations"},"notes":["Dispositions use the canonical vocabulary (parsed/malformed/foreign/unknownVersion); this repo's parser collapses foreign+unknownVersion+malformed into one skip, which is fine for a resolver and insufficient for the export.","Overturned entries carry the OLD expectation and the ruling, so the table records the settled question rather than silently flipping."]} +{"kind":"meta","source":"keyhive-todo-app-demo src/record.test.ts + namestore guard suite (44 tests total across the repo) + canonical rulings 2026-09-02/03","date":"2026-09-02","revision":4,"revisionNote":"rev 4: base64 canonicality RULED strict (reference decode_key_field: canonical padded STANDARD, trailing bits rejected) - two spelling vectors added; x=0-sign-bit and y>=p decompression vectors added (unreachable by fill fixtures); horizon-masked contest pinned; documents:1 stated on the rotation tie; expected.document now present on every bound classify vector - ASSERT IT: a wire-order-first document mutant passed a 39-test runner that checked status+serial only. Earlier, rev 3: all key/document fixtures moved to point-valid fills (docs 1/3/11, keys 6/9) - rev-2 fixtures fill(2)/fill(7) are not ed25519 curve points, so seven rev-2 classify expectations were canonically unsatisfiable; two non-point parse vectors added; point-validity reference table pinned in the source suite. Earlier, rev 2: 'u64-adjacent serials stay distinct' gained an explicit nowMs (the rev-1 vector was unsatisfiable under canonical deferral-before-selection and pinned this repo's pre-deferral bug); three deferral vectors added, one marked overturned against this repo's own old behaviour.","format":{"serials":"decimal strings everywhere (JSON numbers cannot hold u64)","nowMs":"decimal-string clock for classify vectors; deferral rule is serial > nowMs + skewBoundMs, applied BEFORE selection. Absent nowMs means every serial in the vector is far in the past: the expectation must hold at any realistic present-day clock.","skewBoundMs":"300000","nowSecondsCaveat":"the JS classifyRecords surface takes nowSeconds and refuses clocks past ~year 5138, so the u64-adjacent vector is exercised via the internal Rust rule only - by design (per ono)","documents":"a COUNT of distinct documents, not a list; bound implies exactly one. On contested it can be 1: a rotation tie (one document, two generation keys).","document":"doc1/doc2 name the p= values fill(32,1)/fill(32,3); all key/doc fixtures are verified curve points except NONPOINT = fill(32,2)","deferred":"count of records set aside by the skew rule; 0 when omitted from input conditions but stated explicitly in unbound expectations","runners":"assert expected.document on every bound vector, not just status+serial - a wire-order-first selection mutant is invisible otherwise (mutation-proven against a 39-test runner)"},"notes":["Dispositions use the canonical vocabulary (parsed/malformed/foreign/unknownVersion); this repo's parser collapses foreign+unknownVersion+malformed into one skip, which is fine for a resolver and insufficient for the export.","Overturned entries carry the OLD expectation and the ruling, so the table records the settled question rather than silently flipping."]} {"kind":"parse","name":"valid record round-trips","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"parsed","serial":"7","gBytes":32,"pBytes":32} {"kind":"parse","name":"live record: brooklynzelenka.com","input":"v=ONO0;k=ed25519;n=1787792719795;g=8XXKlRi7D9msSp8U4TZo0AzQ99InBDquIYprrW7NoI4=;p=nJ8I/xDYHbttOOpAzRaYFgGpvdtYmlGuXNsaNKWz+Us=","expected":"parsed","serial":"1787792719795","note":"captured from live DoH+DNSSEC; pins the format production already serves"} {"kind":"parse","name":"serial at u64 ceiling","input":"v=ONO0;k=ed25519;n=18446744073709551615;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"parsed","serial":"18446744073709551615"} @@ -13,25 +13,30 @@ {"kind":"parse","name":"33-byte p=","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMD","expected":"malformed"} {"kind":"parse","name":"32-byte non-point p=","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=","expected":"malformed","note":"ratified grammar (specs/serialization.md, 2026-08-19): key fields MUST decompress to curve points; fill(2) is the reference non-point. Added rev 3: BOTH TS parsers accepted this until ono's harness refused the fixtures."} {"kind":"parse","name":"32-byte non-point g=","input":"v=ONO0;k=ed25519;n=7;g=AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgI=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"malformed","note":"same rule, and the sharper half: g= is never verified against during resolution, and the record is malformed anyway - parsers must agree on whether a record EXISTS."} +{"kind":"parse","name":"non-canonical base64: dirty spare padding bits","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQF=","expected":"malformed","note":"RULED (reference decode_key_field, base64 STANDARD: canonical padding, trailing bits rejected; spec: unique canonical spelling, parse-then-to_string is the identity). Forgiving decoders (atob, Buffer.from base64) equate this with the canonical spelling of DOC1 - both TS parsers did, found independently in both repos' round-2 reviews."} +{"kind":"parse","name":"non-canonical base64: missing padding","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE","expected":"malformed","note":"same ruling: 44 chars of canonical padded base64, exactly."} +{"kind":"parse","name":"x=0 with sign bit set (y=1, bit 255)","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIA=","expected":"malformed","note":"RFC 8032 5.1.3 step 4: the negative-zero spelling encodes no point. Unreachable by any fill fixture; mutation testing showed it otherwise unguarded."} +{"kind":"parse","name":"y >= p (fill 0xff)","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=//////////////////////////////////////////8=","expected":"malformed","note":"non-canonical field element: masked y = 2^255-1 past p = 2^255-19."} {"kind":"parse","name":"fields out of order","input":"v=ONO0;k=ed25519;n=1;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=","expected":"malformed","note":"fixed order, exactly 5 fields"} {"kind":"parse","name":"unknown field appended","input":"v=ONO0;k=ed25519;n=1;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=;x=1","expected":"malformed"} {"kind":"parse","name":"unknown algorithm","input":"v=ONO0;k=x25519;n=1;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"malformed","note":"ruling: malformed, NOT foreign"} {"kind":"parse","name":"future version","input":"v=ONO1;k=ed25519;n=1;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","expected":"unknownVersion","note":"skip-as-future; this repo's parser collapses to skip"} {"kind":"parse","name":"foreign TXT","input":"v=SPF1 include:example.com","expected":"foreign"} {"kind":"parse","name":"empty string","input":"","expected":"foreign"} -{"kind":"parse","name":"over 255 chars","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE= ","expected":"malformed","note":"TXT character-string ceiling"} +{"kind":"parse","name":"over 255 chars","input":"v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE= ","expected":"malformed","note":"TXT character-string ceiling. HONESTY: this vector cannot discriminate a ceiling-enforcing parser from one without the check - a grammar-conforming record maxes out at 133 chars, so this input already fails the grammar. Defence-in-depth documentation, mutation-proven unable to go red on its own."} {"kind":"classify","name":"highest serial wins regardless of wire order","input":["v=ONO0;k=ed25519;n=3;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM=","v=ONO0;k=ed25519;n=5;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="],"expected":{"status":"bound","serial":"7","document":"doc2"}} {"kind":"classify","name":"rotation invariance","input":["v=ONO0;k=ed25519;n=5;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM=","v=ONO0;k=ed25519;n=3;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="],"expected":{"status":"bound","serial":"7","document":"doc2"},"note":"answer must be invariant under any permutation of the set"} {"kind":"classify","name":"contested: two documents at tied top serial","input":["v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM="],"expected":{"status":"contested","serial":"7","documents":2},"note":"refuse to choose; contested is distinct from unbound"} -{"kind":"classify","name":"rotation tie: same document, different g= keys","input":["v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=7;g=BgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgY=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="],"expected":{"status":"contested","serial":"7"},"overturned":{"old":"bound, arbitrary g= won: dedup keyed on document alone in both TS copies","ruling":"canonical contested shape carries generation, so a mid-rotation tie is visible and refused"}} -{"kind":"classify","name":"duplicate record is agreement","input":["v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="],"expected":{"status":"bound","serial":"7"}} +{"kind":"classify","name":"rotation tie: same document, different g= keys","input":["v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=7;g=BgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgYGBgY=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="],"expected":{"status":"contested","serial":"7","documents":1},"overturned":{"old":"bound, arbitrary g= won: dedup keyed on document alone in both TS copies","ruling":"canonical contested shape carries generation, so a mid-rotation tie is visible and refused"}} +{"kind":"classify","name":"duplicate record is agreement","input":["v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="],"expected":{"status":"bound","serial":"7","document":"doc1"}} {"kind":"classify","name":"older record loses without contesting","input":["v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=6;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM="],"expected":{"status":"bound","serial":"7","document":"doc1"}} {"kind":"classify","name":"contest at a lower serial is ignored","input":["v=ONO0;k=ed25519;n=7;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=6;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM=","v=ONO0;k=ed25519;n=6;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=CwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCws="],"expected":{"status":"bound","serial":"7","document":"doc1"},"note":"added on review: our property tested rotation, not this"} -{"kind":"classify","name":"unparseable records do not deny the set","input":["v=SPF1 include:example.com","","v=ONO0;k=ed25519;n=3;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=x25519;n=9;g=AA==;p=AA=="],"expected":{"status":"bound","serial":"3"}} +{"kind":"classify","name":"unparseable records do not deny the set","input":["v=SPF1 include:example.com","","v=ONO0;k=ed25519;n=3;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=x25519;n=9;g=AA==;p=AA=="],"expected":{"status":"bound","serial":"3","document":"doc1"}} {"kind":"classify","name":"u64-adjacent serials stay distinct","nowMs":"18446744073709551615","input":["v=ONO0;k=ed25519;n=18446744073709551614;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=18446744073709551615;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM="],"expected":{"status":"bound","serial":"18446744073709551615","document":"doc2"},"note":"REVISED: previous revision carried no nowMs, so canonical semantics deferred both records and the vector pinned this repo's pre-deferral behaviour. Explicit ceiling clock keeps the BigInt-distinctness point: Number coercion equates these two serials."} {"kind":"classify","name":"far-future serial is deferred, honest record wins","nowMs":"1800000000000","input":["v=ONO0;k=ed25519;n=1800000000000;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=1800000300001;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM="],"expected":{"status":"bound","serial":"1800000000000","document":"doc1","deferred":1},"overturned":{"old":"bound to the forged far-future serial: this repo's selectBinding had no skew deferral, so a planted serial won selection while the README described the defence","ruling":"deferral precedes selection (verifier/state.rs); serial > nowMs + 300000 is set aside before the max is taken"},"note":"THE poisoning-bound vector: the forged record is exactly 1ms past the bound"} {"kind":"classify","name":"only deferred records is nothing-usable-yet, not unbound-silent","nowMs":"1800000000000","input":["v=ONO0;k=ed25519;n=1800000300001;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=1800000300002;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM="],"expected":{"status":"unbound","deferred":2},"note":"deferred count distinguishes a silent domain from a jammed one"} {"kind":"classify","name":"serial exactly at the skew bound is legitimate","nowMs":"1800000000000","input":["v=ONO0;k=ed25519;n=1800000300000;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="],"expected":{"status":"bound","serial":"1800000300000","document":"doc1"},"note":"boundary: <= nowMs + 300000 selects; deferral starts strictly past it"} +{"kind":"classify","name":"a contest entirely beyond the skew horizon is masked by deferral","nowMs":"1800000000000","input":["v=ONO0;k=ed25519;n=1800000300001;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=","v=ONO0;k=ed25519;n=1800000300001;g=CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=;p=AwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwM="],"expected":{"status":"unbound","deferred":2},"note":"deferral precedes selection, so a STAGED far-future contest cannot jam the name - it is set aside whole. Correct canonical order; previously unpinned in all three implementations."} {"kind":"classify","name":"empty set","input":[],"expected":{"status":"unbound","deferred":0}} {"kind":"classify","name":"nothing usable","input":["v=SPF1","junk"],"expected":{"status":"unbound","deferred":0}} {"kind":"nextSerial","name":"first mint takes the clock","last":null,"now":"1000","expected":"1000"} diff --git a/tests/verified-directory.test.mjs b/tests/verified-directory.test.mjs index d8c9eac..4e138d1 100644 --- a/tests/verified-directory.test.mjs +++ b/tests/verified-directory.test.mjs @@ -20,13 +20,14 @@ const { createVerificationCache, } = await import("../dist/onomancy/index.js"); +// The real module, with only resolution faked: the grammar, the RRset +// rules, and the anchor decoder are never stubbed, so these pins exercise +// the same judgement path an application gets. +const onomancy = await import("@inkandswitch/onomancy"); +const moduleWith = (resolveHostname) => ({ ...onomancy, resolveHostname }); + const noopRuntime = () => - createOnomancyRuntime({ - resolveHostname: async () => ({ records: [] }), - Name: class { - constructor() {} - }, - }); + createOnomancyRuntime(moduleWith(async () => ({ records: [] }))); test("publish strips every verification decoration, nothing else", async () => { let published; @@ -74,7 +75,8 @@ test("two subscriptions sharing one callback survive one unsubscribe", async () notice: "", lookup: () => undefined, list: () => [], - // An identity-deduplicating base, like the demo's localDirectory. + // An identity-deduplicating base: a directory that dedupes listeners + // by identity would collapse two subscriptions sharing one callback. subscribe: (fn) => { baseListeners.add(fn); return () => baseListeners.delete(fn); @@ -103,57 +105,32 @@ test("two subscriptions sharing one callback survive one unsubscribe", async () }); test("contests a rotation tie: same document, different generation keys", async () => { - // Candidate vector for classifyRecords: the old selection keyed ties on - // document alone, so this case read as agreeing duplicates and picked a - // generation arbitrarily. The reference verifier refuses it. - const { createOnomancyRuntime } = await import("../dist/onomancy/index.js"); + // Selection keyed on the document alone reads this as agreeing duplicates + // and picks a generation arbitrarily; the reference verifier refuses it. + // The rule now lives in the module's classifyRecords — the pin here is + // that the runtime's mapping carries the contest through: the flag set, + // the one document still reported. const A = "aa".repeat(32); const b64 = (h) => Buffer.from(h, "hex").toString("base64"); const rec = (g) => `v=ONO0;k=ed25519;n=5;g=${g};p=${b64(A)}`; const runtime = createOnomancyRuntime( - { - resolveHostname: async () => ({ - records: [rec(b64("11".repeat(32))), rec(b64("22".repeat(32)))], - }), - Name: class { - constructor() {} - }, - }, + moduleWith(async () => ({ + records: [rec(b64("11".repeat(32))), rec(b64("22".repeat(32)))], + })), { now: () => 1788000000000 } ); const binding = await runtime.resolveBoundIds("a.example"); assert.equal(binding.contested, true, "rotation tie must be contested"); assert.equal(binding.ids.length, 1, "one document, still reported"); + assert.equal(binding.ids[0], A, "and it is the document, in hex"); }); -test("g= and p= must decompress to ed25519 curve points", async () => { - // The ratified grammar: "decoders MUST reject a unit whose key field does - // not decompress" (specs/serialization.md). Reference validity table for - // fill(32, k) fixtures, from onomancy's harness. - const { parseRecord } = await import("../dist/onomancy/index.js"); - const b64 = (k) => Buffer.from(new Uint8Array(32).fill(k)).toString("base64"); - const valid = [0, 1, 3, 6, 9, 10, 11, 12, 16]; - const invalid = [2, 4, 5, 7, 8, 13, 14, 15]; - for (const k of valid) { - assert.ok( - parseRecord(`v=ONO0;k=ed25519;n=5;g=${b64(1)};p=${b64(k)}`), - `fill(${k}) is a point and must parse` - ); - } - for (const k of invalid) { - assert.equal( - parseRecord(`v=ONO0;k=ed25519;n=5;g=${b64(1)};p=${b64(k)}`), - undefined, - `fill(${k}) is not a point and must be malformed` - ); - assert.equal( - parseRecord(`v=ONO0;k=ed25519;n=5;g=${b64(k)};p=${b64(1)}`), - undefined, - `non-point g= fill(${k}) must be malformed too` - ); - } -}); +// The parser-rule pins that used to live here — fill-fixture point +// validity, the x=0 sign-bit edge, non-canonical base64 spellings — moved +// with the parser itself: the rules are onomancy's (`TxtRecord`, tested in +// Rust and replayed from the shared vectors in conformance-vectors.test.mjs), +// and this library holds no copy left to pin. // Local runs can test a stale dist; CI rebuilds first. Warn, do not fail. { @@ -174,49 +151,6 @@ test("g= and p= must decompress to ed25519 curve points", async () => { } } -test("x = 0 with the sign bit set is not a point", async () => { - // RFC 8032 §5.1.3 final rule: x = 0 cannot carry a sign bit. y = 1 gives - // x = 0 (the neutral element) and is valid; the same y with the sign bit - // set is the one encoding class the fill(k) table cannot reach. - const { parseRecord } = await import("../dist/onomancy/index.js"); - const y1 = new Uint8Array(32); - y1[0] = 1; - const signed = Uint8Array.from(y1); - signed[31] |= 0x80; - const b64 = (u8) => Buffer.from(u8).toString("base64"); - const rec = (p) => - `v=ONO0;k=ed25519;n=5;g=${b64(new Uint8Array(32).fill(1))};p=${p}`; - assert.ok(parseRecord(rec(b64(y1))), "y=1, x=0 unsigned is a valid point"); - assert.equal( - parseRecord(rec(b64(signed))), - undefined, - "x=0 + sign bit is not" - ); -}); - -test("non-canonical base64 spellings are malformed, not aliases", async () => { - // The grammar is strict: one record has one spelling. atob is forgiving, - // so without the canonical round-trip, unpadded and trailing-bit variants - // of one key parse as the same record - the parser-differential class - - // and two spellings of one generation key manufacture a phantom contest. - const { parseRecord } = await import("../dist/onomancy/index.js"); - const key = Buffer.from(new Uint8Array(32).fill(1)).toString("base64"); // canonical, ends "=" - const unpadded = key.slice(0, -1); - // Flip a low bit in the final character: same decoded bytes under atob. - const chars = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - const last = key[42]; - const trailing = key.slice(0, 42) + chars[chars.indexOf(last) + 1] + "="; - const rec = (g) => `v=ONO0;k=ed25519;n=5;g=${g};p=${key}`; - assert.ok(parseRecord(rec(key)), "canonical spelling parses"); - assert.equal(parseRecord(rec(unpadded)), undefined, "unpadded is malformed"); - assert.equal( - parseRecord(rec(trailing)), - undefined, - "trailing-bit variant is malformed" - ); -}); - test("a contested serial does not enter the ratchet", async () => { // The ratchet remembers the highest serial ACCEPTED. Pin: contested@10 // (refused), then the zone heals to a single record at the SAME serial on @@ -238,18 +172,7 @@ test("a contested serial does not enter the ratchet", async () => { { records: [rec(G1)], freshness: "stale" }, // healed @10, stale chain ]; const rt = createOnomancyRuntime( - { - resolveHostname: async () => script.shift(), - Name: class { - constructor(raw) { - const bare = raw.startsWith("@") ? raw.slice(1) : raw; - this.anchor = "@" + bare.toLowerCase(); - this.anchorKind = "dns"; - this.segments = []; - } - free() {} - }, - }, + moduleWith(async () => script.shift()), { now: () => 1788000000000 } ); const cache = createVerificationCache(); From 14166fc18e5dca44ff58168e364382aba0b66845 Mon Sep 17 00:00:00 2001 From: Brooklyn Zelenka Date: Wed, 2 Sep 2026 23:16:33 -0700 Subject: [PATCH 7/7] Improve comments in nameResolution.ts Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Signed-off-by: Brooklyn Zelenka --- apps/component-test-app/src/nameResolution.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/component-test-app/src/nameResolution.ts b/apps/component-test-app/src/nameResolution.ts index 608c936..3244260 100644 --- a/apps/component-test-app/src/nameResolution.ts +++ b/apps/component-test-app/src/nameResolution.ts @@ -144,11 +144,10 @@ async function namestoreOf( * is not one (E5/E8). * * Scalar strings (`ImmutableString`, the only encoding a conforming - * reader matches) and plain JS strings — this app's own pre-migration - * writes, which Automerge stored as `Text`. The `Text` branch is a - * KNOWING leniency bounded by behaviour rather than structure (neither - * app splices edge values); it keeps old edges resolving through the - * migration window and shares the legacy branch's removal condition. + * reader matches) and plain JS strings from legacy data. This leniency is + * bounded by behaviour rather than structure (neither app splices edge + * values); it keeps old edges resolving through the migration window + * and shares the legacy branch's removal condition. */ function edgeUrlOf(value: unknown): AutomergeUrl | undefined { const text = isImmutableString(value)