diff --git a/Cargo.lock b/Cargo.lock index 62b68b34..e856c29b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5031,6 +5031,20 @@ dependencies = [ "rayon", ] +[[package]] +name = "provekit-verifier" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1ff1bcdce6c807b04657898aae2fd863bd4958b7ded64aaad4f10156061d7b6" +dependencies = [ + "anyhow", + "ark-std 0.5.0", + "provekit-common", + "provekit-whir", + "rayon", + "tracing", +] + [[package]] name = "provekit-whir" version = "0.1.1" @@ -8176,6 +8190,7 @@ version = "0.17.1" dependencies = [ "alloy", "base64 0.22.1", + "ciborium", "clap", "dirs", "eyre", @@ -8190,6 +8205,7 @@ dependencies = [ "tracing-subscriber 0.3.23", "walletkit-core", "world-id-core", + "world-id-proof", ] [[package]] @@ -9067,6 +9083,7 @@ dependencies = [ "eyre", "provekit-common", "provekit-prover", + "provekit-verifier", "rand 0.8.6", "rayon", "reqwest 0.12.28", diff --git a/Cargo.toml b/Cargo.toml index 61874cd3..9b3a151f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,3 +56,17 @@ opt-level = 'z' # Optimize for size. lto = true # Enable Link Time Optimization. panic = "abort" debug = false + +# ProveKit has an additional `pattern` in the Whir R1CS proof which is used in debugging builds, +# because this isn't serialized, we explicitly remove it here as WalletKit only uses serialized proofs. +[profile.dev.package.provekit-common] +debug-assertions = false + +[profile.dev.package.provekit-whir] +debug-assertions = false + +[profile.dev.package.provekit-verifier] +debug-assertions = false + +[profile.dev.package.provekit-prover] +debug-assertions = false diff --git a/walletkit-cli/Cargo.toml b/walletkit-cli/Cargo.toml index f0b314cf..325a2b3f 100644 --- a/walletkit-cli/Cargo.toml +++ b/walletkit-cli/Cargo.toml @@ -15,8 +15,10 @@ path = "src/main.rs" [dependencies] walletkit-core = { workspace = true, features = ["issuers", "embed-zkeys"] } 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"] } base64 = "0.22" +ciborium = "0.2" clap = { version = "4", features = ["derive", "env"] } dirs = "6" eyre = "0.6" diff --git a/walletkit-cli/src/commands/proof.rs b/walletkit-cli/src/commands/proof.rs index 13759d20..58c67a45 100644 --- a/walletkit-cli/src/commands/proof.rs +++ b/walletkit-cli/src/commands/proof.rs @@ -6,15 +6,17 @@ 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}; +use world_id_core::primitives::{rp::RpId, FieldElement, OwnershipProof}; use world_id_core::requests::{ ProofRequest as CoreProofRequest, ProofResponse as CoreProofResponse, RequestItem, RequestVersion, }; +use world_id_proof::ownership_proof::verify_ownership_proof; use crate::output; @@ -93,6 +95,18 @@ pub enum ProofCommand { #[arg(long, default_value = "test_signal")] signal: String, }, + /// Verify a WIP-103 ownership proof from a base64-encoded file. + VerifyOwnership { + /// Path to a file containing the base64url-encoded ownership proof, or `-` for stdin. + #[arg(long)] + proof: String, + /// Nonce used when generating the proof, as a 32-byte hex field element (with optional `0x` prefix). + #[arg(long)] + nonce: String, + /// Credential `sub` (commitment) the proof claims ownership of, as a 32-byte hex field element. + #[arg(long)] + sub: String, + }, } fn read_file_or_stdin(path: &str) -> eyre::Result { @@ -248,12 +262,15 @@ fn print_verify_items_human(results: &[VerifyItemResult]) { for r in results { if r.verified { println!( - " [PASS] {} (issuer_schema_id={})", - r.identifier, r.issuer_schema_id + " {} {} (issuer_schema_id={})", + output::pass_label(), + r.identifier, + r.issuer_schema_id ); } else { println!( - " [FAIL] {} (issuer_schema_id={}): {}", + " {} {} (issuer_schema_id={}): {}", + output::fail_label(), r.identifier, r.issuer_schema_id, r.error.as_deref().unwrap_or("unknown") @@ -459,6 +476,57 @@ async fn run_test(cli: &Cli, signal: &str) -> eyre::Result<()> { Ok(()) } +fn parse_field_element(value: &str, label: &str) -> eyre::Result { + value.trim().parse::().wrap_err_with(|| { + format!("invalid {label}: expected 32-byte hex field element") + }) +} + +fn run_verify_ownership( + cli: &Cli, + proof_path: &str, + nonce: &str, + sub: &str, +) -> eyre::Result<()> { + let b64 = read_file_or_stdin(proof_path)?; + let bytes = BASE64_URL_SAFE_NO_PAD + .decode(b64.trim()) + .wrap_err("invalid base64 ownership proof")?; + let proof: OwnershipProof = ciborium::from_reader(&bytes[..]) + .wrap_err("failed to decode ownership proof CBOR")?; + + let nonce_fe = parse_field_element(nonce, "--nonce")?; + let sub_fe = parse_field_element(sub, "--sub")?; + + let result = verify_ownership_proof(&proof, nonce_fe, sub_fe); + let merkle_root = proof.merkle_root.to_string(); + + if cli.json { + output::print_json_data( + &serde_json::json!({ + "verified": result.is_ok(), + "merkle_root": merkle_root, + "error": result.as_ref().err().map(|e| format!("{e:#}")), + }), + true, + ); + } else if let Err(ref err) = result { + println!( + "{} ownership proof verification failed: {err:#}", + output::fail_label() + ); + println!(" merkle_root: {merkle_root}"); + } else { + println!("{} ownership proof verified", output::pass_label()); + println!(" merkle_root: {merkle_root}"); + } + + if result.is_err() { + std::process::exit(1); + } + Ok(()) +} + pub async fn run(cli: &Cli, action: &ProofCommand) -> eyre::Result<()> { match action { ProofCommand::Generate { request, now } => { @@ -476,5 +544,8 @@ pub async fn run(cli: &Cli, action: &ProofCommand) -> eyre::Result<()> { verifier_address, } => run_verify(cli, request, response, verifier_address.as_deref()).await, ProofCommand::Test { signal } => run_test(cli, signal).await, + ProofCommand::VerifyOwnership { proof, nonce, sub } => { + run_verify_ownership(cli, proof, nonce, sub) + } } } diff --git a/walletkit-cli/src/output.rs b/walletkit-cli/src/output.rs index d558be20..086db4cf 100644 --- a/walletkit-cli/src/output.rs +++ b/walletkit-cli/src/output.rs @@ -1,5 +1,31 @@ //! Output formatting helpers for human-readable and JSON modes. +use std::io::IsTerminal as _; + +const GREEN: &str = "\x1b[32m"; +const RED: &str = "\x1b[31m"; +const RESET: &str = "\x1b[0m"; + +fn colorize(label: &str, color: &str) -> String { + if std::io::stdout().is_terminal() { + format!("{color}{label}{RESET}") + } else { + label.to_string() + } +} + +/// Returns `[PASS]` colored green when stdout is a TTY, otherwise plain. +#[must_use] +pub fn pass_label() -> String { + colorize("[PASS]", GREEN) +} + +/// Returns `[FAIL]` colored red when stdout is a TTY, otherwise plain. +#[must_use] +pub fn fail_label() -> String { + colorize("[FAIL]", RED) +} + /// Prints a raw JSON value wrapped in the standard envelope. pub fn print_json_data(data: &serde_json::Value, json: bool) { if json {