feat(app): gate rewards-clawback-* strings on a proven viewer authority - #404
Conversation
Adds ClawbackAuthority: a witness constructible only from a wallet-key- derived ViewerPuzzleHash that is byte-equal to a commitment's own clawback_puzzle_hash, and the sole producer of the four finished rewards-clawback-* sentences via ProvenClawback::open. The gate takes the CAPABILITY and derives the comparand itself, closing the class of forgeable witness pane.rs:WarningsShown left open (that type is unchanged; out of scope). - New crates/dig-app-core/src/rewards/clawback.rs: ViewerPuzzleHash (single constructor, no Copy/Clone/Default/PartialEq/From), ClawbackAuthority::prove, ProvenClawback::open (consumes the witness by value; refuses the whole window on a checked_sub underflow rather than showing a stand-in zero). - copy.rs's four CLAWBACK_* Msg constants narrow to pub(super); ALL_KEYS untouched so the 14-locale/forbidden-phrase sweeps still walk them. No .ftl file touched; no affordance ships. - Moved pane.rs's test-only string-literal scanner to a shared rewards::test_scan module so clawback's key-isolation test reuses it instead of duplicating it. - Workspace root Cargo.toml 15.5.1 -> 15.5.2. Refs #3281 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The version-bump commit updated Cargo.toml's workspace.package.version but left the three path-member entries in Cargo.lock (dig-app, dig-app-core, diga) pinned at 15.5.1, which every CI job's --locked cargo invocation refuses to reconcile. Refs #3281 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… private copy consts Two CI root causes on #404, no behaviour change. Cause A (Clippy, Test + coverage, both Native confirmers): when the string-literal extractor moved out of `pane.rs` into `rewards::test_scan` for reuse by `rewards::clawback`, the `use` landed in `mod tests` while both call sites live in `mod rewards_sections_tests` -- so line 75 was an unused import and lines 588-589 were E0425. The import now sits in the module that calls it; there is still exactly ONE extractor (`rewards/test_scan.rs`). Cause B (Doc-link hygiene): three rustdoc failures, all doc text. 1-2. Intra-doc links into the `#[cfg(test)]` module cannot resolve under `cargo doc` (broken_intra_doc_links); the two test names are now plain backticks, not links. 3. Four `ProvenClawback` field docs linked `copy::CLAWBACK_*`, which this ticket deliberately downgraded to `pub(super)` (private_intra_doc_links). The DOC is fixed, not the visibility: the four constants stay `pub(super)` and no `pub` re-export, alias or accessor was added -- that downgrade is the security gate this PR exists to add. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI finish — 5 red checks, two root causes, no design changeHead Cause A ( Cause B (
Acceptance still intact (from the run's own log, not from a rollup): 3132 tests run, 3132 passed, One CI-infrastructure note, not a code red. Still a DRAFT; the parent lane owns the merge. 🤖 Generated with Claude Code |
ADVERSARIAL GATE (third leg) — CHANGES-REQUIREDHead attacked: Attack thesis carried in from #403: a number can be correctly typed, read-only, verb-free, perfectly It does, once, structurally. One merge blocker; two required-before-a-renderer follow-ups; four of the F1 (MERGE-BLOCKING) —
|
SECURITY audit — IN PROGRESS, not the verdictHead audited: Two findings so far, both LIVE. Posting as formed; verdict follows in a separate comment. FINDING 1 — LIVE, gating. The witness is never bound to the commitment it was proved against.
So the two commitments need not be the same one. Concrete exploit, in-crate, two lines: let viewer = ViewerPuzzleHash::from_wallet_key(&my_key);
let a = ClawbackAuthority::prove(&viewer, &my_own_slot).unwrap(); // matches: legitimate
let p = ProvenClawback::open(a, &strangers_slot).unwrap(); // renders the STRANGER's slot
Every figure is the stranger's. The one viewer-true value is the hash. This is the predecessor's The doc comment asserts the property the compiler does not hold.
Non- Remedy shape (not prescriptive): have FINDING 2 — LIVE gate-integrity, gating.
|
SECURITY GATE VERDICT: CHANGES-REQUIREDHead audited: Gating (both in the comment above)
Non-gating
Areas clear, with why
ScopeAudited at Not covered: No shared checkout was mutated. |
… ALL_KEYS Salvages a session-cap-killed dirty edit that half-applied S1's shape change (ClawbackAuthority gained a commitment field but its constructor never set it, and ProvenClawback::open still took a second, independent commitment parameter -- worse than either end state, since the splice remained expressible). - S1: ClawbackAuthority::prove now stores the matched commitment by value; ProvenClawback::open takes exactly one argument and reads every figure from authority.commitment, so the S1 splice (open(prove(&viewer,&mine).unwrap(), &strangers_slot)) is unrepresentable, not merely unchecked. Added a third compile_fail,E0061 doctest proving the second parameter no longer exists. - F1: ProvenClawback's four fields are private with &str accessors; #[non_exhaustive] was considered and rejected (leaves fields writable). Corrected the doc comments that claimed the compiler already held this. - S2: copy::ALL_KEYS is private and #[cfg(test)] (its only two callers are copy.rs's own test module; a non-test private const with no non-test caller is dead code under --no-default-features -D warnings). Corrected the "no caller can forge" / "ONLY reachable" overclaims to state reach-narrowing, since Msg::new + a literal key string still renders the sentence from any crate. - F2: no_module_outside_clawback_names_a_clawback_key now enumerates src/rewards/ at test time via std::fs::read_dir instead of a hardcoded five-file list, so mod.rs, wire.rs and test_scan.rs (previously uncovered) are scanned and a tenth file is covered automatically. - F3: corrected the doc classifying the raw-timestamp epoch_index/ epoch_start_date collision as "a known display gap, not a security one" -- it is the only identifier naming which commitment is being withdrawn and is not yet trustworthy. No wire field invented; the code fix is a follow-up ticket per the ruling. dig_ecosystem#3281 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
5f71420 to
fd3c69f
Compare
SECURITY GATE VERDICT: PASSHead audited: Prior two gating findings — both verified closed, not just claimedS1 (witness proved a hash but not its subject). S2 ( Adversarial F1 ( Round-3 attack on the new shape (per the brief's "one type further out" thesis) — clean
Scope note (not a finding)
Residual tickets — confirmed honest, not re-filed
Neither ticket's defect is reachable in this diff (no renderer exists yet for either), so correctly Verified independently, not trusted from the commit message
What I did not coverDid not re-audit KG: |
Adversarial re-gate (third leg) — head
|
MichaelTaylor3d
left a comment
There was a problem hiding this comment.
CORRECTNESS GATE (L2 review, first run on this PR) - PASS
Head reviewed: fd3c69f95021388f6613f502cc09ad0f7af4c61e. Fresh context, read every changed line from git objects at this head (not the PR description), read-only in D:/worktrees/dig-app-3281. No prior correctness/L2 review has ever run on #404 -- this is a full review of the whole diff against merge-base d34f387c2cc3ab5b2064ac3634ef882dd15ec263, not a delta review.
This PR already carries an independent adversarial round (loop-decider, CHANGES-REQUIRED at 7e2b6b1d, two findings) and an independent security gate (loop-security, CHANGES-REQUIRED at 7e2b6b1d, then PASS at this exact head fd3c69f9). I re-verified their closed findings myself rather than trusting the transcripts, plus ran the checks assigned to this gate specifically.
Acceptance criteria -- checked one by one
- Witness airtight, subject bound (S1/round-2 fix).
ClawbackAuthority { matched: Bytes32, commitment: RewardDistributorCommitment }(clawback.rs:155-164), both fields private, only constructor isprove(&ViewerPuzzleHash, &RewardDistributorCommitment) -> Option<Self>(clawback.rs:171-180).ProvenClawback::open(authority: ClawbackAuthority) -> Option<Self>takes exactly one argument (clawback.rs:249) -- the S1 spliceopen(prove(&viewer,&mine).unwrap(), &strangers_slot)is a compile error (E0061), not a runtime re-check. Every figure insideopenreads fromauthority.commitment/authority.matchedonly -- no second commitment in scope to diverge from. Confirmed by reading the body, not the comment. ProvenClawbackfields private (F1 fix).clawback.rs:202-211-- all four fields private,&straccessors only (clawback.rs:215-234), nopubfield, noFrom/Default/builder/#[cfg(test)]forge constructor anywhere in the file.Clone/PartialEq/Eqstay (finished text, not a capability) -- correctly reasoned in the doc, and true: cloning doesn't reopen the private fields.ALL_KEYSprivate (S2 fix).copy.rs:163-164--#[cfg(test)] const ALL_KEYS(nopub). Grepped the crate: only callers arecopy.rs's own two test functions (copy.rs:211,copy.rs:285). Cannot be named from outside the crate, or even from a non-test build inside it -- closes the sibling-forgery hole cleanly.copy::CLAWBACK_*narrowed topub(super).copy.rs:60,66,70,72.no_module_outside_clawback_names_a_clawback_keyenumeratessrc/rewards/viastd::fs::read_dir(clawback.rs:380-413), not a hardcoded list -- verified byls: the directory holdscadence.rs, clawback.rs, client.rs, copy.rs, mod.rs, pane.rs, reading.rs, tab_placement.rs, test_scan.rs, wire.rs(10 files), 2 excluded by name (clawback.rs,copy.rs-- both justified: self-referential/defining), leaving 8 scanned by the real enumeration. Themust_be_scannedassertion only requires 4 of those 8 by name as a floor against the enumeration silently returning nothing (a movedCARGO_MANIFEST_DIR) -- the other 4 (cadence.rs,client.rs,reading.rs,tab_placement.rs) are still covered by the loop itself, just not asserted by name; that's an acceptable non-vacuity floor, not a coverage gap.the_guard_itself_trips_on_a_planted_key(clawback.rs:432-435) proves the extractor itself is live.- Wire stays 4 fields.
wire.rs:104-109untouched, still exactlyepoch_start,clawback_puzzle_hash,rewards_base_units,recoverable_base_units. Nowithdrawal_share_bps, no/10_000, no hand division anywhere inclawback.rs--forfeited_base_units = rewards_base_units.checked_sub(recoverable_base_units)?(clawback.rs:251-253),Nonerefuses the whole window, never a stand-in zero -- confirmed bychecked_sub_underflow_refuses_the_whole_window(clawback.rs:496-509), which constructs an inconsistent commitment (recoverable > rewards) and assertsopen(...).is_none(). - Money honesty. Every rendered figure (
slot_amount,returned_amount,forfeited_amount) goes throughamount_with_unit(Asset::DIG, ...)(clawback.rs:277-279) -- no raw integer, no/1_000, no hand-rolled divisor anywhere in the diff.Asset::DIG/ticker()are pre-existing, untouched infrastructure outside this diff's blast radius. - Hardcoded English (historical blind spot check). Grepped
clawback.rsandcopy.rsfor anypub/pub(super)function returning a literal English sentence outside theMsg/fluent path -- none found; all four rendered strings go throughcopy::CLAWBACK_*->Msg::with/.text()into the existing 14-locale catalog, confirmed present in all 14.ftlfiles (grep -l rewards-clawback-confirm-title *.ftl= 14). No.ftlfile is touched by this PR, matching the claim. - Doc honesty. Scanned for overclaim phrasing ("no caller can forge", "unreachable"): none remains in
clawback.rs/copy.rsat this head.clawback.rs:7explicitly states the narrower true claim ("narrower than 'the sole producer of those sentences'"), andcopy.rs:47-59states theMsg::new/literal-key hatch honestly rather than claiming unreachability. This is the corrected language from the adversarial/security rounds, verified present, not just asserted fixed. compile_faildoctests pin error codes.clawback.rs:118=compile_fail,E0061(wrong arg count),clawback.rs:137=compile_fail,E0603(private path),clawback.rs:144=compile_fail,E0382(use-after-move). All three name the specific code, not a barecompile_fail. CI's Doc-link hygiene / doctest run is green at this head.- Non-vacuous acceptance test.
rendered_body_names_the_viewers_own_hash_and_a_strangers_commitment_renders_nothing(clawback.rs:461-492): expected hash comes fromindependently_derived_root_puzzle_hash()(clawback.rs:328-339), a second, independent implementation viachia_bls::master_to_wallet_unhardened+StandardArgs::curry_tree_hash-- not borrowed back fromfrom_wallet_keyunder test. Assertions are on ownedStrings returned by the accessors (proven.confirm_body()etc.), not a&strborrowed from a dropped local -- confirmedtest_scan.rs:32-48(the moved extractor) still returns ownedStrings, nounsafe/transmute. Reasoned through by hand: deleting theviewer.0 == commitment_phequality inprove(making it unconditionallySome) would make the stranger arm'sassert!(ClawbackAuthority::prove(&viewer, &strangers_commitment).is_none())(clawback.rs:491) fail -- the test does go red on the exact defect it exists to catch. - Version bump.
Cargo.toml/Cargo.lock15.5.1->15.5.2, workspace-root file, matches "Check version increment" green. - Residual tickets honestly documented, not re-filed.
dig_ecosystem#3289(raw epoch timestamp) matches the comment atclawback.rs:261-274verbatim including the corrected "NOT merely a display gap" framing.dig_ecosystem#3290("nothing committed" vs "could not be read") -- no reading-state enum exists in this diff, consistent with the ticket being still-open and unaddressed here. Neither re-filed in this review. - Untouched-and-passing set (
commitment_has_exactly_the_four_spec_fields_and_no_more,tab_id_all_is_still_the_six_labels,activity_tab_emits_zero_action_rows, the five warning blocks) -- confirmed viagit diff --statthese files/tests are not touched by this diff; CI reports them passing. - Blast radius. Grepped the crate for
ClawbackAuthority|ProvenClawback|clawback::outsideclawback.rs/copy.rs/test_scan.rs-- zero hits. Nothing renders this yet, matching the PR's own claim.pane.rs's diff is a verbatim test-helper move (function_body/string_literals->test_scan.rs) plus oneuseline -- behaviour-preserving, confirmed by reading the diff line-for-line.
CI
11 required/guard checks green by name at fd3c69f9: Rustfmt, Clippy, Test + coverage (>=80% lines, gated), Check version increment, Lint commit messages, Headless build, Doc-link hygiene, Phase-stamp guard, Lane-scratch guard, plus Native confirmer (macos-latest). Only Native confirmer (windows-latest) is pending (not in the required set per the dispatch brief).
What I did not do
Did not re-run the test suite myself locally (CI's own green Test + coverage run, read test bodies to confirm they assert the right thing rather than just exist, and independently re-derived what the stranger-arm deletion would do by hand). Did not re-audit pane.rs's pre-existing WarningsShown/status-copy shape -- out of this diff's scope, called out by the PR's own module doc as a separate, unresolved defect class. Did not re-verify WalletKey::puzzle_hash()'s derivation math beyond confirming the test's independent second derivation agrees with it (from_wallet_key_matches_the_independently_derived_curry_tree_hash) -- dig-account internals are outside this crate's diff.
No new findings from this gate. Both prior gating rounds are verified closed at this head, not just claimed closed.
Verdict: PASS
Head: fd3c69f95021388f6613f502cc09ad0f7af4c61e
KG: a-type-level-gate-can-be-airtight-up-to-its-product-type-and-a-pub-field-gives-the-whole-property-away (confirms the prior adversarial-gate KG entry -- closed correctly on the second attempt, verified independently rather than trusted)
…1 created
- wire.rs: RewardDistributorCommitment's doc claimed "nothing constructs,
reads or paints from it" -- false since the S1 fix: ClawbackAuthority::prove
now constructs it into the witness and ProvenClawback::open reads three of
its fields. Corrected.
- clawback.rs module doc: names the untracked gap this module's "bound to a
commitment" claim does NOT cover -- record PROVENANCE (parsed chain read vs.
in-crate struct literal) is not established by `prove`, tracked as
dig_ecosystem#3294.
- F3's promised follow-up ticket is now named: dig_ecosystem#3289 (the
epoch_index/epoch_start_date raw-timestamp gap).
- Names dig_ecosystem#3290 for the brief's SPEC §2.6 clause 5 note ("nothing
committed" vs. "could not be read" is unrepresentable in these types today)
next to the related wire-gap comment it sits beside, doc-only, no field
added.
dig_ecosystem#3281
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
loop-decider (adversarial confirm of my own F1/F2/F3) — CHANGES-REQUIREDHead judged: F1 —
|
…cation wire.rs's RewardDistributorCommitment doc cited dig-node#593/#594 as "open, unmerged" -- both had merged before this head, reintroducing the exact staleness pattern already banned on #402 (cite a durable version or code fact, never another repo's PR state). Replace it with a dig-app- internal fact instead: no constructor of this type anywhere in this crate sits outside #[cfg(test)] code (verified: clawback.rs:364,524 and wire.rs:180 are all under #[cfg(test)] mod tests). Also reworded "every value ... in the running program today" so it no longer implies a production value exists -- it does not, only test fixtures do. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
loop-decider re-gate (round 4, doc-honesty only) -- PASSHead judged: F1: CLOSEDThe born-false citation ( The three cited facts
The doctest stub does NOT falsify fact 2
No external-PR-state or release-state citation survives in
|
Summary
Refs #3281. Builds
ClawbackAuthority: a witness constructible only from a wallet-key-derivedViewerPuzzleHashthat is byte-equal to a commitment's ownclawback_puzzle_hash, and the SOLE producer of the four finishedrewards-clawback-*sentences (ProvenClawback::open). The gate takes the CAPABILITY and derives the comparand itself, so a caller cannot forge it by supplying both compared values in one line — the class of defectpane.rs'sWarningsShown(pane.rs:646-682) left open, which is unchanged and out of scope here.What shipped
crates/dig-app-core/src/rewards/clawback.rs:ViewerPuzzleHash([Bytes32])— only constructorfrom_wallet_key; noCopy/Clone/Default/PartialEq/From/from_bytes.ClawbackAuthority::prove(&ViewerPuzzleHash, &RewardDistributorCommitment) -> Option<Self>— privatematched: Bytes32; noCopy/Clone/Default.ProvenClawback::open(ClawbackAuthority, &RewardDistributorCommitment) -> Option<Self>— consumes the witness by value, returns the four already-formattedStrings. Refuses the whole window (None) on achecked_subunderflow rather than showing a stand-in zero forfeited figure.compile_faildoctests onClawbackAuthority:copy::CLAWBACK_CONFIRM_BODYunreachable from outside the crate; a consumed witness cannot be reused.copy.rs's fourCLAWBACK_*Msgconstants narrowpub->pub(super).ALL_KEYSbyte-identical, so the 14-locale completeness/forbidden-phrase sweeps still walk them. No.ftlfile touched.pane.rs's test-only string-literal scanner (function_body/string_literals) moved to a sharedrewards::test_scanmodule soclawback's key-isolation test reuses the ONE implementation instead of writing a second (plan step 6).Cargo.toml15.5.1->15.5.2.Blast radius
Checked by grep (gitnexus was NOT-INDEXED in this worktree at dispatch):
rewards/mod.rs(newpub mod clawback;+test_scanline),rewards/copy.rs(visibility narrow only — grepped for external callers of the four consts outsiderewards/: none),rewards/pane.rs(test-helper move only, behaviour-preserving). No other module in the crate references the touched symbols.Test plan
Rustfmt,Clippy,Test + coverage (>=80% lines, gated),Check version increment,Lint commit messages,Headless build (--no-default-features)clawback.rs:no_module_outside_clawback_names_a_clawback_key,the_guard_itself_trips_on_a_planted_key,from_wallet_key_matches_the_independently_derived_curry_tree_hash,a_one_bit_flipped_commitment_hash_proves_nothing,rendered_body_names_the_viewers_own_hash_and_a_strangers_commitment_renders_nothing,checked_sub_underflow_refuses_the_whole_window, plus 2compile_faildoctests.commitment_has_exactly_the_four_spec_fields_and_no_more(wire.rs),tab_id_all_is_still_the_six_labels,activity_tab_emits_zero_action_rows.DO NOT MERGE — gate round in progress. Parent lane owns the merge.
🤖 Generated with Claude Code