Skip to content

Commit f3045e4

Browse files
feat(wallet)!: bind every approval to the gate's custody scope
R5: a PolicyAuthorizer now stamps a CustodyScope (profile index, plus the configured wallet for a hot profile) onto every permission it mints, and sign_approved refuses one minted by a different profile's gate. Without it a host holding a hot gate for profile 0 and a signer for vault profile 1 could route profile 1's spend through profile 0's gate: it auto-approves (the gate judges outputs, never whose key owns the inputs) and profile 1's signer holds the key, so the vault destination rule and clawback window never ran. The two sides have genuinely different provenance -- the scope comes from the host's persisted custody configuration at gate construction, the signer's from the live master seed at sign time -- so this is a real comparison rather than two derivations of one input. Also: C4 routes the three gate-refusal tests through dig-account's own derivation instead of the dependency's; C5 LF-normalizes the production/test split so the structural invariants hold on a CRLF checkout; the summary single-derivation invariant now tracks analyze(); and the confirm-twice compile-fail fixture follows confirm_with. Refs #1702 Co-Authored-By: Claude <noreply@anthropic.com>
1 parent b5c2f6d commit f3045e4

10 files changed

Lines changed: 402 additions & 34 deletions

src/wallet/approval.rs

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ use dig_wallet_backend::types::TransactionSummary;
3737
use crate::auth::provider::{AuthProvider, SpendConfirmRequest, SpendDecision};
3838
use crate::error::{AccountError, Result};
3939
use crate::id::{AccountId, ProfileIx};
40+
use crate::wallet::policy::CustodyScope;
4041
use crate::wallet::summary::SpendSummary;
4142

4243
/// The spends a ruling was made about, together with the single derivation made from them.
@@ -58,6 +59,9 @@ struct AuthorizedSpend {
5859
/// disagree shape this whole type exists to remove. What protects the caller is that `coin_spends`
5960
/// below is the same `Vec` the gate judged.
6061
verified: TransactionSummary,
62+
/// WHOSE money the gate that minted this was configured to rule over, so the signer can refuse a
63+
/// permission granted by some other profile's gate. See [`CustodyScope`].
64+
scope: CustodyScope,
6165
}
6266

6367
/// A spend the custody gate has PERMITTED, carrying the exact coin spends it permitted.
@@ -77,12 +81,14 @@ impl SpendApproval {
7781
coin_spends: Vec<CoinSpend>,
7882
summary: SpendSummary,
7983
verified: TransactionSummary,
84+
scope: CustodyScope,
8085
) -> Self {
8186
Self {
8287
inner: AuthorizedSpend {
8388
coin_spends,
8489
summary,
8590
verified,
91+
scope,
8692
},
8793
}
8894
}
@@ -102,6 +108,12 @@ impl SpendApproval {
102108
pub(crate) fn verified(&self) -> &TransactionSummary {
103109
&self.inner.verified
104110
}
111+
112+
/// The custody scope the minting gate was configured for. `pub(crate)`: the signer's admission
113+
/// check, not a value a host reads.
114+
pub(crate) fn scope(&self) -> &CustodyScope {
115+
&self.inner.scope
116+
}
105117
}
106118

107119
/// A spend the custody gate would permit only with a human's agreement — the escalatable outcome.
@@ -119,12 +131,14 @@ impl PendingApproval {
119131
coin_spends: Vec<CoinSpend>,
120132
summary: SpendSummary,
121133
verified: TransactionSummary,
134+
scope: CustodyScope,
122135
) -> Self {
123136
Self {
124137
inner: AuthorizedSpend {
125138
coin_spends,
126139
summary,
127140
verified,
141+
scope,
128142
},
129143
}
130144
}
@@ -227,6 +241,16 @@ mod tests {
227241
}
228242
}
229243

244+
/// Any scope: these tests are about the ceremony's OUTCOME, not about admission, which is
245+
/// enforced in the signer and tested in `policy.rs`/`authorizer.rs`.
246+
fn scope() -> CustodyScope {
247+
CustodyScope::new(
248+
ProfileIx::ROOT,
249+
&crate::wallet::policy::CustodyPolicy::Hot(Default::default()),
250+
chia_protocol::Bytes32::new([0u8; 32]),
251+
)
252+
}
253+
230254
fn summary() -> SpendSummary {
231255
SpendSummary::new(
232256
SpendTier::Confirm,
@@ -263,7 +287,7 @@ mod tests {
263287
/// from the same construction, so they cannot describe different spends.
264288
#[test]
265289
fn an_approval_exposes_the_spends_and_the_summary_it_was_built_from() {
266-
let approval = SpendApproval::new(vec![coin_spend(99)], summary(), verified());
290+
let approval = SpendApproval::new(vec![coin_spend(99)], summary(), verified(), scope());
267291
assert_eq!(approval.coin_spends(), &[coin_spend(99)]);
268292
assert_eq!(approval.summary().recipients[0].amount_mojos, 42);
269293
assert_eq!(approval.summary().fee, 7);
@@ -273,7 +297,7 @@ mod tests {
273297
/// spends, so these are the spends that become signable.
274298
#[test]
275299
fn confirming_a_pending_approval_yields_an_approval_over_the_very_same_spends() {
276-
let pending = PendingApproval::new(vec![coin_spend(1234)], summary(), verified());
300+
let pending = PendingApproval::new(vec![coin_spend(1234)], summary(), verified(), scope());
277301
assert_eq!(pending.summary().recipients[0].amount_mojos, 42);
278302

279303
let approval = pending.decided(SpendDecision::Approve).unwrap();
@@ -284,7 +308,7 @@ mod tests {
284308
/// A decline is `UserDeclined` — terminal — not an escalatable refusal a caller could re-prompt.
285309
#[test]
286310
fn a_declined_ceremony_denies_outright_and_never_yields_an_approval() {
287-
let pending = PendingApproval::new(vec![coin_spend(1)], summary(), verified());
311+
let pending = PendingApproval::new(vec![coin_spend(1)], summary(), verified(), scope());
288312
let err = denial(pending.decided(SpendDecision::Decline(Some("not mine".into()))));
289313
assert!(
290314
matches!(&err, AccountError::UserDeclined(m) if m.contains("declined") && m.contains("not mine")),
@@ -295,7 +319,7 @@ mod tests {
295319
/// A decline with no stated reason is still a decline, and still denied.
296320
#[test]
297321
fn a_reasonless_decline_is_still_denied() {
298-
let pending = PendingApproval::new(vec![coin_spend(1)], summary(), verified());
322+
let pending = PendingApproval::new(vec![coin_spend(1)], summary(), verified(), scope());
299323
let err = denial(pending.decided(SpendDecision::Decline(None)));
300324
assert!(matches!(err, AccountError::UserDeclined(_)), "{err:?}");
301325
}

src/wallet/authorizer.rs

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,7 @@ mod tests {
212212
let coin_spends = ctx.take();
213213

214214
let gate = PolicyAuthorizer::new(
215+
ProfileIx::ROOT,
215216
CustodyPolicy::Hot(HotWallet {
216217
auto_send_limit: 10_000,
217218
}),
@@ -241,4 +242,150 @@ mod tests {
241242
"the signature is paired with the very spends the gate approved"
242243
);
243244
}
245+
246+
/// A spend of profile `ix`'s coins, paying `recipient`, ready for the gate.
247+
///
248+
/// Built at the profile's OWN canonical puzzle hash, so the profile's signer genuinely controls
249+
/// the coin — which is what makes the scope tests below able to fail for only one reason.
250+
fn send_from_profile(ix: ProfileIx, recipient: chia_protocol::Bytes32) -> Vec<CoinSpend> {
251+
use chia_puzzle_types::Memos;
252+
use chia_wallet_sdk::driver::{SpendContext, StandardLayer};
253+
use chia_wallet_sdk::types::Conditions;
254+
255+
let key = WalletKey::from_seed_at(&seed().master_seed()[..], ix);
256+
let mut ctx = SpendContext::new();
257+
let hint = ctx.hint(recipient).unwrap();
258+
StandardLayer::new(key.public_key())
259+
.spend(
260+
&mut ctx,
261+
chia_protocol::Coin::new(
262+
chia_protocol::Bytes32::new([1u8; 32]),
263+
key.puzzle_hash(),
264+
1_000,
265+
),
266+
Conditions::new()
267+
.create_coin(recipient, 600, hint)
268+
.create_coin(key.puzzle_hash(), 390, Memos::None)
269+
.reserve_fee(10),
270+
)
271+
.unwrap();
272+
ctx.take()
273+
}
274+
275+
/// A gate ruling for `profile`, whose configured hot wallet is `profile`'s own wallet.
276+
fn hot_gate_for(profile: ProfileIx) -> crate::wallet::enforcer::PolicyAuthorizer {
277+
use crate::wallet::autosend::{AutoSendPolicy, OpClassLimits};
278+
use crate::wallet::clock::FixedClock;
279+
use crate::wallet::enforcer::PolicyAuthorizer;
280+
use crate::wallet::policy::HotWallet;
281+
282+
let wallet = WalletKey::from_seed_at(&seed().master_seed()[..], profile);
283+
PolicyAuthorizer::new(
284+
profile,
285+
CustodyPolicy::Hot(HotWallet {
286+
auto_send_limit: 10_000,
287+
}),
288+
AutoSendPolicy {
289+
enabled: true,
290+
small_send: OpClassLimits::enabled_up_to(1_000),
291+
period_cap_mojos: 1_000,
292+
..AutoSendPolicy::default()
293+
},
294+
&wallet.address().unwrap(),
295+
Arc::new(FixedClock::new(1_800_000_000)),
296+
)
297+
.unwrap()
298+
}
299+
300+
/// Approve `coin_spends` through `gate`, insisting on the auto-approved outcome.
301+
fn auto_approved(
302+
gate: &crate::wallet::enforcer::PolicyAuthorizer,
303+
coin_spends: &[CoinSpend],
304+
) -> crate::wallet::approval::SpendApproval {
305+
use crate::wallet::approval::SpendRuling;
306+
use crate::wallet::autosend::SpendOpClass;
307+
match gate.authorize_op(coin_spends, SpendOpClass::SmallSend) {
308+
Ok(SpendRuling::Approved(approval)) => approval,
309+
Ok(SpendRuling::RequiresConfirmation(_)) => panic!("a 610 mojo send is within bounds"),
310+
Err(e) => panic!("the gate refused a legitimate send: {e}"),
311+
}
312+
}
313+
314+
/// **An approval minted by one profile's gate cannot be signed by another profile's signer.**
315+
///
316+
/// The exploit this closes: profile 1 holds VAULT coins, so its spends are meant to face the vault
317+
/// destination rule and the clawback window. A host that also holds an ordinary `Hot` gate for
318+
/// profile 0 can route profile 1's spend through THAT gate instead — it auto-approves, because the
319+
/// gate inspects only the spend's outputs and its own configuration, never the profile the input
320+
/// coins belong to — and then sign with profile 1's signer, which does control those coins.
321+
/// Hot-wallet treatment for vault money, with every advertised bound satisfied.
322+
///
323+
/// The fixture is built so the scope check is the ONLY thing that can refuse: the coins are
324+
/// profile 1's own, so profile 1's signer genuinely holds the key and the signature would
325+
/// otherwise succeed. `a_gate_and_signer_for_the_same_profile_still_sign` is the control that
326+
/// proves it.
327+
#[test]
328+
fn an_approval_minted_for_one_profile_is_refused_by_another_profiles_signer() {
329+
use crate::wallet::money_signer::MoneySigner;
330+
331+
let coin_spends = send_from_profile(ProfileIx(1), chia_protocol::Bytes32::new([7u8; 32]));
332+
let approval = auto_approved(&hot_gate_for(ProfileIx::ROOT), &coin_spends);
333+
334+
let vault_profile_signer =
335+
WalletOps::new(seed(), ProfileIx(1), live()).money_signer(Network::Mainnet);
336+
let err = vault_profile_signer
337+
.sign_approved(approval)
338+
.expect_err("a signer must refuse an approval another profile's gate minted");
339+
assert!(
340+
matches!(&err, crate::error::AccountError::PolicyDenied(m)
341+
if m.contains("profile")),
342+
"the refusal must name the mismatch, not a key failure: {err:?}"
343+
);
344+
}
345+
346+
/// The truthful control: same coins, same signer, gate scoped to the RIGHT profile — and it signs.
347+
///
348+
/// Without this the test above proves nothing, because a signer for the wrong profile would also
349+
/// fail simply by not holding the key. Here the key, the coins and the gate all agree, so the only
350+
/// difference between the two tests is which profile the gate was built for.
351+
#[test]
352+
fn a_gate_and_signer_for_the_same_profile_still_sign() {
353+
use crate::wallet::money_signer::MoneySigner;
354+
355+
let coin_spends = send_from_profile(ProfileIx(1), chia_protocol::Bytes32::new([7u8; 32]));
356+
let approval = auto_approved(&hot_gate_for(ProfileIx(1)), &coin_spends);
357+
358+
let bundle = WalletOps::new(seed(), ProfileIx(1), live())
359+
.money_signer(Network::Mainnet)
360+
.sign_approved(approval)
361+
.expect("a correctly-scoped approval must still sign");
362+
assert_eq!(bundle.coin_spends, coin_spends);
363+
}
364+
365+
/// **A relocked session stops signing, even through a signer built while it was live.**
366+
///
367+
/// The approval is minted and the signer built BEFORE the lock, which is the whole hazard: the old
368+
/// signer copied the seed at construction, so it kept signing forever and `lock()` was a hint. The
369+
/// signer now observes the session, so revocation reaches it.
370+
#[test]
371+
fn a_signer_built_before_the_lock_refuses_after_it() {
372+
use crate::wallet::money_signer::MoneySigner;
373+
374+
let coin_spends = send_from_profile(ProfileIx(1), chia_protocol::Bytes32::new([7u8; 32]));
375+
let approval = auto_approved(&hot_gate_for(ProfileIx(1)), &coin_spends);
376+
377+
let residency = live();
378+
let signer =
379+
WalletOps::new(seed(), ProfileIx(1), residency.clone()).money_signer(Network::Mainnet);
380+
381+
residency.revoke();
382+
383+
let err = signer
384+
.sign_approved(approval)
385+
.expect_err("a signer must not outlive the unlock that produced it");
386+
assert!(
387+
matches!(err, crate::error::AccountError::Locked),
388+
"a locked session is a named outcome, not a generic spend failure: {err:?}"
389+
);
390+
}
244391
}

src/wallet/enforcer.rs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,11 @@ use chia_protocol::{Bytes32, CoinSpend};
6666
use chia_wallet_sdk::utils::Address;
6767

6868
use crate::error::{AccountError, Result};
69+
use crate::id::ProfileIx;
6970
use crate::wallet::approval::{PendingApproval, SpendApproval, SpendRuling};
7071
use crate::wallet::autosend::{AutoSendPolicy, SpendOpClass, MAX_LEDGER_ENTRIES};
7172
use crate::wallet::clock::Clock;
72-
use crate::wallet::policy::CustodyPolicy;
73+
use crate::wallet::policy::{CustodyPolicy, CustodyScope};
7374
use crate::wallet::summary::{DerivedSpend, SpendSummary, SpendTier};
7475

7576
/// One auto-approved spend, recorded so the rolling period cap can be measured.
@@ -120,6 +121,9 @@ pub struct PolicyAuthorizer {
120121
/// the coin, whereas two address strings can differ (network prefix, casing) while naming the
121122
/// same key, and two identical strings cannot be compared at all if either fails to decode.
122123
hot_wallet_puzzle_hash: Bytes32,
124+
/// WHOSE money this gate rules over, stamped onto every permission it mints so a signer for a
125+
/// different profile refuses it. See [`CustodyScope`].
126+
scope: CustodyScope,
123127
clock: Arc<dyn Clock>,
124128
/// Auto-approvals inside the current rolling window, oldest first.
125129
recent: Mutex<VecDeque<AutoSendRecord>>,
@@ -139,6 +143,7 @@ impl PolicyAuthorizer {
139143
/// here so an unusable value is a construction error rather than a comparison that silently never
140144
/// matches at authorization time.
141145
pub fn new(
146+
profile: ProfileIx,
142147
custody: CustodyPolicy,
143148
auto_send: AutoSendPolicy,
144149
hot_wallet_address: &str,
@@ -150,6 +155,7 @@ impl PolicyAuthorizer {
150155
))
151156
})?;
152157
Ok(Self {
158+
scope: CustodyScope::new(profile, &custody, hot_wallet.puzzle_hash),
153159
custody,
154160
auto_send,
155161
hot_wallet_puzzle_hash: hot_wallet.puzzle_hash,
@@ -273,6 +279,7 @@ impl PolicyAuthorizer {
273279
coin_spends.to_vec(),
274280
derived.summary,
275281
derived.verified,
282+
self.scope,
276283
))),
277284
CapVerdict::OverCap => self.escalate(coin_spends, derived),
278285
}
@@ -394,6 +401,7 @@ impl PolicyAuthorizer {
394401
coin_spends.to_vec(),
395402
derived.summary,
396403
derived.verified,
404+
self.scope,
397405
)))
398406
}
399407

@@ -445,7 +453,6 @@ enum CapVerdict {
445453
#[cfg(test)]
446454
mod tests {
447455
use super::*;
448-
use crate::id::ProfileIx;
449456
use crate::keys::wallet_key::WalletKey;
450457
use crate::wallet::autosend::{OpClassLimits, DEFAULT_PERIOD_SECONDS};
451458
use crate::wallet::clock::{FixedClock, UnreadableClock};
@@ -460,6 +467,11 @@ mod tests {
460467
/// than whatever the wall clock happens to read.
461468
const NOW: u64 = 1_800_000_000;
462469

470+
/// The profile every gate in this module rules for. These tests exercise the gate's DECISIONS,
471+
/// which do not depend on the index; the scope it stamps is exercised where it is enforced, in
472+
/// `authorizer.rs` and `policy.rs`.
473+
const GATE_PROFILE: ProfileIx = ProfileIx::ROOT;
474+
463475
/// Comfortably above every per-transaction limit these tests configure, so a spend's TIER is
464476
/// decided by the custody policy while its APPROVAL is decided by the auto-send limits. If this
465477
/// were set at the auto-send limit instead, an over-limit spend would be refused by
@@ -627,6 +639,7 @@ mod tests {
627639
) -> (PolicyAuthorizer, Arc<FixedClock>) {
628640
let clock = Arc::new(FixedClock::new(NOW));
629641
let gate = PolicyAuthorizer::new(
642+
GATE_PROFILE,
630643
custody,
631644
auto_send,
632645
&hot_wallet().address().unwrap(),
@@ -1276,6 +1289,7 @@ mod tests {
12761289
#[test]
12771290
fn an_unreadable_clock_refuses_rather_than_assuming_an_empty_window() {
12781291
let gate = PolicyAuthorizer::new(
1292+
GATE_PROFILE,
12791293
hot_custody(),
12801294
permissive_auto_send(),
12811295
&hot_wallet().address().unwrap(),
@@ -1365,6 +1379,7 @@ mod tests {
13651379
fn a_gate_cannot_be_built_on_an_undecodable_hot_wallet_address() {
13661380
for address in ["", "nonsense", "xch1zzzz"] {
13671381
let err = PolicyAuthorizer::new(
1382+
GATE_PROFILE,
13681383
hot_custody(),
13691384
permissive_auto_send(),
13701385
address,

0 commit comments

Comments
 (0)