diff --git a/contracts/credence_delegation/src/lib.rs b/contracts/credence_delegation/src/lib.rs index bfc09d5..34d0a72 100644 --- a/contracts/credence_delegation/src/lib.rs +++ b/contracts/credence_delegation/src/lib.rs @@ -65,6 +65,8 @@ enum DataKey { #[contract] pub struct CredenceDelegation; +const MAX_NONCE_INVALIDATION_SPAN: u64 = 10_000; + #[contractimpl] impl CredenceDelegation { /// Initialize the contract with an admin address. @@ -280,6 +282,25 @@ impl CredenceDelegation { nonce::get_nonce(&e, &identity) } + /// Invalidate a bounded range of nonces for compromised-key recovery. + /// + /// Advances nonce to `new_nonce`, invalidating all payloads signed with + /// nonces in `[current_nonce, new_nonce)`. + /// + /// Security properties: + /// - Only `identity` can invalidate its own nonce stream. + /// - Nonce remains strictly monotonic (`new_nonce` must be greater). + /// - Range size is capped to keep gas predictable. + pub fn invalidate_nonce_range(e: Env, identity: Address, new_nonce: u64) { + identity.require_auth(); + let (from_nonce, to_nonce) = + nonce::invalidate_nonce_range(&e, &identity, new_nonce, MAX_NONCE_INVALIDATION_SPAN); + e.events().publish( + (Symbol::new(&e, "nonce_invalidated"), identity), + (from_nonce, to_nonce), + ); + } + // ----------------------------------------------------------------------- // Pausable pass-throughs // ----------------------------------------------------------------------- diff --git a/contracts/credence_delegation/src/nonce.rs b/contracts/credence_delegation/src/nonce.rs index d7a6008..2e4e008 100644 --- a/contracts/credence_delegation/src/nonce.rs +++ b/contracts/credence_delegation/src/nonce.rs @@ -32,3 +32,34 @@ pub fn consume_nonce(e: &Env, identity: &Address, expected_nonce: u64) { .instance() .set(&DataKey::Nonce(identity.clone()), &next); } + +/// Advances nonce to `new_nonce`, invalidating the half-open range +/// `[current_nonce, new_nonce)`. +/// +/// This allows compromised-key recovery by skipping potentially leaked, +/// pre-signed delegated payloads without submitting each nonce one-by-one. +/// +/// # Panics +/// Panics if `new_nonce <= current_nonce` or if the span exceeds `max_span`. +pub fn invalidate_nonce_range( + e: &Env, + identity: &Address, + new_nonce: u64, + max_span: u64, +) -> (u64, u64) { + let current = get_nonce(e, identity); + if new_nonce <= current { + panic!("new nonce must be greater than current nonce"); + } + let span = new_nonce + .checked_sub(current) + .expect("nonce underflow during invalidation"); + if span > max_span { + panic!("nonce invalidation exceeds max batch size"); + } + + e.storage() + .instance() + .set(&DataKey::Nonce(identity.clone()), &new_nonce); + (current, new_nonce) +} diff --git a/contracts/credence_delegation/src/test_domain_separation.rs b/contracts/credence_delegation/src/test_domain_separation.rs index efab4c4..95b11b3 100644 --- a/contracts/credence_delegation/src/test_domain_separation.rs +++ b/contracts/credence_delegation/src/test_domain_separation.rs @@ -10,7 +10,7 @@ //! 5. Cross-method replay: a revoke payload cannot be reused as a delegate payload. use super::*; -use soroban_sdk::testutils::{Address as _, Ledger as _}; +use soroban_sdk::testutils::Address as _; use soroban_sdk::Env; // --------------------------------------------------------------------------- @@ -29,7 +29,7 @@ fn setup() -> (Env, CredenceDelegationClient<'static>, Address) { /// Build a valid `DelegatedActionPayload` for the given parameters. fn make_payload( - e: &Env, + _e: &Env, domain: DomainTag, owner: &Address, target: &Address, @@ -344,6 +344,88 @@ fn wrong_target_in_payload_rejected() { // Happy path: full delegated round-trip (delegate → revoke) // --------------------------------------------------------------------------- +#[test] +fn partial_nonce_invalidation_skips_range_and_allows_next_nonce() { + let (e, client, contract_id) = setup(); + let owner = Address::generate(&e); + let delegate = Address::generate(&e); + let expiry = e.ledger().timestamp() + 86_400; + + // Consume nonces 0 and 1 normally. + let p0 = make_payload(&e, DomainTag::Delegate, &owner, &delegate, &contract_id, 0); + client.execute_delegated_delegate( + &owner, + &delegate, + &DelegationType::Attestation, + &expiry, + &p0, + ); + let p1 = make_payload(&e, DomainTag::Delegate, &owner, &delegate, &contract_id, 1); + client.execute_delegated_delegate(&owner, &delegate, &DelegationType::Management, &expiry, &p1); + assert_eq!(client.get_nonce(&owner), 2); + + // Invalidate [2, 4): nonce 2 and 3 become unusable. + client.invalidate_nonce_range(&owner, &4); + assert_eq!(client.get_nonce(&owner), 4); + + // Fresh nonce 4 must still be usable. + let p4 = make_payload(&e, DomainTag::Delegate, &owner, &delegate, &contract_id, 4); + client.execute_delegated_delegate( + &owner, + &delegate, + &DelegationType::Attestation, + &expiry, + &p4, + ); + assert_eq!(client.get_nonce(&owner), 5); +} + +#[test] +#[should_panic(expected = "invalid nonce")] +fn full_nonce_invalidation_rejects_previously_valid_payload() { + let (e, client, contract_id) = setup(); + let owner = Address::generate(&e); + let delegate = Address::generate(&e); + let expiry = e.ledger().timestamp() + 86_400; + + // This payload is valid at nonce 0 before invalidation. + let stale_payload = make_payload(&e, DomainTag::Delegate, &owner, &delegate, &contract_id, 0); + + // Invalidate a full early range [0, 10). + client.invalidate_nonce_range(&owner, &10); + assert_eq!(client.get_nonce(&owner), 10); + + // Previously valid payload must now fail. + client.execute_delegated_delegate( + &owner, + &delegate, + &DelegationType::Attestation, + &expiry, + &stale_payload, + ); +} + +#[test] +#[should_panic(expected = "nonce invalidation exceeds max batch size")] +fn nonce_invalidation_range_bound_enforced() { + let (e, client, _) = setup(); + let owner = Address::generate(&e); + + // MAX_NONCE_INVALIDATION_SPAN is 10_000, so 10_001 must fail from nonce 0. + client.invalidate_nonce_range(&owner, &10_001); +} + +#[test] +#[should_panic(expected = "new nonce must be greater than current nonce")] +fn nonce_invalidation_must_be_monotonic() { + let (e, client, _) = setup(); + let owner = Address::generate(&e); + + client.invalidate_nonce_range(&owner, &1); + // Reusing the same target is non-monotonic and must fail. + client.invalidate_nonce_range(&owner, &1); +} + #[test] fn happy_path_delegated_delegate_then_revoke() { let (e, client, contract_id) = setup();