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:
parent
b7bb483989
commit
e67e695a63
4 changed files with 142 additions and 5 deletions
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,9 +68,12 @@ export {
|
|||
canonicalFields,
|
||||
canonicalRoute,
|
||||
canonicalSession,
|
||||
isCSharpWhiteSpace,
|
||||
isLoopbackEndpoint,
|
||||
isNullOrWhiteSpaceCSharp,
|
||||
isProtectedEndpoint,
|
||||
isUnsignedCertificate,
|
||||
trimCSharp,
|
||||
verifyAuthorityRoute,
|
||||
verifyP256,
|
||||
verifyProviderSessionProof,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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" +
|
||||
|
|
|
|||
Loading…
Reference in a new issue