Skip to content

Commit b5c2f6d

Browse files
chore(wallet): checkpoint round-2 work for #1702
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 5a0b0b1 commit b5c2f6d

5 files changed

Lines changed: 238 additions & 48 deletions

File tree

src/wallet/approval.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -276,18 +276,18 @@ mod tests {
276276
let pending = PendingApproval::new(vec![coin_spend(1234)], summary(), verified());
277277
assert_eq!(pending.summary().recipients[0].amount_mojos, 42);
278278

279-
let approval = pending.confirmed(SpendDecision::Approve).unwrap();
279+
let approval = pending.decided(SpendDecision::Approve).unwrap();
280280
assert_eq!(approval.coin_spends(), &[coin_spend(1234)]);
281281
assert_eq!(approval.summary(), &summary());
282282
}
283283

284-
/// A decline is `PolicyDenied` — terminal — not an escalatable refusal a caller could re-prompt.
284+
/// A decline is `UserDeclined` — terminal — not an escalatable refusal a caller could re-prompt.
285285
#[test]
286286
fn a_declined_ceremony_denies_outright_and_never_yields_an_approval() {
287287
let pending = PendingApproval::new(vec![coin_spend(1)], summary(), verified());
288-
let err = denial(pending.confirmed(SpendDecision::Decline(Some("not mine".into()))));
288+
let err = denial(pending.decided(SpendDecision::Decline(Some("not mine".into()))));
289289
assert!(
290-
matches!(&err, AccountError::PolicyDenied(m) if m.contains("declined") && m.contains("not mine")),
290+
matches!(&err, AccountError::UserDeclined(m) if m.contains("declined") && m.contains("not mine")),
291291
"{err:?}"
292292
);
293293
}
@@ -296,7 +296,7 @@ mod tests {
296296
#[test]
297297
fn a_reasonless_decline_is_still_denied() {
298298
let pending = PendingApproval::new(vec![coin_spend(1)], summary(), verified());
299-
let err = denial(pending.confirmed(SpendDecision::Decline(None)));
300-
assert!(matches!(err, AccountError::PolicyDenied(_)), "{err:?}");
299+
let err = denial(pending.decided(SpendDecision::Decline(None)));
300+
assert!(matches!(err, AccountError::UserDeclined(_)), "{err:?}");
301301
}
302302
}

src/wallet/authorizer.rs

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -142,18 +142,26 @@ mod tests {
142142
)
143143
}
144144

145+
/// A handle for a session that is still unlocked.
146+
///
147+
/// Every `WalletOps` here is built through this, so the residency argument is never accidentally a
148+
/// revoked one — the revoked case is exercised deliberately, by name, below.
149+
fn live() -> Arc<Residency> {
150+
Arc::new(Residency::new())
151+
}
152+
145153
#[test]
146154
fn wallet_key_matches_the_canonical_derivation_at_the_profile_index() {
147155
let s = seed();
148-
let ops = WalletOps::new(s.clone(), ProfileIx(5));
156+
let ops = WalletOps::new(s.clone(), ProfileIx(5), live());
149157
let expected = WalletKey::from_seed_at(&s.master_seed()[..], ProfileIx(5));
150158
assert_eq!(ops.wallet_key().secret_key(), expected.secret_key());
151159
}
152160

153161
#[test]
154162
fn public_passthroughs_match_the_wallet_key_without_exposing_it() {
155163
let s = seed();
156-
let ops = WalletOps::new(s, ProfileIx(2));
164+
let ops = WalletOps::new(s, ProfileIx(2), live());
157165
// The public read-only surface exposes exactly the wallet key's public identifiers.
158166
assert_eq!(ops.public_key(), ops.wallet_key().public_key());
159167
assert_eq!(ops.puzzle_hash(), ops.wallet_key().puzzle_hash());
@@ -163,8 +171,8 @@ mod tests {
163171
#[test]
164172
fn distinct_profiles_derive_distinct_wallet_keys() {
165173
let s = seed();
166-
let k0 = WalletOps::new(s.clone(), ProfileIx::ROOT).wallet_key();
167-
let k1 = WalletOps::new(s, ProfileIx(1)).wallet_key();
174+
let k0 = WalletOps::new(s.clone(), ProfileIx::ROOT, live()).wallet_key();
175+
let k1 = WalletOps::new(s, ProfileIx(1), live()).wallet_key();
168176
assert_ne!(k0.secret_key(), k1.secret_key());
169177
}
170178

@@ -186,7 +194,7 @@ mod tests {
186194
use chia_wallet_sdk::driver::{SpendContext, StandardLayer};
187195
use chia_wallet_sdk::types::Conditions;
188196

189-
let ops = WalletOps::new(seed(), ProfileIx::ROOT);
197+
let ops = WalletOps::new(seed(), ProfileIx::ROOT, live());
190198
let wallet_ph = ops.puzzle_hash();
191199

192200
let mut ctx = SpendContext::new();
@@ -226,7 +234,6 @@ mod tests {
226234

227235
let bundle = ops
228236
.money_signer(Network::Mainnet)
229-
.unwrap()
230237
.sign_approved(approval)
231238
.expect("an approved, wallet-owned send must sign");
232239
assert_eq!(

src/wallet/autosend.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ mod tests {
194194
assert_eq!(policy.period_cap_mojos, 0);
195195
assert_eq!(policy.period_seconds, DEFAULT_PERIOD_SECONDS);
196196
for op_class in SpendOpClass::CONFIGURABLE {
197-
let limits = policy.limits_for(op_class).unwrap();
197+
let limits = policy.configured_limits(op_class).unwrap();
198198
assert!(!limits.enabled, "{op_class:?} must default to disabled");
199199
assert_eq!(
200200
limits.per_tx_limit_mojos, 0,
@@ -324,6 +324,7 @@ mod tests {
324324
small_send: OpClassLimits::default(),
325325
period_seconds: 3_600,
326326
period_cap_mojos: 1_000,
327+
max_confirmations_per_period: 8,
327328
};
328329
let json = serde_json::to_string(&policy).unwrap();
329330
assert_eq!(

src/wallet/enforcer.rs

Lines changed: 179 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,9 @@ mod tests {
601601
small_send: OpClassLimits::enabled_up_to(u64::MAX),
602602
period_seconds: DEFAULT_PERIOD_SECONDS,
603603
period_cap_mojos: u64::MAX,
604+
// Permissive here too, so a test that escalates repeatedly fails on the rule it is
605+
// about rather than on the prompt ceiling. The ceiling has its own tests.
606+
max_confirmations_per_period: u32::MAX,
604607
}
605608
}
606609

@@ -667,6 +670,43 @@ mod tests {
667670
}
668671

669672
/// The same for a `Result<SpendApproval>`, which `PendingApproval::confirmed` returns.
673+
/// Run the ceremony to `decision` through the real consent seam.
674+
///
675+
/// A fixed-answer [`AuthProvider`] stands in for the harness, so these tests exercise
676+
/// [`PendingApproval::confirm_with`] — the only public route from a pending approval to a
677+
/// signature — rather than the crate-private tail it delegates to. A host cannot skip this seam,
678+
/// so neither does the test.
679+
async fn confirmed(
680+
pending: PendingApproval,
681+
decision: crate::auth::provider::SpendDecision,
682+
) -> Result<SpendApproval> {
683+
use crate::auth::factors::AuthFactors;
684+
use crate::auth::provider::{AuthProvider, SpendConfirmRequest, UnlockRequest};
685+
use crate::id::AccountId;
686+
687+
use crate::auth::provider::SpendDecision;
688+
689+
struct Fixed(SpendDecision);
690+
691+
#[async_trait::async_trait]
692+
impl AuthProvider for Fixed {
693+
async fn collect_factors(&self, _: UnlockRequest) -> Result<AuthFactors> {
694+
unreachable!("a spend ceremony never collects unlock factors")
695+
}
696+
async fn confirm_spend(&self, _: SpendConfirmRequest) -> Result<SpendDecision> {
697+
Ok(self.0.clone())
698+
}
699+
}
700+
701+
pending
702+
.confirm_with(
703+
&Fixed(decision),
704+
AccountId::new("ceremony-fixture"),
705+
ProfileIx::ROOT,
706+
)
707+
.await
708+
}
709+
670710
fn denial(result: Result<SpendApproval>) -> AccountError {
671711
match result {
672712
Ok(_) => panic!("expected a denial, got a signable approval"),
@@ -1150,8 +1190,8 @@ mod tests {
11501190

11511191
/// A `Confirm`-tier spend reaches the confirm path and, once confirmed, becomes signable — the
11521192
/// whole ceremony, end to end, on a spend the gate will never auto-approve.
1153-
#[test]
1154-
fn a_confirm_tier_spend_reaches_the_ceremony_and_a_confirmation_makes_it_signable() {
1193+
#[tokio::test]
1194+
async fn a_confirm_tier_spend_reaches_the_ceremony_and_a_confirmation_makes_it_signable() {
11551195
let gate = gate_with(hot_custody(), permissive_auto_send());
11561196
let coin_spends = pays_third_party(CUSTODY_AUTO_SEND_CEILING + 1);
11571197

@@ -1163,8 +1203,8 @@ mod tests {
11631203
"the user must be shown the spend's real value"
11641204
);
11651205

1166-
let approved = escalated
1167-
.confirmed(crate::auth::provider::SpendDecision::Approve)
1206+
let approved = confirmed(escalated, crate::auth::provider::SpendDecision::Approve)
1207+
.await
11681208
.expect("a confirmed spend becomes signable");
11691209
assert_eq!(
11701210
approved.coin_spends(),
@@ -1180,16 +1220,22 @@ mod tests {
11801220

11811221
/// A declined ceremony yields no approval and charges nothing — a refusal must never cost the
11821222
/// user their allowance.
1183-
#[test]
1184-
fn a_declined_ceremony_yields_no_approval_and_charges_nothing() {
1223+
#[tokio::test]
1224+
async fn a_declined_ceremony_yields_no_approval_and_charges_nothing() {
11851225
let gate = gate_with(hot_custody(), permissive_auto_send());
11861226
let escalated = pending(gate.authorize_op(
11871227
&pays_third_party(CUSTODY_AUTO_SEND_CEILING + 1),
11881228
SpendOpClass::Tip,
11891229
));
11901230

1191-
let err = denial(escalated.confirmed(crate::auth::provider::SpendDecision::Decline(None)));
1192-
assert!(matches!(err, AccountError::PolicyDenied(_)), "{err}");
1231+
let err = denial(
1232+
confirmed(
1233+
escalated,
1234+
crate::auth::provider::SpendDecision::Decline(None),
1235+
)
1236+
.await,
1237+
);
1238+
assert!(matches!(err, AccountError::UserDeclined(_)), "{err}");
11931239
assert_eq!(ledger_total(&gate), 0);
11941240
}
11951241

@@ -1446,24 +1492,89 @@ mod tests {
14461492
approval(gate.authorize_op(&pays_third_party(1_000), SpendOpClass::Tip));
14471493
}
14481494

1449-
/// The layered invariant this gate deliberately does NOT hold alone: an un-hinted output is
1450-
/// change, excluded from the summary's recipients, so no amount limit here can see it. The
1451-
/// composition is what protects the wallet — the policy gate approves this spend (its whole
1452-
/// visible effect is a 1 mojo fee) and the money signer still refuses to sign, because the change
1453-
/// output pays a puzzle hash the wallet does not own.
1495+
/// **The value the gate charges is the value that LEAVES — the author cannot shrink it by
1496+
/// omitting a memo.**
1497+
///
1498+
/// This is the #1702 exploit, and the fixture is built so that the *only* thing that can refuse it
1499+
/// is the rule under test. The destination is `ProfileIx(1)` of the spender's OWN seed: an address
1500+
/// `owns_puzzle_hash` accepts, inside the signer's `0..address_gap` window. So the downstream
1501+
/// "change must be wallet-owned" check — the thing that refused the previous version of this test,
1502+
/// where the destination was a stranger — is silent here by construction. Aim the same 999 mojos at
1503+
/// a stranger and it is impossible to tell which layer refused.
1504+
///
1505+
/// Before the fix, `analyze` filed the un-hinted output as CHANGE, `recipients` was empty, the
1506+
/// summary read "no recipients, fee 1", the ledger was charged **1**, and the signature authorized
1507+
/// **999**. The human would have been shown a 1 mojo fee and asked to approve a spend of the coin.
14541508
#[test]
1455-
fn refuses_to_sign_unhinted_value_leaving_the_wallet_even_when_the_policy_approves() {
1456-
use crate::wallet::money_signer::{LocalMoneySigner, MoneySigner};
1457-
use dig_wallet_backend::types::Network;
1509+
fn an_unhinted_output_to_an_owned_derivation_is_counted_not_hidden() {
1510+
let attacker_owned = WalletKey::from_seed_at(&SPENDER_SEED, ProfileIx(1)).puzzle_hash();
1511+
assert_ne!(
1512+
attacker_owned,
1513+
spender().puzzle_hash(),
1514+
"the fixture must pay a DIFFERENT derivation, or it is testing genuine change"
1515+
);
1516+
1517+
let mut ctx = SpendContext::new();
1518+
StandardLayer::new(spender().public_key())
1519+
.spend(
1520+
&mut ctx,
1521+
Coin::new(Bytes32::new([1u8; 32]), spender().puzzle_hash(), 1_000),
1522+
// No memo: `analyze` files this as change, so a hinted-recipient sum cannot see it.
1523+
Conditions::new()
1524+
.create_coin(attacker_owned, 999, Memos::None)
1525+
.reserve_fee(1),
1526+
)
1527+
.unwrap();
1528+
let coin_spends = ctx.take();
1529+
1530+
let gate = gate_with(
1531+
hot_custody(),
1532+
AutoSendPolicy {
1533+
enabled: true,
1534+
rebalance: OpClassLimits::enabled_up_to(10),
1535+
period_cap_mojos: 10,
1536+
..AutoSendPolicy::default()
1537+
},
1538+
);
1539+
1540+
// Escalated, not approved: 1_000 is far past the 10 mojo per-transaction bound.
1541+
let escalated = pending(gate.authorize_op(&coin_spends, SpendOpClass::Rebalance));
1542+
let summary = escalated.summary();
1543+
assert_eq!(
1544+
summary.recipients.len(),
1545+
1,
1546+
"the un-hinted output must appear in the line the human confirms: {summary}"
1547+
);
1548+
assert_eq!(summary.recipients[0].amount_mojos, 999);
1549+
assert_eq!(
1550+
summary.native_total_mojos(),
1551+
1_000,
1552+
"the whole coin leaves, so the whole coin is what is weighed"
1553+
);
1554+
assert_eq!(
1555+
ledger_total(&gate),
1556+
0,
1557+
"nothing was auto-approved, so nothing was charged"
1558+
);
1559+
}
14581560

1561+
/// The truthful control for the test above: **genuine change is still free.**
1562+
///
1563+
/// Identical spend, one field changed — the output pays the exact puzzle hash of the coin being
1564+
/// spent. Value demonstrably has not moved, so it is excluded, the total is the fee alone, and the
1565+
/// spend auto-approves under the same 10 mojo bound that escalated the exploit.
1566+
///
1567+
/// Without this control the test above would also pass on an implementation that counted every
1568+
/// output unconditionally — which would make every real send unspendable while looking strict.
1569+
#[test]
1570+
fn change_returning_to_the_spent_coins_own_puzzle_hash_is_not_counted() {
14591571
let mut ctx = SpendContext::new();
14601572
StandardLayer::new(spender().public_key())
14611573
.spend(
14621574
&mut ctx,
14631575
Coin::new(Bytes32::new([1u8; 32]), spender().puzzle_hash(), 1_000),
1464-
// Un-hinted: `analyze` files this as CHANGE, so it never reaches `recipients`.
14651576
Conditions::new()
1466-
.create_coin(third_party().puzzle_hash(), 999, Memos::None)
1577+
.create_coin(spender().puzzle_hash(), 999, Memos::None)
14671578
.reserve_fee(1),
14681579
)
14691580
.unwrap();
@@ -1481,20 +1592,58 @@ mod tests {
14811592
let approved = approval(gate.authorize_op(&coin_spends, SpendOpClass::Rebalance));
14821593
assert!(
14831594
approved.summary().recipients.is_empty(),
1484-
"an un-hinted output is invisible to the summary"
1595+
"returning change to the coin's own puzzle hash moves no value"
14851596
);
1486-
assert_eq!(approved.summary().fee, 1, "only the fee is visible");
1597+
assert_eq!(approved.summary().fee, 1);
1598+
assert_eq!(ledger_total(&gate), 1, "only the fee is charged");
1599+
}
14871600

1488-
let signer = LocalMoneySigner::new_canonical(
1489-
SPENDER_SEED.to_vec(),
1490-
ProfileIx::ROOT.0,
1491-
Network::Mainnet,
1492-
)
1493-
.unwrap();
1494-
let err = signer.sign_approved(approved).unwrap_err();
1495-
assert!(
1496-
matches!(err, AccountError::Spend(_)),
1497-
"the signer must refuse un-hinted value leaving the wallet: {err}"
1601+
/// **Change paid to a FRESH derivation of the same wallet is counted. That is intended.**
1602+
///
1603+
/// This is the deliberate cost of the rule above, recorded as behaviour rather than discovered as a
1604+
/// bug (`SPEC.md` §6.1.1). This layer holds no key: it cannot tell a fresh derivation of the user's
1605+
/// own wallet from a stranger's address, and the only way it could would be to accept "any owned
1606+
/// derivation" as change — which is exactly the exfiltration target the exploit above uses.
1607+
///
1608+
/// So the rule over-counts, and a legitimate send whose change goes to a fresh address escalates to
1609+
/// the human instead of auto-sending. Over-counting asks a person; under-counting signs. Only one
1610+
/// of those is a custody failure.
1611+
#[test]
1612+
fn change_to_a_fresh_derivation_is_deliberately_overcounted_and_escalates() {
1613+
let fresh_change = WalletKey::from_seed_at(&SPENDER_SEED, ProfileIx(9)).puzzle_hash();
1614+
1615+
let mut ctx = SpendContext::new();
1616+
let recipient = third_party().puzzle_hash();
1617+
let hint = ctx.hint(recipient).unwrap();
1618+
StandardLayer::new(spender().public_key())
1619+
.spend(
1620+
&mut ctx,
1621+
Coin::new(Bytes32::new([1u8; 32]), spender().puzzle_hash(), 1_000),
1622+
Conditions::new()
1623+
.create_coin(recipient, 5, hint)
1624+
.create_coin(fresh_change, 994, Memos::None)
1625+
.reserve_fee(1),
1626+
)
1627+
.unwrap();
1628+
let coin_spends = ctx.take();
1629+
1630+
let gate = gate_with(
1631+
hot_custody(),
1632+
AutoSendPolicy {
1633+
enabled: true,
1634+
rebalance: OpClassLimits::enabled_up_to(10),
1635+
period_cap_mojos: 1_000,
1636+
..AutoSendPolicy::default()
1637+
},
1638+
);
1639+
1640+
// The genuine payment is 5 mojos, well inside the 10 mojo bound. It escalates anyway, because
1641+
// the 994 of change to an address this layer cannot vouch for is counted as leaving.
1642+
let escalated = pending(gate.authorize_op(&coin_spends, SpendOpClass::Rebalance));
1643+
assert_eq!(
1644+
escalated.summary().native_total_mojos(),
1645+
1_000,
1646+
"the fresh-derivation change is counted, by design"
14981647
);
14991648
}
15001649

0 commit comments

Comments
 (0)