Repository navigation
Receipt v2 binding core + two inert settlement guards (D-147) - #1517
Conversation
…(D-147) Inert: no live wiring, nothing merged, no RepID moves, gates stay on. - receipt-binding.ts (NEW): the versioned v2 binding — deliverable_sha256, work_statement_hash, evaluation_spec_hash, execution_origin, controller_assurance, settled_evidence_id composed into one content-hashed struct the engine signature would cover. verifyReceiptBinding rebuilds from presented material and names each attack (swapped deliverable, changed criteria, forged PASS, substituted/stale/replayed origin, wrong settlement); origin authenticity delegates to the existing verifyExecutionOrigin. A v1 receipt has no binding and is never silently upgraded. 13 attack tests. Embedding it into the issued/public signed receipt is left for Sean (MAJOR). - lane-a-dryrun.ts: a 0 (or <=0) LANE_A_ACCEPT_THRESHOLD is now INVALID and falls back to 1.0 — a zero threshold made score>=0 accept every deliverable, including all-criteria-failed (red-team #3). +5 tests. - evidence-pack.ts: add `complete`, distinct from `ok` — a pack that cites trinity_tasks:<id> but was built without the task-existence join is grounded (ok) but NOT complete; a completeness claim requires that check to have run. +6 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Strix Security ReviewTo fix before merging (1)
PR summary: Adds an inert, unwired Receipt v2 binding module that content-hashes six settlement-relevant facts (deliverable, contract, acceptance criteria, execution origin, controller assurance, settlement id) for future signing, plus two hardening guards: a zero/out-of-range Lane A acceptance threshold now falls back to 1.0, and evidence packs expose a `complete` flag that requires the task-existence check to have actually run. Nothing is wired into scoring or settlement. Review summaryReviewed all six changed files. The new 1 resolved finding
Ruled out (7)
Review history (2)
Fixed the findings? re-run the review, or tag Review 2 on this PR · Reviewed by Strix |
| if (!origin.authentic) mismatches.push('origin_not_authentic'); | ||
|
|
||
| return { ok: mismatches.length === 0, mismatches, originReasons: origin.authentic ? [] : origin.reasons }; |
There was a problem hiding this comment.
🟢 Receipt v2 binding reports replayed one-time mission admission as authentic when the caller supplies roots but no seen-nonce set
Severity: LOW · CWE-294
verifyReceiptBinding documents replay as an origin attack it catches and states that a real check only needs the pinned admission/owner roots. Replay detection actually lives in verifyExecutionOrigin behind the optional seenNonces option, which the new module forwards but neither requires nor reports. A caller supplying roots only — what the docstring prescribes — gets ok: true and an empty mismatches for a replayed one-time mission admission and its owner grant: admission signature, root pinning, expiry, mission and grant binding all pass unchanged on a replay, and the admission nonce is never consulted. One admitted execution can therefore be presented repeatedly with different material and still verify as authentic, defeating the one-time-use guarantee the binding is meant to enforce at settlement. Nothing in the returned object distinguishes "replay checked and passed" from "replay check never ran".
| if (!origin.authentic) mismatches.push('origin_not_authentic'); | |
| return { ok: mismatches.length === 0, mismatches, originReasons: origin.authentic ? [] : origin.reasons }; | |
| if (!origin.authentic) mismatches.push('origin_not_authentic'); | |
| // Fail-visible: with no `seenNonces` the one-time-nonce replay check never ran. Say so, instead of returning a | |
| // clean result that reads as "replay checked and passed" (the evidence-pack `task_existence_check_skipped` convention). | |
| const originReasons = origin.authentic ? [] : [...origin.reasons]; | |
| const replayCheckRan = originOpts.seenNonces !== undefined; | |
| if (!replayCheckRan) originReasons.push('replay_check_skipped'); | |
| return { ok: mismatches.length === 0, mismatches, originReasons, replayCheckRan }; |
Prompt to fix with AI
This is a security vulnerability found during a code review.
Vulnerability: Receipt v2 binding reports replayed one-time mission admission as authentic when the caller supplies roots but no seen-nonce set
Severity: LOW
CWE: CWE-294
`verifyReceiptBinding` documents replay as an origin attack it catches and states that a real check only needs the pinned admission/owner roots. Replay detection actually lives in `verifyExecutionOrigin` behind the optional `seenNonces` option, which the new module forwards but neither requires nor reports. A caller supplying roots only — what the docstring prescribes — gets `ok: true` and an empty `mismatches` for a replayed one-time mission admission and its owner grant: admission signature, root pinning, expiry, mission and grant binding all pass unchanged on a replay, and the admission nonce is never consulted. One admitted execution can therefore be presented repeatedly with different material and still verify as authentic, defeating the one-time-use guarantee the binding is meant to enforce at settlement. Nothing in the returned object distinguishes "replay checked and passed" from "replay check never ran".
Location: src/services/receipt-binding.ts:194-196
Context: Primary fix: report that the replay check did not run instead of returning a clean result
```
// Before:
if (!origin.authentic) mismatches.push('origin_not_authentic');
return { ok: mismatches.length === 0, mismatches, originReasons: origin.authentic ? [] : origin.reasons };
// After:
if (!origin.authentic) mismatches.push('origin_not_authentic');
// Fail-visible: with no `seenNonces` the one-time-nonce replay check never ran. Say so, instead of returning a
// clean result that reads as "replay checked and passed" (the evidence-pack `task_existence_check_skipped` convention).
const originReasons = origin.authentic ? [] : [...origin.reasons];
const replayCheckRan = originOpts.seenNonces !== undefined;
if (!replayCheckRan) originReasons.push('replay_check_skipped');
return { ok: mismatches.length === 0, mismatches, originReasons, replayCheckRan };
```
Location: src/services/receipt-binding.ts:145-150
Context: Supporting change: carry the replay-check state on the result type
```
// Before:
export interface BindingVerification {
ok: boolean;
mismatches: BindingMismatch[];
/** when origin_not_authentic is present, the reasons `verifyExecutionOrigin` gave (e.g. admission_expired, admission_replayed). */
originReasons: string[];
}
// After:
export interface BindingVerification {
ok: boolean;
mismatches: BindingMismatch[];
/** when origin_not_authentic is present, the reasons `verifyExecutionOrigin` gave (e.g. admission_expired, admission_replayed). */
originReasons: string[];
/** false => no `seenNonces` was supplied, so the one-time-nonce replay check never ran; this is NOT "replay checked and passed". */
replayCheckRan: boolean;
}
```
Location: src/services/receipt-binding.ts:168-171
Context: Supporting change: keep the no-binding refusal consistent with the new result type
```
// Before:
if (!bound || bound.version !== RECEIPT_BINDING_VERSION) {
// No v2 binding to verify. A v1 receipt is NOT silently treated as if it passed v2 checks.
return { ok: false, mismatches: [], originReasons: ['no_v2_binding'] };
}
// After:
if (!bound || bound.version !== RECEIPT_BINDING_VERSION) {
// No v2 binding to verify. A v1 receipt is NOT silently treated as if it passed v2 checks.
return { ok: false, mismatches: [], originReasons: ['no_v2_binding'], replayCheckRan: false };
}
```
Location: src/services/receipt-binding.ts:156-156
Context: Documentation: the documented contract must name the nonce set, not the roots alone
```
// Before:
* inert default) origin is never authentic, so a real check must supply the external roots.
// After:
* inert default) origin is never authentic, so a real check must supply the external roots. It must ALSO supply
* `seenNonces` — without it the one-time-nonce replay check never runs and `replayCheckRan` is false.
```
How to fix:
Treat a missing nonce set as an unrun check rather than a passed one. Either require a seen-nonce set for settlement-grade verification, or keep the option optional but expose that the replay check did not run: add a `replayCheckRan` flag that is false when no nonce set was supplied, add a `replay_check_skipped` origin reason, and correct the docstring so the documented contract names both the pinned roots and the nonce set. This follows the `task_existence_check_skipped` convention this same change adopts for the evidence pack, letting a settlement caller refuse when one-time use was never verified. Update the interface, both return paths, and the accompanying attack tests so a roots-only verification is asserted to be visibly distinguishable from a replay-checked one.
Please fix this vulnerability. If you propose a fix, make it concise and minimal.React 👍 / 👎 to tune Strix for this repo. A repo collaborator (or the PR author) can resolve this thread to dismiss the finding.
| export interface BindingVerification { | ||
| ok: boolean; | ||
| mismatches: BindingMismatch[]; | ||
| /** when origin_not_authentic is present, the reasons `verifyExecutionOrigin` gave (e.g. admission_expired, admission_replayed). */ | ||
| originReasons: string[]; | ||
| } |
There was a problem hiding this comment.
Supporting change: carry the replay-check state on the result type
| export interface BindingVerification { | |
| ok: boolean; | |
| mismatches: BindingMismatch[]; | |
| /** when origin_not_authentic is present, the reasons `verifyExecutionOrigin` gave (e.g. admission_expired, admission_replayed). */ | |
| originReasons: string[]; | |
| } | |
| export interface BindingVerification { | |
| ok: boolean; | |
| mismatches: BindingMismatch[]; | |
| /** when origin_not_authentic is present, the reasons `verifyExecutionOrigin` gave (e.g. admission_expired, admission_replayed). */ | |
| originReasons: string[]; | |
| /** false => no `seenNonces` was supplied, so the one-time-nonce replay check never ran; this is NOT "replay checked and passed". */ | |
| replayCheckRan: boolean; | |
| } |
| if (!bound || bound.version !== RECEIPT_BINDING_VERSION) { | ||
| // No v2 binding to verify. A v1 receipt is NOT silently treated as if it passed v2 checks. | ||
| return { ok: false, mismatches: [], originReasons: ['no_v2_binding'] }; | ||
| } |
There was a problem hiding this comment.
Supporting change: keep the no-binding refusal consistent with the new result type
| if (!bound || bound.version !== RECEIPT_BINDING_VERSION) { | |
| // No v2 binding to verify. A v1 receipt is NOT silently treated as if it passed v2 checks. | |
| return { ok: false, mismatches: [], originReasons: ['no_v2_binding'] }; | |
| } | |
| if (!bound || bound.version !== RECEIPT_BINDING_VERSION) { | |
| // No v2 binding to verify. A v1 receipt is NOT silently treated as if it passed v2 checks. | |
| return { ok: false, mismatches: [], originReasons: ['no_v2_binding'], replayCheckRan: false }; | |
| } |
| * Verify a holder's presented material against a signed binding. Rebuilds the binding from the material and | ||
| * compares field-by-field, then checks origin authenticity via the existing primitive. Fail-closed: a missing | ||
| * binding (a v1 receipt) is refused, never passed. Origin roots are passed through — with none pinned (the | ||
| * inert default) origin is never authentic, so a real check must supply the external roots. |
There was a problem hiding this comment.
Documentation: the documented contract must name the nonce set, not the roots alone
| * inert default) origin is never authentic, so a real check must supply the external roots. | |
| * inert default) origin is never authentic, so a real check must supply the external roots. It must ALSO supply | |
| * `seenNonces` — without it the one-time-nonce replay check never runs and `replayCheckRan` is false. |
…ntic run The six bound fields were each verified for self-consistency, but nothing tied deliverable_sha256 to the artifact the origin attests. So a receipt could commit one deliverable while its authentic execution origin attested DIFFERENT bytes, and verifyReceiptBinding returned ok:true — 'provenance bound to the deliverable' held only by coincidence (the fixture set artifact_digest = sha(deliverable)). Add a case-insensitive cross-check: deliverable_sha256 must equal the origin's artifact_digest, else 'deliverable_origin_mismatch'. New attack test isolates it (every other field self-consistent, origin authentic -> this is the only failure). Inert: no caller in any scoring/settlement path; +11/-1 plus one test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Red-team follow-up (self-review before merge): added a 14th attack test and a 3-line verifier fix. Gap found: the six bound fields were each verified for self-consistency, but Fix: case-insensitive cross-check Severity: integrity / defense-in-depth. It needs a buggy or dishonest issuer to be reachable (a holder can't desync a self-consistent signed binding), but the verifier is the independent gate and should catch a deliverable/origin desync rather than assume it. Still inert — no caller in any scoring/settlement path. 14/14 tests, tsc clean. rev 6836e69. |
|
CI |
…nibble flip) (#1518) tests/xc1-l1-regrade-2.test.ts built its one-nibble-different recipient with `seller.address[41] === 'f' ? '0' : 'f'`. ethers returns an EIP-55 checksummed (mixed-case) address, so when the seller's last nibble is a checksum-uppercased 'F', `'F' === 'f'` is false and the flip appends 'f' — a CASE-only change that evaluateNpa (which compares addresses case-insensitively, asserted by XC1-PF-00) reads as the SAME address. The probe then sees no refusal (`[]`) and fails, ~1/32 runs (P(last nibble = f) x P(checksum uppercases it)). This intermittently reddens the `test` job on unrelated PRs (seen on #1517, not #1516). Fix: compare the last char case-insensitively so the flip is always value-distinct. Proven over all last-nibble cases: uppercase 'F' now maps to a genuinely different nibble; every other case is unchanged. Test-only; no src/ or behavior change. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
What (all INERT — nothing wired to live scoring/settlement, nothing merged, no RepID moves, gates stay on)
Three independent pieces of D-147. The big one (
receipt-binding.ts) is the reviewable brick for Receipt v2; the two guards close named red-team footguns.1.
receipt-binding.ts(NEW) — the versioned Receipt v2 bindingThe signed combined receipt today commits to the HAL output + verdict, but not to the facts a payout depends on. v2 composes six into one content-hashed struct the engine signature would cover:
deliverable_sha256work_statement_hashevaluation_spec_hashexecution_origincontroller_assurancesettled_evidence_idverifyReceiptBindingrebuilds a binding from the material a holder presents and names each attack: swapped deliverable, changed acceptance criteria, forged PASS, substituted / stale / replayed origin, incorrect settlement. Origin authenticity is not re-implemented — it delegates to the existing, testedverifyExecutionOriginprimitive (one root set governs both). A v1 receipt has no binding and is never silently upgraded. 13 attack tests.Left for Sean (MAJOR / HITL): embedding the binding into the issued/public signed receipt is a public-receipt-surface + canonical-settlement change (r23 / D-107). This file adds no field to any issued receipt and has zero callers in any scoring/settlement path.
2.
lane-a-dryrun.ts— zero acceptance threshold is invalidLANE_A_ACCEPT_THRESHOLD=0madescore >= 0accept every deliverable, including all-criteria-failed (red-team #3). A0/<=0/ out-of-range knob now falls back to the default1.0; valid range is(0, 1]. +5 tests.3.
evidence-pack.ts— a COMPLETE claim requires the task-existence check to have runAdds
complete, distinct fromok. A pack that citestrinity_tasks:<id>but was built without the opt-intaskEvidencejoin is grounded (ok) but notcomplete— existence was never verified.okstays true for a legitimate non-task caller; only a completeness claim demands the join have run. +6 tests.Verification
receipt-binding-d147.test.ts13/13 ·evidence-pack.test.ts+lane-a-dryrun.test.ts33/33 ·tsc --noEmitclean.origin/main(af51b05); diff is D-147-only (+459/−3).Gate
Inert per r23 (no live state change). Independent of the D-148 PR (#1516). Merge is Sean's call.
🤖 Generated with Claude Code