diff --git a/Cargo.lock b/Cargo.lock index 5da23a4e0..1d6d18755 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8212,6 +8212,7 @@ dependencies = [ "tracing", "tracing-subscriber 0.3.23", "walletkit-core", + "walletkit-testkit", "world-id-core", "world-id-proof", ] @@ -8282,6 +8283,24 @@ dependencies = [ "zip", ] +[[package]] +name = "walletkit-testkit" +version = "0.20.0" +dependencies = [ + "alloy", + "alloy-core", + "eyre", + "rand 0.8.6", + "reqwest 0.12.28", + "serde", + "serde_json", + "tokio", + "tracing-subscriber 0.3.23", + "uuid", + "walletkit-core", + "world-id-core", +] + [[package]] name = "want" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 8b4831efe..b70c2be21 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "walletkit", "walletkit-db", "walletkit-cli", + "walletkit-testkit", ] resolver = "2" diff --git a/walletkit-cli/Cargo.toml b/walletkit-cli/Cargo.toml index 325a2b3f1..5616e5496 100644 --- a/walletkit-cli/Cargo.toml +++ b/walletkit-cli/Cargo.toml @@ -14,6 +14,7 @@ path = "src/main.rs" [dependencies] walletkit-core = { workspace = true, features = ["issuers", "embed-zkeys"] } +walletkit-testkit = { path = "../walletkit-testkit" } world-id-core = { workspace = true } world-id-proof = { workspace = true, features = ["zk-ownership-verify"] } alloy = { version = "2", default-features = false, features = ["contract", "json", "getrandom", "signer-local"] } diff --git a/walletkit-cli/src/commands/credential.rs b/walletkit-cli/src/commands/credential.rs index ef00a9249..65db29c5d 100644 --- a/walletkit-cli/src/commands/credential.rs +++ b/walletkit-cli/src/commands/credential.rs @@ -6,6 +6,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use clap::Subcommand; use eyre::WrapErr as _; use walletkit_core::{Credential, FieldElement}; +use walletkit_testkit::issuer::issue_faux_credential; +use walletkit_testkit::TestEnv; use crate::output; @@ -210,75 +212,11 @@ async fn run_show(cli: &Cli, issuer_schema_id: u64) -> eyre::Result<()> { Ok(()) } -const FAUX_ISSUER_URL: &str = "https://faux-issuer.us.id-infra.worldcoin.dev/issue"; -pub const FAUX_ISSUER_SCHEMA_ID: u64 = 128; - -/// Result of issuing a test credential from the faux issuer. -pub struct IssuedTestCredential { - pub credential_id: u64, - pub issuer_schema_id: u64, - pub expires_at: u64, - pub blinding_factor: FieldElement, -} - -/// Issues a test credential from the staging faux issuer (schema 128). -pub async fn issue_test_credential( - authenticator: &walletkit_core::Authenticator, - store: &walletkit_core::storage::CredentialStore, -) -> eyre::Result { - let bf = authenticator - .generate_credential_blinding_factor_remote(FAUX_ISSUER_SCHEMA_ID) - .await - .wrap_err("blinding factor generation failed")?; - - let sub = authenticator.compute_credential_sub(&bf); - let sub_hex = sub.to_hex_string(); - - let client = reqwest::Client::new(); - let resp = client - .post(FAUX_ISSUER_URL) - .json(&serde_json::json!({ "sub": sub_hex })) - .send() - .await - .wrap_err("faux issuer request failed")?; - - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - eyre::bail!("faux issuer returned {status}: {body}"); - } - - let body: serde_json::Value = resp - .json() - .await - .wrap_err("failed to parse faux issuer response")?; - - let cred_value = body.get("credential").ok_or_else(|| { - eyre::eyre!("faux issuer response missing 'credential' field") - })?; - - let cred_bytes = - serde_json::to_vec(cred_value).wrap_err("failed to serialize credential")?; - let cred = Credential::from_bytes(cred_bytes) - .wrap_err("invalid credential from faux issuer")?; - let expires_at = cred.expires_at(); - - let now = now_secs()?; - let id = store - .store_credential(&cred, &bf, expires_at, None, now) - .wrap_err("store credential failed")?; - - Ok(IssuedTestCredential { - credential_id: id, - issuer_schema_id: FAUX_ISSUER_SCHEMA_ID, - expires_at, - blinding_factor: bf, - }) -} - async fn run_issue_test(cli: &Cli) -> eyre::Result<()> { let (authenticator, store) = init_authenticator(cli).await?; - let result = issue_test_credential(&authenticator, &store).await?; + let env = TestEnv::staging(); + let now = now_secs()?; + let result = issue_faux_credential(&env, &authenticator, &store, now).await?; if cli.json { output::print_json_data( diff --git a/walletkit-cli/src/commands/mod.rs b/walletkit-cli/src/commands/mod.rs index 70f3103e2..c4f2ee0ac 100644 --- a/walletkit-cli/src/commands/mod.rs +++ b/walletkit-cli/src/commands/mod.rs @@ -17,7 +17,7 @@ use eyre::WrapErr as _; use walletkit_core::storage::{cache_embedded_groth16_material, CredentialStore}; use walletkit_core::{Authenticator, Groth16Materials}; -use crate::provider::create_fs_credential_store; +use walletkit_testkit::storage::create_fs_credential_store; /// `WalletKit` CLI — developer tool for World ID wallet operations. #[derive(Parser)] diff --git a/walletkit-cli/src/commands/proof.rs b/walletkit-cli/src/commands/proof.rs index cabaa76c8..81bcc0563 100644 --- a/walletkit-cli/src/commands/proof.rs +++ b/walletkit-cli/src/commands/proof.rs @@ -3,30 +3,26 @@ use std::io::Read; use std::time::{SystemTime, UNIX_EPOCH}; -use alloy::providers::ProviderBuilder; -use alloy::signers::{local::PrivateKeySigner, SignerSync}; -use alloy::sol; use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine as _}; use clap::Subcommand; use eyre::WrapErr as _; -use rand::rngs::OsRng; use walletkit_core::requests::ProofRequest; -use world_id_core::primitives::{rp::RpId, FieldElement, OwnershipProof, SessionId}; +use walletkit_core::Environment; +use walletkit_testkit::issuer::issue_faux_credential; +use walletkit_testkit::proof::{ + build_test_request, verify_proof_onchain, VerifyItemResult, +}; +use walletkit_testkit::TestEnv; +use world_id_core::primitives::{FieldElement, OwnershipProof, SessionId}; use world_id_core::requests::{ ProofRequest as CoreProofRequest, ProofResponse as CoreProofResponse, ProofType, - RequestItem, RequestVersion, }; use world_id_proof::ownership_proof::verify_ownership_proof; use crate::output; -use walletkit_core::Environment; - -use super::credential::{issue_test_credential, FAUX_ISSUER_SCHEMA_ID}; use super::{init_authenticator, resolve_environment, Cli}; -const DEFAULT_RPC_URL: &str = "https://worldchain-mainnet.g.alchemy.com/public"; - const MAX_INPUT_BYTES: u64 = 10 * 1024 * 1024; // 10 MiB // `WorldIDVerifier` proxy addresses on World Chain Mainnet (chain 480), from @@ -46,36 +42,6 @@ const fn world_id_verifier_address( } } -sol!( - #[allow(clippy::too_many_arguments)] - #[sol(rpc)] - interface IWorldIDVerifier { - function verify( - uint256 nullifier, - uint256 action, - uint64 rpId, - uint256 nonce, - uint256 signalHash, - uint64 expiresAtMin, - uint64 issuerSchemaId, - uint256 credentialGenesisIssuedAtMin, - uint256[5] calldata zeroKnowledgeProof - ) external view; - - function verifySession( - uint64 rpId, - uint256 nonce, - uint256 signalHash, - uint64 expiresAtMin, - uint64 issuerSchemaId, - uint256 credentialGenesisIssuedAtMin, - uint256 sessionId, - uint256[2] calldata sessionNullifier, - uint256[5] calldata zeroKnowledgeProof - ) external view; - } -); - #[derive(Subcommand)] pub enum ProofCommand { /// Generate a proof from a request JSON. @@ -178,15 +144,32 @@ fn parse_session_id_arg(session_id: &str) -> Result { .map_err(|err| format!("invalid session id: {err}")) } +/// Builds a [`TestEnv`] for on-chain verification, honoring the CLI's +/// `--rpc-url` and resolving the verifier address from `--verifier-address` +/// (if provided) or otherwise the `WorldIDVerifier` for the CLI's `--environment`. +fn verify_env(cli: &Cli, verifier_address: Option<&str>) -> eyre::Result { + let mut env = TestEnv::staging(); + if let Some(rpc) = cli.rpc_url.as_deref() { + env.worldchain_rpc_url = rpc.to_string(); + } + env.world_id_verifier = match verifier_address { + Some(addr) => addr.parse().wrap_err("invalid verifier address")?, + None => world_id_verifier_address(&resolve_environment(cli)?), + }; + Ok(env) +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time after epoch") + .as_secs() +} + async fn run_generate(cli: &Cli, request: &str, now: Option) -> eyre::Result<()> { let (authenticator, _store) = init_authenticator(cli).await?; - let ts = now.unwrap_or_else(|| { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time after epoch") - .as_secs() - }); + let ts = now.unwrap_or_else(now_secs); let json_str = read_file_or_stdin(request)?; let proof_request = @@ -237,141 +220,6 @@ fn run_inspect_request(cli: &Cli, request: &str) -> eyre::Result<()> { Ok(()) } -/// Result of verifying one proof response item against the `WorldIDVerifier` contract. -pub struct VerifyItemResult { - pub issuer_schema_id: u64, - pub identifier: String, - pub verified: bool, - pub error: Option, -} - -/// Resolves the verifier contract address: the explicit `--verifier-address` -/// override if provided, otherwise the `WorldIDVerifier` for the CLI's `--environment`. -fn verifier_address_or_default( - cli: &Cli, - verifier_address: Option<&str>, -) -> eyre::Result { - verifier_address.map_or_else( - || Ok(world_id_verifier_address(&resolve_environment(cli)?)), - |addr| { - addr.parse::() - .wrap_err("invalid verifier address") - }, - ) -} - -/// Verifies a proof request + response pair on-chain. Returns per-item results. -pub async fn verify_proof_onchain( - cli: &Cli, - proof_request: &CoreProofRequest, - proof_response: &CoreProofResponse, - verifier_address: Option<&str>, -) -> eyre::Result> { - if let Some(ref err) = proof_response.error { - eyre::bail!("proof response contains error: {err}"); - } - proof_request - .validate_response(proof_response) - .wrap_err("proof response does not match proof request")?; - - let rpc_url = cli.rpc_url.as_deref().unwrap_or(DEFAULT_RPC_URL); - let provider = ProviderBuilder::new().connect_http(rpc_url.parse()?); - let verifier_addr = verifier_address_or_default(cli, verifier_address)?; - let verifier_contract = IWorldIDVerifier::new(verifier_addr, &provider); - - let action = if proof_request.proof_type == ProofType::Uniqueness { - Some( - proof_request - .action - .ok_or_else(|| eyre::eyre!("proof request has no action"))?, - ) - } else { - None - }; - let session_id = - if proof_request.proof_type.is_session() { - Some(proof_response.session_id.ok_or_else(|| { - eyre::eyre!("session proof response missing session_id") - })?) - } else { - None - }; - let nonce = proof_request.nonce; - let rp_id = proof_request.rp_id.into_inner(); - - let mut results = Vec::new(); - for response_item in &proof_response.responses { - let request_item = proof_request - .find_request_by_issuer_schema_id(response_item.issuer_schema_id) - .ok_or_else(|| { - eyre::eyre!( - "no matching request item for issuer_schema_id={}", - response_item.issuer_schema_id - ) - })?; - - let credential_genesis_issued_at_min = request_item - .genesis_issued_at_min - .unwrap_or_default() - .try_into()?; - - let result = match proof_request.proof_type { - ProofType::Uniqueness => { - let nullifier = response_item - .nullifier - .ok_or_else(|| eyre::eyre!("response item missing nullifier"))?; - - verifier_contract - .verify( - nullifier.into(), - action.expect("validated above").into(), - rp_id, - nonce.into(), - request_item.signal_hash().into(), - response_item.expires_at_min, - response_item.issuer_schema_id, - credential_genesis_issued_at_min, - response_item.proof.as_ethereum_representation(), - ) - .call() - .await - .map(|_| ()) - } - ProofType::CreateSession | ProofType::Session => { - let session_nullifier = - response_item.session_nullifier.ok_or_else(|| { - eyre::eyre!("response item missing session_nullifier") - })?; - let session_id = session_id.expect("validated above"); - - verifier_contract - .verifySession( - rp_id, - nonce.into(), - request_item.signal_hash().into(), - response_item.expires_at_min, - response_item.issuer_schema_id, - credential_genesis_issued_at_min, - session_id.commitment.into(), - session_nullifier.as_ethereum_representation(), - response_item.proof.as_ethereum_representation(), - ) - .call() - .await - .map(|_| ()) - } - }; - - results.push(VerifyItemResult { - issuer_schema_id: response_item.issuer_schema_id, - identifier: response_item.identifier.clone(), - verified: result.is_ok(), - error: result.err().map(|e| format!("{e:#}")), - }); - } - Ok(results) -} - fn print_verify_items_human(results: &[VerifyItemResult]) { for r in results { if r.verified { @@ -421,9 +269,8 @@ async fn run_verify( let proof_response: CoreProofResponse = serde_json::from_str(&response_json).wrap_err("invalid proof response")?; - let results = - verify_proof_onchain(cli, &proof_request, &proof_response, verifier_address) - .await?; + let env = verify_env(cli, verifier_address)?; + let results = verify_proof_onchain(&env, &proof_request, &proof_response).await?; let all_passed = results.iter().all(|r| r.verified); if cli.json { @@ -447,74 +294,6 @@ async fn run_verify( Ok(()) } -/// Staging RP ID registered on the `RpRegistry` contract. -const STAGING_RP_ID: u64 = 46; - -/// ECDSA private key for the staging RP (secp256k1). -const STAGING_RP_SIGNING_KEY: [u8; 32] = alloy::primitives::hex!( - "1111111111111111111111111111111111111111111111111111111111111111" -); - -/// Builds a signed test proof request using hardcoded staging RP keys. -fn build_test_request( - issuer_schema_id: u64, - signal: &str, - expires_in: u64, - proof_type: ProofType, - session_id: Option, -) -> eyre::Result { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time after epoch") - .as_secs(); - - let nonce = FieldElement::random(&mut OsRng); - let created_at = now; - let expires_at = now + expires_in; - - let signer = PrivateKeySigner::from_bytes(&STAGING_RP_SIGNING_KEY.into()) - .wrap_err("failed to create signer")?; - - let action = - (proof_type == ProofType::Uniqueness).then(|| FieldElement::from(1u64)); - let msg = world_id_core::primitives::rp::compute_rp_signature_msg( - *nonce, - created_at, - expires_at, - action.map(|action| *action), - ); - let signature = signer.sign_message_sync(&msg).wrap_err("signing failed")?; - - let rp_id = RpId::new(STAGING_RP_ID); - let request_item = RequestItem::new( - "test".to_string(), - issuer_schema_id, - Some(signal.as_bytes().to_vec()), - None, - None, - ); - - Ok(CoreProofRequest { - id: "test_request".to_string(), - version: RequestVersion::V1, - proof_type, - created_at, - expires_at, - rp_id, - oprf_key_id: serde_json::from_value(serde_json::json!(format!( - "0x{:040x}", - STAGING_RP_ID - ))) - .wrap_err("failed to construct oprf_key_id")?, - session_id, - action, - signature, - nonce, - requests: vec![request_item], - constraints: None, - }) -} - fn run_generate_test_request( cli: &Cli, issuer_schema_id: u64, @@ -533,9 +312,12 @@ fn run_generate_test_request( (ProofType::Session, Some(session_id)) => Some(session_id), (_, None) => None, }; + let env = TestEnv::staging(); let request = build_test_request( + &env, issuer_schema_id, signal, + now_secs(), expires_in, proof_type, session_id, @@ -559,18 +341,22 @@ async fn run_test( verifier_address: Option<&str>, ) -> eyre::Result<()> { let (authenticator, store) = init_authenticator(cli).await?; + let env = verify_env(cli, verifier_address)?; + let now = now_secs(); if !cli.json { eprintln!("Issuing test credential from faux issuer..."); } - let issued = issue_test_credential(&authenticator, &store).await?; + let issued = issue_faux_credential(&env, &authenticator, &store, now).await?; if !cli.json { eprintln!("Generating test proof request..."); } let proof_request = build_test_request( - FAUX_ISSUER_SCHEMA_ID, + &env, + env.faux_issuer_schema_id, signal, + now, 300, ProofType::Uniqueness, None, @@ -579,24 +365,18 @@ async fn run_test( if !cli.json { eprintln!("Generating proof..."); } - let ts = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("system time after epoch") - .as_secs(); let walletkit_request = ProofRequest::from_json(&serde_json::to_string(&proof_request)?) .wrap_err("invalid proof request")?; let proof_response = authenticator - .generate_proof(&walletkit_request, Some(ts)) + .generate_proof(&walletkit_request, Some(now)) .await .wrap_err("proof generation failed")?; if !cli.json { eprintln!("Verifying proof on-chain..."); } - let results = - verify_proof_onchain(cli, &proof_request, &proof_response.0, verifier_address) - .await?; + let results = verify_proof_onchain(&env, &proof_request, &proof_response.0).await?; let all_passed = results.iter().all(|r| r.verified); if cli.json { diff --git a/walletkit-cli/src/commands/setup.rs b/walletkit-cli/src/commands/setup.rs index 8e7bb7228..d7ebc69a6 100644 --- a/walletkit-cli/src/commands/setup.rs +++ b/walletkit-cli/src/commands/setup.rs @@ -2,8 +2,9 @@ use walletkit_core::storage::cache_embedded_groth16_material; +use walletkit_testkit::storage::create_fs_credential_store; + use crate::output; -use crate::provider::create_fs_credential_store; use super::auth::{register_and_poll, RegisterOutcome}; use super::{resolve_root, Cli}; diff --git a/walletkit-cli/src/commands/wallet.rs b/walletkit-cli/src/commands/wallet.rs index 7047d32c7..2ed90213a 100644 --- a/walletkit-cli/src/commands/wallet.rs +++ b/walletkit-cli/src/commands/wallet.rs @@ -4,8 +4,9 @@ use clap::Subcommand; use eyre::WrapErr as _; use walletkit_core::storage::{cache_embedded_groth16_material, StoragePaths}; +use walletkit_testkit::storage::create_fs_credential_store; + use crate::output; -use crate::provider::create_fs_credential_store; use super::{init_authenticator, resolve_root, Cli}; diff --git a/walletkit-cli/src/main.rs b/walletkit-cli/src/main.rs index 1cba58111..52c988af6 100644 --- a/walletkit-cli/src/main.rs +++ b/walletkit-cli/src/main.rs @@ -5,7 +5,6 @@ mod commands; mod latency; mod output; -mod provider; use std::sync::{Arc, Mutex}; diff --git a/walletkit-cli/src/provider.rs b/walletkit-cli/src/provider.rs deleted file mode 100644 index cc808799c..000000000 --- a/walletkit-cli/src/provider.rs +++ /dev/null @@ -1,140 +0,0 @@ -//! Filesystem-backed storage provider for the CLI. -//! -//! This is a dev-local provider — the device keystore is a no-op passthrough -//! since encryption at the keystore layer adds no value for local development. -//! The `SQLCipher` databases are still encrypted with a random `K_intermediate`. - -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use walletkit_core::storage::{ - AtomicBlobStore, CredentialStore, DeviceKeystore, StorageError, StoragePaths, - StorageProvider, -}; - -/// No-op device keystore that passes data through without encryption. -/// -/// Suitable only for development and testing. In production, the real -/// `DeviceKeystore` is backed by the platform's secure enclave. -pub struct NoopDeviceKeystore; - -impl DeviceKeystore for NoopDeviceKeystore { - fn seal( - &self, - _associated_data: Vec, - plaintext: Vec, - ) -> Result, StorageError> { - Ok(plaintext) - } - - fn open_sealed( - &self, - _associated_data: Vec, - ciphertext: Vec, - ) -> Result, StorageError> { - Ok(ciphertext) - } -} - -/// Filesystem-backed atomic blob store. -/// -/// Stores blobs as files under a base directory with atomic rename-into-place -/// semantics. -pub struct FsAtomicBlobStore { - base: PathBuf, -} - -impl FsAtomicBlobStore { - /// Creates a new blob store rooted at `base`. - pub fn new(base: &Path) -> Self { - Self { - base: base.to_path_buf(), - } - } -} - -impl AtomicBlobStore for FsAtomicBlobStore { - fn read(&self, path: String) -> Result>, StorageError> { - let full = self.base.join(&path); - match fs::read(&full) { - Ok(bytes) => Ok(Some(bytes)), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(StorageError::BlobStore(format!( - "read {}: {e}", - full.display() - ))), - } - } - - fn write_atomic(&self, path: String, bytes: Vec) -> Result<(), StorageError> { - let full = self.base.join(&path); - if let Some(parent) = full.parent() { - fs::create_dir_all(parent).map_err(|e| { - StorageError::BlobStore(format!("mkdir {}: {e}", parent.display())) - })?; - } - let tmp = full.with_extension("tmp"); - fs::write(&tmp, &bytes).map_err(|e| { - StorageError::BlobStore(format!("write {}: {e}", tmp.display())) - })?; - fs::rename(&tmp, &full).map_err(|e| { - StorageError::BlobStore(format!("rename {}: {e}", full.display())) - })?; - Ok(()) - } - - fn delete(&self, path: String) -> Result<(), StorageError> { - let full = self.base.join(&path); - match fs::remove_file(&full) { - Ok(()) => Ok(()), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(e) => Err(StorageError::BlobStore(format!( - "delete {}: {e}", - full.display() - ))), - } - } -} - -/// Filesystem storage provider that ties together all components. -pub struct FsStorageProvider { - keystore: Arc, - blob_store: Arc, - paths: Arc, -} - -impl FsStorageProvider { - /// Creates a new provider rooted at the given directory. - pub fn open(root: &Path) -> Self { - let keystore = Arc::new(NoopDeviceKeystore); - let blob_store = Arc::new(FsAtomicBlobStore::new(root)); - let paths = Arc::new(StoragePaths::new(root)); - Self { - keystore, - blob_store, - paths, - } - } -} - -impl StorageProvider for FsStorageProvider { - fn keystore(&self) -> Arc { - self.keystore.clone() - } - - fn blob_store(&self) -> Arc { - self.blob_store.clone() - } - - fn paths(&self) -> Arc { - Arc::clone(&self.paths) - } -} - -/// Creates a `CredentialStore` backed by the filesystem at `root`. -pub fn create_fs_credential_store(root: &Path) -> eyre::Result> { - let provider = FsStorageProvider::open(root); - let store = CredentialStore::from_provider(&provider)?; - Ok(Arc::new(store)) -} diff --git a/walletkit-testkit/Cargo.toml b/walletkit-testkit/Cargo.toml new file mode 100644 index 000000000..5befb4b3d --- /dev/null +++ b/walletkit-testkit/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "walletkit-testkit" +description = "Reusable end-to-end test helpers for WalletKit — storage providers, staging fixtures, credential issuance, and on-chain proof verification." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +# Not published: ships staging URLs and test-RP private keys, and is consumed by git rev. +publish = false + +[dependencies] +walletkit-core = { workspace = true, features = ["issuers", "embed-zkeys"] } +world-id-core = { workspace = true, features = ["authenticator", "embed-zkeys"] } +alloy-core = { workspace = true } +alloy = { version = "2", default-features = false, features = ["contract", "json", "getrandom", "signer-local"] } +eyre = "0.6" +rand = "0.8" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +uuid = { version = "1.10", features = ["v4"] } + +[dev-dependencies] +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } + +[lints] +workspace = true diff --git a/walletkit-testkit/src/authenticator.rs b/walletkit-testkit/src/authenticator.rs new file mode 100644 index 000000000..00b154cac --- /dev/null +++ b/walletkit-testkit/src/authenticator.rs @@ -0,0 +1,93 @@ +//! Authenticator setup helpers. +//! +//! [`init_authenticator`] builds a filesystem-backed [`Authenticator`] ready to +//! generate proofs (cached Groth16 materials + `init_with_defaults` + +//! `init_storage`), mirroring the CLI's setup. [`register_account`] registers +//! (or initializes) the on-chain account via [`CoreAuthenticator::init_or_register`] +//! and returns its `leaf_index`, which the local-`EdDSA` issuer needs to build a +//! credential subject. + +use std::path::Path; +use std::sync::Arc; + +use alloy::primitives::Address; +use eyre::WrapErr as _; +use walletkit_core::storage::{cache_embedded_groth16_material, CredentialStore}; +use walletkit_core::{defaults, Authenticator, Groth16Materials}; +use world_id_core::Authenticator as CoreAuthenticator; + +use crate::env::TestEnv; +use crate::storage::create_fs_credential_store; + +/// Initializes a filesystem-backed [`Authenticator`] and its [`CredentialStore`]. +/// +/// Creates an [`crate::storage::FsStorageProvider`]-backed store rooted at +/// `root`, caches the embedded Groth16 materials, initializes the authenticator +/// with SDK defaults for `env`'s environment and RPC, and runs `init_storage` +/// with the given `now` (unix seconds). +/// +/// # Errors +/// +/// Returns an error if the store, materials cache, authenticator init, or +/// storage init fails. +pub async fn init_authenticator( + env: &TestEnv, + seed: &[u8], + root: &Path, + now: u64, +) -> eyre::Result<(Arc, Arc)> { + let store = create_fs_credential_store(root)?; + let paths = store.storage_paths()?; + cache_embedded_groth16_material(&paths)?; + let materials = Arc::new( + Groth16Materials::from_cache(Arc::new(paths)) + .wrap_err("failed to load cached Groth16 materials")?, + ); + + let authenticator = Authenticator::init_with_defaults( + seed, + Some(env.worldchain_rpc_url.clone()), + &env.environment, + None, + materials, + store.clone(), + ) + .await + .wrap_err("authenticator init failed")?; + + authenticator + .init_storage(now) + .wrap_err("storage init failed")?; + + Ok((Arc::new(authenticator), store)) +} + +/// Registers (or initializes) the on-chain account and returns its `leaf_index`. +/// +/// Wraps [`CoreAuthenticator::init_or_register`] against `env`'s environment and +/// RPC. The `leaf_index` is required to build a credential subject for the +/// local-`EdDSA` issuance path. +/// +/// # Errors +/// +/// Returns an error if the staging config cannot be built or if account +/// creation/init fails. +pub async fn register_account( + env: &TestEnv, + seed: &[u8], + recovery_address: Option
, +) -> eyre::Result { + let config = defaults::default_config( + &env.environment, + Some(env.worldchain_rpc_url.clone()), + None, + ) + .wrap_err("failed to build config")?; + + let core_authenticator = + CoreAuthenticator::init_or_register(seed, config, recovery_address) + .await + .wrap_err("account creation/init failed")?; + + Ok(core_authenticator.leaf_index()) +} diff --git a/walletkit-testkit/src/env.rs b/walletkit-testkit/src/env.rs new file mode 100644 index 000000000..dc996cf97 --- /dev/null +++ b/walletkit-testkit/src/env.rs @@ -0,0 +1,101 @@ +//! Test environment configuration. +//! +//! [`TestEnv`] centralizes every staging-registered constant that the test +//! helpers need — RP id/signing key, on-chain verifier address, World Chain RPC, +//! and the two issuer fixtures (hosted faux-issuer and local `EdDSA`). The +//! [`Default`] impl returns [`TestEnv::staging`]; individual fields can be +//! overridden for pointing helpers at other environments or fixtures. +//! +//! All fixtures are pre-registered on staging: the RP on the `RpRegistry` +//! contract and both issuers on the `CredentialSchemaIssuerRegistry`. Proofs +//! verify on-chain because the `WorldIDVerifier` resolves the issuer public key +//! from the on-chain registry by schema id. + +use alloy::primitives::{address, hex, Address}; +use walletkit_core::Environment; + +/// Staging RP ID registered on the `RpRegistry` contract. +pub const STAGING_RP_ID: u64 = 46; + +/// ECDSA private key (secp256k1) for the staging RP. +pub const STAGING_RP_SIGNING_KEY: [u8; 32] = + hex!("1111111111111111111111111111111111111111111111111111111111111111"); + +/// `WorldIDVerifier` proxy contract address on staging (World Chain Mainnet 480). +pub const STAGING_WORLD_ID_VERIFIER: Address = + address!("0x703a6316c975DEabF30b637c155edD53e24657DB"); + +/// Default RPC URL for World Chain Mainnet (chain 480). +pub const DEFAULT_WORLDCHAIN_RPC_URL: &str = + "https://worldchain-mainnet.g.alchemy.com/public"; + +/// Hosted faux-issuer endpoint (staging). +pub const FAUX_ISSUER_URL: &str = "https://faux-issuer.us.id-infra.worldcoin.dev/issue"; + +/// Issuer schema ID served by the hosted faux issuer. +pub const FAUX_ISSUER_SCHEMA_ID: u64 = 128; + +/// Issuer schema ID registered for the local `EdDSA` issuer on staging. +pub const LOCAL_ISSUER_SCHEMA_ID: u64 = 47; + +/// `EdDSA` private key (32 bytes) for the registered local issuer. +pub const LOCAL_ISSUER_EDDSA_KEY: [u8; 32] = + hex!("1111111111111111111111111111111111111111111111111111111111111111"); + +/// Configuration for the test helpers, centralizing all staging fixtures. +/// +/// Construct with [`TestEnv::staging`] (also the [`Default`]) and override any +/// field as needed. Every helper in this crate takes a `&TestEnv` so callers can +/// point the same flow at a different RP, verifier, RPC, or issuer. +#[derive(Debug, Clone)] +pub struct TestEnv { + /// RP ID registered on the `RpRegistry` contract. + pub rp_id: u64, + /// ECDSA private key (secp256k1) used to sign proof requests as the RP. + pub rp_signing_key: [u8; 32], + /// On-chain `WorldIDVerifier` contract address. + pub world_id_verifier: Address, + /// World Chain RPC URL used for on-chain verification. + pub worldchain_rpc_url: String, + /// Hosted faux-issuer endpoint. + pub faux_issuer_url: String, + /// Issuer schema ID served by the hosted faux issuer. + pub faux_issuer_schema_id: u64, + /// Issuer schema ID registered for the local `EdDSA` issuer. + pub local_issuer_schema_id: u64, + /// `EdDSA` private key for the local issuer. + pub local_issuer_eddsa_key: [u8; 32], + /// World ID environment these fixtures belong to. + pub environment: Environment, +} + +impl TestEnv { + /// Returns the staging configuration with all pre-registered fixtures. + #[must_use] + pub fn staging() -> Self { + Self { + rp_id: STAGING_RP_ID, + rp_signing_key: STAGING_RP_SIGNING_KEY, + world_id_verifier: STAGING_WORLD_ID_VERIFIER, + worldchain_rpc_url: DEFAULT_WORLDCHAIN_RPC_URL.to_string(), + faux_issuer_url: FAUX_ISSUER_URL.to_string(), + faux_issuer_schema_id: FAUX_ISSUER_SCHEMA_ID, + local_issuer_schema_id: LOCAL_ISSUER_SCHEMA_ID, + local_issuer_eddsa_key: LOCAL_ISSUER_EDDSA_KEY, + environment: Environment::Staging, + } + } + + /// Overrides the World Chain RPC URL, returning the modified config. + #[must_use] + pub fn with_rpc_url(mut self, rpc_url: impl Into) -> Self { + self.worldchain_rpc_url = rpc_url.into(); + self + } +} + +impl Default for TestEnv { + fn default() -> Self { + Self::staging() + } +} diff --git a/walletkit-testkit/src/flow.rs b/walletkit-testkit/src/flow.rs new file mode 100644 index 000000000..b9d9d2802 --- /dev/null +++ b/walletkit-testkit/src/flow.rs @@ -0,0 +1,115 @@ +//! High-level end-to-end convenience flow. +//! +//! [`generate_and_verify_test_proof`] wires the whole pipeline together — account +//! registration, authenticator init, credential issuance, proof-request signing, +//! proof generation, and on-chain verification — for either issuance strategy. +//! It mirrors the CLI's `proof test` subcommand. + +use std::path::Path; + +use alloy::primitives::Address; +use eyre::WrapErr as _; +use world_id_core::requests::ProofType; + +use crate::authenticator::{init_authenticator, register_account}; +use crate::env::TestEnv; +use crate::issuer::{ + issue_faux_credential, issue_local_credential, IssuedTestCredential, +}; +use crate::proof::{build_test_request, verify_proof_onchain, VerifyItemResult}; + +/// Credential issuance strategy for [`generate_and_verify_test_proof`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IssuanceStrategy { + /// Hosted faux issuer over HTTP (schema 128). + Faux, + /// Local `EdDSA` issuer (schema 47), deterministic and service-independent. + LocalEdDSA, +} + +/// Outcome of an end-to-end test proof run. +#[derive(Debug, Clone)] +pub struct TestProofOutcome { + /// The credential that was issued and stored. + pub issued: IssuedTestCredential, + /// On-chain verification results, one per response item. + pub results: Vec, +} + +impl TestProofOutcome { + /// Returns `true` if every response item verified on-chain. + #[must_use] + pub fn all_verified(&self) -> bool { + self.results.iter().all(|r| r.verified) + } +} + +/// How far in the future issued credentials and proof requests expire. +const CREDENTIAL_TTL_SECS: u64 = 3600; +const REQUEST_TTL_SECS: u64 = 300; + +/// Runs the full issue → prove → verify flow for the given issuance strategy. +/// +/// Registers (or initializes) the account, initializes a filesystem-backed +/// authenticator rooted at `root`, issues a uniqueness credential via `strategy`, +/// builds and signs a proof request for `signal`, generates the proof, and +/// verifies it on-chain. `now` (unix seconds) is used consistently throughout. +/// +/// # Errors +/// +/// Returns an error if any stage (registration, init, issuance, proof +/// generation, or verification setup) fails. +pub async fn generate_and_verify_test_proof( + env: &TestEnv, + seed: &[u8], + root: &Path, + strategy: IssuanceStrategy, + signal: &str, + now: u64, +) -> eyre::Result { + let leaf_index = register_account(env, seed, Some(Address::ZERO)) + .await + .wrap_err("account registration failed")?; + + let (authenticator, store) = init_authenticator(env, seed, root, now).await?; + + let issued = match strategy { + IssuanceStrategy::Faux => { + issue_faux_credential(env, &authenticator, &store, now).await? + } + IssuanceStrategy::LocalEdDSA => { + issue_local_credential( + env, + &authenticator, + &store, + leaf_index, + now, + now, + now + CREDENTIAL_TTL_SECS, + ) + .await? + } + }; + + let core_request = build_test_request( + env, + issued.issuer_schema_id, + signal, + now, + REQUEST_TTL_SECS, + ProofType::Uniqueness, + None, + )?; + + let walletkit_request: walletkit_core::requests::ProofRequest = + core_request.clone().into(); + let proof_response = authenticator + .generate_proof(&walletkit_request, Some(now)) + .await + .wrap_err("proof generation failed")?; + let response = proof_response.into_inner(); + + let results = verify_proof_onchain(env, &core_request, &response).await?; + + Ok(TestProofOutcome { issued, results }) +} diff --git a/walletkit-testkit/src/issuer.rs b/walletkit-testkit/src/issuer.rs new file mode 100644 index 000000000..1f8f15afe --- /dev/null +++ b/walletkit-testkit/src/issuer.rs @@ -0,0 +1,170 @@ +//! Test credential issuance. +//! +//! Two interchangeable strategies, both producing an [`IssuedTestCredential`] +//! stored in the provided [`CredentialStore`]: +//! +//! - [`issue_faux_credential`] — calls the hosted faux issuer over HTTP +//! (schema 128). Exercises the real hosted issuer service. +//! - [`issue_local_credential`] — signs a credential locally with the +//! registered staging issuer's `EdDSA` key (schema 47). Deterministic and +//! service-independent, but requires the account `leaf_index` (see +//! [`crate::authenticator::register_account`]). + +use eyre::WrapErr as _; +use walletkit_core::storage::CredentialStore; +use walletkit_core::{Authenticator, Credential, FieldElement}; +use world_id_core::{ + Credential as CoreCredential, EdDSAPrivateKey, FieldElement as CoreFieldElement, +}; + +use crate::env::TestEnv; + +/// A credential that was issued and stored for a test. +#[derive(Debug, Clone)] +pub struct IssuedTestCredential { + /// Local store ID assigned to the stored credential. + pub credential_id: u64, + /// Issuer schema ID the credential was issued under. + pub issuer_schema_id: u64, + /// Credential expiry (unix seconds). + pub expires_at: u64, + /// Blinding factor used to derive the credential subject. + pub blinding_factor: FieldElement, +} + +/// Builds an unsigned base credential with subject derived from `leaf_index` +/// and `blinding_factor`. +/// +/// The caller is responsible for setting the issuer and signature before use. +#[must_use] +pub fn build_base_credential( + issuer_schema_id: u64, + leaf_index: u64, + genesis_issued_at: u64, + expires_at: u64, + blinding_factor: CoreFieldElement, +) -> CoreCredential { + let sub = CoreCredential::compute_sub(leaf_index, blinding_factor); + CoreCredential::new() + .issuer_schema_id(issuer_schema_id) + .subject(sub) + .genesis_issued_at(genesis_issued_at) + .expires_at(expires_at) +} + +/// Issues a credential from the hosted faux issuer (HTTP, schema 128) and stores it. +/// +/// Generates a blinding factor via OPRF, requests a credential for the derived +/// subject, and stores the returned credential with `now` (unix seconds) as the +/// reference time. +/// +/// # Errors +/// +/// Returns an error if blinding-factor generation, the faux-issuer request, the +/// response parsing, or storing the credential fails. +pub async fn issue_faux_credential( + env: &TestEnv, + authenticator: &Authenticator, + store: &CredentialStore, + now: u64, +) -> eyre::Result { + let bf = authenticator + .generate_credential_blinding_factor_remote(env.faux_issuer_schema_id) + .await + .wrap_err("blinding factor generation failed")?; + + let sub_hex = authenticator.compute_credential_sub(&bf).to_hex_string(); + + let client = reqwest::Client::new(); + let resp = client + .post(&env.faux_issuer_url) + .json(&serde_json::json!({ "sub": sub_hex })) + .send() + .await + .wrap_err("faux issuer request failed")?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + eyre::bail!("faux issuer returned {status}: {body}"); + } + + let body: serde_json::Value = resp + .json() + .await + .wrap_err("failed to parse faux issuer response")?; + + let cred_value = body.get("credential").ok_or_else(|| { + eyre::eyre!("faux issuer response missing 'credential' field") + })?; + + let cred_bytes = + serde_json::to_vec(cred_value).wrap_err("failed to serialize credential")?; + let cred = Credential::from_bytes(cred_bytes) + .wrap_err("invalid credential from faux issuer")?; + let expires_at = cred.expires_at(); + + let credential_id = store + .store_credential(&cred, &bf, expires_at, None, now) + .wrap_err("store credential failed")?; + + Ok(IssuedTestCredential { + credential_id, + issuer_schema_id: env.faux_issuer_schema_id, + expires_at, + blinding_factor: bf, + }) +} + +/// Issues a credential signed locally by the staging issuer's `EdDSA` key +/// (schema 47) and stores it. +/// +/// Generates a blinding factor via OPRF, builds a credential subject from +/// `leaf_index`, signs it with the configured issuer key, and stores it with +/// `now` (unix seconds) as the reference time. `genesis_issued_at` and +/// `expires_at` are set explicitly on the credential. +/// +/// # Errors +/// +/// Returns an error if blinding-factor generation, credential hashing, or +/// storing the credential fails. +pub async fn issue_local_credential( + env: &TestEnv, + authenticator: &Authenticator, + store: &CredentialStore, + leaf_index: u64, + now: u64, + genesis_issued_at: u64, + expires_at: u64, +) -> eyre::Result { + let issuer_secret_key = EdDSAPrivateKey::from_bytes(env.local_issuer_eddsa_key); + let issuer_public_key = issuer_secret_key.public(); + + let bf = authenticator + .generate_credential_blinding_factor_remote(env.local_issuer_schema_id) + .await + .wrap_err("blinding factor generation failed")?; + + let mut credential = build_base_credential( + env.local_issuer_schema_id, + leaf_index, + genesis_issued_at, + expires_at, + bf.0, + ); + credential.issuer = issuer_public_key; + let credential_hash = credential.hash().wrap_err("failed to hash credential")?; + credential.signature = Some(issuer_secret_key.sign(*credential_hash)); + + let walletkit_credential: Credential = credential.into(); + let credential_id = store + .store_credential(&walletkit_credential, &bf, expires_at, None, now) + .wrap_err("store credential failed")?; + + Ok(IssuedTestCredential { + credential_id, + issuer_schema_id: env.local_issuer_schema_id, + expires_at, + blinding_factor: bf, + }) +} diff --git a/walletkit-testkit/src/lib.rs b/walletkit-testkit/src/lib.rs new file mode 100644 index 000000000..fba27c8aa --- /dev/null +++ b/walletkit-testkit/src/lib.rs @@ -0,0 +1,32 @@ +//! `walletkit-testkit` — reusable end-to-end test helpers for World ID v4. +//! +//! This crate consolidates the e2e/test-support logic that was previously +//! duplicated across `walletkit-core`'s integration tests, the `walletkit-cli` +//! binary, and external consumers (e.g. orb-tools). It is **test-support only** +//! and is never published to crates.io: it ships staging URLs and pre-registered +//! test-RP / test-issuer private keys, and is consumed by git revision. +//! +//! # What it provides +//! +//! - [`TestEnv`] — a config struct centralizing all staging constants (RP id/key, +//! on-chain verifier address, World Chain RPC, faux-issuer URL + schema, local +//! issuer key + schema). [`TestEnv::default`] is staging; every field is +//! overridable. +//! - Storage providers usable with the `walletkit-core` storage traits: a +//! filesystem-backed [`FsStorageProvider`] for tests and CLI-style local state. +//! - Credential issuance via two interchangeable strategies: the hosted +//! faux-issuer (HTTP, schema 128) and a local `EdDSA` issuer (schema 47, +//! deterministic, no service dependency). +//! - Proof-request construction signed by the staging RP key, and on-chain +//! verification against the staging `WorldIDVerifier` contract. +//! - A high-level `generate_and_verify_test_proof` convenience that wires the +//! whole flow together for either issuance strategy. + +pub mod authenticator; +pub mod env; +pub mod flow; +pub mod issuer; +pub mod proof; +pub mod storage; + +pub use env::TestEnv; diff --git a/walletkit-testkit/src/proof.rs b/walletkit-testkit/src/proof.rs new file mode 100644 index 000000000..0fe725371 --- /dev/null +++ b/walletkit-testkit/src/proof.rs @@ -0,0 +1,237 @@ +//! Proof-request construction and on-chain verification. +//! +//! [`build_test_request`] assembles a [`ProofRequest`] signed by the staging RP +//! key from a [`TestEnv`], replacing the hardcoded constants the CLI and +//! integration tests previously carried. On-chain verification (added alongside) +//! checks a request/response pair against the staging `WorldIDVerifier`. + +use alloy::providers::ProviderBuilder; +use alloy::signers::{local::PrivateKeySigner, SignerSync}; +use alloy::sol; +use eyre::WrapErr as _; +use rand::rngs::OsRng; +use world_id_core::primitives::{rp::RpId, FieldElement, SessionId}; +use world_id_core::requests::{ + ProofRequest, ProofResponse, ProofType, RequestItem, RequestVersion, +}; + +use crate::env::TestEnv; + +sol!( + #[allow(clippy::too_many_arguments)] + #[sol(rpc)] + interface IWorldIDVerifier { + function verify( + uint256 nullifier, + uint256 action, + uint64 rpId, + uint256 nonce, + uint256 signalHash, + uint64 expiresAtMin, + uint64 issuerSchemaId, + uint256 credentialGenesisIssuedAtMin, + uint256[5] calldata zeroKnowledgeProof + ) external view; + + function verifySession( + uint64 rpId, + uint256 nonce, + uint256 signalHash, + uint64 expiresAtMin, + uint64 issuerSchemaId, + uint256 credentialGenesisIssuedAtMin, + uint256 sessionId, + uint256[2] calldata sessionNullifier, + uint256[5] calldata zeroKnowledgeProof + ) external view; + } +); + +/// Builds a proof [`ProofRequest`] signed by the RP key configured in `env`. +/// +/// `now` is the request's `created_at` (unix seconds); the request expires at +/// `now + expires_in`. For uniqueness proofs an `action` of `1` is set and +/// included in the RP signature; session proofs carry no action. Pass an +/// existing `session_id` for [`ProofType::Session`]. +/// +/// # Errors +/// +/// Returns an error if the RP signer cannot be constructed from the configured +/// key, if signing the RP message fails, or if the `oprf_key_id` cannot be +/// derived from the RP id. +pub fn build_test_request( + env: &TestEnv, + issuer_schema_id: u64, + signal: &str, + now: u64, + expires_in: u64, + proof_type: ProofType, + session_id: Option, +) -> eyre::Result { + let nonce = FieldElement::random(&mut OsRng); + let created_at = now; + let expires_at = now + expires_in; + + let signer = PrivateKeySigner::from_bytes(&env.rp_signing_key.into()) + .wrap_err("failed to create RP signer")?; + + let action = + (proof_type == ProofType::Uniqueness).then(|| FieldElement::from(1u64)); + let msg = world_id_core::primitives::rp::compute_rp_signature_msg( + *nonce, + created_at, + expires_at, + action.map(|action| *action), + ); + let signature = signer.sign_message_sync(&msg).wrap_err("signing failed")?; + + let request_item = RequestItem::new( + "test".to_string(), + issuer_schema_id, + Some(signal.as_bytes().to_vec()), + None, + None, + ); + + Ok(ProofRequest { + id: "test_request".to_string(), + version: RequestVersion::V1, + proof_type, + created_at, + expires_at, + rp_id: RpId::new(env.rp_id), + oprf_key_id: serde_json::from_value(serde_json::json!(format!( + "0x{:040x}", + env.rp_id + ))) + .wrap_err("failed to construct oprf_key_id")?, + session_id, + action, + signature, + nonce, + requests: vec![request_item], + constraints: None, + }) +} + +/// Result of verifying one proof-response item against the `WorldIDVerifier`. +#[derive(Debug, Clone)] +pub struct VerifyItemResult { + /// Issuer schema ID of the verified credential. + pub issuer_schema_id: u64, + /// Identifier of the request item this result corresponds to. + pub identifier: String, + /// Whether the on-chain `verify`/`verifySession` call succeeded. + pub verified: bool, + /// Error detail when `verified` is `false`. + pub error: Option, +} + +/// Verifies a proof request/response pair on-chain against the staging +/// `WorldIDVerifier`, returning one [`VerifyItemResult`] per response item. +/// +/// The verifier contract address and RPC URL are taken from `env`. Uniqueness +/// proofs are checked via `verify`; create-session and session proofs via +/// `verifySession`. +/// +/// # Errors +/// +/// Returns an error if the response carries an error, if the response does not +/// match the request, if the RPC URL is invalid, or if a required field +/// (action, session id, nullifier, session nullifier) is missing for the +/// proof type. +pub async fn verify_proof_onchain( + env: &TestEnv, + proof_request: &ProofRequest, + proof_response: &ProofResponse, +) -> eyre::Result> { + if let Some(ref err) = proof_response.error { + eyre::bail!("proof response contains error: {err}"); + } + proof_request + .validate_response(proof_response) + .wrap_err("proof response does not match proof request")?; + + let provider = ProviderBuilder::new().connect_http(env.worldchain_rpc_url.parse()?); + let verifier_contract = IWorldIDVerifier::new(env.world_id_verifier, &provider); + + let nonce = proof_request.nonce; + let rp_id = proof_request.rp_id.into_inner(); + + let mut results = Vec::new(); + for response_item in &proof_response.responses { + let request_item = proof_request + .find_request_by_issuer_schema_id(response_item.issuer_schema_id) + .ok_or_else(|| { + eyre::eyre!( + "no matching request item for issuer_schema_id={}", + response_item.issuer_schema_id + ) + })?; + + let credential_genesis_issued_at_min = request_item + .genesis_issued_at_min + .unwrap_or_default() + .try_into()?; + + let result = match proof_request.proof_type { + ProofType::Uniqueness => { + let nullifier = response_item + .nullifier + .ok_or_else(|| eyre::eyre!("response item missing nullifier"))?; + let action = proof_request + .action + .ok_or_else(|| eyre::eyre!("proof request has no action"))?; + + verifier_contract + .verify( + nullifier.into(), + action.into(), + rp_id, + nonce.into(), + request_item.signal_hash().into(), + response_item.expires_at_min, + response_item.issuer_schema_id, + credential_genesis_issued_at_min, + response_item.proof.as_ethereum_representation(), + ) + .call() + .await + .map(|_| ()) + } + ProofType::CreateSession | ProofType::Session => { + let session_nullifier = + response_item.session_nullifier.ok_or_else(|| { + eyre::eyre!("response item missing session_nullifier") + })?; + let session_id = proof_response.session_id.ok_or_else(|| { + eyre::eyre!("session proof response missing session_id") + })?; + + verifier_contract + .verifySession( + rp_id, + nonce.into(), + request_item.signal_hash().into(), + response_item.expires_at_min, + response_item.issuer_schema_id, + credential_genesis_issued_at_min, + session_id.commitment.into(), + session_nullifier.as_ethereum_representation(), + response_item.proof.as_ethereum_representation(), + ) + .call() + .await + .map(|_| ()) + } + }; + + results.push(VerifyItemResult { + issuer_schema_id: response_item.issuer_schema_id, + identifier: response_item.identifier.clone(), + verified: result.is_ok(), + error: result.err().map(|e| format!("{e:#}")), + }); + } + Ok(results) +} diff --git a/walletkit-testkit/src/storage.rs b/walletkit-testkit/src/storage.rs new file mode 100644 index 000000000..b39077053 --- /dev/null +++ b/walletkit-testkit/src/storage.rs @@ -0,0 +1,190 @@ +//! Reusable [`StorageProvider`] implementations for tests. +//! +//! `walletkit-core` exposes the storage *traits* ([`StorageProvider`], +//! [`DeviceKeystore`], [`AtomicBlobStore`]) but ships no reusable provider +//! *impls*, so every (testing) consumer re-rolls them. This module provides +//! [`FsStorageProvider`]: filesystem-backed with a no-op device keystore. +//! +//! Important: This is a test-only provider and must not be used in production. +//! Vault encryption keys are stored in plaintext on disk. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use uuid::Uuid; +use walletkit_core::storage::{ + AtomicBlobStore, CredentialStore, DeviceKeystore, StorageError, StoragePaths, + StorageProvider, +}; + +// --------------------------------------------------------------------------- +// Shared test keystore +// --------------------------------------------------------------------------- + +/// No-op device keystore that passes data through without encryption. +/// +/// Suitable only for development and testing. In production the real +/// `DeviceKeystore` is backed by the platform's secure enclave. Used by +/// [`FsStorageProvider`]. +pub struct NoopDeviceKeystore; + +impl DeviceKeystore for NoopDeviceKeystore { + fn seal( + &self, + _associated_data: Vec, + plaintext: Vec, + ) -> Result, StorageError> { + Ok(plaintext) + } + + fn open_sealed( + &self, + _associated_data: Vec, + ciphertext: Vec, + ) -> Result, StorageError> { + Ok(ciphertext) + } +} + +// --------------------------------------------------------------------------- +// Filesystem provider +// --------------------------------------------------------------------------- + +/// Filesystem-backed [`AtomicBlobStore`]. +/// +/// Stores blobs as files under a base directory with atomic rename-into-place +/// semantics. +pub struct FsAtomicBlobStore { + base: PathBuf, +} + +impl FsAtomicBlobStore { + /// Creates a new blob store rooted at `base`. + #[must_use] + pub fn new(base: &Path) -> Self { + Self { + base: base.to_path_buf(), + } + } +} + +impl AtomicBlobStore for FsAtomicBlobStore { + fn read(&self, path: String) -> Result>, StorageError> { + let full = self.base.join(&path); + match std::fs::read(&full) { + Ok(bytes) => Ok(Some(bytes)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(StorageError::BlobStore(format!( + "read {}: {e}", + full.display() + ))), + } + } + + fn write_atomic(&self, path: String, bytes: Vec) -> Result<(), StorageError> { + let full = self.base.join(&path); + if let Some(parent) = full.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + StorageError::BlobStore(format!("mkdir {}: {e}", parent.display())) + })?; + } + let tmp = full.with_extension("tmp"); + std::fs::write(&tmp, &bytes).map_err(|e| { + StorageError::BlobStore(format!("write {}: {e}", tmp.display())) + })?; + std::fs::rename(&tmp, &full).map_err(|e| { + StorageError::BlobStore(format!("rename {}: {e}", full.display())) + })?; + Ok(()) + } + + fn delete(&self, path: String) -> Result<(), StorageError> { + let full = self.base.join(&path); + match std::fs::remove_file(&full) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(StorageError::BlobStore(format!( + "delete {}: {e}", + full.display() + ))), + } + } +} + +/// Filesystem [`StorageProvider`] tying together the no-op keystore, fs blob +/// store, and on-disk paths. +pub struct FsStorageProvider { + keystore: Arc, + blob_store: Arc, + paths: Arc, +} + +impl FsStorageProvider { + /// Creates a new provider rooted at the given directory. + #[must_use] + pub fn open(root: &Path) -> Self { + Self { + keystore: Arc::new(NoopDeviceKeystore), + blob_store: Arc::new(FsAtomicBlobStore::new(root)), + paths: Arc::new(StoragePaths::new(root)), + } + } +} + +impl StorageProvider for FsStorageProvider { + fn keystore(&self) -> Arc { + self.keystore.clone() + } + + fn blob_store(&self) -> Arc { + self.blob_store.clone() + } + + fn paths(&self) -> Arc { + Arc::clone(&self.paths) + } +} + +// --------------------------------------------------------------------------- +// Store constructors and helpers +// --------------------------------------------------------------------------- + +/// Returns a unique, non-existent temp directory path for test storage. +#[must_use] +pub fn temp_root() -> PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!("walletkit-test-{}", Uuid::new_v4())); + path +} + +/// Creates a [`CredentialStore`] backed by the filesystem at `root`. +/// +/// # Errors +/// +/// Returns an error if the underlying [`CredentialStore`] cannot be initialized. +pub fn create_fs_credential_store( + root: &Path, +) -> Result, StorageError> { + let provider = FsStorageProvider::open(root); + Ok(Arc::new(CredentialStore::from_provider(&provider)?)) +} + +/// Removes all on-disk artifacts (vault, cache, lock, `WorldID` dir) under `root`. +/// +/// Best-effort: missing files are ignored. Use to clean up after +/// [`FsStorageProvider`]-backed tests. +pub fn cleanup_storage(root: &Path) { + let paths = StoragePaths::new(root); + let vault = paths.vault_db_path(); + let cache = paths.cache_db_path(); + let lock = paths.lock_path(); + let _ = std::fs::remove_file(&vault); + let _ = std::fs::remove_file(vault.with_extension("sqlite-wal")); + let _ = std::fs::remove_file(vault.with_extension("sqlite-shm")); + let _ = std::fs::remove_file(&cache); + let _ = std::fs::remove_file(cache.with_extension("sqlite-wal")); + let _ = std::fs::remove_file(cache.with_extension("sqlite-shm")); + let _ = std::fs::remove_file(lock); + let _ = std::fs::remove_dir_all(paths.worldid_dir()); + let _ = std::fs::remove_dir_all(paths.root()); +} diff --git a/walletkit-testkit/tests/e2e.rs b/walletkit-testkit/tests/e2e.rs new file mode 100644 index 000000000..a54304f30 --- /dev/null +++ b/walletkit-testkit/tests/e2e.rs @@ -0,0 +1,93 @@ +//! End-to-end staging integration tests for `walletkit-testkit`. +//! +//! These tests drive the full [`generate_and_verify_test_proof`] flow against +//! **live staging infrastructure** (OPRF nodes, indexer, gateway, faux issuer, +//! and the on-chain `WorldIDVerifier`). They are `#[ignore]`d by default and run +//! only on demand: +//! +//! ```text +//! cargo test -p walletkit-testkit --test e2e -- --ignored --nocapture +//! ``` + +#![allow( + clippy::missing_panics_doc, + clippy::missing_errors_doc, + reason = "integration tests" +)] + +use std::time::{SystemTime, UNIX_EPOCH}; + +use walletkit_testkit::flow::{generate_and_verify_test_proof, IssuanceStrategy}; +use walletkit_testkit::storage::{cleanup_storage, temp_root}; +use walletkit_testkit::TestEnv; + +/// Shared test seed (matches the existing core integration tests). +const TEST_SEED: [u8; 32] = [7u8; 32]; + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time after epoch") + .as_secs() +} + +fn init_tracing() { + let _ = tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .try_init(); +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires staging infrastructure"] +async fn e2e_faux_issuer_proof() { + init_tracing(); + let env = TestEnv::staging(); + let root = temp_root(); + + let outcome = generate_and_verify_test_proof( + &env, + &TEST_SEED, + &root, + IssuanceStrategy::Faux, + "test_signal", + now_secs(), + ) + .await + .expect("faux-issuer flow should succeed"); + + cleanup_storage(&root); + assert!( + outcome.all_verified(), + "faux-issued proof should verify on-chain: {:?}", + outcome.results + ); +} + +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires staging infrastructure"] +async fn e2e_local_eddsa_proof() { + init_tracing(); + let env = TestEnv::staging(); + let root = temp_root(); + + let outcome = generate_and_verify_test_proof( + &env, + &TEST_SEED, + &root, + IssuanceStrategy::LocalEdDSA, + "test_signal", + now_secs(), + ) + .await + .expect("local-EdDSA flow should succeed"); + + cleanup_storage(&root); + assert!( + outcome.all_verified(), + "local-EdDSA-issued proof should verify on-chain: {:?}", + outcome.results + ); +}