Read whitespace and the signature refusals as C# does

Two edge alignments that have to land together, because the test for the
first asserts the messages of the second.

`String.prototype.trim` is not `char.IsWhiteSpace`. JavaScript counts U+FEFF,
which .NET does not; .NET counts U+0085, which JavaScript does not. Every
`trim()` in the shared verifier stood in for a C# `IsNullOrWhiteSpace` or
`Trim()`, so both were wrong at those two code points: a U+0085 signature was
an unsigned route to C# and a bad signature to TypeScript, and a U+FEFF
signature the other way round. `isCSharpWhiteSpace`, `trimCSharp` and
`isNullOrWhiteSpaceCSharp` are that set, and `isUnsignedCertificate` and the
signature trim now use it. The test pins all 34 code points Soul probed on
both sides against the answers .NET 10 gave on 2026-09-16.

The C# `Verify` also has three signature refusals, not one: a signature that
is not base64, a decoded length that is not 64, and a signature that decodes
but does not verify. TypeScript collapsed the first two into the third and
lost the distinction the reference draws. Split, with each message pinned by
its exact C# string. `verifyP256` keeps its own checks: it is the silent
`VerifySession` path, which returns false for all three.

Mutations: the whitespace-set site reverted to `String.prototype.trim`,
U+0085 dropped from the set, and the two new messages merged back into
"invalid" -- each killed by its test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-16 17:09:48 +02:00
commit e67e695a63
4 changed files with 142 additions and 5 deletions

View file

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

View file

@ -68,9 +68,12 @@ export {
canonicalFields,
canonicalRoute,
canonicalSession,
isCSharpWhiteSpace,
isLoopbackEndpoint,
isNullOrWhiteSpaceCSharp,
isProtectedEndpoint,
isUnsignedCertificate,
trimCSharp,
verifyAuthorityRoute,
verifyP256,
verifyProviderSessionProof,

View file

@ -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);

View file

@ -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" +