diff --git a/packages/cultnet-ts/src/cultmesh-authority.ts b/packages/cultnet-ts/src/cultmesh-authority.ts index 40bcff1a..f3a9121d 100644 --- a/packages/cultnet-ts/src/cultmesh-authority.ts +++ b/packages/cultnet-ts/src/cultmesh-authority.ts @@ -44,13 +44,45 @@ export interface CultMeshAuthorityTrustPolicy { now?: () => number; } +/** + * `char.IsWhiteSpace` in .NET: Unicode Zs, plus Zl (U+2028), Zp (U+2029), + * U+0009-U+000D and U+0085. + * + * `String.prototype.trim` is a different set and cannot stand in for it: + * JavaScript counts U+FEFF, which .NET does not, and skips U+0085, which .NET + * counts. U+200B and U+180E are whitespace to neither (U+180E left Zs in + * Unicode 6.3). Wherever the C# reference writes `IsNullOrWhiteSpace` or + * `Trim()`, this is the set it means, so this is the set the TypeScript rules + * use. + */ +export function isCSharpWhiteSpace(character: string): boolean { + const code = character.charCodeAt(0); + return code === 0x20 || (code >= 0x09 && code <= 0x0d) || code === 0x85 || code === 0xa0 || + code === 0x1680 || (code >= 0x2000 && code <= 0x200a) || + code === 0x2028 || code === 0x2029 || code === 0x202f || code === 0x205f || code === 0x3000; +} + +/** `string.Trim()` in .NET, over `isCSharpWhiteSpace`. */ +export function trimCSharp(value: string): string { + let start = 0; + let end = value.length; + while (start < end && isCSharpWhiteSpace(value[start]!)) start += 1; + while (end > start && isCSharpWhiteSpace(value[end - 1]!)) end -= 1; + return value.slice(start, end); +} + +/** `string.IsNullOrWhiteSpace` in .NET, over `isCSharpWhiteSpace`. */ +export function isNullOrWhiteSpaceCSharp(value: string | undefined | null): boolean { + return value === undefined || value === null || trimCSharp(value).length === 0; +} + // A missing certificate, or one whose signature is empty or whitespace, is an // unsigned route (`CultMeshRouteCertificate` trims the signature; `Verify` // treats a whitespace signature as no certificate). The verifier and the // browser's session-proof short-circuit share this one reading, as the C# // `Verify` and `IsLocalDevelopment` do. export function isUnsignedCertificate(certificate: CultMeshAuthorityRouteCertificate | undefined): boolean { - return !certificate || certificate.signature.trim() === ""; + return !certificate || isNullOrWhiteSpaceCSharp(certificate.signature); } // Rule for rule, this is `CultMeshAuthorityTrustPolicy.Verify` in the C# @@ -67,7 +99,7 @@ export async function verifyAuthorityRoute( if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint)) return; throw new Error("Remote CultMesh routes require an Odin-signed authority certificate."); } - const signature = certificate.signature.trim(); + const signature = trimCSharp(certificate.signature); if (!isProtectedEndpoint(route.endpoint) && !(trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint))) { throw new Error("Authenticated remote CultMesh routes require TLS or QUIC channel protection."); } @@ -77,6 +109,21 @@ export async function verifyAuthorityRoute( if (now < certificate.issuedAtUnixMilliseconds || now >= certificate.expiresAtUnixMilliseconds) { throw new Error("The Odin route certificate is not currently valid."); } + // Three refusals, not one. `Verify` in the C# reference decodes the signature + // itself and names each failure separately: `Convert.FromBase64String` throwing + // is "not base64", a decoded length other than 64 is "not IEEE P1363 P-256", + // and only a decoded-but-wrong signature is "invalid". `VerifySession` is the + // silent path: it returns false for all three, which is why `verifyP256` keeps + // its own checks. + let signatureBytes: Uint8Array; + try { + signatureBytes = base64ToBytes(signature); + } catch { + throw new Error("The Odin route signature is not base64."); + } + if (signatureBytes.byteLength !== 64) { + throw new Error("The Odin route signature is not IEEE P1363 P-256."); + } if (!await verifyP256(root, canonicalRoute(route), signature)) { throw new Error("The Odin route certificate signature is invalid."); } diff --git a/packages/cultnet-ts/src/index.ts b/packages/cultnet-ts/src/index.ts index 4987b177..073d2587 100644 --- a/packages/cultnet-ts/src/index.ts +++ b/packages/cultnet-ts/src/index.ts @@ -68,9 +68,12 @@ export { canonicalFields, canonicalRoute, canonicalSession, + isCSharpWhiteSpace, isLoopbackEndpoint, + isNullOrWhiteSpaceCSharp, isProtectedEndpoint, isUnsignedCertificate, + trimCSharp, verifyAuthorityRoute, verifyP256, verifyProviderSessionProof, diff --git a/packages/cultnet-ts/test/cultmesh-authority.test.ts b/packages/cultnet-ts/test/cultmesh-authority.test.ts index 5cd47245..b8fc2509 100644 --- a/packages/cultnet-ts/test/cultmesh-authority.test.ts +++ b/packages/cultnet-ts/test/cultmesh-authority.test.ts @@ -20,8 +20,11 @@ import { canonicalFields, canonicalRoute, canonicalSession, + isCSharpWhiteSpace, isLoopbackEndpoint, isProtectedEndpoint, + isUnsignedCertificate, + trimCSharp, verifyAuthorityRoute, verifyP256, verifyProviderSessionProof, @@ -219,12 +222,32 @@ test("a signature that is not 64 P1363 bytes is refused", async () => { { ...route, certificate: { ...route.certificate!, signature: bytesToBase64(wrong) } }, remoteTrust(odin.publicKey), ), - /signature is invalid/, + /not IEEE P1363 P-256/, ); } assert.equal(await verifyP256(odin.publicKey, canonicalRoute(route), "not base64!"), false); }); +// The C# `Verify` has three signature refusals with three texts. Collapsing any +// two of them loses a distinction the reference draws, so each is pinned by its +// exact C# string, not by a fragment. +test("the three signature refusals carry the C# reference's exact messages", async () => { + const { route, odin } = signedRoute(); + const certificate = route.certificate!; + const cases: readonly (readonly [string, string])[] = [ + ["not base64!", "The Odin route signature is not base64."], + [bytesToBase64(base64ToBytes(certificate.signature).subarray(0, 63)), "The Odin route signature is not IEEE P1363 P-256."], + [bytesToBase64(new Uint8Array(64)), "The Odin route certificate signature is invalid."], + ]; + for (const [signature, message] of cases) { + await assert.rejects( + verifyAuthorityRoute({ ...route, certificate: { ...certificate, signature } }, remoteTrust(odin.publicKey)), + (error: Error) => error.message === message, + message, + ); + } +}); + 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); @@ -306,6 +329,49 @@ test("isLoopbackEndpoint is the C# System.Uri.IsLoopback set", () => { } }); +// `char.IsWhiteSpace` as .NET 10 answered for each of these code points on +// 2026-09-16, through `CultMeshAuthorityTrustPolicy` over a signature of that +// one character. `String.prototype.trim` disagrees on two of them: U+0085, +// which .NET counts and JavaScript does not, and U+FEFF, which JavaScript +// counts and .NET does not. +const CSHARP_WHITESPACE_ANSWERS: readonly (readonly [number, boolean])[] = [ + [0x0000, false], [0x0009, true], [0x000a, true], [0x000b, true], [0x000c, true], [0x000d, true], + [0x001c, false], [0x001d, false], [0x001e, false], [0x001f, false], [0x0020, true], + [0x0085, true], [0x00a0, true], [0x1680, true], [0x180e, false], + [0x2000, true], [0x2001, true], [0x2002, true], [0x2003, true], [0x2004, true], [0x2005, true], + [0x2006, true], [0x2007, true], [0x2008, true], [0x2009, true], [0x200a, true], + [0x200b, false], [0x200c, false], [0x2028, true], [0x2029, true], + [0x202f, true], [0x205f, true], [0x3000, true], [0xfeff, false], +]; + +test("the whitespace set is C#'s char.IsWhiteSpace, not String.prototype.trim", async () => { + assert.equal(CSHARP_WHITESPACE_ANSWERS.length, 34); + const { route, odin } = signedRoute("ws://127.0.0.1:4050/mesh"); + for (const [code, csharp] of CSHARP_WHITESPACE_ANSWERS) { + const character = String.fromCharCode(code); + const label = `U+${code.toString(16).toUpperCase().padStart(4, "0")}`; + assert.equal(isCSharpWhiteSpace(character), csharp, label); + const padded = `${character}a${character}`; + assert.equal(trimCSharp(padded), csharp ? "a" : padded, label); + // The same answer where the rule is actually consumed: a certificate whose + // whole signature is this one character is unsigned exactly when C# says so. + const certificate = { ...route.certificate!, signature: character }; + assert.equal(isUnsignedCertificate(certificate), csharp, label); + const blank = { ...route, certificate }; + const trust = { ...remoteTrust(odin.publicKey), mode: "local-development" as const }; + if (csharp) { + await verifyAuthorityRoute(blank, trust); + } else { + await assert.rejects(verifyAuthorityRoute(blank, trust), /not base64|not IEEE P1363/, label); + } + } + // The two code points where `trim` would have answered differently. + assert.equal("…".trim().length, 1); + assert.equal(isCSharpWhiteSpace("…"), true); + assert.equal("".trim().length, 0); + assert.equal(isCSharpWhiteSpace(""), false); +}); + test("base64 helpers round-trip across the 32 KiB chunk boundary", () => { const bytes = new Uint8Array(70_000).map((_, index) => index % 251); assert.deepEqual(base64ToBytes(bytesToBase64(bytes)), bytes); diff --git a/scripts/mutate-cultmesh-authority.mjs b/scripts/mutate-cultmesh-authority.mjs index 6053cb14..176c7f7f 100644 --- a/scripts/mutate-cultmesh-authority.mjs +++ b/scripts/mutate-cultmesh-authority.mjs @@ -59,14 +59,35 @@ const mutations = [ }, { rule: "isUnsignedCertificate treats a whitespace signature as unsigned", - old: 'return !certificate || certificate.signature.trim() === "";', + old: "return !certificate || isNullOrWhiteSpaceCSharp(certificate.signature);", new: 'return !certificate || certificate.signature === "";', }, { rule: "isUnsignedCertificate treats an empty signature as unsigned", - old: 'return !certificate || certificate.signature.trim() === "";', + old: "return !certificate || isNullOrWhiteSpaceCSharp(certificate.signature);", new: "return !certificate;", }, + { + rule: "the whitespace set is C#'s char.IsWhiteSpace, not String.prototype.trim", + old: "return !certificate || isNullOrWhiteSpaceCSharp(certificate.signature);", + new: 'return !certificate || certificate.signature.trim() === "";', + }, + { + rule: "U+0085 is whitespace to C# (String.prototype.trim disagrees)", + old: "code === 0x85 || ", + new: "", + }, + { + rule: "the two non-verifying signature refusals keep their own C# messages", + old: ' throw new Error("The Odin route signature is not base64.");\n' + + " }\n" + + " if (signatureBytes.byteLength !== 64) {\n" + + ' throw new Error("The Odin route signature is not IEEE P1363 P-256.");\n', + new: ' throw new Error("The Odin route certificate signature is invalid.");\n' + + " }\n" + + " if (signatureBytes.byteLength !== 64) {\n" + + ' throw new Error("The Odin route certificate signature is invalid.");\n', + }, { rule: "root lookup precedes the validity window", old: " const root = roots.get(certificate.odinKeyId);\n" +