Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions contracts/credence_delegation/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
// -----------------------------------------------------------------------
Expand Down
31 changes: 31 additions & 0 deletions contracts/credence_delegation/src/nonce.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
86 changes: 84 additions & 2 deletions contracts/credence_delegation/src/test_domain_separation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

// ---------------------------------------------------------------------------
Expand All @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
Loading