diff --git a/contracts/agent-passport-validator/src/lib.rs b/contracts/agent-passport-validator/src/lib.rs index 6a947ad..782a269 100644 --- a/contracts/agent-passport-validator/src/lib.rs +++ b/contracts/agent-passport-validator/src/lib.rs @@ -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] @@ -153,6 +157,13 @@ pub struct VerifyResult { pub error: Option, } +#[contracttype] +#[derive(Clone, Debug)] +pub struct CredentialRevokedEvent { + pub root: BytesN<32>, + pub revoked_at_ledger: u32, +} + #[contracttype] enum DataKey { Admin, @@ -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] @@ -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 { @@ -557,8 +575,16 @@ impl AgentPassportValidator { root: BytesN<32>, success: bool, ) -> Result { + // 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(); @@ -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 { @@ -607,12 +634,34 @@ 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(), @@ -620,7 +669,6 @@ impl AgentPassportValidator { ledger: env.ledger().sequence(), success: true, }; - let persistent = env.storage().persistent(); let key = DataKey::AuditEntry(seq); persistent.set(&key, &record); @@ -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 { env.storage().persistent().get(&DataKey::AuditEntry(seq)) } diff --git a/contracts/agent-passport-validator/src/test.rs b/contracts/agent-passport-validator/src/test.rs index 4a91f73..63e95d7 100644 --- a/contracts/agent-passport-validator/src/test.rs +++ b/contracts/agent-passport-validator/src/test.rs @@ -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(); @@ -551,4 +689,5 @@ fn verification_rate_limit_is_isolated_by_wallet() { } assert!(client.verify_credential(&second_actor, &root, &true)); + main } diff --git a/contracts/agent-passport-validator/test_snapshots/test/can_manage_registry_roots.1.json b/contracts/agent-passport-validator/test_snapshots/test/can_manage_registry_roots.1.json index 8cd0aee..feaedf3 100644 --- a/contracts/agent-passport-validator/test_snapshots/test/can_manage_registry_roots.1.json +++ b/contracts/agent-passport-validator/test_snapshots/test/can_manage_registry_roots.1.json @@ -74,7 +74,7 @@ [] ], "ledger": { - "protocol_version": 26, + "protocol_version": 27, "sequence_number": 0, "timestamp": 0, "network_id": "0000000000000000000000000000000000000000000000000000000000000000", diff --git a/contracts/agent-passport-validator/test_snapshots/test/init_is_one_shot.1.json b/contracts/agent-passport-validator/test_snapshots/test/init_is_one_shot.1.json index 2a47ff0..bd1478f 100644 --- a/contracts/agent-passport-validator/test_snapshots/test/init_is_one_shot.1.json +++ b/contracts/agent-passport-validator/test_snapshots/test/init_is_one_shot.1.json @@ -32,7 +32,7 @@ [] ], "ledger": { - "protocol_version": 26, + "protocol_version": 27, "sequence_number": 0, "timestamp": 0, "network_id": "0000000000000000000000000000000000000000000000000000000000000000", diff --git a/contracts/agent-passport-validator/test_snapshots/test/mixed_batch_results.1.json b/contracts/agent-passport-validator/test_snapshots/test/mixed_batch_results.1.json index 4c729a1..b474f03 100644 --- a/contracts/agent-passport-validator/test_snapshots/test/mixed_batch_results.1.json +++ b/contracts/agent-passport-validator/test_snapshots/test/mixed_batch_results.1.json @@ -32,7 +32,7 @@ [] ], "ledger": { - "protocol_version": 26, + "protocol_version": 27, "sequence_number": 0, "timestamp": 0, "network_id": "0000000000000000000000000000000000000000000000000000000000000000", diff --git a/contracts/agent-passport-validator/test_snapshots/test/registers_a_valid_passport.1.json b/contracts/agent-passport-validator/test_snapshots/test/registers_a_valid_passport.1.json index 36bf4da..7293dcd 100644 --- a/contracts/agent-passport-validator/test_snapshots/test/registers_a_valid_passport.1.json +++ b/contracts/agent-passport-validator/test_snapshots/test/registers_a_valid_passport.1.json @@ -36,7 +36,7 @@ [] ], "ledger": { - "protocol_version": 26, + "protocol_version": 27, "sequence_number": 1000, "timestamp": 0, "network_id": "0000000000000000000000000000000000000000000000000000000000000000", diff --git a/contracts/agent-passport-validator/test_snapshots/test/rejects_batch_too_large.1.json b/contracts/agent-passport-validator/test_snapshots/test/rejects_batch_too_large.1.json index 0c4ce04..af825a1 100644 --- a/contracts/agent-passport-validator/test_snapshots/test/rejects_batch_too_large.1.json +++ b/contracts/agent-passport-validator/test_snapshots/test/rejects_batch_too_large.1.json @@ -32,7 +32,7 @@ [] ], "ledger": { - "protocol_version": 26, + "protocol_version": 27, "sequence_number": 0, "timestamp": 0, "network_id": "0000000000000000000000000000000000000000000000000000000000000000", diff --git a/contracts/agent-passport-validator/test_snapshots/test/rejects_nullifier_replay.1.json b/contracts/agent-passport-validator/test_snapshots/test/rejects_nullifier_replay.1.json index 4613280..5a85527 100644 --- a/contracts/agent-passport-validator/test_snapshots/test/rejects_nullifier_replay.1.json +++ b/contracts/agent-passport-validator/test_snapshots/test/rejects_nullifier_replay.1.json @@ -33,7 +33,7 @@ [] ], "ledger": { - "protocol_version": 26, + "protocol_version": 27, "sequence_number": 0, "timestamp": 0, "network_id": "0000000000000000000000000000000000000000000000000000000000000000", diff --git a/contracts/agent-passport-validator/test_snapshots/test/rejects_tampered_public_input.1.json b/contracts/agent-passport-validator/test_snapshots/test/rejects_tampered_public_input.1.json index 9fe10db..8884a7f 100644 --- a/contracts/agent-passport-validator/test_snapshots/test/rejects_tampered_public_input.1.json +++ b/contracts/agent-passport-validator/test_snapshots/test/rejects_tampered_public_input.1.json @@ -33,7 +33,7 @@ [] ], "ledger": { - "protocol_version": 26, + "protocol_version": 27, "sequence_number": 0, "timestamp": 0, "network_id": "0000000000000000000000000000000000000000000000000000000000000000", diff --git a/contracts/agent-passport-validator/test_snapshots/test/rejects_unknown_registry_root.1.json b/contracts/agent-passport-validator/test_snapshots/test/rejects_unknown_registry_root.1.json index 92113d7..7c8e33d 100644 --- a/contracts/agent-passport-validator/test_snapshots/test/rejects_unknown_registry_root.1.json +++ b/contracts/agent-passport-validator/test_snapshots/test/rejects_unknown_registry_root.1.json @@ -32,7 +32,7 @@ [] ], "ledger": { - "protocol_version": 26, + "protocol_version": 27, "sequence_number": 0, "timestamp": 0, "network_id": "0000000000000000000000000000000000000000000000000000000000000000", diff --git a/contracts/agent-passport-validator/test_snapshots/test/rejects_wrong_input_count.1.json b/contracts/agent-passport-validator/test_snapshots/test/rejects_wrong_input_count.1.json index 0c4ce04..af825a1 100644 --- a/contracts/agent-passport-validator/test_snapshots/test/rejects_wrong_input_count.1.json +++ b/contracts/agent-passport-validator/test_snapshots/test/rejects_wrong_input_count.1.json @@ -32,7 +32,7 @@ [] ], "ledger": { - "protocol_version": 26, + "protocol_version": 27, "sequence_number": 0, "timestamp": 0, "network_id": "0000000000000000000000000000000000000000000000000000000000000000", diff --git a/contracts/agent-passport-validator/test_snapshots/test/verifies_a_batch_of_passports.1.json b/contracts/agent-passport-validator/test_snapshots/test/verifies_a_batch_of_passports.1.json index cd6b99a..df6fc7a 100644 --- a/contracts/agent-passport-validator/test_snapshots/test/verifies_a_batch_of_passports.1.json +++ b/contracts/agent-passport-validator/test_snapshots/test/verifies_a_batch_of_passports.1.json @@ -32,7 +32,7 @@ [] ], "ledger": { - "protocol_version": 26, + "protocol_version": 27, "sequence_number": 0, "timestamp": 0, "network_id": "0000000000000000000000000000000000000000000000000000000000000000", diff --git a/sdk/bindings/src/index.ts b/sdk/bindings/src/index.ts index 905a076..0f3fc94 100644 --- a/sdk/bindings/src/index.ts +++ b/sdk/bindings/src/index.ts @@ -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"} } diff --git a/sdk/package-lock.json b/sdk/package-lock.json index 1a4d203..7d256ee 100644 --- a/sdk/package-lock.json +++ b/sdk/package-lock.json @@ -504,7 +504,7 @@ }, "node_modules/@types/node": { "version": "26.1.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-26.1.1.tgz", "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", "dev": true, "license": "MIT", diff --git a/sdk/src/PassportClient.ts b/sdk/src/PassportClient.ts index a23ae0e..ee4287d 100644 --- a/sdk/src/PassportClient.ts +++ b/sdk/src/PassportClient.ts @@ -11,7 +11,10 @@ export enum PassportError { InvalidProof = "InvalidProof", BatchTooLarge = "BatchTooLarge", UnknownRegistryRoot = "UnknownRegistryRoot", + feat/credential-revocation + CredentialRevoked = "CredentialRevoked", RateLimitExceeded = "RateLimitExceeded", + main Unknown = "Unknown", } @@ -50,8 +53,11 @@ const mapSymbolToPassportError = (err: unknown): PassportError | undefined => { return PassportError.BatchTooLarge; case "UnknownRegistryRoot": return PassportError.UnknownRegistryRoot; + feat/credential-revocation + case "CredentialRevoked": + return PassportError.CredentialRevoked; case "RateLimitExceeded": - return PassportError.RateLimitExceeded; + return PassportError.RateLimitExceeded; main default: return PassportError.Unknown; } diff --git a/sdk/src/passport.ts b/sdk/src/passport.ts index 16f7ae8..ca671d0 100644 --- a/sdk/src/passport.ts +++ b/sdk/src/passport.ts @@ -209,6 +209,29 @@ function utcWeekStart(ts: number): number { return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), mon); } +/** + * Check if an error is a credential revocation error. + * Works with PassportError enum values, contract error symbols, and error objects. + */ +export function isRevocationError(error: unknown): boolean { + if (typeof error === "string") { + return error === "CredentialRevoked"; + } + if (error && typeof error === "object") { + const obj = error as Record; + if (typeof obj.error === "string") { + return obj.error === "CredentialRevoked"; + } + if (typeof obj.message === "string") { + return obj.message === "CredentialRevoked"; + } + if (typeof obj.code === "number") { + return obj.code === 8; + } + } + return false; +} + export function authorizePassportSpend( agentId: string, amount: number,