Skip to content

Commit 401a17a

Browse files
motxxclaude
andauthored
feat: invariant regression suite (PR-1, discipline layer) (#51)
Declares INV-01/INV-02/INV-03 in docs/threat-model.md as the executable spec of Anchr's README safety claims. Adds scripts/lint-invariants.ts to enforce three-way drift detection between the threat model, the test suite, and a hash-locked justification file (docs/threat-model.lock.json). Cleans up three silent-pass patterns in e2e/pentest/ that were masking real test bugs: oracle-attacks.test.ts:31,64 and ssrf.test.ts:78 were using query.id when the API returns query_id, so they silently short-circuited. Fixing them exposed a rate-limit cross-contamination bug where the rate-limit test starved every other test in the same run; isolated that test to its own x-real-ip bucket. INV-01 ships as tests-pending-PR-2 — the Rust tests land after the tlsn-verifier crate is refactored to expose a library with a typed error enum. INV-02 is enforced by two preimage-protection tests in e2e/pentest/oracle-attacks.test.ts. INV-03 is cross- referenced against existing ATTACK: tests in regtest-htlc-*.ts via // INV-03 metadata comments. README safety-claim bullets now deep-link to the matching invariant in docs/threat-model.md. CI runs lint:invariants alongside lint:arch in Phase 1 of test-all.sh. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent af1132e commit 401a17a

12 files changed

Lines changed: 575 additions & 19 deletions

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ Anchr is a protocol for atomically exchanging cryptographic proofs and Bitcoin p
66

77
A Requester posts a bounty. A Worker produces a cryptographic proof (TLSNotary for web data, C2PA for photos). An Oracle verifies the proof. Payment releases only when verification passes.
88

9-
- Requester can't revoke payment (sats locked in escrow before work begins)
10-
- Worker can't forge proofs (verification is cryptographic)
11-
- Oracle can't steal funds (escrow requires Worker's signature to redeem)
9+
- Requester can't revoke payment (sats locked in escrow before work begins) — see [INV-03](docs/threat-model.md#inv-03-requester-cant-unlock-escrow-before-timeout)
10+
- Worker can't forge proofs (verification is cryptographic) — see [INV-01](docs/threat-model.md#inv-01-worker-cant-forge-tlsn-proofs)
11+
- Oracle can't steal funds (escrow requires Worker's signature to redeem) — see [INV-02](docs/threat-model.md#inv-02-oracle-cant-release-preimage-without-valid-proof)
1212
- For high-value queries, t-of-n independent Oracles verify via FROST threshold signing
1313

1414
## Architecture

deno.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@
6969

7070
"lint:deps": "cd crates/frost-signer && cargo audit 2>/dev/null; cd ../tlsn-prover && cargo audit 2>/dev/null; cd ../tlsn-server && cargo audit 2>/dev/null; echo 'Cargo audit complete'",
7171
"lint:arch": "deno run --allow-read scripts/arch-lint.ts",
72+
"lint:invariants": "deno run --allow-read scripts/lint-invariants.ts",
7273
"lint:refactor": "deno run --allow-read scripts/refactor-lint.ts",
7374
"setup:hooks": "git config core.hooksPath scripts/git-hooks && echo '✓ git hooks installed (core.hooksPath → scripts/git-hooks)'",
7475
"build:ui": "deno run --allow-all scripts/build-ui.ts",

docs/threat-model.lock.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"INV-01": {
3+
"hash": "sha256:6c96002e19c303b091d4c18435607c2a018b872b57f9dc64f15777b138e902f6",
4+
"justification": "initial declaration"
5+
},
6+
"INV-02": {
7+
"hash": "sha256:1046a5c773b861332c33cb05f6a4218cdfefc7ca3b1b958130f17892767fddd5",
8+
"justification": "initial declaration"
9+
},
10+
"INV-03": {
11+
"hash": "sha256:febdfbf2f18da92803b0d7756d072de33f1128e14ae115d357f1e1e7dfe5c1f7",
12+
"justification": "initial declaration"
13+
}
14+
}

docs/threat-model.md

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
# Threat Model
2+
3+
This document enumerates Anchr's cryptographic and protocol-state invariants.
4+
Every safety claim in `README.md` must map to one of these invariants. Every
5+
invariant must have at least one test. CI enforces both directions via
6+
`deno task lint:invariants`.
7+
8+
## How this document works
9+
10+
Each invariant has the shape:
11+
12+
- **Claim** — one-line property the protocol guarantees.
13+
- **Attack** — the adversary behavior this invariant defends against.
14+
- **Expected** — the observable outcome when the attack is attempted.
15+
- **Tests** — file paths + test names (or `fn` names for Rust).
16+
- **Status**`enforced` (tests live-bear), `tests-pending-PR-N` (declared,
17+
tests land in a follow-up PR), or `cross-referenced` (covered by existing
18+
attack-class tests, marked via `// INV-NN` comment).
19+
20+
An invariant without tests breaks CI. A test whose name references an
21+
invariant not declared here also breaks CI.
22+
23+
When an invariant's Claim/Attack/Expected body changes, the matching entry
24+
in `docs/threat-model.lock.json` must be updated with a fresh hash plus a
25+
`justification` string describing the change. This is a drift guardrail:
26+
you can't silently weaken an invariant without a PR reviewer seeing the
27+
hash bump.
28+
29+
## Invariants
30+
31+
### INV-01: Worker can't forge TLSN proofs
32+
33+
**Status:** `tests-pending-PR-2`
34+
35+
**Claim:** The Oracle's TLSN verifier rejects any presentation whose
36+
transcript, notary signature, or MPC-TLS MAC chain is invalid. A Worker
37+
cannot produce a presentation for an HTTPS response they did not actually
38+
observe.
39+
40+
**Attack:** Generate a valid TLSN presentation, mutate a byte in the
41+
transcript commitment / notary signature / target-host field, submit to
42+
the Oracle's verifier.
43+
44+
**Expected:** Verifier returns a typed error (`VerifierError::Transcript`,
45+
`::Signature`, or `::Server` per mutation class). Oracle does NOT release
46+
the preimage. Oracle does NOT emit a FROST signature share.
47+
48+
**Tests:** Declared here. Implementation lands in PR-2 after the
49+
`tlsn-verifier` crate is refactored to expose a `lib.rs` + typed error
50+
enum. Target path: `crates/tlsn-verifier/tests/invariants.rs::inv_01_*`.
51+
52+
**Why pending:** The `tlsn-verifier` crate is currently `[[bin]]`-only
53+
with `anyhow::Error`. Writing `assert_matches!(err, VerifierError::Transcript)`
54+
requires extracting a library + typed error enum, which is scoped to PR-2.
55+
56+
### INV-02: Oracle can't release preimage without valid proof
57+
58+
**Status:** `enforced`
59+
60+
**Claim:** The Oracle's HTTP wrapper never returns the Cashu HTLC preimage
61+
in response to a `POST /queries/:id/result` unless verification passes.
62+
Protocol-layer outcome: regardless of which cryptographic check fails
63+
(missing presentation, malformed JSON, wrong signature, expired
64+
presentation, empty worker_pubkey), the response body does not contain
65+
`preimage`.
66+
67+
**Attack:** Submit adversarial payloads to `POST /queries/:id/result`:
68+
missing presentation, malformed JSON, invalid worker_pubkey, oracle not
69+
yet registered.
70+
71+
**Expected:** HTTP response body has no `preimage` field. HTTP status
72+
rejects (4xx) or returns `ok: false`. Oracle's preimage store is not
73+
decremented.
74+
75+
**Tests:**
76+
- `e2e/pentest/oracle-attacks.test.ts` — `ORACLE-ATTACK: Preimage
77+
protection` suite (both tests).
78+
79+
### INV-03: Requester can't unlock escrow before timeout
80+
81+
**Status:** `cross-referenced`
82+
83+
**Claim:** Cashu HTLC proofs locked with `locktime > now` cannot be
84+
redeemed via the Requester's refund key. Only the Worker's key + valid
85+
preimage can redeem before locktime. The Mint enforces this, not the
86+
application layer.
87+
88+
**Attack:** Requester attempts to swap HTLC proofs back to themselves
89+
before `locktime` has elapsed, presenting only their refund key.
90+
91+
**Expected:** Cashu Mint rejects the swap (returns `null` from
92+
`attemptRedeem`). Funds remain locked until locktime expires.
93+
94+
**Tests:** Cross-referenced from existing attack-class tests, annotated
95+
with `// INV-03` comments:
96+
- `e2e/regtest-htlc-trustless.test.ts` — `ATTACK: Requester refund key
97+
before locktime → Mint REJECTS`
98+
- `e2e/regtest-htlc-attacks.test.ts` — `ATTACK: Requester redeems own
99+
HTLC proofs before locktime — fails`
100+
101+
Related (not INV-03 but same surface, kept for context):
102+
`LEGIT: Requester refund key after locktime → Mint ACCEPTS` demonstrates
103+
the refund path works once locktime elapses.
104+
105+
## Future invariants (declared, not yet specified)
106+
107+
- **INV-04:** FROST t-of-n threshold safety — no subset of size < t can
108+
produce a valid aggregate signature. Likely cross-referenced to
109+
`e2e/frost-threshold.test.ts::ATTACK: 1-of-3 (below threshold) →
110+
aggregation fails` once declared.
111+
- **INV-05:** C2PA manifest signature + GPS binding. Scoped after
112+
`crates/` gets a C2PA verifier.

e2e/pentest/oracle-attacks.test.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import { APP_BASE, authedHeaders } from "./helpers.ts";
1111

1212
const ORACLE_BASE = Deno.env.get("PENTEST_ORACLE_URL") ?? APP_BASE;
1313

14+
// INV-02: Oracle can't release preimage without valid proof
1415
describe("ORACLE-ATTACK: Preimage protection", () => {
16+
// INV-02
1517
test("cannot retrieve preimage for non-verified query", async () => {
1618
// Create query with HTLC
1719
const createRes = await fetch(`${APP_BASE}/queries`, {
@@ -28,10 +30,13 @@ describe("ORACLE-ATTACK: Preimage protection", () => {
2830
}),
2931
});
3032
const query = await createRes.json();
31-
if (!query.id) return;
33+
// POST /queries returns { query_id, ... }, not { id, ... }. The old
34+
// `if (!query.id) return` silently passed when creation succeeded —
35+
// the preimage-leak assertion below never ran. Force a real assertion.
36+
expect(query.query_id).toBeDefined();
3237

3338
// Try to get preimage without verification
34-
const res = await fetch(`${APP_BASE}/queries/${query.id}/result`, {
39+
const res = await fetch(`${APP_BASE}/queries/${query.query_id}/result`, {
3540
method: "POST",
3641
headers: authedHeaders(),
3742
body: JSON.stringify({
@@ -42,10 +47,12 @@ describe("ORACLE-ATTACK: Preimage protection", () => {
4247
});
4348
const body = await res.json();
4449

45-
// Preimage must NOT be leaked
46-
expect(body.preimage).toBeUndefined();
50+
// Preimage must NOT be leaked. Server may return null or omit the field;
51+
// both are acceptable — what's not acceptable is any real string value.
52+
expect(body.preimage ?? undefined).toBeUndefined();
4753
});
4854

55+
// INV-02
4956
test("cannot retrieve preimage with empty worker_pubkey", async () => {
5057
const createRes = await fetch(`${APP_BASE}/queries`, {
5158
method: "POST",
@@ -61,10 +68,10 @@ describe("ORACLE-ATTACK: Preimage protection", () => {
6168
}),
6269
});
6370
const query = await createRes.json();
64-
if (!query.id) return;
71+
expect(query.query_id).toBeDefined();
6572

6673
// Try with empty string worker_pubkey
67-
const res = await fetch(`${APP_BASE}/queries/${query.id}/result`, {
74+
const res = await fetch(`${APP_BASE}/queries/${query.query_id}/result`, {
6875
method: "POST",
6976
headers: authedHeaders(),
7077
body: JSON.stringify({
@@ -73,7 +80,7 @@ describe("ORACLE-ATTACK: Preimage protection", () => {
7380
}),
7481
});
7582
const body = await res.json();
76-
expect(body.preimage).toBeUndefined();
83+
expect(body.preimage ?? undefined).toBeUndefined();
7784
});
7885
});
7986

e2e/pentest/rate-limit.test.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,20 @@ import { APP_BASE, authedHeaders } from "./helpers.ts";
1010

1111
describe("RATE-LIMIT: Burst protection", () => {
1212
test("returns 429 after exceeding rate limit", async () => {
13-
const BURST = 65; // default is 60/min
13+
// Pentest server sets RATE_LIMIT_MAX=500 (see scripts/test-all.sh);
14+
// burst above that to verify the limiter still trips. This test uses
15+
// its own x-real-ip bucket so the 500+ requests don't poison other tests.
16+
const BURST = 520;
1417
let got429 = false;
18+
// Use a distinct x-real-ip so we exhaust our own bucket, not the default
19+
// socket-IP bucket that every other pentest test shares. Without this,
20+
// this test poisons the shared bucket and every subsequent test gets 429.
21+
const headers = authedHeaders({ "x-real-ip": "pentest-rate-burst" });
1522

1623
for (let i = 0; i < BURST; i++) {
1724
const res = await fetch(`${APP_BASE}/queries`, {
1825
method: "POST",
19-
headers: authedHeaders(),
26+
headers,
2027
body: JSON.stringify({ description: `rate-test-${i}` }),
2128
});
2229
if (res.status === 429) {
@@ -35,13 +42,16 @@ describe("RATE-LIMIT: Header spoofing resistance", () => {
3542
test("X-Forwarded-For does not bypass rate limit", async () => {
3643
// The server should NOT trust X-Forwarded-For for rate limiting.
3744
// Send requests with different X-Forwarded-For but same real IP.
45+
// Use a distinct x-real-ip so this test has its own bucket and doesn't
46+
// poison later tests (see burst-protection test for rationale).
3847
let got429 = false;
48+
const baseHeaders = authedHeaders({ "x-real-ip": "pentest-rate-xff" });
3949

40-
for (let i = 0; i < 65; i++) {
50+
for (let i = 0; i < 520; i++) {
4151
const res = await fetch(`${APP_BASE}/queries`, {
4252
method: "POST",
4353
headers: {
44-
...authedHeaders(),
54+
...baseHeaders,
4555
"x-forwarded-for": `10.0.0.${i % 256}`,
4656
},
4757
body: JSON.stringify({ description: `xff-bypass-${i}` }),

e2e/pentest/ssrf.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,10 +75,12 @@ describe("SSRF: Attachment redirect validation", () => {
7575
body: JSON.stringify({ description: "ssrf-redirect-test" }),
7676
});
7777
const query = await createRes.json();
78-
if (!query.id) return;
78+
// POST /queries returns { query_id, ... }, not { id, ... }. Silent-pass
79+
// previously hid that this whole test was a no-op.
80+
expect(query.query_id).toBeDefined();
7981

8082
// Submit result with an internal URL as attachment
81-
await fetch(`${APP_BASE}/queries/${query.id}/result`, {
83+
const submitRes = await fetch(`${APP_BASE}/queries/${query.query_id}/result`, {
8284
method: "POST",
8385
headers: authedHeaders(),
8486
body: JSON.stringify({
@@ -91,9 +93,10 @@ describe("SSRF: Attachment redirect validation", () => {
9193
worker_pubkey: "test",
9294
}),
9395
});
96+
await submitRes.body?.cancel();
9497

9598
// Try to access the attachment via redirect
96-
const attRes = await fetch(`${APP_BASE}/queries/${query.id}/attachments/0`, {
99+
const attRes = await fetch(`${APP_BASE}/queries/${query.query_id}/attachments/0`, {
97100
redirect: "manual", // Don't follow redirects
98101
});
99102

e2e/regtest-htlc-attacks.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,7 @@ suite("e2e: Multi-Party Attacks", () => {
405405
expect(second).toBeNull(); // Mint MUST reject — proofs already spent
406406
});
407407

408+
// INV-03: Requester can't unlock escrow before timeout
408409
test("ATTACK: Requester redeems own HTLC proofs before locktime — fails", async () => {
409410
const worker = generateKeypair();
410411
const requester = generateKeypair();

e2e/regtest-htlc-trustless.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,7 @@ suite("e2e: HTLC trustless properties (real Cashu Mint)", () => {
374374
// 8. Refund path BEFORE locktime (Requester tries early refund)
375375
// ---------------------------------------------------------------------------
376376

377+
// INV-03: Requester can't unlock escrow before timeout
377378
test("ATTACK: Requester refund key before locktime → Mint REJECTS", async () => {
378379
const worker = generateKeypair();
379380
const requester = generateKeypair();

scripts/lint-invariants.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* Unit tests for scripts/lint-invariants.ts internals.
3+
*
4+
* These test the pure parsing + hashing functions by invoking the script
5+
* against synthetic fixtures. The repo-level lint (I001-I004) is covered
6+
* end-to-end by the `deno task lint:invariants` run in CI.
7+
*/
8+
import { test } from "@std/testing/bdd";
9+
import { expect } from "@std/expect";
10+
11+
// We re-declare the parser here as a black-box test — the production
12+
// script has `if (import.meta.main)` so importing it is side-effect-free.
13+
const mod = await import("./lint-invariants.ts");
14+
15+
// The production script doesn't export its internals; to keep the blast
16+
// radius small, these tests exercise the lint() function against a mocked
17+
// file layout by temporarily swapping in fixture files. That's heavier
18+
// than needed for a 4-rule lint. Instead we test the end-to-end behavior
19+
// via the real threat-model.md on HEAD, plus a handful of string-level
20+
// regressions against the parser regexes.
21+
22+
test("INV-NN heading regex matches only proper declarations", () => {
23+
const cases: Array<[string, boolean]> = [
24+
["### INV-01: Worker can't forge", true],
25+
["### INV-99: Some future invariant", true],
26+
["## INV-01: wrong heading level", false],
27+
["#### INV-01: too deep", false],
28+
["### INV-1: missing padding", false], // require at least 2 digits? actually regex is \d+
29+
["text INV-01: inline mention", false],
30+
];
31+
const re = /^### (INV-\d+):/;
32+
for (const [line, expected] of cases) {
33+
const matched = re.test(line);
34+
if (line === "### INV-1: missing padding") {
35+
// \d+ allows single digit too — document current behavior.
36+
expect(matched).toBe(true);
37+
} else {
38+
expect(matched).toBe(expected);
39+
}
40+
}
41+
});
42+
43+
test("test() string regex finds INV-NN test annotations", () => {
44+
const re = /test\(\s*["'`](INV-\d+):/g;
45+
const samples = [
46+
`test("INV-01: tampered", () => {})`,
47+
`test('INV-02: missing', () => {})`,
48+
`test(\`INV-03: locktime\`, () => {})`,
49+
`it("INV-04: nope", () => {})`, // it() not matched — intentional
50+
`test("ATTACK: not an invariant", () => {})`,
51+
];
52+
const hits = samples
53+
.map((s) => Array.from(s.matchAll(re)).map((m) => m[1]))
54+
.flat();
55+
expect(hits).toEqual(["INV-01", "INV-02", "INV-03"]);
56+
});
57+
58+
test("// INV-NN metadata comment regex matches line comments only", () => {
59+
const re = /\/\/\s*(INV-\d+)\b/g;
60+
const samples = [
61+
` // INV-03: Requester refund key before locktime`,
62+
`//INV-02`,
63+
`/* INV-01 */`, // block comment — still matches /* then // fails; documented behavior
64+
`const s = "http://INV-99/url";`, // false positive guard: \b ensures INV-99 is a word boundary
65+
];
66+
const hits = samples
67+
.map((s) => Array.from(s.matchAll(re)).map((m) => m[1]))
68+
.flat();
69+
// /* INV-01 */ does NOT match `//` pattern, correctly.
70+
// "http://INV-99/url" — `//` is followed by "INV-99" — this IS a false positive.
71+
// Documenting current behavior: URLs in strings can false-positive I002.
72+
// Mitigation: users don't typically reference INV-NN in URLs. If this
73+
// becomes a problem, tighten the regex to require whitespace or line-start.
74+
expect(hits).toContain("INV-03");
75+
expect(hits).toContain("INV-02");
76+
expect(hits).toContain("INV-99"); // known false-positive from URL
77+
});
78+
79+
test("fn inv_NN_* regex matches Rust test functions", () => {
80+
const re = /fn\s+inv_(\d+)_/g;
81+
const samples = [
82+
`fn inv_01_tampered_transcript_rejected() {}`,
83+
`fn inv_02_preimage_protection() {}`,
84+
`fn regular_test() {}`,
85+
`pub fn inv_42_future() {}`,
86+
];
87+
const hits = samples
88+
.map((s) => Array.from(s.matchAll(re)).map((m) => m[1]))
89+
.flat();
90+
expect(hits).toEqual(["01", "02", "42"]);
91+
});
92+
93+
test("repo-level lint passes on HEAD", async () => {
94+
const violations = await mod.lint();
95+
if (violations.length > 0) {
96+
console.error("Violations:", violations);
97+
}
98+
expect(violations).toEqual([]);
99+
});

0 commit comments

Comments
 (0)