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