From c2ffc32ff6d559384fcf1a56253dcd1226633fbe Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 16:24:41 +0200 Subject: [PATCH] Make the TypeScript route verifier the C# rules exactly verifyP256 handed WebCrypto `view.slice().buffer`. For a Node Buffer, `slice` is `subarray`, so `.buffer` is the shared pool: a pooled Buffer or a subarray of a larger allocation verified false while the same bytes as a plain Uint8Array verified true. Copy exactly the viewed bytes. Trust rules that had drifted from `CultMeshAuthorityProof.cs`: - A certificate whose signature is empty or whitespace is an unsigned route (C# `Verify` line 120): local-development loopback accepts it, everything else refuses with the unsigned message. - Loopback is `System.Uri.IsLoopback`: any 127.0.0.0/8 host, `::1`, the IPv4-mapped `::ffff:127.0.0.1` (only `.1`), and the host names `localhost` and `loopback`. WHATWG URL normalises `127.1`, `0x7f000001` and `2130706433` the way System.Uri does. Probed on the C# side through the public policy before the rule was written; the test pins that set. - The root lookup runs before the validity window, so an expired route under an unknown root is refused as untrusted on both sides. - A policy listing one Odin key id twice is refused at first use, the closest a plain object gets to the C# constructor throwing. One test per rule, one mutation per rule in the runner's list. A fixture whose protocol ids are unsorted and whose expected transcript is the sorted one means removing the sort is now killed. The runner now matches anchors in the file's own line ending. On an autocrlf checkout the two multi-line anchors it already had ("route transcript field order", "session transcript binds the nonce") matched nothing, so the run threw at those entries instead of testing them. Co-Authored-By: Claude Fable 5.1 --- packages/cultnet-ts/src/cultmesh-authority.ts | 53 +++++++-- .../test/cultmesh-authority.test.ts | 101 +++++++++++++++++- scripts/mutate-cultmesh-authority.mjs | 64 ++++++++++- 3 files changed, 203 insertions(+), 15 deletions(-) diff --git a/packages/cultnet-ts/src/cultmesh-authority.ts b/packages/cultnet-ts/src/cultmesh-authority.ts index c579f347..fd0fcce6 100644 --- a/packages/cultnet-ts/src/cultmesh-authority.ts +++ b/packages/cultnet-ts/src/cultmesh-authority.ts @@ -44,29 +44,49 @@ export interface CultMeshAuthorityTrustPolicy { now?: () => number; } +// Rule for rule, this is `CultMeshAuthorityTrustPolicy.Verify` in the C# +// reference, in the same order: unsigned, channel protection, root lookup, +// validity window, signature. The same input yields the same refusal on both +// sides. A certificate whose signature is empty or whitespace is unsigned +// (`CultMeshRouteCertificate` trims the signature; `Verify` treats a +// whitespace signature as no certificate). export async function verifyAuthorityRoute( route: CultMeshAuthorityRouteView, trust: CultMeshAuthorityTrustPolicy, ): Promise { + const roots = trustedOdinRoots(trust); const certificate = route.certificate; - if (!certificate) { + const signature = certificate?.signature.trim() ?? ""; + if (!certificate || signature === "") { if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint)) return; throw new Error("Remote CultMesh routes require an Odin-signed authority certificate."); } if (!isProtectedEndpoint(route.endpoint) && !(trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint))) { throw new Error("Authenticated remote CultMesh routes require TLS or QUIC channel protection."); } + const root = roots.get(certificate.odinKeyId); + if (!root) throw new Error(`Odin key '${certificate.odinKeyId}' is not trusted by this consumer.`); const now = trust.now?.() ?? Date.now(); if (now < certificate.issuedAtUnixMilliseconds || now >= certificate.expiresAtUnixMilliseconds) { throw new Error("The Odin route certificate is not currently valid."); } - const root = trust.odinRoots?.find(candidate => candidate.keyId === certificate.odinKeyId); - if (!root) throw new Error(`Odin key '${certificate.odinKeyId}' is not trusted by this consumer.`); - if (!await verifyP256(root, canonicalRoute(route), certificate.signature)) { + if (!await verifyP256(root, canonicalRoute(route), signature)) { throw new Error("The Odin route certificate signature is invalid."); } } +// The C# policy is a dictionary keyed by Odin key id and its constructor +// throws on a duplicate. A TypeScript policy is a plain object, so the same +// refusal happens at the first use of the policy, before any route is judged. +function trustedOdinRoots(trust: CultMeshAuthorityTrustPolicy): Map { + const roots = new Map(); + for (const root of trust.odinRoots ?? []) { + if (roots.has(root.keyId)) throw new Error(`Odin root key id '${root.keyId}' is listed more than once in the trust policy.`); + roots.set(root.keyId, root); + } + return roots; +} + /** Verifies the provider's session proof over the accepted handshake. */ export function verifyProviderSessionProof( request: CultMeshSessionOpenMessage, @@ -142,7 +162,7 @@ export async function verifyP256( raw.set(y, 33); const publicKey = await crypto.subtle.importKey( "raw", - raw.slice().buffer as ArrayBuffer, + ownedBytes(raw), { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"], @@ -150,18 +170,35 @@ export async function verifyP256( return await crypto.subtle.verify( { name: "ECDSA", hash: "SHA-256" }, publicKey, - signature.slice().buffer as ArrayBuffer, - payload.slice().buffer as ArrayBuffer, + ownedBytes(signature), + ownedBytes(payload), ); } catch { return false; } } +// WebCrypto reads an `ArrayBuffer` whole. A view's `.buffer` is not the view: +// a Node `Buffer` (a subarray of a shared pool) or any offset `Uint8Array` +// would hand WebCrypto the pool, and `Buffer.prototype.slice` does not copy. +// Copy exactly the viewed bytes so every view verifies alike. +function ownedBytes(view: Uint8Array): ArrayBuffer { + return view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength) as ArrayBuffer; +} + +/** + * The C# rule (`CultMeshAuthorityProof.cs`, `IsLoopback`): `System.Uri.IsLoopback`, + * which is any `127.0.0.0/8` IPv4 host, `::1`, the IPv4-mapped `::ffff:127.0.0.1` + * (and only `.1`), and the host names `localhost` and `loopback`, case-insensitively. + * WHATWG `URL` already normalises `127.1`, `0x7f000001` and `2130706433` to + * `127.0.0.1` and serialises the mapped form as `::ffff:7f00:1`, as `System.Uri` + * does on its side. `localhost.` is not loopback on either side. + */ export function isLoopbackEndpoint(value: string): boolean { try { const host = new URL(value).hostname.replace(/^\[|\]$/g, "").toLowerCase(); - return host === "localhost" || host === "127.0.0.1" || host === "::1"; + return host === "localhost" || host === "loopback" || host === "::1" || host === "::ffff:7f00:1" || + /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host); } catch { return false; } diff --git a/packages/cultnet-ts/test/cultmesh-authority.test.ts b/packages/cultnet-ts/test/cultmesh-authority.test.ts index cb3a62da..03a9b9f0 100644 --- a/packages/cultnet-ts/test/cultmesh-authority.test.ts +++ b/packages/cultnet-ts/test/cultmesh-authority.test.ts @@ -17,6 +17,7 @@ import type { CultMeshSessionOpenMessage } from "../src/contracts"; import { base64ToBytes, bytesToBase64, + canonicalFields, canonicalRoute, canonicalSession, isLoopbackEndpoint, @@ -120,6 +121,22 @@ test("an unsigned loopback route is accepted only under local-development", asyn await assert.rejects(verifyAuthorityRoute(unsigned, { mode: "authenticated-remote" }), /Odin-signed authority certificate/); }); +test("a certificate whose signature is empty or whitespace is an unsigned route, as in C#", async () => { + for (const signature of ["", " ", "\n\t "]) { + const remote = signedRoute(); + const remoteBlank = { ...remote.route, certificate: { ...remote.route.certificate!, signature } }; + await assert.rejects(verifyAuthorityRoute(remoteBlank, remoteTrust(remote.odin.publicKey)), /Odin-signed authority certificate/); + await assert.rejects( + verifyAuthorityRoute(remoteBlank, { ...remoteTrust(remote.odin.publicKey), mode: "local-development" }), + /Odin-signed authority certificate/, + ); + const loopback = signedRoute("ws://127.0.0.1:4050/mesh"); + const loopbackBlank = { ...loopback.route, certificate: { ...loopback.route.certificate!, signature } }; + await verifyAuthorityRoute(loopbackBlank, { ...remoteTrust(loopback.odin.publicKey), mode: "local-development" }); + await assert.rejects(verifyAuthorityRoute(loopbackBlank, remoteTrust(loopback.odin.publicKey)), /Odin-signed authority certificate/); + } +}); + test("a certified route on an unprotected scheme is refused unless it is loopback under local-development", async () => { const remote = signedRoute("ws://provider.example/mesh"); await assert.rejects(verifyAuthorityRoute(remote.route, remoteTrust(remote.odin.publicKey)), /channel protection/); @@ -154,6 +171,31 @@ test("an Odin root the consumer does not trust is refused before any signature c await assert.rejects(verifyAuthorityRoute(route, remoteTrust(stranger.publicKey)), /signature is invalid/); }); +test("the root lookup precedes the validity window, so an expired route under an unknown root is refused as untrusted (C# order)", async () => { + const { route, odin } = signedRoute(); + const expired = route.certificate!.expiresAtUnixMilliseconds + 1; + await assert.rejects(verifyAuthorityRoute(route, remoteTrust(odin.publicKey, expired)), /not currently valid/); + await assert.rejects( + verifyAuthorityRoute(route, { mode: "authenticated-remote", odinRoots: [{ ...odin.publicKey, keyId: "other-root" }], now: () => expired }), + /is not trusted/, + ); +}); + +test("a trust policy listing one Odin key id twice is refused before any route is judged, as the C# constructor throws", async () => { + const { route, odin } = signedRoute(); + const twin = { ...keyPair("odin-root-1").publicKey }; + for (const odinRoots of [[odin.publicKey, twin], [twin, odin.publicKey], [odin.publicKey, odin.publicKey]]) { + await assert.rejects( + verifyAuthorityRoute(route, { mode: "authenticated-remote", odinRoots, now: () => NOW }), + /'odin-root-1' is listed more than once/, + ); + } + await assert.rejects( + verifyAuthorityRoute({ ...route, endpoint: "ws://127.0.0.1:4050/mesh", certificate: undefined }, { mode: "local-development", odinRoots: [twin, twin] }), + /listed more than once/, + ); +}); + test("a mutated endpoint invalidates the Odin signature", async () => { const { route, odin } = signedRoute(); await assert.rejects( @@ -177,6 +219,50 @@ test("a signature that is not 64 P1363 bytes is refused", async () => { assert.equal(await verifyP256(odin.publicKey, canonicalRoute(route), "not base64!"), false); }); +test("verifyP256 reads exactly the viewed bytes: a Uint8Array, a pooled Buffer and a subarray of a large allocation verify alike", async () => { + const { route, odin } = signedRoute(); + const payload = canonicalRoute(route); + const signature = route.certificate!.signature; + const pooled = Buffer.from(payload); + const large = Buffer.alloc(16 * 1024, 0xa5); + const subarray = large.subarray(1000, 1000 + payload.byteLength); + subarray.set(payload); + const offsetView = new Uint8Array(new ArrayBuffer(payload.byteLength + 64), 32, payload.byteLength); + offsetView.set(payload); + for (const view of [payload, pooled, subarray, offsetView]) { + assert.equal(await verifyP256(odin.publicKey, view, signature), true, `${view.constructor.name} byteOffset=${view.byteOffset}`); + } + const signatureBytes = base64ToBytes(signature); + const sigLarge = Buffer.alloc(16 * 1024, 0x5a); + sigLarge.set(signatureBytes, 4000); + const sigView = sigLarge.subarray(4000, 4064); + assert.equal(await verifyP256(odin.publicKey, payload, bytesToBase64(sigView)), true); + assert.equal(await verifyP256(odin.publicKey, Buffer.from(payload.subarray(1)), signature), false); +}); + +test("protocol ids are sorted ordinally into the transcript, whatever order the route lists them in", () => { + const { route } = signedRoute(); + const unsorted = { ...route, protocolIds: ["cultmesh.realtime.v1", "cultmesh.content.v1", "cultmesh.documents.v1"] }; + const certificate = route.certificate!; + const expected = canonicalFields( + "gamecult.cultmesh.route-certificate.v1", + route.verseId, + route.authorityRuntimeId, + route.endpoint, + "cultmesh.content.v1cultmesh.documents.v1cultmesh.realtime.v1", + "0", + route.generation, + certificate.providerKey.keyId, + certificate.providerKey.x, + certificate.providerKey.y, + certificate.odinKeyId, + String(certificate.issuedAtUnixMilliseconds), + String(certificate.expiresAtUnixMilliseconds), + ); + assert.deepEqual(canonicalRoute(unsorted), expected); + assert.deepEqual(canonicalRoute({ ...unsorted, protocolIds: [...unsorted.protocolIds!].reverse() }), expected); +}); + test("the provider session proof binds the nonce, the endpoint and the certified key", async () => { const { route, provider } = signedRoute(); const open = request(); @@ -196,11 +282,20 @@ test("isProtectedEndpoint is the C# rule: wss, https, or any scheme containing q } }); -test("isLoopbackEndpoint recognises localhost, 127.0.0.1 and ::1 only", () => { - for (const loopback of ["ws://localhost:4050/mesh", "ws://127.0.0.1:4050/mesh", "ws://[::1]:4050/mesh", "wss://LOCALHOST/mesh"]) { +// The accepted and refused hosts here are what `System.Uri.IsLoopback` answered +// through `CultMeshAuthorityTrustPolicy` on 2026-09-16; keep them in step. +test("isLoopbackEndpoint is the C# System.Uri.IsLoopback set", () => { + for (const loopback of [ + "ws://localhost:4050/mesh", "wss://LOCALHOST/mesh", "ws://loopback:4050/mesh", "ws://LoopBack:4050/mesh", + "ws://127.0.0.1:4050/mesh", "ws://127.0.0.2/mesh", "ws://127.255.255.254/mesh", "ws://127.1/mesh", "ws://0x7f000001/mesh", "ws://2130706433/mesh", + "ws://[::1]:4050/mesh", "ws://[0:0:0:0:0:0:0:1]:4050/mesh", "ws://[::ffff:127.0.0.1]/mesh", "ws://[::ffff:7f00:1]/mesh", + ]) { assert.equal(isLoopbackEndpoint(loopback), true, loopback); } - for (const remote of ["ws://192.0.2.10:4050/mesh", "wss://provider.example/mesh", "ws://127.0.0.2/mesh", "not a url"]) { + for (const remote of [ + "ws://192.0.2.10:4050/mesh", "wss://provider.example/mesh", "ws://[::ffff:127.0.0.2]/mesh", "ws://localhost./mesh", + "ws://localhost.localdomain/mesh", "ws://127.example/mesh", "ws://0.0.0.0/mesh", "ws://[::]/mesh", "not a url", + ]) { assert.equal(isLoopbackEndpoint(remote), false, remote); } }); diff --git a/scripts/mutate-cultmesh-authority.mjs b/scripts/mutate-cultmesh-authority.mjs index a8d705f7..3e093414 100644 --- a/scripts/mutate-cultmesh-authority.mjs +++ b/scripts/mutate-cultmesh-authority.mjs @@ -43,8 +43,43 @@ const mutations = [ }, { rule: "Odin root lookup by key id", - old: "candidate => candidate.keyId === certificate.odinKeyId", - new: "candidate => true", + old: "const root = roots.get(certificate.odinKeyId);", + new: "const root = [...roots.values()][0];", + }, + { + rule: "empty or whitespace signature is an unsigned route", + old: 'if (!certificate || signature === "") {', + new: "if (!certificate) {", + }, + { + rule: "root lookup precedes the validity window", + old: " const root = roots.get(certificate.odinKeyId);\n" + + " if (!root) throw new Error(`Odin key '${certificate.odinKeyId}' is not trusted by this consumer.`);\n" + + " const now = trust.now?.() ?? Date.now();\n" + + " if (now < certificate.issuedAtUnixMilliseconds || now >= certificate.expiresAtUnixMilliseconds) {\n" + + ' throw new Error("The Odin route certificate is not currently valid.");\n' + + " }\n", + new: " const now = trust.now?.() ?? Date.now();\n" + + " if (now < certificate.issuedAtUnixMilliseconds || now >= certificate.expiresAtUnixMilliseconds) {\n" + + ' throw new Error("The Odin route certificate is not currently valid.");\n' + + " }\n" + + " const root = roots.get(certificate.odinKeyId);\n" + + " if (!root) throw new Error(`Odin key '${certificate.odinKeyId}' is not trusted by this consumer.`);\n", + }, + { + rule: "duplicate Odin root key ids refuse the policy", + old: " if (roots.has(root.keyId)) throw new Error(`Odin root key id '${root.keyId}' is listed more than once in the trust policy.`);\n", + new: "", + }, + { + rule: "protocol ids are sorted into the transcript", + old: "].sort().join(", + new: "].join(", + }, + { + rule: "verifyP256 copies the viewed payload bytes, not the pool behind a Buffer", + old: " ownedBytes(payload),", + new: " payload.slice().buffer as ArrayBuffer,", }, { rule: "route transcript field order", @@ -74,6 +109,21 @@ const mutations = [ old: 'host === "::1"', new: 'host === "::2"', }, + { + rule: "loopback is all of 127.0.0.0/8", + old: "/^127\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$/.test(host)", + new: 'host === "127.0.0.1"', + }, + { + rule: "loopback recognises the host name loopback", + old: 'host === "loopback" || ', + new: "", + }, + { + rule: "loopback recognises the IPv4-mapped ::ffff:127.0.0.1", + old: 'host === "::ffff:7f00:1" ||', + new: "", + }, { rule: "protected scheme: any scheme containing quic", old: 'scheme.includes("quic")', @@ -90,6 +140,11 @@ const original = readFileSync(target); const originalText = original.toString("utf8"); const originalDigest = digest(original); +// Anchors are written with "\n"; the checkout may be CRLF (autocrlf). Match +// the file's own line ending so a multi-line anchor cannot silently miss. +const eol = originalText.includes("\r\n") ? "\r\n" : "\n"; +const withEol = text => text.replace(/\r?\n/g, eol); + function digest(bytes) { return createHash("sha256").update(bytes).digest("hex"); } @@ -128,11 +183,12 @@ try { if (!controlPass) failed = true; else { for (const mutation of mutations) { - const count = occurrences(originalText, mutation.old); + const [old, replacement] = [withEol(mutation.old), withEol(mutation.new)]; + const count = occurrences(originalText, old); if (count !== 1) { throw new Error(`anchor for '${mutation.rule}' matched ${count} times, expected exactly 1`); } - writeFileSync(target, Buffer.from(originalText.replace(mutation.old, mutation.new), "utf8")); + writeFileSync(target, Buffer.from(originalText.replace(old, replacement), "utf8")); const pass = testsPass(); restore(); results.push({ rule: mutation.rule, outcome: pass ? "SURVIVED" : "killed" });