diff --git a/Cargo.lock b/Cargo.lock index a8b930624..ed159926f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1659,6 +1659,31 @@ dependencies = [ "zeroize", ] +[[package]] +name = "bon" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f47dbe92550676ee653353c310dfb9cf6ba17ee70396e1f7cf0a2020ad49b2fe" +dependencies = [ + "bon-macros", + "rustversion", +] + +[[package]] +name = "bon-macros" +version = "3.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" +dependencies = [ + "darling", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.117", +] + [[package]] name = "borsh" version = "1.6.1" @@ -8209,6 +8234,7 @@ dependencies = [ "alloy-core", "backon", "base64 0.22.1", + "bon", "chacha20poly1305", "chrono", "ciborium", @@ -8218,6 +8244,7 @@ dependencies = [ "getrandom 0.3.4", "hex", "hkdf", + "itertools 0.14.0", "log", "mockito", "rand 0.8.6", diff --git a/walletkit-core/Cargo.toml b/walletkit-core/Cargo.toml index 136516ba0..fa0c6d515 100644 --- a/walletkit-core/Cargo.toml +++ b/walletkit-core/Cargo.toml @@ -26,6 +26,7 @@ backon = "1.6" base64 = { version = "0.22" } hex = "0.4" hkdf = "0.12" +itertools = "0.14.0" log = "0.4" rand = "0.8" reqwest = { version = "0.12", default-features = false, features = ["json"] } @@ -64,6 +65,7 @@ reqwest = { version = "0.12", default-features = false, features = [ rustls = { version = "0.23", features = ["ring"] } [dev-dependencies] +bon = "3" alloy = { version = "2", default-features = false, features = [ "getrandom", "json", diff --git a/walletkit-core/src/lib.rs b/walletkit-core/src/lib.rs index 6fe182ba8..fd80d63ee 100644 --- a/walletkit-core/src/lib.rs +++ b/walletkit-core/src/lib.rs @@ -131,9 +131,6 @@ pub use user_agent::{UserAgent, UserAgentBuilder}; /// Proof requests and responses in World ID v4. pub mod requests; -/// Pre-flight check of whether stored credentials can satisfy a [`requests::ProofRequest`]. -pub mod proof_request_credential_constraints_check; - mod proof; pub use proof::OwnershipProof; diff --git a/walletkit-core/src/proof_request_credential_constraints_check.rs b/walletkit-core/src/proof_request_credential_constraints_check.rs deleted file mode 100644 index 895d17580..000000000 --- a/walletkit-core/src/proof_request_credential_constraints_check.rs +++ /dev/null @@ -1,697 +0,0 @@ -//! Pre-flight check of whether the credential store can satisfy a proof request. -//! -//! # Overview -//! -//! [`crate::proof_request_credential_constraints_check::check_credentials_against_proof_request`] -//! evaluates every request item in a proof request against the contents of the local -//! [`crate::storage::CredentialStore`] and returns a -//! [`crate::proof_request_credential_constraints_check::CredentialConstraintsCheckResult`] describing: -//! -//! - **`is_satisfied`** — whether the overall request (including any constraint -//! expression) can be fulfilled with the credentials currently in the store. -//! - **`check_results`** — one -//! [`crate::proof_request_credential_constraints_check::CredentialConstraintsCheckItem`] per request item, -//! always populated regardless of `is_satisfied`, so the caller can identify -//! exactly which credentials are present or missing. -//! -//! # Per-item evaluation -//! -//! For each request item the check verifies that the store contains at least one -//! credential that is: -//! -//! 1. **Not expired** — `expires_at > now`. -//! 2. **Fresh enough** — `genesis_issued_at >= genesis_issued_at_min` (defaults to 0 -//! when the request item omits the field, meaning any issuance time is accepted). -//! 3. **Long-lived enough** — `expires_at > expires_at_min` (defaults to the proof -//! request's `created_at` when the request item omits the field). -//! -//! Multiple credentials with the same `issuer_schema_id` can exist in the store. -//! The item is considered satisfied if **any** of them passes all three checks, -//! which matches proof-generation behaviour (it selects the most recently updated -//! qualifying credential). -//! -//! # Constraint expressions -//! -//! When the proof request carries a constraint expression (`Any`, `All`, or -//! `Enumerate`), `is_satisfied` reflects whether the expression evaluates to `true` -//! given the per-item results. The expression is validated for structural limits -//! (max depth 2, max `MAX_CONSTRAINT_NODES` nodes) before evaluation; violations -//! are returned as errors rather than `is_satisfied = false`. -//! -//! When there is no constraint expression every request item must be satisfied. -//! -//! # UI usage -//! -//! `check_results` is intended for the UI layer. When `is_satisfied` is `false`, -//! iterate `check_results` and surface items where `has_credential` is `false` to -//! tell the user which credentials are missing or do not meet the request's time -//! constraints. -//! -//! # Examples -//! -//! The table below shows how constraints, available credentials, and per-item -//! results combine. ✓ = `has_credential: true`, ✗ = `has_credential: false`. -//! -//! | Request items | Constraints | Credentials in store | `is_satisfied` | `check_results` | -//! |-----------------------|----------------------------------|-----------------------|----------------|------------------------------------| -//! | orb, mnc | _(none)_ | orb, mnc | `true` | orb ✓, mnc ✓ | -//! | orb, mnc | _(none)_ | orb only | `false` | orb ✓, mnc ✗ | -//! | orb, mnc | _(none)_ | mnc only | `false` | orb ✗, mnc ✓ | -//! | orb, mnc | `Any(orb, mnc)` | orb only | `true` | orb ✓, mnc ✗ | -//! | orb, mnc | `Any(orb, mnc)` | mnc only | `true` | orb ✗, mnc ✓ | -//! | orb, mnc | `Any(orb, mnc)` | _(none)_ | `false` | orb ✗, mnc ✗ | -//! | orb, mnc | `All(orb, mnc)` | orb only | `false` | orb ✓, mnc ✗ | -//! | orb, mnc | `All(orb, mnc)` | mnc only | `false` | orb ✗, mnc ✓ | -//! | orb, mnc, passport | `All(orb, Any(mnc, passport))` | orb, mnc | `true` | orb ✓, mnc ✓, passport ✗ | -//! | orb, mnc, passport | `All(orb, Any(mnc, passport))` | orb, passport | `true` | orb ✓, mnc ✗, passport ✓ | -//! | orb, mnc, passport | `All(orb, Any(mnc, passport))` | mnc, passport | `false` | orb ✗, mnc ✓, passport ✓ | -//! | orb, mnc, passport | `All(passport, Any(orb, mnc))` | passport, mnc | `true` | orb ✗, mnc ✓, passport ✓ | -//! | orb, mnc, passport | `All(passport, Any(orb, mnc))` | orb only | `false` | orb ✓, mnc ✗, passport ✗ | - -use std::collections::HashMap; - -use world_id_core::requests::MAX_CONSTRAINT_NODES; - -use crate::requests::ProofRequest; -use crate::storage::{CredentialRecord, CredentialStore, StorageError}; - -/// Error returned by [`check_credentials_against_proof_request`]. -#[derive(Debug, thiserror::Error, uniffi::Error)] -pub enum CredentialConstraintsCheckError { - /// Credential store query failed. - #[error(transparent)] - Storage(#[from] StorageError), - /// The constraint expression exceeds the maximum nesting depth of 2. - #[error("constraint nesting exceeds maximum allowed depth")] - ConstraintTooDeep, - /// The constraint expression exceeds the maximum node count. - #[error("constraints exceed maximum allowed size")] - ConstraintTooLarge, -} - -/// Check result for a single request item. -#[derive(Debug, Clone, uniffi::Record)] -pub struct CredentialConstraintsCheckItem { - /// The RP-defined identifier for this request item (e.g. `"orb"`, `"document"`). - pub identifier: String, - /// Issuer schema ID required by this item. - pub issuer_schema_id: u64, - /// `true` when the store contains at least one non-expired credential that meets - /// all time constraints (`genesis_issued_at_min`, `expires_at_min`) for this item. - pub has_credential: bool, -} - -/// Result of [`check_credentials_against_proof_request`]. -#[derive(Debug, Clone, uniffi::Record)] -pub struct CredentialConstraintsCheckResult { - /// `true` when the constraint tree (or all items, if no constraints) is satisfied. - pub is_satisfied: bool, - /// One entry per request item in the proof request, in the same order. - /// - /// Always populated regardless of `is_satisfied`. When `is_satisfied` is `false`, - /// items with `has_credential = false` identify what is missing or does not meet - /// the request's time constraints. - pub check_results: Vec, -} - -/// Checks whether `store` holds the credentials required to fulfill `request`. -/// -/// See the [module-level documentation](self) for a full description of the -/// evaluation logic and intended usage. -/// -/// # Errors -/// -/// - [`CredentialConstraintsCheckError::Storage`] if the credential store query fails. -/// - [`CredentialConstraintsCheckError::ConstraintTooDeep`] if the constraint tree exceeds depth 2. -/// - [`CredentialConstraintsCheckError::ConstraintTooLarge`] if the constraint tree exceeds the node limit. -#[uniffi::export] -pub fn check_credentials_against_proof_request( - request: &ProofRequest, - store: &CredentialStore, - now: u64, -) -> Result { - let records = store.list_credentials(None, now)?; - - let mut by_schema: HashMap> = HashMap::new(); - for r in records.iter().filter(|r| !r.is_expired) { - by_schema.entry(r.issuer_schema_id).or_default().push(r); - } - - let mut check_results: Vec = Vec::new(); - for item in &request.0.requests { - let expires_min = item.expires_at_min.unwrap_or(request.0.created_at); - let genesis_min = item.genesis_issued_at_min.unwrap_or(0); - - let has_credential = - by_schema.get(&item.issuer_schema_id).is_some_and(|creds| { - creds.iter().any(|r| { - r.expires_at > expires_min && r.genesis_issued_at >= genesis_min - }) - }); - - check_results.push(CredentialConstraintsCheckItem { - identifier: item.identifier.clone(), - issuer_schema_id: item.issuer_schema_id, - has_credential, - }); - } - - let is_satisfied = match &request.0.constraints { - None => check_results.iter().all(|i| i.has_credential), - Some(expr) => { - // TODO: replace with `request.0.validate_constraints()?` once - // walletkit bumps world-id-core to 0.11.x. - if !expr.validate_max_depth(2) { - return Err(CredentialConstraintsCheckError::ConstraintTooDeep); - } - if !expr.validate_max_nodes(MAX_CONSTRAINT_NODES) { - return Err(CredentialConstraintsCheckError::ConstraintTooLarge); - } - expr.evaluate(&|id: &str| { - check_results - .iter() - .any(|i| i.identifier == id && i.has_credential) - }) - } - }; - - Ok(CredentialConstraintsCheckResult { - is_satisfied, - check_results, - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - use alloy_core::primitives::{Signature, U160}; - use taceo_oprf::types::OprfKeyId; - use world_id_core::{ - primitives::rp::RpId, - requests::{ - ConstraintExpr, ConstraintNode, ProofRequest as CoreProofRequest, - ProofType, RequestItem, RequestVersion, - }, - FieldElement as CoreFieldElement, - }; - - use crate::{ - storage::tests_utils::{ - cleanup_test_storage, temp_root_path, InMemoryStorageProvider, - }, - Credential, FieldElement, - }; - use world_id_core::Credential as CoreCredential; - - fn dummy_request( - items: Vec, - constraints: Option>, - ) -> ProofRequest { - let core = CoreProofRequest { - id: "test".to_string(), - version: RequestVersion::V1, - proof_type: ProofType::Uniqueness, - created_at: 0, - expires_at: u64::MAX, - rp_id: RpId::new(1), - oprf_key_id: OprfKeyId::new(U160::from(1u64)), - session_id: None, - action: None, - signature: Signature::test_signature(), - nonce: CoreFieldElement::ZERO, - requests: items, - constraints, - }; - ProofRequest(core) - } - - fn store_with_credentials( - issuer_ids: &[u64], - now: u64, - ) -> (CredentialStore, std::path::PathBuf) { - let root = temp_root_path(); - let provider = InMemoryStorageProvider::new(&root); - let store = CredentialStore::from_provider(&provider).expect("create store"); - store.init(42, now).expect("init"); - - for &id in issuer_ids { - let cred: Credential = CoreCredential::new() - .issuer_schema_id(id) - .genesis_issued_at(now) - .into(); - store - .store_credential( - &cred, - &FieldElement::from(1u64), - now + 9999, - None, - now, - ) - .expect("store credential"); - } - (store, root) - } - - #[test] - fn no_constraints_all_satisfied() { - let now = 1000; - let (store, root) = store_with_credentials(&[100, 200], now); - let request = dummy_request( - vec![ - RequestItem::new("a".into(), 100, None, None, None), - RequestItem::new("b".into(), 200, None, None, None), - ], - None, - ); - let result = - check_credentials_against_proof_request(&request, &store, now).unwrap(); - assert!(result.is_satisfied); - assert!(result.check_results.iter().all(|i| i.has_credential)); - cleanup_test_storage(&root); - } - - #[test] - fn no_constraints_one_missing() { - let now = 1000; - let (store, root) = store_with_credentials(&[100], now); - let request = dummy_request( - vec![ - RequestItem::new("a".into(), 100, None, None, None), - RequestItem::new("b".into(), 999, None, None, None), - ], - None, - ); - let result = - check_credentials_against_proof_request(&request, &store, now).unwrap(); - assert!(!result.is_satisfied); - assert!(result.check_results[0].has_credential); - assert!(!result.check_results[1].has_credential); - assert_eq!(result.check_results[1].identifier, "b"); - assert_eq!(result.check_results[1].issuer_schema_id, 999); - cleanup_test_storage(&root); - } - - #[test] - fn expired_credential_not_counted() { - let now = 5000; - let root = temp_root_path(); - let provider = InMemoryStorageProvider::new(&root); - let store = CredentialStore::from_provider(&provider).expect("create store"); - store.init(42, 1000).expect("init"); - - let cred: Credential = CoreCredential::new() - .issuer_schema_id(100) - .genesis_issued_at(1000) - .into(); - store - .store_credential(&cred, &FieldElement::from(1u64), 2000, None, 1000) - .expect("store"); - - let request = dummy_request( - vec![RequestItem::new("a".into(), 100, None, None, None)], - None, - ); - - let result = - check_credentials_against_proof_request(&request, &store, now).unwrap(); - assert!(!result.is_satisfied); - assert!(!result.check_results[0].has_credential); - cleanup_test_storage(&root); - } - - #[test] - fn any_constraint_one_branch_satisfied() { - let now = 1000; - let (store, root) = store_with_credentials(&[100], now); - let request = dummy_request( - vec![ - RequestItem::new("a".into(), 100, None, None, None), - RequestItem::new("b".into(), 999, None, None, None), - ], - Some(ConstraintExpr::Any { - any: vec![ - ConstraintNode::Type("a".into()), - ConstraintNode::Type("b".into()), - ], - }), - ); - assert!( - check_credentials_against_proof_request(&request, &store, now) - .unwrap() - .is_satisfied - ); - cleanup_test_storage(&root); - } - - #[test] - fn all_constraint_one_branch_missing() { - let now = 1000; - let (store, root) = store_with_credentials(&[100], now); - let request = dummy_request( - vec![ - RequestItem::new("a".into(), 100, None, None, None), - RequestItem::new("b".into(), 999, None, None, None), - ], - Some(ConstraintExpr::All { - all: vec![ - ConstraintNode::Type("a".into()), - ConstraintNode::Type("b".into()), - ], - }), - ); - let result = - check_credentials_against_proof_request(&request, &store, now).unwrap(); - assert!(!result.is_satisfied); - assert!(result.check_results[0].has_credential); - assert!(!result.check_results[1].has_credential); - cleanup_test_storage(&root); - } - - #[test] - fn enumerate_constraint_any_branch_satisfies() { - let now = 1000; - let (store, root) = store_with_credentials(&[200], now); - let request = dummy_request( - vec![ - RequestItem::new("a".into(), 999, None, None, None), - RequestItem::new("b".into(), 200, None, None, None), - ], - Some(ConstraintExpr::Enumerate { - enumerate: vec![ - ConstraintNode::Type("a".into()), - ConstraintNode::Type("b".into()), - ], - }), - ); - assert!( - check_credentials_against_proof_request(&request, &store, now) - .unwrap() - .is_satisfied - ); - cleanup_test_storage(&root); - } - - #[test] - fn enumerate_constraint_none_available() { - let now = 1000; - let (store, root) = store_with_credentials(&[], now); - let request = dummy_request( - vec![ - RequestItem::new("a".into(), 100, None, None, None), - RequestItem::new("b".into(), 200, None, None, None), - ], - Some(ConstraintExpr::Enumerate { - enumerate: vec![ - ConstraintNode::Type("a".into()), - ConstraintNode::Type("b".into()), - ], - }), - ); - assert!( - !check_credentials_against_proof_request(&request, &store, now) - .unwrap() - .is_satisfied - ); - cleanup_test_storage(&root); - } - - // ----------------------------------------------------------------------- - // Table cases: A or B or C / A and (B or C) - // ----------------------------------------------------------------------- - - fn three_item_request(constraints: ConstraintExpr<'static>) -> ProofRequest { - dummy_request( - vec![ - RequestItem::new("a".into(), 100, None, None, None), - RequestItem::new("b".into(), 200, None, None, None), - RequestItem::new("c".into(), 300, None, None, None), - ], - Some(constraints), - ) - } - - fn any_a_or_b_or_c() -> ConstraintExpr<'static> { - ConstraintExpr::Any { - any: vec![ - ConstraintNode::Type("a".into()), - ConstraintNode::Type("b".into()), - ConstraintNode::Type("c".into()), - ], - } - } - - fn all_a_and_b_or_c() -> ConstraintExpr<'static> { - ConstraintExpr::All { - all: vec![ - ConstraintNode::Type("a".into()), - ConstraintNode::Expr(ConstraintExpr::Any { - any: vec![ - ConstraintNode::Type("b".into()), - ConstraintNode::Type("c".into()), - ], - }), - ], - } - } - - // A or B or C — only A present → True - #[test] - fn any_abc_only_a_satisfies() { - let now = 1000; - let (store, root) = store_with_credentials(&[100], now); - let result = check_credentials_against_proof_request( - &three_item_request(any_a_or_b_or_c()), - &store, - now, - ) - .unwrap(); - assert!(result.is_satisfied); - assert!( - result - .check_results - .iter() - .find(|i| i.identifier == "a") - .unwrap() - .has_credential - ); - assert!( - !result - .check_results - .iter() - .find(|i| i.identifier == "b") - .unwrap() - .has_credential - ); - assert!( - !result - .check_results - .iter() - .find(|i| i.identifier == "c") - .unwrap() - .has_credential - ); - cleanup_test_storage(&root); - } - - // A or B or C — only B present → True - #[test] - fn any_abc_only_b_satisfies() { - let now = 1000; - let (store, root) = store_with_credentials(&[200], now); - let result = check_credentials_against_proof_request( - &three_item_request(any_a_or_b_or_c()), - &store, - now, - ) - .unwrap(); - assert!(result.is_satisfied); - assert!( - !result - .check_results - .iter() - .find(|i| i.identifier == "a") - .unwrap() - .has_credential - ); - assert!( - result - .check_results - .iter() - .find(|i| i.identifier == "b") - .unwrap() - .has_credential - ); - assert!( - !result - .check_results - .iter() - .find(|i| i.identifier == "c") - .unwrap() - .has_credential - ); - cleanup_test_storage(&root); - } - - // A or B or C — none present → False - #[test] - fn any_abc_none_present() { - let now = 1000; - let (store, root) = store_with_credentials(&[], now); - let result = check_credentials_against_proof_request( - &three_item_request(any_a_or_b_or_c()), - &store, - now, - ) - .unwrap(); - assert!(!result.is_satisfied); - assert!(result.check_results.iter().all(|i| !i.has_credential)); - cleanup_test_storage(&root); - } - - // A and (B or C) — none present → False - #[test] - fn all_a_any_bc_none_present() { - let now = 1000; - let (store, root) = store_with_credentials(&[], now); - let result = check_credentials_against_proof_request( - &three_item_request(all_a_and_b_or_c()), - &store, - now, - ) - .unwrap(); - assert!(!result.is_satisfied); - cleanup_test_storage(&root); - } - - // A and (B or C) — A, B, C all present → True (A satisfies A; B satisfies B or C) - #[test] - fn all_a_any_bc_all_present() { - let now = 1000; - let (store, root) = store_with_credentials(&[100, 200, 300], now); - let result = check_credentials_against_proof_request( - &three_item_request(all_a_and_b_or_c()), - &store, - now, - ) - .unwrap(); - assert!(result.is_satisfied); - assert!(result.check_results.iter().all(|i| i.has_credential)); - cleanup_test_storage(&root); - } - - #[test] - fn constraint_too_deep_returns_error() { - let now = 1000; - let (store, root) = store_with_credentials(&[100], now); - let deep = ConstraintExpr::All { - all: vec![ConstraintNode::Expr(ConstraintExpr::Any { - any: vec![ConstraintNode::Expr(ConstraintExpr::All { - all: vec![ConstraintNode::Type("a".into())], - })], - })], - }; - let request = dummy_request( - vec![RequestItem::new("a".into(), 100, None, None, None)], - Some(deep), - ); - let err = - check_credentials_against_proof_request(&request, &store, now).unwrap_err(); - assert!(matches!( - err, - CredentialConstraintsCheckError::ConstraintTooDeep - )); - cleanup_test_storage(&root); - } - - #[test] - fn constraint_too_large_returns_error() { - use world_id_core::requests::MAX_CONSTRAINT_NODES; - let now = 1000; - let (store, root) = store_with_credentials(&[], now); - // Build a flat Any with MAX_CONSTRAINT_NODES + 1 leaves to exceed the limit. - let nodes: Vec> = (0..=MAX_CONSTRAINT_NODES) - .map(|i| ConstraintNode::Type(format!("t{i}").into())) - .collect(); - let expr = ConstraintExpr::Any { any: nodes }; - let items: Vec = (0..=MAX_CONSTRAINT_NODES) - .map(|i| RequestItem::new(format!("t{i}"), i as u64, None, None, None)) - .collect(); - let request = dummy_request(items, Some(expr)); - let err = - check_credentials_against_proof_request(&request, &store, now).unwrap_err(); - assert!(matches!( - err, - CredentialConstraintsCheckError::ConstraintTooLarge - )); - cleanup_test_storage(&root); - } - - fn store_with_credential_times( - issuer_id: u64, - genesis_issued_at: u64, - expires_at: u64, - now: u64, - ) -> (CredentialStore, std::path::PathBuf) { - let root = temp_root_path(); - let provider = InMemoryStorageProvider::new(&root); - let store = CredentialStore::from_provider(&provider).expect("create store"); - store.init(42, now).expect("init"); - let cred: Credential = CoreCredential::new() - .issuer_schema_id(issuer_id) - .genesis_issued_at(genesis_issued_at) - .into(); - store - .store_credential(&cred, &FieldElement::from(1u64), expires_at, None, now) - .expect("store credential"); - (store, root) - } - - #[test] - fn genesis_issued_at_min_not_met_returns_unsatisfied() { - let now = 1000; - // Credential was issued at t=500; request requires t>=600. - let (store, root) = store_with_credential_times(100, 500, now + 9999, now); - let request = dummy_request( - vec![RequestItem::new("a".into(), 100, None, Some(600), None)], - None, - ); - let result = - check_credentials_against_proof_request(&request, &store, now).unwrap(); - assert!(!result.is_satisfied); - assert!(!result.check_results[0].has_credential); - cleanup_test_storage(&root); - } - - #[test] - fn expires_at_min_not_met_returns_unsatisfied() { - let now = 1000; - // Credential expires at t=2000; request requires expires_at >= 5000. - let (store, root) = store_with_credential_times(100, now, 2000, now); - let request = dummy_request( - vec![RequestItem::new("a".into(), 100, None, None, Some(5000))], - None, - ); - let result = - check_credentials_against_proof_request(&request, &store, now).unwrap(); - assert!(!result.is_satisfied); - assert!(!result.check_results[0].has_credential); - cleanup_test_storage(&root); - } - - #[test] - fn expires_at_min_equal_to_expires_at_returns_unsatisfied() { - let now = 1000; - // Boundary: expires_at == expires_at_min is rejected by the circuit (strict >). - let (store, root) = store_with_credential_times(100, now, 5000, now); - let request = dummy_request( - vec![RequestItem::new("a".into(), 100, None, None, Some(5000))], - None, - ); - let result = - check_credentials_against_proof_request(&request, &store, now).unwrap(); - assert!(!result.is_satisfied); - assert!(!result.check_results[0].has_credential); - cleanup_test_storage(&root); - } -} diff --git a/walletkit-core/src/requests/credential_check.rs b/walletkit-core/src/requests/credential_check.rs new file mode 100644 index 000000000..c9a973af4 --- /dev/null +++ b/walletkit-core/src/requests/credential_check.rs @@ -0,0 +1,327 @@ +//! Pre-flight check of whether the credential store can satisfy a proof request. +//! +//! Call [`ProofRequest::check_credentials`] to evaluate a request against the local +//! credential store before attempting proof generation. See [`ProofRequest`] docs for +//! constraint semantics. + +use std::collections::HashSet; + +use itertools::Itertools; +use world_id_core::requests::ValidationError; + +use crate::requests::ProofRequest; +use crate::storage::{CredentialStore, StorageError}; + +/// Error returned by [`ProofRequest::check_credentials`]. +#[derive(Debug, thiserror::Error, uniffi::Error)] +pub enum CredentialConstraintsCheckError { + /// Credential store query failed. + #[error(transparent)] + Storage(#[from] StorageError), + /// The constraint expression exceeds the maximum nesting depth of 2. + #[error("constraint nesting exceeds maximum allowed depth")] + ConstraintTooDeep, + /// The constraint expression exceeds the maximum node count. + #[error("constraints exceed maximum allowed size")] + ConstraintTooLarge, + /// An unexpected validation error was returned by the upstream crate. + #[error("unexpected validation error: {0}")] + Unknown(String), +} + +impl From for CredentialConstraintsCheckError { + fn from(e: ValidationError) -> Self { + match e { + ValidationError::ConstraintTooDeep => Self::ConstraintTooDeep, + ValidationError::ConstraintTooLarge => Self::ConstraintTooLarge, + other => Self::Unknown(format!("{other:?}")), + } + } +} + +/// Check result for a single request item. +#[derive(Debug, Clone, uniffi::Record)] +pub struct CredentialConstraintsCheckItem { + /// The RP-defined identifier for this request item (e.g. `"orb"`, `"document"`). + pub identifier: String, + /// Issuer schema ID required by this item. + pub issuer_schema_id: u64, + /// `true` when the store contains at least one non-expired credential that meets + /// all time constraints (`genesis_issued_at_min`, `expires_at_min`) for this item. + pub has_credential: bool, +} + +/// Result of [`ProofRequest::check_credentials`]. +#[derive(Debug, Clone, uniffi::Record)] +pub struct CredentialConstraintsCheckResult { + /// `true` when the constraint tree (or all items, if no constraints) is satisfied. + pub is_satisfied: bool, + /// One entry per request item in the proof request, in the same order. + /// + /// Always populated regardless of `is_satisfied`. When `is_satisfied` is `false`, + /// items with `has_credential = false` identify what is missing or does not meet + /// the request's time constraints. + pub check_results: Vec, +} + +#[uniffi::export] +impl ProofRequest { + /// Checks whether `store` holds the credentials required to fulfill this request. + /// + /// For each request item the check verifies that the store contains at least one + /// credential that is not expired and meets `genesis_issued_at_min` and + /// `expires_at_min`. When the request carries a constraint expression, `is_satisfied` + /// reflects whether that expression evaluates to `true` given the per-item results. + /// + /// # Errors + /// + /// - [`CredentialConstraintsCheckError::Storage`] if the credential store query fails. + /// - [`CredentialConstraintsCheckError::ConstraintTooDeep`] if the constraint tree exceeds depth 2. + /// - [`CredentialConstraintsCheckError::ConstraintTooLarge`] if the constraint tree exceeds the node limit. + pub fn check_credentials( + &self, + store: &CredentialStore, + now: u64, + ) -> Result { + self.0.validate_constraints()?; + + let records = store.list_credentials(None, now)?; + + let by_schema = records + .into_iter() + .filter(|r| !r.is_expired) + .into_group_map_by(|r| r.issuer_schema_id); + + let check_results = self + .0 + .requests + .iter() + .map(|item| { + let expires_min = item.effective_expires_at_min(self.0.created_at); + let genesis_min = item.genesis_issued_at_min.unwrap_or(0); + + let has_credential = + by_schema.get(&item.issuer_schema_id).is_some_and(|creds| { + creds.iter().any(|r| { + r.expires_at > expires_min + && r.genesis_issued_at >= genesis_min + }) + }); + + CredentialConstraintsCheckItem { + identifier: item.identifier.clone(), + issuer_schema_id: item.issuer_schema_id, + has_credential, + } + }) + .collect::>(); + + let available_credentials = check_results + .iter() + .filter_map(|r| r.has_credential.then_some(r.issuer_schema_id)) + .collect::>(); + + let is_satisfied = self + .0 + .credentials_to_prove(&available_credentials) + .is_some(); + + Ok(CredentialConstraintsCheckResult { + is_satisfied, + check_results, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use alloy_core::primitives::{Signature, U160}; + use taceo_oprf::types::OprfKeyId; + use world_id_core::{ + primitives::rp::RpId, + requests::{ + ProofRequest as CoreProofRequest, ProofType, RequestItem, RequestVersion, + }, + FieldElement as CoreFieldElement, + }; + + use crate::{ + storage::tests_utils::{ + cleanup_test_storage, temp_root_path, InMemoryStorageProvider, + }, + Credential, FieldElement, + }; + use world_id_core::Credential as CoreCredential; + + fn dummy_request(items: Vec) -> ProofRequest { + let core = CoreProofRequest { + id: "test".to_string(), + version: RequestVersion::V1, + created_at: 0, + expires_at: u64::MAX, + rp_id: RpId::new(1), + oprf_key_id: OprfKeyId::new(U160::from(1u64)), + proof_type: ProofType::default(), + session_id: None, + action: None, + signature: Signature::test_signature(), + nonce: CoreFieldElement::ZERO, + requests: items, + constraints: None, + }; + ProofRequest(core) + } + + /// Creates a test store populated with one credential per entry in `issuer_ids`. + /// `genesis_issued_at` defaults to `init_time`; `expires_at` defaults to `init_time + 9999`. + #[bon::builder] + fn make_store( + issuer_ids: Vec, + init_time: u64, + genesis_issued_at: Option, + expires_at: Option, + ) -> (CredentialStore, std::path::PathBuf) { + let genesis_issued_at = genesis_issued_at.unwrap_or(init_time); + let expires_at = expires_at.unwrap_or(init_time + 9999); + + let root = temp_root_path(); + let provider = InMemoryStorageProvider::new(&root); + let store = CredentialStore::from_provider(&provider).expect("create store"); + store.init(42, init_time).expect("init"); + + for id in issuer_ids { + let cred: Credential = CoreCredential::new() + .issuer_schema_id(id) + .genesis_issued_at(genesis_issued_at) + .into(); + store + .store_credential( + &cred, + &FieldElement::from(1u64), + expires_at, + None, + init_time, + ) + .expect("store credential"); + } + (store, root) + } + + #[test] + fn no_constraints_all_satisfied() { + let now = 1000; + let (store, root) = make_store() + .issuer_ids(vec![100, 200]) + .init_time(now) + .call(); + let request = dummy_request(vec![ + RequestItem::new("a".into(), 100, None, None, None), + RequestItem::new("b".into(), 200, None, None, None), + ]); + let result = request.check_credentials(&store, now).unwrap(); + assert!(result.is_satisfied); + assert!(result.check_results.iter().all(|i| i.has_credential)); + cleanup_test_storage(&root); + } + + #[test] + fn no_constraints_one_missing() { + let now = 1000; + let (store, root) = make_store().issuer_ids(vec![100]).init_time(now).call(); + let request = dummy_request(vec![ + RequestItem::new("a".into(), 100, None, None, None), + RequestItem::new("b".into(), 999, None, None, None), + ]); + let result = request.check_credentials(&store, now).unwrap(); + assert!(!result.is_satisfied); + assert!(result.check_results[0].has_credential); + assert!(!result.check_results[1].has_credential); + assert_eq!(result.check_results[1].identifier, "b"); + assert_eq!(result.check_results[1].issuer_schema_id, 999); + cleanup_test_storage(&root); + } + + #[test] + fn expired_credential_not_counted() { + let now = 5000; + let (store, root) = make_store() + .issuer_ids(vec![100]) + .init_time(1000) + .expires_at(2000) + .call(); + + let request = + dummy_request(vec![RequestItem::new("a".into(), 100, None, None, None)]); + + let result = request.check_credentials(&store, now).unwrap(); + assert!(!result.is_satisfied); + assert!(!result.check_results[0].has_credential); + cleanup_test_storage(&root); + } + + #[test] + fn genesis_issued_at_min_not_met_returns_unsatisfied() { + let now = 1000; + let (store, root) = make_store() + .issuer_ids(vec![100]) + .init_time(now) + .genesis_issued_at(500) + .call(); + let request = dummy_request(vec![RequestItem::new( + "a".into(), + 100, + None, + Some(600), + None, + )]); + let result = request.check_credentials(&store, now).unwrap(); + assert!(!result.is_satisfied); + assert!(!result.check_results[0].has_credential); + cleanup_test_storage(&root); + } + + #[test] + fn expires_at_min_not_met_returns_unsatisfied() { + let now = 1000; + let (store, root) = make_store() + .issuer_ids(vec![100]) + .init_time(now) + .expires_at(2000) + .call(); + let request = dummy_request(vec![RequestItem::new( + "a".into(), + 100, + None, + None, + Some(5000), + )]); + let result = request.check_credentials(&store, now).unwrap(); + assert!(!result.is_satisfied); + assert!(!result.check_results[0].has_credential); + cleanup_test_storage(&root); + } + + #[test] + fn expires_at_min_equal_to_expires_at_returns_unsatisfied() { + let now = 1000; + // Boundary: expires_at == expires_at_min is rejected by the circuit (strict >). + let (store, root) = make_store() + .issuer_ids(vec![100]) + .init_time(now) + .expires_at(5000) + .call(); + let request = dummy_request(vec![RequestItem::new( + "a".into(), + 100, + None, + None, + Some(5000), + )]); + let result = request.check_credentials(&store, now).unwrap(); + assert!(!result.is_satisfied); + assert!(!result.check_results[0].has_credential); + cleanup_test_storage(&root); + } +} diff --git a/walletkit-core/src/requests.rs b/walletkit-core/src/requests/mod.rs similarity index 99% rename from walletkit-core/src/requests.rs rename to walletkit-core/src/requests/mod.rs index e1f0959a7..b0bb3cd63 100644 --- a/walletkit-core/src/requests.rs +++ b/walletkit-core/src/requests/mod.rs @@ -1,3 +1,5 @@ +pub mod credential_check; + use world_id_core::requests::{ ProofRequest as CoreProofRequest, ProofResponse as CoreProofResponse, };