Skip to content

Commit d0e900e

Browse files
test(wallet): prove the #1698 exploit is UNWRITABLE, not merely rejected
Four compile-fail cases (trybuild) plus trait-absence and structural assertions: - the #1698 exploit does not compile: authorize_op takes no description, and no signing method accepts loose coin spends. - SpendApproval::new / PendingApproval::new are unreachable outside the crate. - signing or confirming twice is a use-after-move error, not a nonce check. - neither token implements Clone / Serialize / DeserializeOwned / Debug. - production code mints an approval in exactly one module (the gate), assembles its fields in exactly one module, and derives the summary in exactly one module. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent cb0a4e6 commit d0e900e

11 files changed

Lines changed: 440 additions & 2 deletions

Cargo.lock

Lines changed: 103 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ anyhow = "1"
3939
hex = "0.4"
4040
tokio = { version = "1", features = ["macros", "rt"] }
4141
serde_json = "1"
42+
# Compile-fail proofs: several custody properties of `SpendApproval` are TYPE-SYSTEM properties, so
43+
# the only honest test for them is that the wrong code does not compile.
44+
trybuild = "1.0"
45+
static_assertions = "1.1"
4246

4347
[lints.rust]
4448
unsafe_code = "forbid"
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
//! A ceremony cannot be run once and cashed in twice.
2+
//!
3+
//! `PendingApproval::confirmed` consumes `self`, so one prompt yields at most one signable approval.
4+
//! Without this, a host that held the pending value could convert a single user "yes" into any number
5+
//! of approvals over those spends.
6+
7+
use dig_account::{PendingApproval, SpendDecision};
8+
9+
fn confirm_twice(pending: PendingApproval) {
10+
let _first = pending.confirmed(SpendDecision::Approve);
11+
let _again = pending.confirmed(SpendDecision::Approve);
12+
}
13+
14+
fn main() {}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
error[E0382]: use of moved value: `pending`
2+
--> tests/compile_fail/confirm_a_ceremony_twice.rs:11:18
3+
|
4+
9 | fn confirm_twice(pending: PendingApproval) {
5+
| ------- move occurs because `pending` has type `PendingApproval`, which does not implement the `Copy` trait
6+
10 | let _first = pending.confirmed(SpendDecision::Approve);
7+
| --------------------------------- `pending` moved due to this method call
8+
11 | let _again = pending.confirmed(SpendDecision::Approve);
9+
| ^^^^^^^ value used here after move
10+
|
11+
note: `PendingApproval::confirmed` takes ownership of the receiver `self`, which moves `pending`
12+
--> src/wallet/approval.rs
13+
|
14+
| pub fn confirmed(self, decision: SpendDecision) -> Result<SpendApproval> {
15+
| ^^^^
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
//! A consumer cannot mint its own permission.
2+
//!
3+
//! `SpendApproval`/`PendingApproval` have private fields and `pub(crate)` constructors, so
4+
//! `PolicyAuthorizer` is MECHANICALLY the only minter of a permission — not merely the intended one.
5+
//! Were this to compile, "a dapp approves its own spend" would be a one-line change in a consumer,
6+
//! and every bound this crate advertises would be optional again.
7+
//!
8+
//! The constructors are merely NAMED rather than called, so the recorded verdict is the privacy error
9+
//! alone. Calling them would add an argument-count error that would still be reported if `new` were
10+
//! public — noise that could let this case keep "failing" for the wrong reason.
11+
12+
use dig_account::{PendingApproval, SpendApproval};
13+
14+
fn forge() {
15+
let _mint_an_approval = SpendApproval::new;
16+
let _mint_a_pending_approval = PendingApproval::new;
17+
}
18+
19+
fn main() {}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
error[E0624]: associated function `new` is private
2+
--> tests/compile_fail/mint_an_approval_outside_the_gate.rs:15:44
3+
|
4+
15 | let _mint_an_approval = SpendApproval::new;
5+
| ^^^ private associated function
6+
|
7+
::: src/wallet/approval.rs
8+
|
9+
| / pub(crate) fn new(
10+
| | coin_spends: Vec<CoinSpend>,
11+
| | summary: SpendSummary,
12+
| | verified: TransactionSummary,
13+
| | ) -> Self {
14+
| |_____________- private associated function defined here
15+
16+
error[E0624]: associated function `new` is private
17+
--> tests/compile_fail/mint_an_approval_outside_the_gate.rs:16:53
18+
|
19+
16 | let _mint_a_pending_approval = PendingApproval::new;
20+
| ^^^ private associated function
21+
|
22+
::: src/wallet/approval.rs
23+
|
24+
| / pub(crate) fn new(
25+
| | coin_spends: Vec<CoinSpend>,
26+
| | summary: SpendSummary,
27+
| | verified: TransactionSummary,
28+
| | ) -> Self {
29+
| |_____________- private associated function defined here
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//! Single-use is a MOVE, not a runtime replay check.
2+
//!
3+
//! `sign_approved` takes the approval by value and `SpendApproval` is not `Clone`/`Copy`, so signing
4+
//! the same approval twice is a use-after-move error the compiler catches. There is deliberately no
5+
//! nonce and no spent-set: a replay window that cannot exist needs no bookkeeping to close, and
6+
//! bookkeeping is a thing that can drift out of sync with what it guards.
7+
8+
use dig_account::{MoneySigner, SpendApproval};
9+
10+
fn sign_twice<S: MoneySigner>(signer: &S, approval: SpendApproval) {
11+
let _first = signer.sign_approved(approval);
12+
let _replay = signer.sign_approved(approval);
13+
}
14+
15+
fn main() {}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
error[E0382]: use of moved value: `approval`
2+
--> tests/compile_fail/reuse_an_approval.rs:12:40
3+
|
4+
10 | fn sign_twice<S: MoneySigner>(signer: &S, approval: SpendApproval) {
5+
| -------- move occurs because `approval` has type `SpendApproval`, which does not implement the `Copy` trait
6+
11 | let _first = signer.sign_approved(approval);
7+
| -------- value moved here
8+
12 | let _replay = signer.sign_approved(approval);
9+
| ^^^^^^^^ value used here after move
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
//! The #1698 exploit, as a compile-fail proof.
2+
//!
3+
//! Authorize an honest-looking ONE-MOJO description, then sign a spend of a BILLION mojos. Under
4+
//! dig-account 0.1.2 this compiled and both steps succeeded: the gate judged a `SpendSummary` while
5+
//! the signer signed `&[CoinSpend]`, and nothing connected the two.
6+
//!
7+
//! The property under test is the SHAPE, not a rejection. A regression test asserting `is_err()`
8+
//! would pass equally against a patched digest comparison — and would keep passing if that comparison
9+
//! were later moved, mis-scoped, or fed the wrong bytes. Only non-compilation pins that there are no
10+
//! longer two values that could disagree.
11+
//!
12+
//! Both halves of the exploit fail independently:
13+
//! 1. `authorize_op` no longer accepts a description at all — it takes the spends.
14+
//! 2. no signing method accepts loose coin spends, so there is nothing to sign them with.
15+
16+
use chia_protocol::CoinSpend;
17+
use dig_account::{MoneySigner, PolicyAuthorizer, SpendOpClass, SpendRecipient, SpendSummary, SpendTier};
18+
19+
fn exploit<S: MoneySigner>(gate: &PolicyAuthorizer, signer: &S, real_spends: &[CoinSpend]) {
20+
// A hand-built description of a one-mojo tip to a stranger.
21+
let honest_looking = SpendSummary::new(
22+
SpendTier::AutoSend,
23+
vec![SpendRecipient {
24+
address: "xch1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq".to_string(),
25+
amount_mojos: 1,
26+
asset_id: None,
27+
}],
28+
0,
29+
);
30+
31+
// (1) The gate will not judge a description.
32+
let _ = gate.authorize_op(&honest_looking, SpendOpClass::Tip);
33+
34+
// (2) And nothing will sign the real, billion-mojo spends.
35+
let _ = signer.sign_coin_spends(real_spends);
36+
}
37+
38+
fn main() {}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
error[E0308]: mismatched types
2+
--> tests/compile_fail/the_1698_exploit.rs:32:31
3+
|
4+
32 | let _ = gate.authorize_op(&honest_looking, SpendOpClass::Tip);
5+
| ------------ ^^^^^^^^^^^^^^^ expected `&[CoinSpend]`, found `&SpendSummary`
6+
| |
7+
| arguments to this method are incorrect
8+
|
9+
= note: expected reference `&[CoinSpend]`
10+
found reference `&SpendSummary`
11+
note: method defined here
12+
--> src/wallet/enforcer.rs
13+
|
14+
| pub fn authorize_op(
15+
| ^^^^^^^^^^^^
16+
17+
error[E0599]: no method named `sign_coin_spends` found for reference `&S` in the current scope
18+
--> tests/compile_fail/the_1698_exploit.rs:35:20
19+
|
20+
35 | let _ = signer.sign_coin_spends(real_spends);
21+
| ^^^^^^^^^^^^^^^^ method not found in `&S`

0 commit comments

Comments
 (0)