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 <noreply@anthropic.com>
This commit is contained in:
parent
c00bed8575
commit
c2ffc32ff6
3 changed files with 203 additions and 15 deletions
|
|
@ -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<void> {
|
||||
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<string, CultMeshP256PublicKey> {
|
||||
const roots = new Map<string, CultMeshP256PublicKey>();
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
|
|
|
|||
Loading…
Reference in a new issue