Skip to content
Open
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
59 changes: 56 additions & 3 deletions contracts/agent-passport-validator/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,12 @@ pub enum Error {
UnknownRegistryRoot = 6,
/// Batch size exceeds the limit of 8.
BatchTooLarge = 7,
feat/credential-revocation
/// The credential root has been revoked.
CredentialRevoked = 9,
/// This wallet exceeded the per-ledger credential verification limit.
RateLimitExceeded = 8,
main
}

#[contracttype]
Expand Down Expand Up @@ -153,6 +157,13 @@ pub struct VerifyResult {
pub error: Option<Symbol>,
}

#[contracttype]
#[derive(Clone, Debug)]
pub struct CredentialRevokedEvent {
pub root: BytesN<32>,
pub revoked_at_ledger: u32,
}

#[contracttype]
enum DataKey {
Admin,
Expand All @@ -169,7 +180,11 @@ enum DataKey {
RegistryRoots,
AuditEntry(u64),
AuditSequence,
feat/credential-revocation
/// Revoked credential Merkle root -> revoked (presence == revoked).
RevokedRoot(BytesN<32>),
VerifyCount(Address, u32),
main
}

#[contract]
Expand Down Expand Up @@ -325,6 +340,9 @@ impl AgentPassportValidator {
Error::UnknownRegistryRoot => {
Some(Symbol::new(&env, "UnknownRegistryRoot"))
}
feat/credential-revocation
Error::CredentialRevoked => Some(Symbol::new(&env, "CredentialRevoked")),
main
_ => Some(Symbol::new(&env, "Error")),
};
results.push_back(VerifyResult {
Expand Down Expand Up @@ -557,8 +575,16 @@ impl AgentPassportValidator {
root: BytesN<32>,
success: bool,
) -> Result<bool, Error> {
// Check if the credential root has been revoked first,
// so revoked roots don't consume caller quota/rate-limit budget.
let instance = env.storage().instance();
if instance.has(&DataKey::RevokedRoot(root.clone())) {
return Err(Error::CredentialRevoked);
}

actor.require_auth();

feat/credential-revocation
let ledger_sequence = env.ledger().sequence();
let count_key = DataKey::VerifyCount(actor.clone(), ledger_sequence);
let temporary = env.storage().temporary();
Expand All @@ -580,6 +606,7 @@ impl AgentPassportValidator {
);

let instance = env.storage().instance();
main
let seq: u64 = instance.get(&DataKey::AuditSequence).unwrap_or(0);

let action = if success {
Expand Down Expand Up @@ -607,20 +634,41 @@ impl AgentPassportValidator {
Ok(success)
}

/// Admin-only: Mark a credential Merkle root as revoked.
/// Once revoked, any verify_credential call with this root will fail
/// with CredentialRevoked.
pub fn revoke_credential(env: Env, actor: Address, root: BytesN<32>) -> Result<(), Error> {
actor.require_auth();
// Admin-only check.
let admin: Address = env
.storage()
.instance()
.get(&DataKey::Admin)
.ok_or(Error::NotInitialized)?;
admin.require_auth();

let instance = env.storage().instance();
let seq: u64 = instance.get(&DataKey::AuditSequence).unwrap_or(0);

// Mark the root as revoked.
instance.set(&DataKey::RevokedRoot(root.clone()), &true);

// Emit the credential_revoked event.
env.events().publish(
(Symbol::new(&env, "credential_revoked"),),
CredentialRevokedEvent {
root: root.clone(),
revoked_at_ledger: env.ledger().sequence(),
},
);

// Audit log entry for the revoke action.
let seq: u64 = instance.get(&DataKey::AuditSequence).unwrap_or(0);
let record = AuditRecord {
action: Symbol::new(&env, "revoke"),
actor: actor.clone(),
root,
ledger: env.ledger().sequence(),
success: true,
};

let persistent = env.storage().persistent();
let key = DataKey::AuditEntry(seq);
persistent.set(&key, &record);
Expand All @@ -632,6 +680,11 @@ impl AgentPassportValidator {
Ok(())
}

/// Public read function: check if a credential root has been revoked.
pub fn is_revoked(env: Env, root: BytesN<32>) -> bool {
env.storage().instance().has(&DataKey::RevokedRoot(root))
}

pub fn get_audit_entry(env: Env, seq: u64) -> Option<AuditRecord> {
env.storage().persistent().get(&DataKey::AuditEntry(seq))
}
Expand Down
139 changes: 139 additions & 0 deletions contracts/agent-passport-validator/src/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,144 @@ fn test_audit_logging() {
assert_eq!(entry3.success, true);
}

feat/credential-revocation
// ---------------------------------------------------------------------------
// Credential revocation tests
// ---------------------------------------------------------------------------

#[test]
fn admin_can_revoke_credential_root() {
let env = Env::default();
let client = setup(&env, u256(&env, PI_ROOT));

let root = BytesN::from_array(&env, &[1u8; 32]);
assert!(!client.is_revoked(&root));

env.mock_all_auths();
client.revoke_credential(&Address::generate(&env), &root);

assert!(client.is_revoked(&root));
}

#[test]
fn verify_credential_fails_with_credential_revoked_after_revoke() {
let env = Env::default();
let client = setup(&env, u256(&env, PI_ROOT));

let actor = Address::generate(&env);
let root = BytesN::from_array(&env, &[2u8; 32]);

env.mock_all_auths();

// First, verify succeeds
let res = client.verify_credential(&actor, &root, &true);
assert_eq!(res, true);

// Now revoke the root
client.revoke_credential(&actor, &root);

// Subsequent verify_credential with that root fails with CredentialRevoked
let res = client.try_verify_credential(&actor, &root, &true);
assert_eq!(res, Err(Ok(Error::CredentialRevoked)));
}

#[test]
fn is_revoked_returns_true_for_revoked_roots() {
let env = Env::default();
let client = setup(&env, u256(&env, PI_ROOT));

let root = BytesN::from_array(&env, &[3u8; 32]);
assert!(!client.is_revoked(&root));

env.mock_all_auths();
client.revoke_credential(&Address::generate(&env), &root);

assert!(client.is_revoked(&root));
}

#[test]
fn issue_verify_revoke_verify_flow() {
let env = Env::default();
let client = setup(&env, u256(&env, PI_ROOT));

let actor = Address::generate(&env);
let root = BytesN::from_array(&env, &[4u8; 32]);

env.mock_all_auths();

// Issue → verify (passes)
client.issue_credential(&actor, &root);
let res = client.verify_credential(&actor, &root, &true);
assert_eq!(res, true);

// Revoke
client.revoke_credential(&actor, &root);

// Verify fails with CredentialRevoked
let res = client.try_verify_credential(&actor, &root, &true);
assert_eq!(res, Err(Ok(Error::CredentialRevoked)));
}

#[test]
fn credential_revoked_event_emitted_with_correct_fields() {
let env = Env::default();
let (validator_addr, _, client) = setup_with_id(&env, u256(&env, PI_ROOT));

let actor = Address::generate(&env);
let root = BytesN::from_array(&env, &[5u8; 32]);

env.ledger().set_sequence_number(5000);

env.mock_all_auths();
client.revoke_credential(&actor, &root);

let events = env.events().all().filter_by_contract(&validator_addr);
// We expect at least the credential_revoked event (there may also be audit events)
assert!(events.events().len() >= 1, "expected at least one event");
}

#[test]
fn non_admin_call_to_revoke_credential_returns_unauthorized() {
let env = Env::default();
let client = setup(&env, u256(&env, PI_ROOT));

// A non-admin-generated address calling revoke_credential should fail
// because require_auth() will panic when the address hasn't been authorized.
let non_admin = Address::generate(&env);
let root = BytesN::from_array(&env, &[6u8; 32]);

let res = client.try_revoke_credential(&non_admin, &root);
assert!(res.is_err());
}

#[test]
fn revoked_root_remains_revoked_across_multiple_checks() {
let env = Env::default();
let client = setup(&env, u256(&env, PI_ROOT));

let root = BytesN::from_array(&env, &[7u8; 32]);

env.mock_all_auths();
client.revoke_credential(&Address::generate(&env), &root);

assert!(client.is_revoked(&root));
assert!(client.is_revoked(&root));
assert!(client.is_revoked(&root));
}

#[test]
fn different_roots_are_independent_for_revocation() {
let env = Env::default();
let client = setup(&env, u256(&env, PI_ROOT));

let root_a = BytesN::from_array(&env, &[8u8; 32]);
let root_b = BytesN::from_array(&env, &[9u8; 32]);

env.mock_all_auths();
client.revoke_credential(&Address::generate(&env), &root_a);

assert!(client.is_revoked(&root_a));
assert!(!client.is_revoked(&root_b));
#[test]
fn rate_limits_verification_per_wallet_and_ledger() {
let env = Env::default();
Expand Down Expand Up @@ -551,4 +689,5 @@ fn verification_rate_limit_is_isolated_by_wallet() {
}

assert!(client.verify_credential(&second_actor, &root, &true));
main
}
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@
[]
],
"ledger": {
"protocol_version": 26,
"protocol_version": 27,
"sequence_number": 0,
"timestamp": 0,
"network_id": "0000000000000000000000000000000000000000000000000000000000000000",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
[]
],
"ledger": {
"protocol_version": 26,
"protocol_version": 27,
"sequence_number": 0,
"timestamp": 0,
"network_id": "0000000000000000000000000000000000000000000000000000000000000000",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
[]
],
"ledger": {
"protocol_version": 26,
"protocol_version": 27,
"sequence_number": 0,
"timestamp": 0,
"network_id": "0000000000000000000000000000000000000000000000000000000000000000",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
[]
],
"ledger": {
"protocol_version": 26,
"protocol_version": 27,
"sequence_number": 1000,
"timestamp": 0,
"network_id": "0000000000000000000000000000000000000000000000000000000000000000",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
[]
],
"ledger": {
"protocol_version": 26,
"protocol_version": 27,
"sequence_number": 0,
"timestamp": 0,
"network_id": "0000000000000000000000000000000000000000000000000000000000000000",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
[]
],
"ledger": {
"protocol_version": 26,
"protocol_version": 27,
"sequence_number": 0,
"timestamp": 0,
"network_id": "0000000000000000000000000000000000000000000000000000000000000000",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
[]
],
"ledger": {
"protocol_version": 26,
"protocol_version": 27,
"sequence_number": 0,
"timestamp": 0,
"network_id": "0000000000000000000000000000000000000000000000000000000000000000",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
[]
],
"ledger": {
"protocol_version": 26,
"protocol_version": 27,
"sequence_number": 0,
"timestamp": 0,
"network_id": "0000000000000000000000000000000000000000000000000000000000000000",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
[]
],
"ledger": {
"protocol_version": 26,
"protocol_version": 27,
"sequence_number": 0,
"timestamp": 0,
"network_id": "0000000000000000000000000000000000000000000000000000000000000000",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
[]
],
"ledger": {
"protocol_version": 26,
"protocol_version": 27,
"sequence_number": 0,
"timestamp": 0,
"network_id": "0000000000000000000000000000000000000000000000000000000000000000",
Expand Down
6 changes: 5 additions & 1 deletion sdk/bindings/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@ export const Errors = {
*/
5: {message:"InvalidProof"},
6: {message:"BatchTooLarge"},
7: {message:"UnknownRegistryRoot"}
7: {message:"UnknownRegistryRoot"},
/**
* The credential root has been revoked.
*/
9: {message:"CredentialRevoked"}
}


Expand Down
2 changes: 1 addition & 1 deletion sdk/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading