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" });