From 6de6065fec6085795051d50fd9723ca0f9e8a7a8 Mon Sep 17 00:00:00 2001 From: kilianglas Date: Fri, 10 Jul 2026 10:27:12 +0200 Subject: [PATCH 01/36] feat: allow binding uniqueness proofs to an existing session --- crates/authenticator/src/error.rs | 6 + crates/authenticator/src/prove.rs | 31 ++++- crates/core/tests/generate_proof.rs | 87 ++++++++++++- crates/primitives/src/request/mod.rs | 177 ++++++++++++++++++++++++--- docs/world-id-4-specs/README.md | 7 ++ 5 files changed, 286 insertions(+), 22 deletions(-) diff --git a/crates/authenticator/src/error.rs b/crates/authenticator/src/error.rs index 20d373bc6..3cb97f834 100644 --- a/crates/authenticator/src/error.rs +++ b/crates/authenticator/src/error.rs @@ -135,6 +135,12 @@ pub enum AuthenticatorError { #[error("the expected session id and the generated session id do not match")] SessionIdMismatch, + /// Binding a session to a Uniqueness Proof requires the cached `session_id_r_seed`. + /// Re-deriving it inside a uniqueness request is not possible; run a session-type + /// request first to obtain it. + #[error("session binding requires a cached `session_id_r_seed`")] + SessionSeedRequired, + /// Generic error for other unexpected issues. #[error("{0}")] Generic(String), diff --git a/crates/authenticator/src/prove.rs b/crates/authenticator/src/prove.rs index 6df20e70f..710c01d08 100644 --- a/crates/authenticator/src/prove.rs +++ b/crates/authenticator/src/prove.rs @@ -276,8 +276,9 @@ impl Authenticator { /// - `credentials` — one [`CredentialInput`] per credential to prove, /// matched to request items by `issuer_schema_id`. /// - `account_inclusion_proof` — a cached inclusion proof if available (a fresh one will be fetched otherwise) - /// - `session_id_r_seed` — a cached session `r` seed for Session Proofs. If not available, it will be - /// re-computed. + /// - `session_id_r_seed` — a cached session `r` seed. For Session Proofs it is re-computed + /// if unavailable; for session-bound Uniqueness Proofs ([`ProofRequest::binds_session`]) + /// it is required and the call fails with [`AuthenticatorError::SessionSeedRequired`] otherwise. /// /// # Caller Responsibilities /// 1. The caller must ensure the request can be fulfilled with the credentials which the user has available, @@ -309,7 +310,21 @@ impl Authenticator { // 2. Resolve session seed let (resolved_session_id, resolved_session_seed) = match proof_request.proof_type { - ProofType::Uniqueness => (None, None), + ProofType::Uniqueness => match proof_request.session_id { + // Bind the proof to the existing session. Requires the cached `r`: + // re-deriving it needs a session-type OPRF query the RP signature + // of this request cannot authorize. + Some(session_id) => { + let seed = session_id_r_seed.ok_or(AuthenticatorError::SessionSeedRequired)?; + let computed = + SessionId::from_r_seed(self.leaf_index(), seed, session_id.oprf_seed)?; + if computed != session_id { + return Err(AuthenticatorError::SessionIdMismatch); + } + (Some(session_id), Some(seed)) + } + None => (None, None), + }, ProofType::CreateSession => { let (session_id, seed) = self .build_session_id(proof_request, None, account_inclusion_proof) @@ -363,6 +378,7 @@ impl Authenticator { cred_input.blinding_factor, resolved_session_seed, resolved_session_id, + proof_request.proof_type, proof_request.created_at, )?; responses.push(response_item); @@ -401,8 +417,10 @@ impl Authenticator { /// - `credential`: The Credential to be used for the proof that fulfills the `RequestItem`. /// - `credential_sub_blinding_factor`: The blinding factor for the Credential's sub. /// - `session_id_r_seed`: The session ID random seed, obtained via [`build_session_id`](Self::build_session_id). - /// For Uniqueness Proofs (when `session_id` is `None`), this value is ignored by the circuit. - /// - `session_id`: The expected session ID provided by the RP. Only needed for Session Proofs. Obtained from the RP's [`ProofRequest`]. + /// For unbound Uniqueness Proofs (when `session_id` is `None`), this value is ignored by the circuit. + /// - `session_id`: The expected session ID provided by the RP. Needed for Session Proofs and + /// session-bound Uniqueness Proofs. Obtained from the RP's [`ProofRequest`]. + /// - `proof_type`: Determines whether a Session or Uniqueness response item is produced. /// - `request_timestamp`: The timestamp of the request. Obtained from the RP's [`ProofRequest`]. /// /// # Errors @@ -419,6 +437,7 @@ impl Authenticator { credential_sub_blinding_factor: FieldElement, session_id_r_seed: Option, session_id: Option, + proof_type: ProofType, request_timestamp: u64, ) -> Result { let mut rng = rand::rngs::OsRng; @@ -444,7 +463,7 @@ impl Authenticator { // Construct the appropriate response item based on proof type let nullifier_fe: FieldElement = nullifier.into(); - let response_item = if session_id.is_some() { + let response_item = if proof_type.is_session() { let session_nullifier = SessionNullifier::new(nullifier_fe, action_from_query)?; ResponseItem::new_session( request_item.identifier.clone(), diff --git a/crates/core/tests/generate_proof.rs b/crates/core/tests/generate_proof.rs index 294cc20df..4382bda04 100644 --- a/crates/core/tests/generate_proof.rs +++ b/crates/core/tests/generate_proof.rs @@ -31,7 +31,7 @@ use world_id_gateway::{ spawn_gateway_for_tests, }; use world_id_primitives::{ - Config, FieldElement, ServiceEndpoint, TREE_DEPTH, merkle::AccountInclusionProof, + Config, FieldElement, ServiceEndpoint, SessionId, TREE_DEPTH, merkle::AccountInclusionProof, }; use world_id_test_utils::{ anvil::WorldIDVerifier, @@ -322,6 +322,8 @@ async fn e2e_authenticator_generate_proof() -> Result<()> { .generate_nullifier(&proof_request, None) .await?; assert_ne!(nullifier.oprf_output(), FieldElement::ZERO); + // reused below for the session-bound proof; `generate_proof` does not contact the nodes + let nullifier_for_binding = nullifier.clone(); let credentials = [CredentialInput { credential: credential.clone(), @@ -363,6 +365,89 @@ async fn e2e_authenticator_generate_proof() -> Result<()> { .await?; info!("on-chain proof verification succeeded"); + // ── SESSION-BOUND UNIQUENESS PROOF ── + let session_id_r_seed = FieldElement::random(&mut rng); + let session_id = SessionId::from_r_seed( + leaf_index, + session_id_r_seed, + SessionId::generate_oprf_seed(&mut rng), + )?; + let bound_request = ProofRequest { + session_id: Some(session_id), + ..proof_request.clone() + }; + + // binding requires the cached seed + let err = authenticator + .generate_proof( + &bound_request, + nullifier_for_binding.clone(), + &credentials, + None, + None, + ) + .await + .unwrap_err(); + assert!(matches!(err, AuthenticatorError::SessionSeedRequired)); + + // a seed that does not open the session's commitment is rejected + let err = authenticator + .generate_proof( + &bound_request, + nullifier_for_binding.clone(), + &credentials, + None, + Some(FieldElement::random(&mut rng)), + ) + .await + .unwrap_err(); + assert!(matches!(err, AuthenticatorError::SessionIdMismatch)); + + let bound_result = authenticator + .generate_proof( + &bound_request, + nullifier_for_binding, + &credentials, + None, + Some(session_id_r_seed), + ) + .await?; + info!("generated session-bound uniqueness proof"); + + assert_eq!(bound_result.proof_response.session_id, Some(session_id)); + let bound_item = &bound_result.proof_response.responses[0]; + assert!(bound_item.session_nullifier.is_none()); + let bound_nullifier = bound_item + .nullifier + .expect("bound proof is a uniqueness proof"); + // same RP/action => same deterministic nullifier as the unbound proof + assert_eq!(bound_nullifier, response_item.nullifier.unwrap()); + + // `verify()` pins the sessionId signal to 0, so it must reject the bound proof + let unbound_verify = world_id_verifier + .verify( + bound_nullifier.into(), + rp_fixture.action.into(), + rp_fixture.world_rp_id.into_inner(), + rp_fixture.nonce.into(), + request_item.signal_hash().into(), + bound_item.expires_at_min, + issuer_schema_id, + request_item + .genesis_issued_at_min + .unwrap_or_default() + .try_into() + .expect("u64 fits into U256"), + bound_item.proof.as_ethereum_representation(), + ) + .call() + .await; + assert!( + unbound_verify.is_err(), + "bound proof must not verify with sessionId = 0" + ); + info!("session-bound proof correctly rejected by the sessionId=0 entry point"); + indexer_handle.abort(); info!("e2e_authenticator_generate_proof finished successfully"); Ok(()) diff --git a/crates/primitives/src/request/mod.rs b/crates/primitives/src/request/mod.rs index 287734edc..212b54ed9 100644 --- a/crates/primitives/src/request/mod.rs +++ b/crates/primitives/src/request/mod.rs @@ -55,6 +55,9 @@ impl<'de> serde::Deserialize<'de> for RequestVersion { #[serde(rename_all = "snake_case")] pub enum ProofType { /// A uniqueness proof scoped by the RP-provided action. + /// + /// May carry a `session_id` to bind the proof to an existing session, + /// see [`ProofRequest::binds_session`]. #[default] Uniqueness = 0x00, /// Create a new RP-scoped session identifier and prove it in the same response. @@ -101,7 +104,9 @@ pub struct ProofRequest { pub oprf_key_id: OprfKeyId, /// Session identifier that links proofs for the same user/RP pair across requests. /// - /// Required for [`ProofType::Session`], absent for all other proof types. + /// Required for [`ProofType::Session`], forbidden for [`ProofType::CreateSession`], + /// optional for [`ProofType::Uniqueness`] to bind the proof to an existing session + /// (see [`Self::binds_session`]). /// The proof will only be valid if the session ID is meant for this context and /// this particular World ID holder. pub session_id: Option, @@ -232,7 +237,8 @@ pub struct ProofResponse { /// the newly generated `SessionId`. For subsequent Session Proofs, this /// echoes back the `SessionId` from the request for convenience. /// - /// This is optional as it's not provided in Uniqueness Proofs. + /// For Uniqueness Proofs this is only present when the request asked for + /// session binding ([`ProofRequest::binds_session`]), echoing back the bound `SessionId`. #[serde(skip_serializing_if = "Option::is_none")] pub session_id: Option, /// Error message if the entire proof request failed. @@ -460,14 +466,8 @@ impl ProofRequest { /// combination of `proof_type`, `session_id`, and `action`. pub fn validate_proof_type(&self) -> Result<(), PrimitiveError> { match self.proof_type { - ProofType::Uniqueness => { - if self.session_id.is_some() { - return Err(PrimitiveError::InvalidInput { - attribute: "session_id".to_string(), - reason: "must be omitted for uniqueness proofs".to_string(), - }); - } - } + // `session_id` is allowed for session binding, see `Self::binds_session` + ProofType::Uniqueness => {} ProofType::CreateSession => { if self.session_id.is_some() { return Err(PrimitiveError::InvalidInput { @@ -506,6 +506,17 @@ impl ProofRequest { self.proof_type.is_session() } + /// Returns true if this request asks for a Uniqueness Proof bound to an existing session. + /// + /// A bound proof carries [`SessionId::commitment`] as its `id_commitment` public signal, + /// proving in-circuit that session and nullifier belong to the same World ID. RPs MUST + /// verify the proof against that commitment — with a zero commitment the proof is valid + /// but unbound. + #[must_use] + pub const fn binds_session(&self) -> bool { + self.proof_type.is_uniqueness() && self.session_id.is_some() + } + /// Returns true if this request creates a new session. #[must_use] pub const fn is_create_session(&self) -> bool { @@ -560,7 +571,11 @@ impl ProofRequest { match self.proof_type { ProofType::Uniqueness => { - if response.session_id.is_some() { + if self.binds_session() { + if self.session_id != response.session_id { + return Err(ValidationError::SessionIdMismatch); + } + } else if response.session_id.is_some() { return Err(ValidationError::UnexpectedSessionId); } } @@ -848,6 +863,18 @@ mod tests { FieldElement::try_from(v).expect("test value fits in field") } + /// Creates a session id with a non-zero commitment and a `0x01`-prefixed oprf seed + fn test_session_id(n: u64) -> SessionId { + use ruint::{aliases::U256, uint}; + let seed = U256::from(n) + | uint!(0x0100000000000000000000000000000000000000000000000000000000000000_U256); + SessionId::new( + test_field_element(n), + FieldElement::try_from(seed).expect("test value fits in field"), + ) + .expect("valid session id") + } + #[test] fn constraints_all_any_nested() { // Build a response that has test_req_1 and test_req_2 provided @@ -2380,10 +2407,10 @@ mod tests { #[test] fn test_validate_proof_type_is_strict() { let uniqueness_with_session = ProofRequest { - id: "req_legacy_session".into(), + id: "req_bound_uniqueness".into(), version: RequestVersion::V1, proof_type: ProofType::Uniqueness, - session_id: Some(SessionId::default()), + session_id: Some(test_session_id(1)), action: None, created_at: 1_735_689_600, expires_at: 1_735_689_900, @@ -2401,8 +2428,24 @@ mod tests { constraints: None, }; + // uniqueness + session_id = session-bound uniqueness proof + assert!(uniqueness_with_session.validate_proof_type().is_ok()); + assert!(uniqueness_with_session.binds_session()); + assert!(!uniqueness_with_session.is_session_proof()); + + let plain_uniqueness = ProofRequest { + session_id: None, + ..uniqueness_with_session.clone() + }; + assert!(plain_uniqueness.validate_proof_type().is_ok()); + assert!(!plain_uniqueness.binds_session()); + + let create_session_with_session = ProofRequest { + proof_type: ProofType::CreateSession, + ..uniqueness_with_session.clone() + }; assert!(matches!( - uniqueness_with_session.validate_proof_type(), + create_session_with_session.validate_proof_type(), Err(PrimitiveError::InvalidInput { attribute, .. }) if attribute == "session_id" )); @@ -2411,13 +2454,117 @@ mod tests { session_id: None, ..uniqueness_with_session }; - assert!(matches!( session_without_session.validate_proof_type(), Err(PrimitiveError::InvalidInput { attribute, .. }) if attribute == "session_id" )); } + #[test] + fn test_bound_uniqueness_request_parses_with_default_proof_type() { + let request = ProofRequest { + id: "req_bound".into(), + version: RequestVersion::V1, + proof_type: ProofType::Uniqueness, + session_id: Some(test_session_id(1)), + action: Some(FieldElement::ZERO), + created_at: 1_735_689_600, + expires_at: 1_735_689_900, + rp_id: RpId::new(1), + oprf_key_id: OprfKeyId::new(uint!(1_U160)), + signature: test_signature(), + nonce: test_nonce(), + requests: vec![RequestItem { + identifier: "orb".into(), + issuer_schema_id: 1, + signal: None, + genesis_issued_at_min: None, + expires_at_min: None, + }], + constraints: None, + }; + + // `proof_type` is #[serde(default)]: session_id without proof_type binds + let mut value: serde_json::Value = + serde_json::from_str(&request.to_json().unwrap()).unwrap(); + value.as_object_mut().unwrap().remove("proof_type"); + let parsed = ProofRequest::from_json(&value.to_string()).unwrap(); + assert_eq!(parsed.proof_type, ProofType::Uniqueness); + assert!(parsed.binds_session()); + } + + #[test] + fn test_validate_response_bound_uniqueness_echoes_session_id() { + let session_id = test_session_id(7); + let request = ProofRequest { + id: "req_bound".into(), + version: RequestVersion::V1, + proof_type: ProofType::Uniqueness, + session_id: Some(session_id), + action: Some(FieldElement::ZERO), + created_at: 1_735_689_600, + expires_at: 1_735_689_900, + rp_id: RpId::new(1), + oprf_key_id: OprfKeyId::new(uint!(1_U160)), + signature: test_signature(), + nonce: test_nonce(), + requests: vec![RequestItem { + identifier: "orb".into(), + issuer_schema_id: 1, + signal: None, + genesis_issued_at_min: None, + expires_at_min: None, + }], + constraints: None, + }; + + // bound uniqueness responses carry a uniqueness nullifier + the echoed session id + let valid = ProofResponse { + id: request.id.clone(), + version: RequestVersion::V1, + session_id: Some(session_id), + error: None, + responses: vec![ResponseItem::new_uniqueness( + "orb".into(), + 1, + ZeroKnowledgeProof::default(), + Nullifier::from(test_field_element(1001)), + 1_735_689_600, + )], + }; + assert!(request.validate_response(&valid).is_ok()); + + // downgraded response (no echo) is rejected + let missing_echo = ProofResponse { + session_id: None, + ..valid.clone() + }; + assert!(matches!( + request.validate_response(&missing_echo), + Err(ValidationError::SessionIdMismatch) + )); + + // different session id is rejected + let wrong_echo = ProofResponse { + session_id: Some(test_session_id(8)), + ..valid.clone() + }; + assert!(matches!( + request.validate_response(&wrong_echo), + Err(ValidationError::SessionIdMismatch) + )); + + // plain uniqueness requests still reject any session id in the response + let plain_request = ProofRequest { + session_id: None, + ..request + }; + assert!(matches!( + plain_request.validate_response(&valid), + Err(ValidationError::UnexpectedSessionId) + )); + } + #[test] fn proof_type_protocol_encoding_is_stable() { assert_eq!(ProofType::Uniqueness as u8, 0x00); diff --git a/docs/world-id-4-specs/README.md b/docs/world-id-4-specs/README.md index 5eda5be54..7454bc7b8 100644 --- a/docs/world-id-4-specs/README.md +++ b/docs/world-id-4-specs/README.md @@ -279,6 +279,13 @@ rp->>rp: verify proof (checking sessionId == C' in verifier contract) - The raison d'être is simply to allow usage of the same ZK circuit as for Uniqueness Proofs. Reducing the number of circuits is currently a priority because of the size of the circuits needed to be bundled in Authenticator clients. As World ID moves to a different proving system, this type will no longer be required. - Session Proofs use a randomized `action` as circuit input. This randomized `action` ensures the circuit's nullifier output is unique per proof, preserving the one-time use property. It is verified internally within the circuit. It does not affect `r` derivation. +**Binding Uniqueness Proofs to a Session** + +- A Uniqueness Proof request may include an existing `sessionId`. The proof then carries the session's commitment `C` as its `id_commitment` public signal, proving in-circuit that the session and the nullifier belong to the same World ID. +- The Authenticator requires the cached `r` for this; re-deriving `r` is only possible through a session-type request. +- Verifiers MUST check the proof against the session's commitment — with the signal at `0` the proof is valid but unbound. On-chain this requires a dedicated entry point; the existing `verify()` pins the signal to `0` and rejects bound proofs. +- Binding one `sessionId` to Uniqueness Proofs under different actions intentionally links those actions to the same World ID; Authenticators MUST clearly surface this to users. + ### Web-based Authenticator Provider To allow for an improved user experience, a reference browser-based Authenticator provider is being introduced. This app provides (currently limited) World ID functionality but without leaving the browser. From 82bb6ae8b277474097637ace72f312511a05d320 Mon Sep 17 00:00:00 2001 From: kilianglas Date: Fri, 10 Jul 2026 14:02:12 +0200 Subject: [PATCH 02/36] chore: minor cosmetic changes --- crates/authenticator/src/prove.rs | 37 ++++++++++++++-------------- crates/core/tests/generate_proof.rs | 1 + crates/primitives/src/request/mod.rs | 4 +-- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/crates/authenticator/src/prove.rs b/crates/authenticator/src/prove.rs index 710c01d08..38155c20a 100644 --- a/crates/authenticator/src/prove.rs +++ b/crates/authenticator/src/prove.rs @@ -244,10 +244,8 @@ impl Authenticator { let session_id = SessionId::from_r_seed(self.leaf_index(), resolved_session_id_r_seed, oprf_seed)?; - if let Some(request_session_id) = proof_request.session_id - && request_session_id != session_id - { - return Err(AuthenticatorError::SessionIdMismatch); + if let Some(request_session_id) = proof_request.session_id { + self.validate_cached_session_r_seed(resolved_session_id_r_seed, request_session_id)?; } Ok((session_id, resolved_session_id_r_seed)) @@ -311,16 +309,10 @@ impl Authenticator { // 2. Resolve session seed let (resolved_session_id, resolved_session_seed) = match proof_request.proof_type { ProofType::Uniqueness => match proof_request.session_id { - // Bind the proof to the existing session. Requires the cached `r`: - // re-deriving it needs a session-type OPRF query the RP signature - // of this request cannot authorize. + // Bind the proof to the existing session. Requires the cached `r`. Some(session_id) => { let seed = session_id_r_seed.ok_or(AuthenticatorError::SessionSeedRequired)?; - let computed = - SessionId::from_r_seed(self.leaf_index(), seed, session_id.oprf_seed)?; - if computed != session_id { - return Err(AuthenticatorError::SessionIdMismatch); - } + self.validate_cached_session_r_seed(seed, session_id)?; (Some(session_id), Some(seed)) } None => (None, None), @@ -336,13 +328,7 @@ impl Authenticator { .session_id .expect("session proof must have session_id"); if let Some(seed) = session_id_r_seed { - // Validate the cached seed produces the expected session ID - let computed = - SessionId::from_r_seed(self.leaf_index(), seed, session_id.oprf_seed)?; - - if computed != session_id { - return Err(AuthenticatorError::SessionIdMismatch); - } + self.validate_cached_session_r_seed(seed, session_id)?; (Some(session_id), Some(seed)) } else { // Re-derive the same `r` from the existing session's `oprf_seed` when the @@ -546,6 +532,19 @@ impl Authenticator { Ok(generate_ownership_proof_with_prover(input, prover)?) } + + fn validate_cached_session_r_seed( + &self, + seed: FieldElement, + session_id: SessionId, + ) -> Result<(), AuthenticatorError> { + let computed = SessionId::from_r_seed(self.leaf_index(), seed, session_id.oprf_seed) + .map_err(AuthenticatorError::from)?; + if computed.commitment != session_id.commitment { + return Err(AuthenticatorError::SessionIdMismatch); + } + Ok(()) + } } #[cfg(test)] diff --git a/crates/core/tests/generate_proof.rs b/crates/core/tests/generate_proof.rs index 4382bda04..3b8d0384f 100644 --- a/crates/core/tests/generate_proof.rs +++ b/crates/core/tests/generate_proof.rs @@ -366,6 +366,7 @@ async fn e2e_authenticator_generate_proof() -> Result<()> { info!("on-chain proof verification succeeded"); // ── SESSION-BOUND UNIQUENESS PROOF ── + // Note: We mock a cached r here. This would be initially obtained from an OPRF query. let session_id_r_seed = FieldElement::random(&mut rng); let session_id = SessionId::from_r_seed( leaf_index, diff --git a/crates/primitives/src/request/mod.rs b/crates/primitives/src/request/mod.rs index 212b54ed9..9638354eb 100644 --- a/crates/primitives/src/request/mod.rs +++ b/crates/primitives/src/request/mod.rs @@ -60,9 +60,9 @@ pub enum ProofType { /// see [`ProofRequest::binds_session`]. #[default] Uniqueness = 0x00, - /// Create a new RP-scoped session identifier and prove it in the same response. + /// Create a new RP-scoped `session_id` and prove it in the same response. CreateSession = 0x01, - /// Prove ownership of an existing RP-scoped session identifier. + /// Prove ownership of an existing RP-scoped `session_id`. Session = 0x02, } From cee215614a0d3e5427c0798cb54e8fd3a1caa87c Mon Sep 17 00:00:00 2001 From: kilianglas Date: Fri, 10 Jul 2026 15:13:10 +0200 Subject: [PATCH 03/36] docs: bound proofs verifiable via verifyProofAndSignals, verify() rejects them --- docs/world-id-4-specs/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/world-id-4-specs/README.md b/docs/world-id-4-specs/README.md index 7454bc7b8..3191806d2 100644 --- a/docs/world-id-4-specs/README.md +++ b/docs/world-id-4-specs/README.md @@ -283,7 +283,7 @@ rp->>rp: verify proof (checking sessionId == C' in verifier contract) - A Uniqueness Proof request may include an existing `sessionId`. The proof then carries the session's commitment `C` as its `id_commitment` public signal, proving in-circuit that the session and the nullifier belong to the same World ID. - The Authenticator requires the cached `r` for this; re-deriving `r` is only possible through a session-type request. -- Verifiers MUST check the proof against the session's commitment — with the signal at `0` the proof is valid but unbound. On-chain this requires a dedicated entry point; the existing `verify()` pins the signal to `0` and rejects bound proofs. +- Verifiers MUST check the proof against the session's commitment — with the signal at `0` the proof is valid but unbound. On-chain, `verifyProofAndSignals` accepts the commitment as the `sessionId` signal; the convenience `verify()` entry point pins the signal to `0` and rejects bound proofs. - Binding one `sessionId` to Uniqueness Proofs under different actions intentionally links those actions to the same World ID; Authenticators MUST clearly surface this to users. ### Web-based Authenticator Provider From 5440e412c0689f24e24e7deb9a445b1d8b4d3b60 Mon Sep 17 00:00:00 2001 From: kilianglas Date: Fri, 10 Jul 2026 15:45:19 +0200 Subject: [PATCH 04/36] feat: add verifyWithSession verifier entry point for session-bound proofs --- contracts/src/core/WorldIDVerifier.sol | 27 +++++++++++++ contracts/src/core/WorldIDVerifierV2.sol | 40 +++++++++++++++++++ .../src/core/interfaces/IWorldIDVerifier.sol | 36 +++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/contracts/src/core/WorldIDVerifier.sol b/contracts/src/core/WorldIDVerifier.sol index 01354d1bb..c50f25855 100644 --- a/contracts/src/core/WorldIDVerifier.sol +++ b/contracts/src/core/WorldIDVerifier.sol @@ -122,6 +122,33 @@ contract WorldIDVerifier is WorldIDBase, IWorldIDVerifier { ); } + /// @inheritdoc IWorldIDVerifier + function verifyWithSession( + uint256 nullifier, + uint256 action, + uint64 rpId, + uint256 nonce, + uint256 signalHash, + uint64 expiresAtMin, + uint64 issuerSchemaId, + uint256 credentialGenesisIssuedAtMin, + uint256 sessionId, + uint256[5] calldata zeroKnowledgeProof + ) external view virtual onlyProxy onlyInitialized { + verifyProofAndSignals( + nullifier, + action, + rpId, + nonce, + signalHash, + expiresAtMin, + issuerSchemaId, + credentialGenesisIssuedAtMin, + sessionId, + zeroKnowledgeProof + ); + } + function verifySession( uint64 rpId, uint256 nonce, diff --git a/contracts/src/core/WorldIDVerifierV2.sol b/contracts/src/core/WorldIDVerifierV2.sol index 64e923e55..568bbb0c2 100644 --- a/contracts/src/core/WorldIDVerifierV2.sol +++ b/contracts/src/core/WorldIDVerifierV2.sol @@ -20,6 +20,12 @@ contract WorldIDVerifierV2 is WorldIDVerifier { */ error InvalidAction(); + /** + * @dev Thrown when a session-bound verification is attempted with `sessionId == 0`, + * which would silently degrade to unbound `verify` semantics. + */ + error InvalidSessionId(); + /// @inheritdoc IWorldIDVerifier function verify( uint256 nullifier, @@ -52,6 +58,40 @@ contract WorldIDVerifierV2 is WorldIDVerifier { ); } + /// @inheritdoc IWorldIDVerifier + function verifyWithSession( + uint256 nullifier, + uint256 action, + uint64 rpId, + uint256 nonce, + uint256 signalHash, + uint64 expiresAtMin, + uint64 issuerSchemaId, + uint256 credentialGenesisIssuedAtMin, + uint256 sessionId, + uint256[5] calldata zeroKnowledgeProof + ) external view virtual override onlyProxy onlyInitialized { + if (uint8(action >> 248) != uint8(0)) { + revert InvalidAction(); + } + if (sessionId == 0) { + revert InvalidSessionId(); + } + + verifyProofAndSignals( + nullifier, + action, + rpId, + nonce, + signalHash, + expiresAtMin, + issuerSchemaId, + credentialGenesisIssuedAtMin, + sessionId, + zeroKnowledgeProof + ); + } + /// @inheritdoc IWorldIDVerifier function verifySession( uint64 rpId, diff --git a/contracts/src/core/interfaces/IWorldIDVerifier.sol b/contracts/src/core/interfaces/IWorldIDVerifier.sol index 16235dd2d..110df334e 100644 --- a/contracts/src/core/interfaces/IWorldIDVerifier.sol +++ b/contracts/src/core/interfaces/IWorldIDVerifier.sol @@ -105,6 +105,42 @@ interface IWorldIDVerifier { uint256[5] calldata zeroKnowledgeProof ) external view; + /** + * @notice Verifies a Uniqueness Proof that is bound to an existing session. + * @dev Same as `verify`, except the proof's `session_id` public signal is checked against the + * provided session commitment instead of being pinned to 0. Bound proofs are rejected by + * `verify` and unbound proofs are rejected here — binding is explicit in both directions. + * @dev Public inputs refer to the ZK-circuit public inputs. + * @param nullifier Public output. A unique, one-time identifier derived from (user, rpId, action) that + * lets RPs detect duplicate actions without learning who the user is. + * @param action Public input. An RP-defined context that scopes what the user is proving uniqueness on. + * This parameter generally expects a hashed version reduced to the field. + * @param rpId Public input. Registered RP identifier from the `RpRegistry`. + * @param nonce Public input. Unique nonce for this request provided by the RP. + * @param signalHash Public input. Hash of arbitrary data provided by the RP that gets cryptographically bound into the proof. + * @param expiresAtMin Public input. The minimum expiration required for the Credential used in the proof. If the constraint is not required, + * it should use the current time as the minimum expiration. The Authenticator will normally expose the effective input used in the proof. + * @param issuerSchemaId Public input. Unique identifier for the credential schema and issuer pair. + * @param credentialGenesisIssuedAtMin Public input. Minimum `genesis_issued_at` timestamp that the used credential + * must meet. Can be set to 0 to skip. + * @param sessionId Public input. Commitment of the session the proof is bound to. Must be non-zero; + * use `verify` for unbound Uniqueness Proofs. + * @param zeroKnowledgeProof Encoded World ID Proof. Internally, the first 4 elements are a + * compressed Groth16 proof [a (G1), b (G2), b (G2), c (G1)], and the last element is the Merkle root from the `WorldIDRegistry`. + */ + function verifyWithSession( + uint256 nullifier, + uint256 action, + uint64 rpId, + uint256 nonce, + uint256 signalHash, + uint64 expiresAtMin, + uint64 issuerSchemaId, + uint256 credentialGenesisIssuedAtMin, + uint256 sessionId, + uint256[5] calldata zeroKnowledgeProof + ) external view; + /** * @notice Verifies a Session Proof. * @dev Validates the World ID registration and inclusion, credential issuer registration, From e07919a374ce4e74c0a5352ae92a27f57b9f94c9 Mon Sep 17 00:00:00 2001 From: kilianglas Date: Fri, 10 Jul 2026 15:48:55 +0200 Subject: [PATCH 05/36] feat: generate and verify bound-uniqueness solidity fixture --- crates/core/tests/generate_proof.rs | 22 ++++++ tools/generate-solidity-fixtures/src/main.rs | 72 +++++++++++++++++++- 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/crates/core/tests/generate_proof.rs b/crates/core/tests/generate_proof.rs index 3b8d0384f..5d21d6fbb 100644 --- a/crates/core/tests/generate_proof.rs +++ b/crates/core/tests/generate_proof.rs @@ -449,6 +449,28 @@ async fn e2e_authenticator_generate_proof() -> Result<()> { ); info!("session-bound proof correctly rejected by the sessionId=0 entry point"); + // `verifyWithSession` checks the sessionId signal against the session's commitment + world_id_verifier + .verifyWithSession( + bound_nullifier.into(), + rp_fixture.action.into(), + rp_fixture.world_rp_id.into_inner(), + rp_fixture.nonce.into(), + request_item.signal_hash().into(), + bound_item.expires_at_min, + issuer_schema_id, + request_item + .genesis_issued_at_min + .unwrap_or_default() + .try_into() + .expect("u64 fits into U256"), + session_id.commitment.into(), + bound_item.proof.as_ethereum_representation(), + ) + .call() + .await?; + info!("session-bound proof verified via verifyWithSession"); + indexer_handle.abort(); info!("e2e_authenticator_generate_proof finished successfully"); Ok(()) diff --git a/tools/generate-solidity-fixtures/src/main.rs b/tools/generate-solidity-fixtures/src/main.rs index 0ea28c3fd..d44cba107 100644 --- a/tools/generate-solidity-fixtures/src/main.rs +++ b/tools/generate-solidity-fixtures/src/main.rs @@ -298,8 +298,10 @@ async fn main() -> Result<()> { .generate_nullifier(&uniqueness_request, None) .await?; - // Clone the nullifier data before it's consumed — we reuse it for the session proof. + // Clone the nullifier data before it's consumed — we reuse it for the session proof + // and the session-bound uniqueness proof. let nullifier_data_for_session = nullifier_data.clone(); + let nullifier_data_for_bound = nullifier_data.clone(); let uniqueness_result = authenticator .generate_proof( @@ -393,6 +395,57 @@ async fn main() -> Result<()> { .await?; info!("Session proof verified ✓"); + // ── SESSION-BOUND UNIQUENESS PROOF (same action, bound to the session above) ── + let bound_request = ProofRequest { + proof_type: ProofType::Uniqueness, + session_id: Some(session_id), + ..uniqueness_request.clone() + }; + + let bound_result = authenticator + .generate_proof( + &bound_request, + nullifier_data_for_bound, + &credentials, + None, + Some(session_id_r_seed), + ) + .await?; + let bound_response = &bound_result.proof_response.responses[0]; + let bound_nullifier = bound_response + .nullifier + .expect("bound uniqueness proof should have nullifier"); + // Same RP/action => same deterministic nullifier as the unbound proof. + assert_eq!( + bound_nullifier, + uniqueness_response + .nullifier + .expect("uniqueness proof has nullifier") + ); + + // Verify bound proof on-chain. + info!("Verifying session-bound uniqueness proof on-chain..."); + verifier_instance + .verifyWithSession( + bound_nullifier.into(), + rp_fixture.action.into(), + rp_fixture.world_rp_id.into_inner(), + rp_fixture.nonce.into(), + request_item.signal_hash().into(), + bound_response.expires_at_min, + issuer_schema_id, + request_item + .genesis_issued_at_min + .unwrap_or_default() + .try_into() + .expect("u64 fits into U256"), + session_id.commitment.into(), + bound_response.proof.as_ethereum_representation(), + ) + .call() + .await?; + info!("Session-bound uniqueness proof verified ✓"); + // ── PRINT SOLIDITY FIXTURE ── let u_proof = uniqueness_response.proof.as_ethereum_representation(); @@ -483,6 +536,23 @@ async fn main() -> Result<()> { println!("// session nullifier for verifySession: [nullifier, action]"); println!("[{:#x}, {:#x}]", s_null[0], s_null[1]); + println!(); + + println!("// ── Bound Uniqueness Proof inputs (nullifier/action/sessionId shared above) ──"); + let b_proof = bound_response.proof.as_ethereum_representation(); + println!( + "uint64 boundExpiresAtMin = {:#x};", + bound_response.expires_at_min + ); + + println!(); + println!("uint256[5] boundProof = ["); + println!(" {:#x},", b_proof[0]); + println!(" {:#x},", b_proof[1]); + println!(" {:#x},", b_proof[2]); + println!(" {:#x},", b_proof[3]); + println!(" rootCorrect"); + println!("];"); println!(); println!("// ── Done ──"); From 91ca3cd4fc4875aff50af5f5047d4ca267c69f73 Mon Sep 17 00:00:00 2001 From: kilianglas Date: Mon, 13 Jul 2026 13:51:09 +0200 Subject: [PATCH 06/36] test: add bound uniqueness fixtures and verifyWithSession tests --- contracts/test/core/WorldIDVerifierTest.t.sol | 140 ++++++++++++++---- .../test/core/WorldIDVerifierV2Test.t.sol | 89 ++++++++++- 2 files changed, 192 insertions(+), 37 deletions(-) diff --git a/contracts/test/core/WorldIDVerifierTest.t.sol b/contracts/test/core/WorldIDVerifierTest.t.sol index cadcb7471..6d27bc483 100644 --- a/contracts/test/core/WorldIDVerifierTest.t.sol +++ b/contracts/test/core/WorldIDVerifierTest.t.sol @@ -13,7 +13,7 @@ import {ICredentialSchemaIssuerRegistry} from "../../src/core/interfaces/ICreden uint64 constant credentialIssuerIdCorrect = 1; uint64 constant credentialIssuerIdWrong = 2; -uint64 constant rpIdCorrect = 0x1a6ccf8f70e5de68; +uint64 constant rpIdCorrect = 0x387df34f862cd4e; uint64 constant rpIdWrong = 2; uint256 constant rootCorrect = 0xaf727d9412a9d5c73b685fd09dc39e727064e65b8269b233009edfc105f9853; @@ -24,8 +24,8 @@ contract OprfKeyRegistryMock { // TODO update for mapping of rpId to oprfKeyId if (oprfKeyId == rpIdCorrect) { return BabyJubJub.Affine({ - x: 0xac79da013272129ddceae6d20c0f579abd04b0a00160ed2be2151bf4014e8d, - y: 0x187ce5ac507fe0760e95d1893cc6ebf3a115eb9adeaa355c14cc52722a2275be + x: 0x24a3480be33ae5a83f68fbefe658e65053cbe99ee442c178859070a23372a4d4, + y: 0x2fc70cc380d5bb9d8537a8fd82e98fb29eb837ff3943f5704ec3957f444ec6cd }); } else { return BabyJubJub.Affine({ @@ -57,8 +57,8 @@ contract CredentialSchemaIssuerRegistryMock { { if (issuerSchemaId == credentialIssuerIdCorrect) { return ICredentialSchemaIssuerRegistry.Pubkey({ - x: 0x252c8234509649bb469ecb7a7e758f306b41415f2d80d4d67967902d6f589a81, - y: 0x230e4f93a5f1187639314dd25e595db06dc18de219cfaeb8cfdf81d4afe910d5 + x: 0xf178f8128469f6be2f108a02b2a3f96d107d9466c4f95460ed7d4e8f10384b3, + y: 0x21b8a276d0b75460b075f4a4cc1961938e62f4964bcca87cffce8ada4d6e11d5 }); } else { return ICredentialSchemaIssuerRegistry.Pubkey({ @@ -76,26 +76,44 @@ contract ProofVerifier is Test { uint256 public minExpirationThreshold; - uint256 nullifier = 0x1bae01b23e5f0ee96151331fffb0550351c52e5ee0ced452c762e120723ae702; - uint64 expiresAtMin = 0x699cfa47; - uint256 action = 0x15d4b66e5417cb9875f6a2b5be9814dca80651d7c74b3b21685fdd494566e79f; + uint256 nullifier = 0x5968cd4d3c50bfd2305671d1092bee10ccb679b93db3ca779b6477e4885e476; + uint64 expiresAtMin = 0x6a54cd68; + uint256 action = 0x978cc65f06353d8543971b65da8751833ff1253a192f58bed14f2739c0a345; uint256 signalHash = 0x1578ed0de47522ad0b38e87031739c6a65caecc39ce3410bf3799e756a220f; - uint256 nonce = 0x18e3ab3d5fedc6eaa5e0d06a3a6f3dd5e0bf2d17b18b797a1cc6ff4706169d1e; - uint256 sessionId = 0x2025d8e786806a895f7e50ce403f7d6e33e501772b28116908ad6fa5108172f8; + uint256 nonce = 0x38ed3d4d95deac6e369dde48890d5b14b49a2d26a0b2e8854d429ff7c52cf99; + uint256 sessionId = 0x2018a266d26fbc1cd41743cc3126321302b8f0af39c367fe5718eeafb341d494; + uint256 sessionNonce = 0xa138dc568a2548155f3991625ddec3b466d2a49b19d6ff26a94ba0310cdf5ba; uint256[5] proof = [ - 0x4906f4e17b969ef2cfc44bd96520f01a3f5c32972bca2e10b70e05e03e3d9f13, - 0xd6d9a3456e9af7d8f6f78eb3380deb8c93505c062f62fa18b8ef8a2ccb55db8, - 0xa92a48edeb327b190048648788de9a8eff0abed5dc93bee8881387da40571278, - 0x38f52985c393efb732be8f54b5f00f7f25370ac5945de84e0d8d2f2d298866b8, + 0x2a184f5930f2b6a0f367f649a80757e081b3fb28b76fffcfe325d82df87395b3, + 0xf6849ab589365a7537beeb70014958ae261fe2dd7fdbf5c4823c6b527aefa34, + 0xac5ee090f2ee180619c5c9825f22cee8873fc0d4764b7b0c5ffd7802d8f2e0f9, + 0x4a221ef1d3b5522ac95f38db43afda6863d34836f7a8cff0b50aa9c8ca52e727, rootCorrect ]; uint256[5] sessionProof = [ - 0x4533f8d38447da676c8eac8ec01ce031af1cc140d8397f3baf792be414c28790, - 0xe05c9ada0f2a3ebb5863f0a3412aa852cea67099ce26bb46c44b264af5b6927, - 0x178bbfe59fc10b5ec4359ecb21b9f42fb8afef08e90cd3dec903fdd45cddc930, - 0x409b8908726ca9151d021fcecc882a3f5e93ba35f6043ad0bd51258b55e5018b, + 0x4930872a26a12446d943042e1958e65d4b3eecca9bf2b80c6bdba2dd24f37467, + 0x15bba2773377d7ef96c76ede7d8d12bc5725441961de9f1b330484e1f24a19a8, + 0x86dc81c5fccb1de6766036f308db3b6e1e6eb0fa7d780096d701cc97fb4a20f1, + 0x3d4279efe1806d0c7747915b9852ade3738e8ffd87604e01034ca78e8cb81c54, + rootCorrect + ]; + + // [nullifier, action] tuple of the session proof; the action is randomly generated + // at OPRF-query time and carries the 0x02 session prefix. + uint256[2] sessionNullifier = [ + 0x8ce710377b04b2605fd6c5545f15f638527bd0315f223b8c2c070a4ac0a62d0, + 0x2b6b35cb561c84d7a137fbc715c9df67152fa28c1b81042a4c0e80d1ba01b00 + ]; + + // Uniqueness proof over the same request as `proof`, bound to `sessionId` + // (the session commitment is its `session_id` public signal). + uint256[5] boundProof = [ + 0x3de969d8cdd738c55fd10ccbd127b8cb41d21dc9f827b83e0063e3dcb84e8d3c, + 0x16861d8a24289d3b35f3939bc11162379e7ba20afed09cd2ac87a0bd4bff5194, + 0x94ec109be9e4e3a6a3199ecde261bf300f9f04ccfee6401ebf3689272ed907d, + 0x42010c88d24d3cb7ef95c32b49ccee40acdc083f8d8b3c3a26164bff52dfb699, rootCorrect ]; @@ -132,13 +150,13 @@ contract ProofVerifier is Test { vm.warp(expiresAtMin + 1 hours); worldIDVerifier.verifySession( rpIdCorrect, - nonce, + sessionNonce, signalHash, expiresAtMin, credentialIssuerIdCorrect, 0, sessionId, - [nullifier, action], + sessionNullifier, sessionProof ); } @@ -223,13 +241,13 @@ contract ProofVerifier is Test { vm.expectRevert(abi.encodeWithSelector(Verifier.ProofInvalid.selector)); worldIDVerifier.verifySession( rpIdWrong, // NOTE incorrect rp id - nonce, + sessionNonce, signalHash, expiresAtMin, credentialIssuerIdCorrect, 0, sessionId, - [nullifier, action], + sessionNullifier, sessionProof ); } @@ -239,13 +257,13 @@ contract ProofVerifier is Test { vm.expectRevert(abi.encodeWithSelector(Verifier.ProofInvalid.selector)); worldIDVerifier.verifySession( rpIdCorrect, - nonce, + sessionNonce, signalHash, expiresAtMin, credentialIssuerIdWrong, // NOTE incorrect credential issuer id 0, sessionId, - [nullifier, action], + sessionNullifier, sessionProof ); } @@ -262,13 +280,13 @@ contract ProofVerifier is Test { vm.expectRevert(abi.encodeWithSelector(Verifier.ProofInvalid.selector)); worldIDVerifier.verifySession( rpIdCorrect, - nonce, + sessionNonce, signalHash, expiresAtMin, credentialIssuerIdCorrect, 0, sessionId, - [nullifier, action], + sessionNullifier, brokenProof ); } @@ -282,13 +300,13 @@ contract ProofVerifier is Test { worldIDVerifier.verifySession( rpIdCorrect, - nonce, + sessionNonce, signalHash, expiresAtMin, credentialIssuerIdCorrect, 0, sessionId, - [nullifier, action], + sessionNullifier, invalidRootProof ); } @@ -298,17 +316,79 @@ contract ProofVerifier is Test { vm.expectRevert(abi.encodeWithSelector(IWorldIDVerifier.ExpirationTooOld.selector)); worldIDVerifier.verifySession( rpIdCorrect, - nonce, + sessionNonce, signalHash, expiresAtMin, credentialIssuerIdCorrect, 0, sessionId, - [nullifier, action], + sessionNullifier, sessionProof ); } + // Session-bound Uniqueness Proof Tests + + function test_BoundSuccess() public { + vm.warp(expiresAtMin + 1 hours); + worldIDVerifier.verifyWithSession( + nullifier, + action, + rpIdCorrect, + nonce, + signalHash, + expiresAtMin, + credentialIssuerIdCorrect, + 0, + sessionId, + boundProof + ); + } + + function test_BoundRejectedByVerify() public { + // The bound proof commits to the session id, while verify() pins the signal to 0 + vm.warp(expiresAtMin + 1 hours); + vm.expectRevert(abi.encodeWithSelector(Verifier.ProofInvalid.selector)); + worldIDVerifier.verify( + nullifier, action, rpIdCorrect, nonce, signalHash, expiresAtMin, credentialIssuerIdCorrect, 0, boundProof + ); + } + + function test_UnboundRejectedByVerifyWithSession() public { + // The unbound proof commits to a session id of 0 + vm.warp(expiresAtMin + 1 hours); + vm.expectRevert(abi.encodeWithSelector(Verifier.ProofInvalid.selector)); + worldIDVerifier.verifyWithSession( + nullifier, + action, + rpIdCorrect, + nonce, + signalHash, + expiresAtMin, + credentialIssuerIdCorrect, + 0, + sessionId, + proof + ); + } + + function test_BoundWrongSessionId() public { + vm.warp(expiresAtMin + 1 hours); + vm.expectRevert(abi.encodeWithSelector(Verifier.ProofInvalid.selector)); + worldIDVerifier.verifyWithSession( + nullifier, + action, + rpIdCorrect, + nonce, + signalHash, + expiresAtMin, + credentialIssuerIdCorrect, + 0, + sessionId + 1, // NOTE incorrect session id + boundProof + ); + } + // UpdateOprfKeyRegistry Tests function test_UpdateOprfKeyRegistry() public { diff --git a/contracts/test/core/WorldIDVerifierV2Test.t.sol b/contracts/test/core/WorldIDVerifierV2Test.t.sol index 3118f30e2..5611018ff 100644 --- a/contracts/test/core/WorldIDVerifierV2Test.t.sol +++ b/contracts/test/core/WorldIDVerifierV2Test.t.sol @@ -22,16 +22,16 @@ import { contract WorldIDVerifierV2Test is Test { WorldIDVerifierV2 public verifier; - uint256 nullifier = 0x1bae01b23e5f0ee96151331fffb0550351c52e5ee0ced452c762e120723ae702; - uint64 expiresAtMin = 0x699cfa47; + uint256 nullifier = 0x5968cd4d3c50bfd2305671d1092bee10ccb679b93db3ca779b6477e4885e476; + uint64 expiresAtMin = 0x6a54cd68; uint256 signalHash = 0x1578ed0de47522ad0b38e87031739c6a65caecc39ce3410bf3799e756a220f; - uint256 nonce = 0x18e3ab3d5fedc6eaa5e0d06a3a6f3dd5e0bf2d17b18b797a1cc6ff4706169d1e; + uint256 nonce = 0x38ed3d4d95deac6e369dde48890d5b14b49a2d26a0b2e8854d429ff7c52cf99; uint256[5] proof = [ - 0x4906f4e17b969ef2cfc44bd96520f01a3f5c32972bca2e10b70e05e03e3d9f13, - 0xd6d9a3456e9af7d8f6f78eb3380deb8c93505c062f62fa18b8ef8a2ccb55db8, - 0xa92a48edeb327b190048648788de9a8eff0abed5dc93bee8881387da40571278, - 0x38f52985c393efb732be8f54b5f00f7f25370ac5945de84e0d8d2f2d298866b8, + 0x2a184f5930f2b6a0f367f649a80757e081b3fb28b76fffcfe325d82df87395b3, + 0xf6849ab589365a7537beeb70014958ae261fe2dd7fdbf5c4823c6b527aefa34, + 0xac5ee090f2ee180619c5c9825f22cee8873fc0d4764b7b0c5ffd7802d8f2e0f9, + 0x4a221ef1d3b5522ac95f38db43afda6863d34836f7a8cff0b50aa9c8ca52e727, rootCorrect ]; @@ -147,4 +147,79 @@ contract WorldIDVerifierV2Test is Test { nullifier, action, rpIdCorrect, nonce, signalHash, expiresAtMin, credentialIssuerIdCorrect, 0, proof ); } + + function test_BoundRevertsWhenActionFirstByteNonZero() public { + uint256 action = 0x15d4b66e5417cb9875f6a2b5be9814dca80651d7c74b3b21685fdd494566e79f; + uint256 sessionId = 1; + + vm.warp(expiresAtMin + 1 hours); + vm.expectRevert(abi.encodeWithSelector(WorldIDVerifierV2.InvalidAction.selector)); + verifier.verifyWithSession( + nullifier, + action, + rpIdCorrect, + nonce, + signalHash, + expiresAtMin, + credentialIssuerIdCorrect, + 0, + sessionId, + proof + ); + } + + function testFuzz_BoundRevertsWhenActionFirstByteNonZero(uint256 action) public { + // Ensure the highest byte is non-zero + vm.assume(uint8(action >> 248) != 0); + uint256 sessionId = 1; + + vm.warp(expiresAtMin + 1 hours); + vm.expectRevert(abi.encodeWithSelector(WorldIDVerifierV2.InvalidAction.selector)); + verifier.verifyWithSession( + nullifier, + action, + rpIdCorrect, + nonce, + signalHash, + expiresAtMin, + credentialIssuerIdCorrect, + 0, + sessionId, + proof + ); + } + + function test_BoundRevertsWhenSessionIdZero() public { + // Valid uniqueness action prefix, but a zero session id must not pass — + // it would silently degrade to unbound verify() semantics. + uint256 action = 0x00d4b66e5417cb9875f6a2b5be9814dca80651d7c74b3b21685fdd494566e7; + + vm.warp(expiresAtMin + 1 hours); + vm.expectRevert(abi.encodeWithSelector(WorldIDVerifierV2.InvalidSessionId.selector)); + verifier.verifyWithSession( + nullifier, action, rpIdCorrect, nonce, signalHash, expiresAtMin, credentialIssuerIdCorrect, 0, 0, proof + ); + } + + function test_BoundPassesChecksWhenValid() public { + // 0x00 action prefix and non-zero session id — passes both checks, + // reverts later in proof verification + uint256 action = 0x00d4b66e5417cb9875f6a2b5be9814dca80651d7c74b3b21685fdd494566e7; + uint256 sessionId = 1; + + vm.warp(expiresAtMin + 1 hours); + vm.expectRevert(abi.encodeWithSelector(Verifier.ProofInvalid.selector)); + verifier.verifyWithSession( + nullifier, + action, + rpIdCorrect, + nonce, + signalHash, + expiresAtMin, + credentialIssuerIdCorrect, + 0, + sessionId, + proof + ); + } } From de729fd780991e57e93721e5986fa084c683b275 Mon Sep 17 00:00:00 2001 From: kilianglas Date: Mon, 13 Jul 2026 13:52:09 +0200 Subject: [PATCH 07/36] docs: document verifyWithSession entry point --- docs/world-id-4-specs/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/world-id-4-specs/README.md b/docs/world-id-4-specs/README.md index 3191806d2..f40509f36 100644 --- a/docs/world-id-4-specs/README.md +++ b/docs/world-id-4-specs/README.md @@ -283,7 +283,7 @@ rp->>rp: verify proof (checking sessionId == C' in verifier contract) - A Uniqueness Proof request may include an existing `sessionId`. The proof then carries the session's commitment `C` as its `id_commitment` public signal, proving in-circuit that the session and the nullifier belong to the same World ID. - The Authenticator requires the cached `r` for this; re-deriving `r` is only possible through a session-type request. -- Verifiers MUST check the proof against the session's commitment — with the signal at `0` the proof is valid but unbound. On-chain, `verifyProofAndSignals` accepts the commitment as the `sessionId` signal; the convenience `verify()` entry point pins the signal to `0` and rejects bound proofs. +- Verifiers MUST check the proof against the session's commitment — with the signal at `0` the proof is valid but unbound. On-chain, the dedicated `verifyWithSession()` entry point does this (it rejects `sessionId == 0`); the convenience `verify()` entry point pins the signal to `0` and rejects bound proofs, so binding is explicit in both directions. - Binding one `sessionId` to Uniqueness Proofs under different actions intentionally links those actions to the same World ID; Authenticators MUST clearly surface this to users. ### Web-based Authenticator Provider From 2382247de5d4b81a3d6782e641c345039041ec91 Mon Sep 17 00:00:00 2001 From: kilianglas Date: Mon, 13 Jul 2026 16:37:45 +0200 Subject: [PATCH 08/36] refactor: move verifyWithSession to new IWorldIDVerifierV2 interface --- contracts/src/core/WorldIDVerifier.sol | 27 ------ contracts/src/core/WorldIDVerifierV2.sol | 24 ++--- .../src/core/interfaces/IWorldIDVerifier.sol | 36 ------- .../core/interfaces/IWorldIDVerifierV2.sol | 71 ++++++++++++++ contracts/test/core/WorldIDVerifierTest.t.sol | 72 -------------- .../test/core/WorldIDVerifierV2Test.t.sol | 95 +++++++++++++++++-- crates/core/tests/generate_proof.rs | 7 +- crates/test-utils/src/anvil.rs | 16 +++- tools/generate-solidity-fixtures/src/main.rs | 7 +- 9 files changed, 186 insertions(+), 169 deletions(-) create mode 100644 contracts/src/core/interfaces/IWorldIDVerifierV2.sol diff --git a/contracts/src/core/WorldIDVerifier.sol b/contracts/src/core/WorldIDVerifier.sol index c50f25855..01354d1bb 100644 --- a/contracts/src/core/WorldIDVerifier.sol +++ b/contracts/src/core/WorldIDVerifier.sol @@ -122,33 +122,6 @@ contract WorldIDVerifier is WorldIDBase, IWorldIDVerifier { ); } - /// @inheritdoc IWorldIDVerifier - function verifyWithSession( - uint256 nullifier, - uint256 action, - uint64 rpId, - uint256 nonce, - uint256 signalHash, - uint64 expiresAtMin, - uint64 issuerSchemaId, - uint256 credentialGenesisIssuedAtMin, - uint256 sessionId, - uint256[5] calldata zeroKnowledgeProof - ) external view virtual onlyProxy onlyInitialized { - verifyProofAndSignals( - nullifier, - action, - rpId, - nonce, - signalHash, - expiresAtMin, - issuerSchemaId, - credentialGenesisIssuedAtMin, - sessionId, - zeroKnowledgeProof - ); - } - function verifySession( uint64 rpId, uint256 nonce, diff --git a/contracts/src/core/WorldIDVerifierV2.sol b/contracts/src/core/WorldIDVerifierV2.sol index 568bbb0c2..bd6e08897 100644 --- a/contracts/src/core/WorldIDVerifierV2.sol +++ b/contracts/src/core/WorldIDVerifierV2.sol @@ -3,29 +3,17 @@ pragma solidity ^0.8.13; import {WorldIDVerifier} from "./WorldIDVerifier.sol"; import {IWorldIDVerifier} from "./interfaces/IWorldIDVerifier.sol"; +import {IWorldIDVerifierV2} from "./interfaces/IWorldIDVerifierV2.sol"; /** - * @title WorldIDVerifier + * @title WorldIDVerifierV2 * @author World Contributors * @notice Verifies World ID proofs (Uniqueness and Session proofs). * @dev In addition to verifying the Groth16 Proof, it verifies relevant public inputs to the * circuits through checks with the WorldIDRegistry, CredentialSchemaIssuerRegistry, and OprfKeyRegistry. * @custom:repo https://github.com/world-id/world-id-protocol */ -contract WorldIDVerifierV2 is WorldIDVerifier { - /** - * @dev Thrown when the action is not valid for the type of proof. The prefix is enforced - * to ensure any nullifier request for a Uniqueness Proof is signed by the RP (actions - * without this prefix, i.e. for sessions, it doesn't need to be signed). - */ - error InvalidAction(); - - /** - * @dev Thrown when a session-bound verification is attempted with `sessionId == 0`, - * which would silently degrade to unbound `verify` semantics. - */ - error InvalidSessionId(); - +contract WorldIDVerifierV2 is IWorldIDVerifierV2, WorldIDVerifier { /// @inheritdoc IWorldIDVerifier function verify( uint256 nullifier, @@ -37,7 +25,7 @@ contract WorldIDVerifierV2 is WorldIDVerifier { uint64 issuerSchemaId, uint256 credentialGenesisIssuedAtMin, uint256[5] calldata zeroKnowledgeProof - ) external view virtual override onlyProxy onlyInitialized { + ) external view virtual override(IWorldIDVerifier, WorldIDVerifier) onlyProxy onlyInitialized { if (uint8(action >> 248) != uint8(0)) { revert InvalidAction(); } @@ -58,7 +46,7 @@ contract WorldIDVerifierV2 is WorldIDVerifier { ); } - /// @inheritdoc IWorldIDVerifier + /// @inheritdoc IWorldIDVerifierV2 function verifyWithSession( uint256 nullifier, uint256 action, @@ -103,7 +91,7 @@ contract WorldIDVerifierV2 is WorldIDVerifier { uint256 sessionId, uint256[2] calldata sessionNullifier, uint256[5] calldata zeroKnowledgeProof - ) external view virtual override onlyProxy onlyInitialized { + ) external view virtual override(IWorldIDVerifier, WorldIDVerifier) onlyProxy onlyInitialized { uint256 action = sessionNullifier[1]; if (uint8(action >> 248) != uint8(2)) { revert InvalidAction(); diff --git a/contracts/src/core/interfaces/IWorldIDVerifier.sol b/contracts/src/core/interfaces/IWorldIDVerifier.sol index 110df334e..16235dd2d 100644 --- a/contracts/src/core/interfaces/IWorldIDVerifier.sol +++ b/contracts/src/core/interfaces/IWorldIDVerifier.sol @@ -105,42 +105,6 @@ interface IWorldIDVerifier { uint256[5] calldata zeroKnowledgeProof ) external view; - /** - * @notice Verifies a Uniqueness Proof that is bound to an existing session. - * @dev Same as `verify`, except the proof's `session_id` public signal is checked against the - * provided session commitment instead of being pinned to 0. Bound proofs are rejected by - * `verify` and unbound proofs are rejected here — binding is explicit in both directions. - * @dev Public inputs refer to the ZK-circuit public inputs. - * @param nullifier Public output. A unique, one-time identifier derived from (user, rpId, action) that - * lets RPs detect duplicate actions without learning who the user is. - * @param action Public input. An RP-defined context that scopes what the user is proving uniqueness on. - * This parameter generally expects a hashed version reduced to the field. - * @param rpId Public input. Registered RP identifier from the `RpRegistry`. - * @param nonce Public input. Unique nonce for this request provided by the RP. - * @param signalHash Public input. Hash of arbitrary data provided by the RP that gets cryptographically bound into the proof. - * @param expiresAtMin Public input. The minimum expiration required for the Credential used in the proof. If the constraint is not required, - * it should use the current time as the minimum expiration. The Authenticator will normally expose the effective input used in the proof. - * @param issuerSchemaId Public input. Unique identifier for the credential schema and issuer pair. - * @param credentialGenesisIssuedAtMin Public input. Minimum `genesis_issued_at` timestamp that the used credential - * must meet. Can be set to 0 to skip. - * @param sessionId Public input. Commitment of the session the proof is bound to. Must be non-zero; - * use `verify` for unbound Uniqueness Proofs. - * @param zeroKnowledgeProof Encoded World ID Proof. Internally, the first 4 elements are a - * compressed Groth16 proof [a (G1), b (G2), b (G2), c (G1)], and the last element is the Merkle root from the `WorldIDRegistry`. - */ - function verifyWithSession( - uint256 nullifier, - uint256 action, - uint64 rpId, - uint256 nonce, - uint256 signalHash, - uint64 expiresAtMin, - uint64 issuerSchemaId, - uint256 credentialGenesisIssuedAtMin, - uint256 sessionId, - uint256[5] calldata zeroKnowledgeProof - ) external view; - /** * @notice Verifies a Session Proof. * @dev Validates the World ID registration and inclusion, credential issuer registration, diff --git a/contracts/src/core/interfaces/IWorldIDVerifierV2.sol b/contracts/src/core/interfaces/IWorldIDVerifierV2.sol new file mode 100644 index 000000000..45b2f9ed0 --- /dev/null +++ b/contracts/src/core/interfaces/IWorldIDVerifierV2.sol @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {IWorldIDVerifier} from "./IWorldIDVerifier.sol"; + +/** + * @title IWorldIDVerifierV2 + * @author World Contributors + * @notice Interface for verifying World ID proofs (Uniqueness and Session proofs). + * @dev V2 enforces the action-prefix convention on the convenience entry points (`verify` + * requires the action's most significant byte to be `0x00`, `verifySession` requires `0x02`) + * and adds `verifyWithSession` for Uniqueness Proofs bound to an existing session. + */ +interface IWorldIDVerifierV2 is IWorldIDVerifier { + //////////////////////////////////////////////////////////// + // ERRORS // + //////////////////////////////////////////////////////////// + + /** + * @dev Thrown when the action is not valid for the type of proof. The prefix is enforced + * to ensure any nullifier request for a Uniqueness Proof is signed by the RP (actions + * without this prefix, i.e. for sessions, it doesn't need to be signed). + */ + error InvalidAction(); + + /** + * @dev Thrown when a session-bound verification is attempted with `sessionId == 0`, + * which would silently degrade to unbound `verify` semantics. + */ + error InvalidSessionId(); + + //////////////////////////////////////////////////////////// + // VIEW FUNCTIONS // + //////////////////////////////////////////////////////////// + + /** + * @notice Verifies a Uniqueness Proof that is bound to an existing session. + * @dev Same as `verify`, except the proof's `session_id` public signal is checked against the + * provided session commitment instead of being pinned to 0. Bound proofs are rejected by + * `verify` and unbound proofs are rejected here — binding is explicit in both directions. + * @dev Public inputs refer to the ZK-circuit public inputs. + * @param nullifier Public output. A unique, one-time identifier derived from (user, rpId, action) that + * lets RPs detect duplicate actions without learning who the user is. + * @param action Public input. An RP-defined context that scopes what the user is proving uniqueness on. + * This parameter generally expects a hashed version reduced to the field. + * @param rpId Public input. Registered RP identifier from the `RpRegistry`. + * @param nonce Public input. Unique nonce for this request provided by the RP. + * @param signalHash Public input. Hash of arbitrary data provided by the RP that gets cryptographically bound into the proof. + * @param expiresAtMin Public input. The minimum expiration required for the Credential used in the proof. If the constraint is not required, + * it should use the current time as the minimum expiration. The Authenticator will normally expose the effective input used in the proof. + * @param issuerSchemaId Public input. Unique identifier for the credential schema and issuer pair. + * @param credentialGenesisIssuedAtMin Public input. Minimum `genesis_issued_at` timestamp that the used credential + * must meet. Can be set to 0 to skip. + * @param sessionId Public input. Commitment of the session the proof is bound to. Must be non-zero; + * use `verify` for unbound Uniqueness Proofs. + * @param zeroKnowledgeProof Encoded World ID Proof. Internally, the first 4 elements are a + * compressed Groth16 proof [a (G1), b (G2), b (G2), c (G1)], and the last element is the Merkle root from the `WorldIDRegistry`. + */ + function verifyWithSession( + uint256 nullifier, + uint256 action, + uint64 rpId, + uint256 nonce, + uint256 signalHash, + uint64 expiresAtMin, + uint64 issuerSchemaId, + uint256 credentialGenesisIssuedAtMin, + uint256 sessionId, + uint256[5] calldata zeroKnowledgeProof + ) external view; +} diff --git a/contracts/test/core/WorldIDVerifierTest.t.sol b/contracts/test/core/WorldIDVerifierTest.t.sol index 6d27bc483..39df57f66 100644 --- a/contracts/test/core/WorldIDVerifierTest.t.sol +++ b/contracts/test/core/WorldIDVerifierTest.t.sol @@ -107,16 +107,6 @@ contract ProofVerifier is Test { 0x2b6b35cb561c84d7a137fbc715c9df67152fa28c1b81042a4c0e80d1ba01b00 ]; - // Uniqueness proof over the same request as `proof`, bound to `sessionId` - // (the session commitment is its `session_id` public signal). - uint256[5] boundProof = [ - 0x3de969d8cdd738c55fd10ccbd127b8cb41d21dc9f827b83e0063e3dcb84e8d3c, - 0x16861d8a24289d3b35f3939bc11162379e7ba20afed09cd2ac87a0bd4bff5194, - 0x94ec109be9e4e3a6a3199ecde261bf300f9f04ccfee6401ebf3689272ed907d, - 0x42010c88d24d3cb7ef95c32b49ccee40acdc083f8d8b3c3a26164bff52dfb699, - rootCorrect - ]; - function setUp() public { address oprfKeyRegistry = address(new OprfKeyRegistryMock()); address worldIDRegistryMock = address(new WorldIDRegistryMock()); @@ -327,68 +317,6 @@ contract ProofVerifier is Test { ); } - // Session-bound Uniqueness Proof Tests - - function test_BoundSuccess() public { - vm.warp(expiresAtMin + 1 hours); - worldIDVerifier.verifyWithSession( - nullifier, - action, - rpIdCorrect, - nonce, - signalHash, - expiresAtMin, - credentialIssuerIdCorrect, - 0, - sessionId, - boundProof - ); - } - - function test_BoundRejectedByVerify() public { - // The bound proof commits to the session id, while verify() pins the signal to 0 - vm.warp(expiresAtMin + 1 hours); - vm.expectRevert(abi.encodeWithSelector(Verifier.ProofInvalid.selector)); - worldIDVerifier.verify( - nullifier, action, rpIdCorrect, nonce, signalHash, expiresAtMin, credentialIssuerIdCorrect, 0, boundProof - ); - } - - function test_UnboundRejectedByVerifyWithSession() public { - // The unbound proof commits to a session id of 0 - vm.warp(expiresAtMin + 1 hours); - vm.expectRevert(abi.encodeWithSelector(Verifier.ProofInvalid.selector)); - worldIDVerifier.verifyWithSession( - nullifier, - action, - rpIdCorrect, - nonce, - signalHash, - expiresAtMin, - credentialIssuerIdCorrect, - 0, - sessionId, - proof - ); - } - - function test_BoundWrongSessionId() public { - vm.warp(expiresAtMin + 1 hours); - vm.expectRevert(abi.encodeWithSelector(Verifier.ProofInvalid.selector)); - worldIDVerifier.verifyWithSession( - nullifier, - action, - rpIdCorrect, - nonce, - signalHash, - expiresAtMin, - credentialIssuerIdCorrect, - 0, - sessionId + 1, // NOTE incorrect session id - boundProof - ); - } - // UpdateOprfKeyRegistry Tests function test_UpdateOprfKeyRegistry() public { diff --git a/contracts/test/core/WorldIDVerifierV2Test.t.sol b/contracts/test/core/WorldIDVerifierV2Test.t.sol index 5611018ff..cd8fa0387 100644 --- a/contracts/test/core/WorldIDVerifierV2Test.t.sol +++ b/contracts/test/core/WorldIDVerifierV2Test.t.sol @@ -5,6 +5,7 @@ import {Test} from "forge-std/Test.sol"; import {WorldIDVerifierV2} from "../../src/core/WorldIDVerifierV2.sol"; import {WorldIDVerifier} from "../../src/core/WorldIDVerifier.sol"; import {IWorldIDVerifier} from "../../src/core/interfaces/IWorldIDVerifier.sol"; +import {IWorldIDVerifierV2} from "../../src/core/interfaces/IWorldIDVerifierV2.sol"; import {BabyJubJub} from "oprf-key-registry/src/BabyJubJub.sol"; import {Verifier} from "../../src/core/Verifier.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; @@ -26,6 +27,8 @@ contract WorldIDVerifierV2Test is Test { uint64 expiresAtMin = 0x6a54cd68; uint256 signalHash = 0x1578ed0de47522ad0b38e87031739c6a65caecc39ce3410bf3799e756a220f; uint256 nonce = 0x38ed3d4d95deac6e369dde48890d5b14b49a2d26a0b2e8854d429ff7c52cf99; + uint256 actionCorrect = 0x978cc65f06353d8543971b65da8751833ff1253a192f58bed14f2739c0a345; + uint256 sessionIdCorrect = 0x2018a266d26fbc1cd41743cc3126321302b8f0af39c367fe5718eeafb341d494; uint256[5] proof = [ 0x2a184f5930f2b6a0f367f649a80757e081b3fb28b76fffcfe325d82df87395b3, @@ -35,6 +38,16 @@ contract WorldIDVerifierV2Test is Test { rootCorrect ]; + // Uniqueness proof over the same request as `proof`, bound to `sessionIdCorrect` + // (the session commitment is its `session_id` public signal). + uint256[5] boundProof = [ + 0x3de969d8cdd738c55fd10ccbd127b8cb41d21dc9f827b83e0063e3dcb84e8d3c, + 0x16861d8a24289d3b35f3939bc11162379e7ba20afed09cd2ac87a0bd4bff5194, + 0x94ec109be9e4e3a6a3199ecde261bf300f9f04ccfee6401ebf3689272ed907d, + 0x42010c88d24d3cb7ef95c32b49ccee40acdc083f8d8b3c3a26164bff52dfb699, + rootCorrect + ]; + function setUp() public { address oprfKeyRegistry = address(new OprfKeyRegistryMock()); address worldIDRegistryMock = address(new WorldIDRegistryMock()); @@ -60,7 +73,7 @@ contract WorldIDVerifierV2Test is Test { uint256 action = 0x15d4b66e5417cb9875f6a2b5be9814dca80651d7c74b3b21685fdd494566e79f; vm.warp(expiresAtMin + 1 hours); - vm.expectRevert(abi.encodeWithSelector(WorldIDVerifierV2.InvalidAction.selector)); + vm.expectRevert(abi.encodeWithSelector(IWorldIDVerifierV2.InvalidAction.selector)); verifier.verify( nullifier, action, rpIdCorrect, nonce, signalHash, expiresAtMin, credentialIssuerIdCorrect, 0, proof ); @@ -71,7 +84,7 @@ contract WorldIDVerifierV2Test is Test { vm.assume(uint8(action >> 248) != 0); vm.warp(expiresAtMin + 1 hours); - vm.expectRevert(abi.encodeWithSelector(WorldIDVerifierV2.InvalidAction.selector)); + vm.expectRevert(abi.encodeWithSelector(IWorldIDVerifierV2.InvalidAction.selector)); verifier.verify( nullifier, action, rpIdCorrect, nonce, signalHash, expiresAtMin, credentialIssuerIdCorrect, 0, proof ); @@ -83,7 +96,7 @@ contract WorldIDVerifierV2Test is Test { uint256 sessionId = 1; vm.warp(expiresAtMin + 1 hours); - vm.expectRevert(abi.encodeWithSelector(WorldIDVerifierV2.InvalidAction.selector)); + vm.expectRevert(abi.encodeWithSelector(IWorldIDVerifierV2.InvalidAction.selector)); verifier.verifySession( rpIdCorrect, nonce, @@ -102,7 +115,7 @@ contract WorldIDVerifierV2Test is Test { uint256 sessionId = 1; vm.warp(expiresAtMin + 1 hours); - vm.expectRevert(abi.encodeWithSelector(WorldIDVerifierV2.InvalidAction.selector)); + vm.expectRevert(abi.encodeWithSelector(IWorldIDVerifierV2.InvalidAction.selector)); verifier.verifySession( rpIdCorrect, nonce, @@ -153,7 +166,7 @@ contract WorldIDVerifierV2Test is Test { uint256 sessionId = 1; vm.warp(expiresAtMin + 1 hours); - vm.expectRevert(abi.encodeWithSelector(WorldIDVerifierV2.InvalidAction.selector)); + vm.expectRevert(abi.encodeWithSelector(IWorldIDVerifierV2.InvalidAction.selector)); verifier.verifyWithSession( nullifier, action, @@ -174,7 +187,7 @@ contract WorldIDVerifierV2Test is Test { uint256 sessionId = 1; vm.warp(expiresAtMin + 1 hours); - vm.expectRevert(abi.encodeWithSelector(WorldIDVerifierV2.InvalidAction.selector)); + vm.expectRevert(abi.encodeWithSelector(IWorldIDVerifierV2.InvalidAction.selector)); verifier.verifyWithSession( nullifier, action, @@ -195,7 +208,7 @@ contract WorldIDVerifierV2Test is Test { uint256 action = 0x00d4b66e5417cb9875f6a2b5be9814dca80651d7c74b3b21685fdd494566e7; vm.warp(expiresAtMin + 1 hours); - vm.expectRevert(abi.encodeWithSelector(WorldIDVerifierV2.InvalidSessionId.selector)); + vm.expectRevert(abi.encodeWithSelector(IWorldIDVerifierV2.InvalidSessionId.selector)); verifier.verifyWithSession( nullifier, action, rpIdCorrect, nonce, signalHash, expiresAtMin, credentialIssuerIdCorrect, 0, 0, proof ); @@ -222,4 +235,72 @@ contract WorldIDVerifierV2Test is Test { proof ); } + + function test_BoundSuccess() public { + vm.warp(expiresAtMin + 1 hours); + verifier.verifyWithSession( + nullifier, + actionCorrect, + rpIdCorrect, + nonce, + signalHash, + expiresAtMin, + credentialIssuerIdCorrect, + 0, + sessionIdCorrect, + boundProof + ); + } + + function test_BoundRejectedByVerify() public { + // The bound proof commits to the session id, while verify() pins the signal to 0 + vm.warp(expiresAtMin + 1 hours); + vm.expectRevert(abi.encodeWithSelector(Verifier.ProofInvalid.selector)); + verifier.verify( + nullifier, + actionCorrect, + rpIdCorrect, + nonce, + signalHash, + expiresAtMin, + credentialIssuerIdCorrect, + 0, + boundProof + ); + } + + function test_UnboundRejectedByVerifyWithSession() public { + // The unbound proof commits to a session id of 0 + vm.warp(expiresAtMin + 1 hours); + vm.expectRevert(abi.encodeWithSelector(Verifier.ProofInvalid.selector)); + verifier.verifyWithSession( + nullifier, + actionCorrect, + rpIdCorrect, + nonce, + signalHash, + expiresAtMin, + credentialIssuerIdCorrect, + 0, + sessionIdCorrect, + proof + ); + } + + function test_BoundWrongSessionId() public { + vm.warp(expiresAtMin + 1 hours); + vm.expectRevert(abi.encodeWithSelector(Verifier.ProofInvalid.selector)); + verifier.verifyWithSession( + nullifier, + actionCorrect, + rpIdCorrect, + nonce, + signalHash, + expiresAtMin, + credentialIssuerIdCorrect, + 0, + sessionIdCorrect + 1, // NOTE incorrect session id + boundProof + ); + } } diff --git a/crates/core/tests/generate_proof.rs b/crates/core/tests/generate_proof.rs index 5d21d6fbb..0cb726390 100644 --- a/crates/core/tests/generate_proof.rs +++ b/crates/core/tests/generate_proof.rs @@ -34,7 +34,7 @@ use world_id_primitives::{ Config, FieldElement, ServiceEndpoint, SessionId, TREE_DEPTH, merkle::AccountInclusionProof, }; use world_id_test_utils::{ - anvil::WorldIDVerifier, + anvil::WorldIDVerifierV2, fixtures::{ MerkleFixture, RegistryTestContext, build_base_credential, generate_rp_fixture, single_leaf_merkle_fixture, @@ -340,8 +340,9 @@ async fn e2e_authenticator_generate_proof() -> Result<()> { // verify proof with verifier contract let request_item = &proof_request.requests[0]; - let world_id_verifier: WorldIDVerifier::WorldIDVerifierInstance = - WorldIDVerifier::new(world_id_verifier, anvil.provider()?); + let world_id_verifier: WorldIDVerifierV2::WorldIDVerifierV2Instance< + alloy::providers::DynProvider, + > = WorldIDVerifierV2::new(world_id_verifier, anvil.provider()?); world_id_verifier .verify( response_item diff --git a/crates/test-utils/src/anvil.rs b/crates/test-utils/src/anvil.rs index e8cb65500..d7af66bb5 100644 --- a/crates/test-utils/src/anvil.rs +++ b/crates/test-utils/src/anvil.rs @@ -149,6 +149,16 @@ sol!( ) ); +sol!( + #[allow(clippy::too_many_arguments)] + #[sol(rpc, ignore_unlinked)] + WorldIDVerifierV2, + concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../contracts/out/WorldIDVerifierV2.sol/WorldIDVerifierV2.json" + ) +); + sol!( #[allow(clippy::too_many_arguments)] #[sol(rpc, ignore_unlinked)] @@ -704,12 +714,12 @@ impl TestAnvil { .context("failed to deploy Verifier (Groth16) contract")?; // WorldID verifier (upgradeable, delegates to Groth16 verifier) - let world_id_verifier = WorldIDVerifier::deploy(provider.clone()) + let world_id_verifier = WorldIDVerifierV2::deploy(provider.clone()) .await - .context("failed to deploy WorldIDVerifier contract")?; + .context("failed to deploy WorldIDVerifierV2 contract")?; let init_data = Bytes::from( - WorldIDVerifier::initializeCall { + WorldIDVerifierV2::initializeCall { credentialIssuerRegistry: credential_issuer_registry, worldIDRegistry: world_id_registry, oprfKeyRegistry: oprf_key_registry, diff --git a/tools/generate-solidity-fixtures/src/main.rs b/tools/generate-solidity-fixtures/src/main.rs index d44cba107..fc6b79e7d 100644 --- a/tools/generate-solidity-fixtures/src/main.rs +++ b/tools/generate-solidity-fixtures/src/main.rs @@ -42,7 +42,7 @@ use world_id_primitives::{ merkle::AccountInclusionProof, }; use world_id_test_utils::{ - anvil::WorldIDVerifier, + anvil::WorldIDVerifierV2, fixtures::{ MerkleFixture, RegistryTestContext, build_base_credential, generate_rp_fixture, single_leaf_merkle_fixture, @@ -316,8 +316,9 @@ async fn main() -> Result<()> { // Verify on-chain. info!("Verifying uniqueness proof on-chain..."); - let verifier_instance: WorldIDVerifier::WorldIDVerifierInstance = - WorldIDVerifier::new(world_id_verifier, anvil.provider()?); + let verifier_instance: WorldIDVerifierV2::WorldIDVerifierV2Instance< + alloy::providers::DynProvider, + > = WorldIDVerifierV2::new(world_id_verifier, anvil.provider()?); verifier_instance .verify( uniqueness_response From 50777bc32efb1dcd72a1a1f741a19be82d3668b0 Mon Sep 17 00:00:00 2001 From: kilianglas Date: Mon, 13 Jul 2026 18:24:21 +0200 Subject: [PATCH 09/36] chore: cleanup --- .../core/interfaces/IWorldIDVerifierV2.sol | 2 +- .../test/core/WorldIDVerifierV2Test.t.sol | 21 --- docs/world-id-4-specs/README.md | 123 +++++++++--------- 3 files changed, 62 insertions(+), 84 deletions(-) diff --git a/contracts/src/core/interfaces/IWorldIDVerifierV2.sol b/contracts/src/core/interfaces/IWorldIDVerifierV2.sol index 45b2f9ed0..7c673c9f0 100644 --- a/contracts/src/core/interfaces/IWorldIDVerifierV2.sol +++ b/contracts/src/core/interfaces/IWorldIDVerifierV2.sol @@ -37,7 +37,7 @@ interface IWorldIDVerifierV2 is IWorldIDVerifier { * @notice Verifies a Uniqueness Proof that is bound to an existing session. * @dev Same as `verify`, except the proof's `session_id` public signal is checked against the * provided session commitment instead of being pinned to 0. Bound proofs are rejected by - * `verify` and unbound proofs are rejected here — binding is explicit in both directions. + * `verify` and unbound proofs are rejected here. Hence, binding is explicit in both directions. * @dev Public inputs refer to the ZK-circuit public inputs. * @param nullifier Public output. A unique, one-time identifier derived from (user, rpId, action) that * lets RPs detect duplicate actions without learning who the user is. diff --git a/contracts/test/core/WorldIDVerifierV2Test.t.sol b/contracts/test/core/WorldIDVerifierV2Test.t.sol index cd8fa0387..3b4957df7 100644 --- a/contracts/test/core/WorldIDVerifierV2Test.t.sol +++ b/contracts/test/core/WorldIDVerifierV2Test.t.sol @@ -181,27 +181,6 @@ contract WorldIDVerifierV2Test is Test { ); } - function testFuzz_BoundRevertsWhenActionFirstByteNonZero(uint256 action) public { - // Ensure the highest byte is non-zero - vm.assume(uint8(action >> 248) != 0); - uint256 sessionId = 1; - - vm.warp(expiresAtMin + 1 hours); - vm.expectRevert(abi.encodeWithSelector(IWorldIDVerifierV2.InvalidAction.selector)); - verifier.verifyWithSession( - nullifier, - action, - rpIdCorrect, - nonce, - signalHash, - expiresAtMin, - credentialIssuerIdCorrect, - 0, - sessionId, - proof - ); - } - function test_BoundRevertsWhenSessionIdZero() public { // Valid uniqueness action prefix, but a zero session id must not pass — // it would silently degrade to unbound verify() semantics. diff --git a/docs/world-id-4-specs/README.md b/docs/world-id-4-specs/README.md index f40509f36..b581c2e9c 100644 --- a/docs/world-id-4-specs/README.md +++ b/docs/world-id-4-specs/README.md @@ -30,29 +30,29 @@ Stemming from the enablement of other Authenticators to exist, a reference open- This notes the key **new** features or functionality for this **release** of World ID (v4.0): - Multi-key support: A World ID is not bound to a single key. A user can generate proofs on multiple valid authenticators (e.g. devices, platforms). With the important exception of security properties of the Authenticator, a proof proves the same thing to an RP regardless of which authenticator was used. - - A user can add or remove different valid authenticators to manage their World ID (Portability). - - **Motivation:** Allowing multiple authenticators serves to the Decentralization of the Protocol, with no reliance on a single actor (such as a single Authenticator provider, e.g. World App). Furthermore, abstracting a World ID into a conceptual record vs. a single secret enables the secure and practical existence of multiple Authenticators as well as enabling Recovery in case of loss and rotation in case of compromise. + - A user can add or remove different valid authenticators to manage their World ID (Portability). + - **Motivation:** Allowing multiple authenticators serves to the Decentralization of the Protocol, with no reliance on a single actor (such as a single Authenticator provider, e.g. World App). Furthermore, abstracting a World ID into a conceptual record vs. a single secret enables the secure and practical existence of multiple Authenticators as well as enabling Recovery in case of loss and rotation in case of compromise. - Recovery: Regain access to the same World ID through Recovery Agents. - - **Motivation:** Recovery is a fundamental building block of a Proof-of-Human Protocol (see [Whitepaper](https://whitepaper.world.org/#recovery) on why). While we expect authenticator providers to offer robust backup mechanisms, the user must be able to recover their World ID and related state in a contingency scenario. + - **Motivation:** Recovery is a fundamental building block of a Proof-of-Human Protocol (see [Whitepaper](https://whitepaper.world.org/#recovery) on why). While we expect authenticator providers to offer robust backup mechanisms, the user must be able to recover their World ID and related state in a contingency scenario. - Web-based Authenticator Provider: A limited authenticator that allows usage of World ID in the web browser. This serves both as a reference of an authenticator and also for improved UX for certain RP flows. Functionality is limited as enrollment of credentials is out of the scope for this initial release. - - **Motivation**: A lightweight web-based authenticator enables simpler usage of World ID which provides a better user experience and will enable more growth as interacting with RPs will be significantly simpler. + - **Motivation**: A lightweight web-based authenticator enables simpler usage of World ID which provides a better user experience and will enable more growth as interacting with RPs will be significantly simpler. - Trusted RPs. An authenticator can identify a request comes from a valid RP. - - **Motivation:** Authenticators need to be able to identify that they’re generating proofs for the right recipient to reduce potential for proof phishing (e.g. a malicious actor asking you for a proof meant for a different RP to know if you’ve performed that action). In addition, this enables future introduction of Protocol fees. + - **Motivation:** Authenticators need to be able to identify that they’re generating proofs for the right recipient to reduce potential for proof phishing (e.g. a malicious actor asking you for a proof meant for a different RP to know if you’ve performed that action). In addition, this enables future introduction of Protocol fees. ## Non-Functional Requirements - Privacy. - - Assuming **non-collusion** of nodes of each multi-party system, the user’s privacy cannot be compromised by any single party, neither correlation of multiple actions nor direct identification of a user. For example, it’s impossible to know that a specific user performed a specific action (identification) or that two different Actions were performed by the same user (correlation). - - Addressing the attack vector of collusion of a threshold (or all) nodes is covered in the [Other Risk Considerations](#other-risk-considerations) section. - - Strict requirements for the identifiers handed off by the Protocol are introduced. See details in Tech Specs. - - No human super-cookies. Permanent state or linkable state is as privacy-preserving as possible and is protocol-enforced. Privacy preserving in this context means that it’s not possible to identify a single person, even pseudonymously, across a long period of time without ongoing consent. Any exposed long-living / constant IDs should be protected as secrets. + - Assuming **non-collusion** of nodes of each multi-party system, the user’s privacy cannot be compromised by any single party, neither correlation of multiple actions nor direct identification of a user. For example, it’s impossible to know that a specific user performed a specific action (identification) or that two different Actions were performed by the same user (correlation). + - Addressing the attack vector of collusion of a threshold (or all) nodes is covered in the [Other Risk Considerations](#other-risk-considerations) section. + - Strict requirements for the identifiers handed off by the Protocol are introduced. See details in Tech Specs. + - No human super-cookies. Permanent state or linkable state is as privacy-preserving as possible and is protocol-enforced. Privacy preserving in this context means that it’s not possible to identify a single person, even pseudonymously, across a long period of time without ongoing consent. Any exposed long-living / constant IDs should be protected as secrets. - Security. - - A World ID is not a single secret that needs to be shared or can’t be rotated. - - A World ID cannot be recovered or an authenticator added without verifiable user intent (through knowledge of a secret key of an authorized authenticator). - - Collusion of **all** nodes with an multi-party system (MPC) does not allow performing actions on the user’s behalf. - - User Auditability — each user needs to be able to see account management events that have been authorized with their World ID, for example things like adding / removing of authenticators. + - A World ID is not a single secret that needs to be shared or can’t be rotated. + - A World ID cannot be recovered or an authenticator added without verifiable user intent (through knowledge of a secret key of an authorized authenticator). + - Collusion of **all** nodes with an multi-party system (MPC) does not allow performing actions on the user’s behalf. + - User Auditability — each user needs to be able to see account management events that have been authorized with their World ID, for example things like adding / removing of authenticators. - Migration Path. - - There needs to be a clear migration path for all currently active and relevant use cases to the new version of the Protocol. + - There needs to be a clear migration path for all currently active and relevant use cases to the new version of the Protocol. ## User Flows (Authenticator) @@ -72,14 +72,14 @@ This notes the key **new** features or functionality for this **release** of Wor ## Summary: What is Changing? - A World ID is now a record on an on-chain registry and more importantly a single World ID can have multiple public keys. - - This also means identity commitments (`identityCommitment`) no longer exist. Instead, the identification mechanism is the knowledge of a secret key corresponding to a public key registered in a specific leaf index in the `WorldIDRegistry`. - - Also implies that the on-chain trees of identity commitments is gone in favor of a single `WorldIDRegistry`. + - This also means identity commitments (`identityCommitment`) no longer exist. Instead, the identification mechanism is the knowledge of a secret key corresponding to a public key registered in a specific leaf index in the `WorldIDRegistry`. + - Also implies that the on-chain trees of identity commitments is gone in favor of a single `WorldIDRegistry`. - Creating a World ID now occurs through on-chain registration (vs. as an offline keypair generation previously), and issuing Credentials is now done without on-chain interaction. Credentials are now issued by the Issuer signing them. Previously, the Issuer would add the user’s identity commitment to the relevant on-chain tree. - Nullifiers are enforced one-time use. Previously there was no enforcement of nullifiers being one-time use and they could become pseudonymous identifiers for an RP, now Authenticators will not issue a nullifier more than once. - [**For RPs only**]. When RPs require users to prove they are still the same World ID that originally performed an action, they will be able to store an identifier (a `sessionId`) and provide it to the user for subsequent proofs. With Proof of Human, this allows RPs to establish they are interacting with the same World ID, potentially with different credentials too. See *Session Proofs* for further details. - [**For Issuers only**]. Authentication based on using nullifiers from ZKPs as identifiers is no longer supported. A new authentication mechanism is introduced for issuers. - Access to a World ID can be recovered. A user can designate a *Recovery Agent* for their account which will allow for recovery in case of access to all Authenticators is lost. - - [**Recovery Agent Scope**]. Users may designate the *PoH AMPC* system as their Recovery Agent to recover their World ID. In the future, other Recovery Agents are expected to be available. + - [**Recovery Agent Scope**]. Users may designate the *PoH AMPC* system as their Recovery Agent to recover their World ID. In the future, other Recovery Agents are expected to be available. ## High level overview @@ -90,14 +90,13 @@ Diagram of components for the World ID 4.0 Protocol. 2. Similarly, a **Relying Party Registry** is introduced. This registry contains a list of authorized Relying Parties with their accompanying authorized public keys. The registry permits RPs to authenticate requests for proofs to Authenticators. 3. The multi-party set of **OPRF Nodes** is introduced. This set of nodes are now responsible for generating the nullifiers that users present to RPs to prove uniqueness. The nullifiers are generated through a *Verified Threshold* *Oblivious Pseudorandom Function* (vOPRF) with participation of the OPRF nodes. Nodes verify requests for nullifiers are properly validated by both RPs and users (see *Uniqueness Proofs*), and only then will generate the required output to compute the user’s nullifier. The users then construct the final nullifier and prove its computation in the proof they present to RPs. 1. A multi-party OPRF is necessary because it prevents nullifiers from being guessable, i.e. nullifiers are deterministic but appear random (recall that PRF outputs under a uniformly random key are computationally indistinguishable from a uniformly random function). This could theoretically be accomplished with a regular hash function, but then nullifiers could be brute forced by computing the hash for all possible `leafIndex`es (which are public on-chain). To prevent this, secret entropy is required (in World ID ≤ 3.0, the user provided this entropy). Since this is not available anymore, the entropy now comes from the OPRF nodes. - 2. Additionally, to prevent brute forcing even with involvement of OPRF nodes, OPRF nodes require authentication before computing each hash. They authenticate the user through a ZKP that proves knowledge of an Authenticator secret key authorized in the `WorldIDRegistry` for the particular `leafIndex` for which they are generating a nullifier. + 2. Additionally, to prevent brute forcing even with involvement of OPRF nodes, OPRF nodes require authentication before computing each hash. They authenticate the user through a ZKP that proves knowledge of an Authenticator secret key authorized in the `WorldIDRegistry` for the particular `leafIndex` for which they are generating a nullifier. 3. Importantly, the OPRF nodes compute the keyed-hash function $H_k(x')$ on a blinded input, hence they cannot learn which user is actually performing a request. Furthermore, the OPRF nodes output a proof that attests to the proper computation of $H_k$ given a committed $k_{pk}$, so neither users nor RPs need to blindly trust the OPRF nodes. 4. Similar to how OPRF Nodes are used to generate the nullifiers presented to RPs, these nodes also generate a blinding factor for each credential so there cannot be correlation of World IDs from malicious issuers. 5. More information on the OPRF Nodes can be found in the paper: *“[A Nullifier Protocol based on a Verifiable, Threshold OPRF](https://github.com/TaceoLabs/oprf-service/blob/main/docs/oprf.pdf)”*. - 6. Details about the nature, number, and diversity requirements of OPRF nodes must be established before the production network is live. + 6. Details about the nature, number, and diversity requirements of OPRF nodes must be established before the production network is live. 4. Protocol differences at a glance: - - + | | **World ID ≤3.0** | **World ID 4.0 (2025)** | | --- | --- | --- | | What is a World ID? | A secret. | An entry in public registry. | @@ -147,12 +146,11 @@ RP ->> RP: Verify nullifier uniqueness ``` - The nullifier is computed by the OPRF Nodes. Computing it requires output from a threshold number of nodes to be valid. - - Importantly, the input to the OPRF Nodes is blinded so that no OPRF node can see the raw `leafIndex` (i.e. OPRF nodes only know that the request is from an authorized authenticator). - - Importantly, the nullifier is credential *independent*, so the action can only be performed once regardless of which credentials are available at the time. - - Further information on how the nullifier is computed can be found in the [TACEO OPRF Whitepaper](https://github.com/TaceoLabs/nullifier-oracle-service/blob/main/docs/oprf.pdf). + - Importantly, the input to the OPRF Nodes is blinded so that no OPRF node can see the raw `leafIndex` (i.e. OPRF nodes only know that the request is from an authorized authenticator). + - Importantly, the nullifier is credential *independent*, so the action can only be performed once regardless of which credentials are available at the time. + - Further information on how the nullifier is computed can be found in the [TACEO OPRF Whitepaper](https://github.com/TaceoLabs/nullifier-oracle-service/blob/main/docs/oprf.pdf). - Nullifiers have the following properties, which in combination make them amenable for use by an RP to enforce anonymous per-action uniqueness: - - + | **Property** | **Description** | | --- | --- | | Deterministic | Given the same context (`leafIndex` [blinded], `rpId`, `action`), the nullifier is always the same. Assuming honest behavior of OPRF nodes never rotating their base key. *Note that the credential is intentionally not included in this context. This means that the action can be performed only once, regardless of which credentials are available at the time.* | @@ -161,45 +159,46 @@ RP ->> RP: Verify nullifier uniqueness | Anonymous | A nullifier hides which user generated it. To preserve anonymity, each nullifier must only be used once (otherwise repeated use makes it pseudonymous). This is the responsibility of Authenticators. | | Unlinkable | For any two nullifiers with different contexts, the probability that an adversary can correctly distinguish whether they were derived from the same user is at most negligibly better than random guessing. | | Pre-image resistance | For any given nullifier, and knowing the public context (`rpId`, `action`), it is computationally infeasible to find the pre-image or the `leafIndex`. | + - The authenticator generates two types of different zero-knowledge proofs to be able to deliver a Uniqueness Proof to an RP, - - The query proof $\pi_1$ which proves to the OPRF Nodes that the request is properly authorized by the user. This ZKP proves the request is signed by a public key which is registered for the particularly provided blinded `leafIndex` in the `WorldIDRegistry`. - - A final Uniqueness Proof $\pi_2$ which ensures at least the following constraints: - - *The same constraints of the query proof are evaluated.* - - Correct OPRF evaluation on `leafIndex`, i.e. the generated nullifier is correct for the committed public keys from each OPRF node. - - Request is signed by a public key that is registered for the `leafIndex` in the `WorldIDRegistry` (user authentication). - - The Credential was issued for this World ID, i.e. the Credential’s `sub` matches the blinded `leafIndex` of the user. - - The Credential used in the proof is signed by the Issuer (through the committed key in the `CredentialSchemaIssuerRegistry`). - - Credential is not expired. - - Credential meets the minimum genesis_issued_at constraint provided by the RP. - - Signal and nonce provided by the RP as public inputs are committed. - - *Potential future constraints may include: integrity attestation of device, enforcing the expiration of actions, credential specific checks, etc.* + - The query proof $\pi_1$ which proves to the OPRF Nodes that the request is properly authorized by the user. This ZKP proves the request is signed by a public key which is registered for the particularly provided blinded `leafIndex` in the `WorldIDRegistry`. + - A final Uniqueness Proof $\pi_2$ which ensures at least the following constraints: + - *The same constraints of the query proof are evaluated.* + - Correct OPRF evaluation on `leafIndex`, i.e. the generated nullifier is correct for the committed public keys from each OPRF node. + - Request is signed by a public key that is registered for the `leafIndex` in the `WorldIDRegistry` (user authentication). + - The Credential was issued for this World ID, i.e. the Credential’s `sub` matches the blinded `leafIndex` of the user. + - The Credential used in the proof is signed by the Issuer (through the committed key in the `CredentialSchemaIssuerRegistry`). + - Credential is not expired. + - Credential meets the minimum genesis_issued_at constraint provided by the RP. + - Signal and nonce provided by the RP as public inputs are committed. + - *Potential future constraints may include: integrity attestation of device, enforcing the expiration of actions, credential specific checks, etc.* - **Oblivious Nullifier Pool**. The Oblivious Nullifier Pool is a separate service which offers *Private Intersection Retrieval* and keeps track of used nullifiers. Its function is simply to keep a flat list of used nullifiers such that an authenticator can query if a nullifier has been used before sharing it (and the related $\pi_2$) with an RP if it has been used before. The list is flat (as the nullifier is already unique per-RP-per-action-per-user) relying on the collision-resistance property of the hash function used in the Protocol. - - This system ensures that nullifiers can’t be misused to create long running identifiers. As their name suggests, a nullifier is one-time use. - - The term *oblivious* is used to refer to the fact that this map is queried in a way where the servers serving such requests cannot learn which records where accessed and hence be able to compromise the user’s privacy. - - The main limitation of the nullifier pool is performance at scale. One option is to shard the pool, making trade-offs of anonymity set size vs. performance. This is still in research. - - Initially, this pool will only be used for actions that have a running period longer than a predefined threshold. This is to solve for scaling issues as this system grows. + - This system ensures that nullifiers can’t be misused to create long running identifiers. As their name suggests, a nullifier is one-time use. + - The term *oblivious* is used to refer to the fact that this map is queried in a way where the servers serving such requests cannot learn which records where accessed and hence be able to compromise the user’s privacy. + - The main limitation of the nullifier pool is performance at scale. One option is to shard the pool, making trade-offs of anonymity set size vs. performance. This is still in research. + - Initially, this pool will only be used for actions that have a running period longer than a predefined threshold. This is to solve for scaling issues as this system grows. - **Blinded subjects**. To prevent correlation of users even among issuers, or in case of leaked credentials, the subjects of the credentials are blinded. - - When requesting a new credential from an issuer, the user generates a blinding factor using the OPRF nodes, $\texttt{subjectBlindingFactor}=H_k(\texttt{issuerSchemaId} \mid\mid \texttt{leafIndex})$. - - The user then hashes the blinding factor with their `leafIndex` to compute the `sub` claim of the credential. This value is what issuers include in the credential. - - When a proof is presented, the `subjectBlindingFactor` is used within the Uniqueness Proof circuit to ensure the credential is issued to the right user. The blinding factor acts as entropy to prevent correlation, but the right `leafIndex` as provided in the circuit input must match correctly. + - When requesting a new credential from an issuer, the user generates a blinding factor using the OPRF nodes, $\texttt{subjectBlindingFactor}=H_k(\texttt{issuerSchemaId} \mid\mid \texttt{leafIndex})$. + - The user then hashes the blinding factor with their `leafIndex` to compute the `sub` claim of the credential. This value is what issuers include in the credential. + - When a proof is presented, the `subjectBlindingFactor` is used within the Uniqueness Proof circuit to ensure the credential is issued to the right user. The blinding factor acts as entropy to prevent correlation, but the right `leafIndex` as provided in the circuit input must match correctly. ### Registries - **World ID Registry** - - Each user can grant access to multiple different keys to interact with their World ID. The Authenticator proves control inside a ZKP to prevent long-lived identifiers. - - In order to not leak the user, this needs to be in some structure that allows inclusion proofs. This is accomplished with [Incremental Merkle Trees](https://github.com/zk-kit/zk-kit.solidity/blob/main/packages/imt/contracts/BinaryIMT.sol). - - Each Authenticator registers two keys in the registry. This is done to enable performant operations both on-chain and on zero-knowledge circuits. - - An on-chain key which is an elliptic curve key on the `secp256k1` curve is used to authorize on-chain operations on the contract (e.g. adding an authenticator, removing an authenticator, etc.). The public key is simply represented as an Ethereum address. - - An off-chain key which is an elliptic curve key on the `BabyJubJub` curve is used to sign requests for zero-knowledge proofs. The public key (represented as a curve point) is emitted on-chain and committed to in the contract. + - Each user can grant access to multiple different keys to interact with their World ID. The Authenticator proves control inside a ZKP to prevent long-lived identifiers. + - In order to not leak the user, this needs to be in some structure that allows inclusion proofs. This is accomplished with [Incremental Merkle Trees](https://github.com/zk-kit/zk-kit.solidity/blob/main/packages/imt/contracts/BinaryIMT.sol). + - Each Authenticator registers two keys in the registry. This is done to enable performant operations both on-chain and on zero-knowledge circuits. + - An on-chain key which is an elliptic curve key on the `secp256k1` curve is used to authorize on-chain operations on the contract (e.g. adding an authenticator, removing an authenticator, etc.). The public key is simply represented as an Ethereum address. + - An off-chain key which is an elliptic curve key on the `BabyJubJub` curve is used to sign requests for zero-knowledge proofs. The public key (represented as a curve point) is emitted on-chain and committed to in the contract. - **Relying Party Registry** - - Each RP needs to commit to their authorized public key on the public registry, such that this can be verified in the request proof $\pi_1$ by each queried OPRF node. - - Registering an RP is a public action that anyone can take, but this requires paying a one-time registration fee (see *Registration Fees* below). - - In order to allow for decentralized application creation and registration, the RP Registry will be extended and restrictions further lifted in the future, but for this initial version the following applies: - - At launch, only one authorized key is allowed per RP. This will be extended in the future. + - Each RP needs to commit to their authorized public key on the public registry, such that this can be verified in the request proof $\pi_1$ by each queried OPRF node. + - Registering an RP is a public action that anyone can take, but this requires paying a one-time registration fee (see *Registration Fees* below). + - In order to allow for decentralized application creation and registration, the RP Registry will be extended and restrictions further lifted in the future, but for this initial version the following applies: + - At launch, only one authorized key is allowed per RP. This will be extended in the future. - **Credential Schema Issuer Registry** - - It's a simple registry where Issuers register for each of their credential types a schema and an authorized signatory and get issued an `issuerSchemaId`. This ID represents the combination of an (issuer, schema). For example: (Tools For Humanity, Orb credential). - - The `issuerSchemaId` is included in the credential and is verified as part of all Proofs. When generating and verifying proofs, the signature of a credential is verified against the public key registered in the contract. - - Registering an Issuer Schema also requires paying a one-time registration fee (see *Registration Fees* below). + - It's a simple registry where Issuers register for each of their credential types a schema and an authorized signatory and get issued an `issuerSchemaId`. This ID represents the combination of an (issuer, schema). For example: (Tools For Humanity, Orb credential). + - The `issuerSchemaId` is included in the credential and is verified as part of all Proofs. When generating and verifying proofs, the signature of a credential is verified against the public key registered in the contract. + - Registering an Issuer Schema also requires paying a one-time registration fee (see *Registration Fees* below). ### Registration Fees @@ -208,6 +207,7 @@ Both the Relying Party Registry and the Credential Schema Issuer Registry charge **Why the fee exists.** Registering an RP or an Issuer Schema triggers the initialization of an OPRF key via a multi-round distributed key generation ceremony across the OPRF Nodes. This is a computationally expensive operation with real infrastructure cost. The registration fee is sized to cover the cost of OPRF key generation and storage for at least approximately one year. **How it works.** + - The fee is paid in a configurable ERC-20 token via `safeTransferFrom` at the time of registration, before OPRF key generation begins. **Future: per-request fees.** The registration fee described here covers only the one-time cost of onboarding. A separate per-request fee — enforced by OPRF Nodes as a proof-of-payment requirement during nullifier generation — may be introduced in a future Protocol release (4.1 or 4.2). See *Future Proofing Notes* for details. @@ -275,6 +275,7 @@ rp->>rp: verify proof (checking sessionId == C' in verifier contract) **Recovering `r` for subsequent Session Proofs.** The OPRF is deterministic: the same input and key always produce the same output. This means `r` can be re-derived at any time by calling the OPRF nodes with the original `oprf_seed` (stored in `sessionId`). Caching `r` is an optimization, not a requirement. The OPRF call to derive `r` and the OPRF call to derive the nullifier can be made in parallel. **Session Nullifiers** + - A [`sessionNullifier`](https://docs.rs/world-id-primitives/latest/world_id_primitives/session/struct.SessionNullifier.html) is used for verifying Session Proofs. It must be passed to the verification contract. Internally, the [`sessionNullifier`](https://docs.rs/world-id-primitives/latest/world_id_primitives/session/struct.SessionNullifier.html) implements custom encoding on the Authenticator and on the `WorldIDVerifier` contract. - The raison d'être is simply to allow usage of the same ZK circuit as for Uniqueness Proofs. Reducing the number of circuits is currently a priority because of the size of the circuits needed to be bundled in Authenticator clients. As World ID moves to a different proving system, this type will no longer be required. - Session Proofs use a randomized `action` as circuit input. This randomized `action` ensures the circuit's nullifier output is unique per proof, preserving the one-time use property. It is verified internally within the circuit. It does not affect `r` derivation. @@ -283,16 +284,16 @@ rp->>rp: verify proof (checking sessionId == C' in verifier contract) - A Uniqueness Proof request may include an existing `sessionId`. The proof then carries the session's commitment `C` as its `id_commitment` public signal, proving in-circuit that the session and the nullifier belong to the same World ID. - The Authenticator requires the cached `r` for this; re-deriving `r` is only possible through a session-type request. -- Verifiers MUST check the proof against the session's commitment — with the signal at `0` the proof is valid but unbound. On-chain, the dedicated `verifyWithSession()` entry point does this (it rejects `sessionId == 0`); the convenience `verify()` entry point pins the signal to `0` and rejects bound proofs, so binding is explicit in both directions. +- Verifiers MUST check the proof against the session's commitment. A proof whose `id_commitment` public signal is `0` is valid but unbound. On-chain, the dedicated `verifyWithSession()` entry point does this (it rejects `sessionId == 0`). The convenience `verify()` entry point pins the signal to `0` and rejects bound proofs, so binding is explicit in both directions. - Binding one `sessionId` to Uniqueness Proofs under different actions intentionally links those actions to the same World ID; Authenticators MUST clearly surface this to users. ### Web-based Authenticator Provider -To allow for an improved user experience, a reference browser-based Authenticator provider is being introduced. This app provides (currently limited) World ID functionality but without leaving the browser. +To allow for an improved user experience, a reference browser-based Authenticator provider is being introduced. This app provides (currently limited) World ID functionality but without leaving the browser. 1. At a high-level, it allows **usage** of a World ID. The user can generate proofs in their browser, and this is particularly useful for when working on other devices (such as desktop) or on non-native apps. 2. Whenever an RP requires a user’s World ID proof, they can simply redirect the user to the web app (handled automatically by common SDKs like [ID Kit](https://github.com/worldcoin/idkit)). The user authenticates with their passkey, generates the proof in their browser and passes it back to the RP. -3. Further documentation on the architecture of the reference web-based Authenticator provider will be published in the https://github.com/worldcoin/web-authenticator repository. +3. Further documentation on the architecture of the reference web-based Authenticator provider will be published in the repository. 4. **Credential Enrollment** will not be supported in the initial release, but this may be introduced in the future. ## Migration Considerations @@ -303,8 +304,7 @@ At a high level, every user and RP will need to migrate to the new Protocol. Det - The Protocol, via the Oblivious Nullifier Pool enforces that nullifiers cannot be generated more than once (as long as authenticators are properly implemented), which prevents long running user tracking, increasing the privacy from the previous protocol version. - In adversarial scenarios, these are the most relevant privacy considerations, - - + | Attack scenario | **World ID ≤3.0** | **World ID 4.0 (2025)** | | --- | --- | --- | | Compromised user’s secret | ⚠️ Potentially reveals all past activity if the attacker knows the public app IDs and actions. | ✅ Cannot reveal past activity on its own | @@ -326,7 +326,6 @@ At a high level, every user and RP will need to migrate to the new Protocol. Det - **Authenticator Risk**. Aside from having access to the user’s credentials, an Authenticator must learn of a user’s raw `leafIndex` to be able to generate Proofs. A malicious Authenticator can misuse this to track the user, even though that tracking cannot be correlated to nullifiers provided to RPs on its own. Different strategies to mitigate Authenticator risk are being explored. - **Recovery Agent Risk**. Should a user designate a Recovery Agent, this entity has a special permission that allows it to gain access to the user’s World ID, which could be misused. Beyond the explicit risk of a malicious Recovery Agent compromising a user's World ID, users need to consider the different risks associated with different Recovery Agents based on how they perform authentication. - ## Future Proofing Notes (World ID 4.x future releases and beyond) This is not a comprehensive list, but it outlines general topics that may be the target of upcoming Protocol releases which are not currently covered on this release. From 8d475b16d23688168d70260d276d0718727e7bab Mon Sep 17 00:00:00 2001 From: kilianglas Date: Mon, 13 Jul 2026 19:32:10 +0200 Subject: [PATCH 10/36] chore: drop unrelated README reformatting --- docs/world-id-4-specs/README.md | 123 ++++++++++++++++---------------- 1 file changed, 62 insertions(+), 61 deletions(-) diff --git a/docs/world-id-4-specs/README.md b/docs/world-id-4-specs/README.md index b581c2e9c..f40509f36 100644 --- a/docs/world-id-4-specs/README.md +++ b/docs/world-id-4-specs/README.md @@ -30,29 +30,29 @@ Stemming from the enablement of other Authenticators to exist, a reference open- This notes the key **new** features or functionality for this **release** of World ID (v4.0): - Multi-key support: A World ID is not bound to a single key. A user can generate proofs on multiple valid authenticators (e.g. devices, platforms). With the important exception of security properties of the Authenticator, a proof proves the same thing to an RP regardless of which authenticator was used. - - A user can add or remove different valid authenticators to manage their World ID (Portability). - - **Motivation:** Allowing multiple authenticators serves to the Decentralization of the Protocol, with no reliance on a single actor (such as a single Authenticator provider, e.g. World App). Furthermore, abstracting a World ID into a conceptual record vs. a single secret enables the secure and practical existence of multiple Authenticators as well as enabling Recovery in case of loss and rotation in case of compromise. + - A user can add or remove different valid authenticators to manage their World ID (Portability). + - **Motivation:** Allowing multiple authenticators serves to the Decentralization of the Protocol, with no reliance on a single actor (such as a single Authenticator provider, e.g. World App). Furthermore, abstracting a World ID into a conceptual record vs. a single secret enables the secure and practical existence of multiple Authenticators as well as enabling Recovery in case of loss and rotation in case of compromise. - Recovery: Regain access to the same World ID through Recovery Agents. - - **Motivation:** Recovery is a fundamental building block of a Proof-of-Human Protocol (see [Whitepaper](https://whitepaper.world.org/#recovery) on why). While we expect authenticator providers to offer robust backup mechanisms, the user must be able to recover their World ID and related state in a contingency scenario. + - **Motivation:** Recovery is a fundamental building block of a Proof-of-Human Protocol (see [Whitepaper](https://whitepaper.world.org/#recovery) on why). While we expect authenticator providers to offer robust backup mechanisms, the user must be able to recover their World ID and related state in a contingency scenario. - Web-based Authenticator Provider: A limited authenticator that allows usage of World ID in the web browser. This serves both as a reference of an authenticator and also for improved UX for certain RP flows. Functionality is limited as enrollment of credentials is out of the scope for this initial release. - - **Motivation**: A lightweight web-based authenticator enables simpler usage of World ID which provides a better user experience and will enable more growth as interacting with RPs will be significantly simpler. + - **Motivation**: A lightweight web-based authenticator enables simpler usage of World ID which provides a better user experience and will enable more growth as interacting with RPs will be significantly simpler. - Trusted RPs. An authenticator can identify a request comes from a valid RP. - - **Motivation:** Authenticators need to be able to identify that they’re generating proofs for the right recipient to reduce potential for proof phishing (e.g. a malicious actor asking you for a proof meant for a different RP to know if you’ve performed that action). In addition, this enables future introduction of Protocol fees. + - **Motivation:** Authenticators need to be able to identify that they’re generating proofs for the right recipient to reduce potential for proof phishing (e.g. a malicious actor asking you for a proof meant for a different RP to know if you’ve performed that action). In addition, this enables future introduction of Protocol fees. ## Non-Functional Requirements - Privacy. - - Assuming **non-collusion** of nodes of each multi-party system, the user’s privacy cannot be compromised by any single party, neither correlation of multiple actions nor direct identification of a user. For example, it’s impossible to know that a specific user performed a specific action (identification) or that two different Actions were performed by the same user (correlation). - - Addressing the attack vector of collusion of a threshold (or all) nodes is covered in the [Other Risk Considerations](#other-risk-considerations) section. - - Strict requirements for the identifiers handed off by the Protocol are introduced. See details in Tech Specs. - - No human super-cookies. Permanent state or linkable state is as privacy-preserving as possible and is protocol-enforced. Privacy preserving in this context means that it’s not possible to identify a single person, even pseudonymously, across a long period of time without ongoing consent. Any exposed long-living / constant IDs should be protected as secrets. + - Assuming **non-collusion** of nodes of each multi-party system, the user’s privacy cannot be compromised by any single party, neither correlation of multiple actions nor direct identification of a user. For example, it’s impossible to know that a specific user performed a specific action (identification) or that two different Actions were performed by the same user (correlation). + - Addressing the attack vector of collusion of a threshold (or all) nodes is covered in the [Other Risk Considerations](#other-risk-considerations) section. + - Strict requirements for the identifiers handed off by the Protocol are introduced. See details in Tech Specs. + - No human super-cookies. Permanent state or linkable state is as privacy-preserving as possible and is protocol-enforced. Privacy preserving in this context means that it’s not possible to identify a single person, even pseudonymously, across a long period of time without ongoing consent. Any exposed long-living / constant IDs should be protected as secrets. - Security. - - A World ID is not a single secret that needs to be shared or can’t be rotated. - - A World ID cannot be recovered or an authenticator added without verifiable user intent (through knowledge of a secret key of an authorized authenticator). - - Collusion of **all** nodes with an multi-party system (MPC) does not allow performing actions on the user’s behalf. - - User Auditability — each user needs to be able to see account management events that have been authorized with their World ID, for example things like adding / removing of authenticators. + - A World ID is not a single secret that needs to be shared or can’t be rotated. + - A World ID cannot be recovered or an authenticator added without verifiable user intent (through knowledge of a secret key of an authorized authenticator). + - Collusion of **all** nodes with an multi-party system (MPC) does not allow performing actions on the user’s behalf. + - User Auditability — each user needs to be able to see account management events that have been authorized with their World ID, for example things like adding / removing of authenticators. - Migration Path. - - There needs to be a clear migration path for all currently active and relevant use cases to the new version of the Protocol. + - There needs to be a clear migration path for all currently active and relevant use cases to the new version of the Protocol. ## User Flows (Authenticator) @@ -72,14 +72,14 @@ This notes the key **new** features or functionality for this **release** of Wor ## Summary: What is Changing? - A World ID is now a record on an on-chain registry and more importantly a single World ID can have multiple public keys. - - This also means identity commitments (`identityCommitment`) no longer exist. Instead, the identification mechanism is the knowledge of a secret key corresponding to a public key registered in a specific leaf index in the `WorldIDRegistry`. - - Also implies that the on-chain trees of identity commitments is gone in favor of a single `WorldIDRegistry`. + - This also means identity commitments (`identityCommitment`) no longer exist. Instead, the identification mechanism is the knowledge of a secret key corresponding to a public key registered in a specific leaf index in the `WorldIDRegistry`. + - Also implies that the on-chain trees of identity commitments is gone in favor of a single `WorldIDRegistry`. - Creating a World ID now occurs through on-chain registration (vs. as an offline keypair generation previously), and issuing Credentials is now done without on-chain interaction. Credentials are now issued by the Issuer signing them. Previously, the Issuer would add the user’s identity commitment to the relevant on-chain tree. - Nullifiers are enforced one-time use. Previously there was no enforcement of nullifiers being one-time use and they could become pseudonymous identifiers for an RP, now Authenticators will not issue a nullifier more than once. - [**For RPs only**]. When RPs require users to prove they are still the same World ID that originally performed an action, they will be able to store an identifier (a `sessionId`) and provide it to the user for subsequent proofs. With Proof of Human, this allows RPs to establish they are interacting with the same World ID, potentially with different credentials too. See *Session Proofs* for further details. - [**For Issuers only**]. Authentication based on using nullifiers from ZKPs as identifiers is no longer supported. A new authentication mechanism is introduced for issuers. - Access to a World ID can be recovered. A user can designate a *Recovery Agent* for their account which will allow for recovery in case of access to all Authenticators is lost. - - [**Recovery Agent Scope**]. Users may designate the *PoH AMPC* system as their Recovery Agent to recover their World ID. In the future, other Recovery Agents are expected to be available. + - [**Recovery Agent Scope**]. Users may designate the *PoH AMPC* system as their Recovery Agent to recover their World ID. In the future, other Recovery Agents are expected to be available. ## High level overview @@ -90,13 +90,14 @@ Diagram of components for the World ID 4.0 Protocol. 2. Similarly, a **Relying Party Registry** is introduced. This registry contains a list of authorized Relying Parties with their accompanying authorized public keys. The registry permits RPs to authenticate requests for proofs to Authenticators. 3. The multi-party set of **OPRF Nodes** is introduced. This set of nodes are now responsible for generating the nullifiers that users present to RPs to prove uniqueness. The nullifiers are generated through a *Verified Threshold* *Oblivious Pseudorandom Function* (vOPRF) with participation of the OPRF nodes. Nodes verify requests for nullifiers are properly validated by both RPs and users (see *Uniqueness Proofs*), and only then will generate the required output to compute the user’s nullifier. The users then construct the final nullifier and prove its computation in the proof they present to RPs. 1. A multi-party OPRF is necessary because it prevents nullifiers from being guessable, i.e. nullifiers are deterministic but appear random (recall that PRF outputs under a uniformly random key are computationally indistinguishable from a uniformly random function). This could theoretically be accomplished with a regular hash function, but then nullifiers could be brute forced by computing the hash for all possible `leafIndex`es (which are public on-chain). To prevent this, secret entropy is required (in World ID ≤ 3.0, the user provided this entropy). Since this is not available anymore, the entropy now comes from the OPRF nodes. - 2. Additionally, to prevent brute forcing even with involvement of OPRF nodes, OPRF nodes require authentication before computing each hash. They authenticate the user through a ZKP that proves knowledge of an Authenticator secret key authorized in the `WorldIDRegistry` for the particular `leafIndex` for which they are generating a nullifier. + 2. Additionally, to prevent brute forcing even with involvement of OPRF nodes, OPRF nodes require authentication before computing each hash. They authenticate the user through a ZKP that proves knowledge of an Authenticator secret key authorized in the `WorldIDRegistry` for the particular `leafIndex` for which they are generating a nullifier. 3. Importantly, the OPRF nodes compute the keyed-hash function $H_k(x')$ on a blinded input, hence they cannot learn which user is actually performing a request. Furthermore, the OPRF nodes output a proof that attests to the proper computation of $H_k$ given a committed $k_{pk}$, so neither users nor RPs need to blindly trust the OPRF nodes. 4. Similar to how OPRF Nodes are used to generate the nullifiers presented to RPs, these nodes also generate a blinding factor for each credential so there cannot be correlation of World IDs from malicious issuers. 5. More information on the OPRF Nodes can be found in the paper: *“[A Nullifier Protocol based on a Verifiable, Threshold OPRF](https://github.com/TaceoLabs/oprf-service/blob/main/docs/oprf.pdf)”*. - 6. Details about the nature, number, and diversity requirements of OPRF nodes must be established before the production network is live. + 6. Details about the nature, number, and diversity requirements of OPRF nodes must be established before the production network is live. 4. Protocol differences at a glance: - + + | | **World ID ≤3.0** | **World ID 4.0 (2025)** | | --- | --- | --- | | What is a World ID? | A secret. | An entry in public registry. | @@ -146,11 +147,12 @@ RP ->> RP: Verify nullifier uniqueness ``` - The nullifier is computed by the OPRF Nodes. Computing it requires output from a threshold number of nodes to be valid. - - Importantly, the input to the OPRF Nodes is blinded so that no OPRF node can see the raw `leafIndex` (i.e. OPRF nodes only know that the request is from an authorized authenticator). - - Importantly, the nullifier is credential *independent*, so the action can only be performed once regardless of which credentials are available at the time. - - Further information on how the nullifier is computed can be found in the [TACEO OPRF Whitepaper](https://github.com/TaceoLabs/nullifier-oracle-service/blob/main/docs/oprf.pdf). + - Importantly, the input to the OPRF Nodes is blinded so that no OPRF node can see the raw `leafIndex` (i.e. OPRF nodes only know that the request is from an authorized authenticator). + - Importantly, the nullifier is credential *independent*, so the action can only be performed once regardless of which credentials are available at the time. + - Further information on how the nullifier is computed can be found in the [TACEO OPRF Whitepaper](https://github.com/TaceoLabs/nullifier-oracle-service/blob/main/docs/oprf.pdf). - Nullifiers have the following properties, which in combination make them amenable for use by an RP to enforce anonymous per-action uniqueness: - + + | **Property** | **Description** | | --- | --- | | Deterministic | Given the same context (`leafIndex` [blinded], `rpId`, `action`), the nullifier is always the same. Assuming honest behavior of OPRF nodes never rotating their base key. *Note that the credential is intentionally not included in this context. This means that the action can be performed only once, regardless of which credentials are available at the time.* | @@ -159,46 +161,45 @@ RP ->> RP: Verify nullifier uniqueness | Anonymous | A nullifier hides which user generated it. To preserve anonymity, each nullifier must only be used once (otherwise repeated use makes it pseudonymous). This is the responsibility of Authenticators. | | Unlinkable | For any two nullifiers with different contexts, the probability that an adversary can correctly distinguish whether they were derived from the same user is at most negligibly better than random guessing. | | Pre-image resistance | For any given nullifier, and knowing the public context (`rpId`, `action`), it is computationally infeasible to find the pre-image or the `leafIndex`. | - - The authenticator generates two types of different zero-knowledge proofs to be able to deliver a Uniqueness Proof to an RP, - - The query proof $\pi_1$ which proves to the OPRF Nodes that the request is properly authorized by the user. This ZKP proves the request is signed by a public key which is registered for the particularly provided blinded `leafIndex` in the `WorldIDRegistry`. - - A final Uniqueness Proof $\pi_2$ which ensures at least the following constraints: - - *The same constraints of the query proof are evaluated.* - - Correct OPRF evaluation on `leafIndex`, i.e. the generated nullifier is correct for the committed public keys from each OPRF node. - - Request is signed by a public key that is registered for the `leafIndex` in the `WorldIDRegistry` (user authentication). - - The Credential was issued for this World ID, i.e. the Credential’s `sub` matches the blinded `leafIndex` of the user. - - The Credential used in the proof is signed by the Issuer (through the committed key in the `CredentialSchemaIssuerRegistry`). - - Credential is not expired. - - Credential meets the minimum genesis_issued_at constraint provided by the RP. - - Signal and nonce provided by the RP as public inputs are committed. - - *Potential future constraints may include: integrity attestation of device, enforcing the expiration of actions, credential specific checks, etc.* + - The query proof $\pi_1$ which proves to the OPRF Nodes that the request is properly authorized by the user. This ZKP proves the request is signed by a public key which is registered for the particularly provided blinded `leafIndex` in the `WorldIDRegistry`. + - A final Uniqueness Proof $\pi_2$ which ensures at least the following constraints: + - *The same constraints of the query proof are evaluated.* + - Correct OPRF evaluation on `leafIndex`, i.e. the generated nullifier is correct for the committed public keys from each OPRF node. + - Request is signed by a public key that is registered for the `leafIndex` in the `WorldIDRegistry` (user authentication). + - The Credential was issued for this World ID, i.e. the Credential’s `sub` matches the blinded `leafIndex` of the user. + - The Credential used in the proof is signed by the Issuer (through the committed key in the `CredentialSchemaIssuerRegistry`). + - Credential is not expired. + - Credential meets the minimum genesis_issued_at constraint provided by the RP. + - Signal and nonce provided by the RP as public inputs are committed. + - *Potential future constraints may include: integrity attestation of device, enforcing the expiration of actions, credential specific checks, etc.* - **Oblivious Nullifier Pool**. The Oblivious Nullifier Pool is a separate service which offers *Private Intersection Retrieval* and keeps track of used nullifiers. Its function is simply to keep a flat list of used nullifiers such that an authenticator can query if a nullifier has been used before sharing it (and the related $\pi_2$) with an RP if it has been used before. The list is flat (as the nullifier is already unique per-RP-per-action-per-user) relying on the collision-resistance property of the hash function used in the Protocol. - - This system ensures that nullifiers can’t be misused to create long running identifiers. As their name suggests, a nullifier is one-time use. - - The term *oblivious* is used to refer to the fact that this map is queried in a way where the servers serving such requests cannot learn which records where accessed and hence be able to compromise the user’s privacy. - - The main limitation of the nullifier pool is performance at scale. One option is to shard the pool, making trade-offs of anonymity set size vs. performance. This is still in research. - - Initially, this pool will only be used for actions that have a running period longer than a predefined threshold. This is to solve for scaling issues as this system grows. + - This system ensures that nullifiers can’t be misused to create long running identifiers. As their name suggests, a nullifier is one-time use. + - The term *oblivious* is used to refer to the fact that this map is queried in a way where the servers serving such requests cannot learn which records where accessed and hence be able to compromise the user’s privacy. + - The main limitation of the nullifier pool is performance at scale. One option is to shard the pool, making trade-offs of anonymity set size vs. performance. This is still in research. + - Initially, this pool will only be used for actions that have a running period longer than a predefined threshold. This is to solve for scaling issues as this system grows. - **Blinded subjects**. To prevent correlation of users even among issuers, or in case of leaked credentials, the subjects of the credentials are blinded. - - When requesting a new credential from an issuer, the user generates a blinding factor using the OPRF nodes, $\texttt{subjectBlindingFactor}=H_k(\texttt{issuerSchemaId} \mid\mid \texttt{leafIndex})$. - - The user then hashes the blinding factor with their `leafIndex` to compute the `sub` claim of the credential. This value is what issuers include in the credential. - - When a proof is presented, the `subjectBlindingFactor` is used within the Uniqueness Proof circuit to ensure the credential is issued to the right user. The blinding factor acts as entropy to prevent correlation, but the right `leafIndex` as provided in the circuit input must match correctly. + - When requesting a new credential from an issuer, the user generates a blinding factor using the OPRF nodes, $\texttt{subjectBlindingFactor}=H_k(\texttt{issuerSchemaId} \mid\mid \texttt{leafIndex})$. + - The user then hashes the blinding factor with their `leafIndex` to compute the `sub` claim of the credential. This value is what issuers include in the credential. + - When a proof is presented, the `subjectBlindingFactor` is used within the Uniqueness Proof circuit to ensure the credential is issued to the right user. The blinding factor acts as entropy to prevent correlation, but the right `leafIndex` as provided in the circuit input must match correctly. ### Registries - **World ID Registry** - - Each user can grant access to multiple different keys to interact with their World ID. The Authenticator proves control inside a ZKP to prevent long-lived identifiers. - - In order to not leak the user, this needs to be in some structure that allows inclusion proofs. This is accomplished with [Incremental Merkle Trees](https://github.com/zk-kit/zk-kit.solidity/blob/main/packages/imt/contracts/BinaryIMT.sol). - - Each Authenticator registers two keys in the registry. This is done to enable performant operations both on-chain and on zero-knowledge circuits. - - An on-chain key which is an elliptic curve key on the `secp256k1` curve is used to authorize on-chain operations on the contract (e.g. adding an authenticator, removing an authenticator, etc.). The public key is simply represented as an Ethereum address. - - An off-chain key which is an elliptic curve key on the `BabyJubJub` curve is used to sign requests for zero-knowledge proofs. The public key (represented as a curve point) is emitted on-chain and committed to in the contract. + - Each user can grant access to multiple different keys to interact with their World ID. The Authenticator proves control inside a ZKP to prevent long-lived identifiers. + - In order to not leak the user, this needs to be in some structure that allows inclusion proofs. This is accomplished with [Incremental Merkle Trees](https://github.com/zk-kit/zk-kit.solidity/blob/main/packages/imt/contracts/BinaryIMT.sol). + - Each Authenticator registers two keys in the registry. This is done to enable performant operations both on-chain and on zero-knowledge circuits. + - An on-chain key which is an elliptic curve key on the `secp256k1` curve is used to authorize on-chain operations on the contract (e.g. adding an authenticator, removing an authenticator, etc.). The public key is simply represented as an Ethereum address. + - An off-chain key which is an elliptic curve key on the `BabyJubJub` curve is used to sign requests for zero-knowledge proofs. The public key (represented as a curve point) is emitted on-chain and committed to in the contract. - **Relying Party Registry** - - Each RP needs to commit to their authorized public key on the public registry, such that this can be verified in the request proof $\pi_1$ by each queried OPRF node. - - Registering an RP is a public action that anyone can take, but this requires paying a one-time registration fee (see *Registration Fees* below). - - In order to allow for decentralized application creation and registration, the RP Registry will be extended and restrictions further lifted in the future, but for this initial version the following applies: - - At launch, only one authorized key is allowed per RP. This will be extended in the future. + - Each RP needs to commit to their authorized public key on the public registry, such that this can be verified in the request proof $\pi_1$ by each queried OPRF node. + - Registering an RP is a public action that anyone can take, but this requires paying a one-time registration fee (see *Registration Fees* below). + - In order to allow for decentralized application creation and registration, the RP Registry will be extended and restrictions further lifted in the future, but for this initial version the following applies: + - At launch, only one authorized key is allowed per RP. This will be extended in the future. - **Credential Schema Issuer Registry** - - It's a simple registry where Issuers register for each of their credential types a schema and an authorized signatory and get issued an `issuerSchemaId`. This ID represents the combination of an (issuer, schema). For example: (Tools For Humanity, Orb credential). - - The `issuerSchemaId` is included in the credential and is verified as part of all Proofs. When generating and verifying proofs, the signature of a credential is verified against the public key registered in the contract. - - Registering an Issuer Schema also requires paying a one-time registration fee (see *Registration Fees* below). + - It's a simple registry where Issuers register for each of their credential types a schema and an authorized signatory and get issued an `issuerSchemaId`. This ID represents the combination of an (issuer, schema). For example: (Tools For Humanity, Orb credential). + - The `issuerSchemaId` is included in the credential and is verified as part of all Proofs. When generating and verifying proofs, the signature of a credential is verified against the public key registered in the contract. + - Registering an Issuer Schema also requires paying a one-time registration fee (see *Registration Fees* below). ### Registration Fees @@ -207,7 +208,6 @@ Both the Relying Party Registry and the Credential Schema Issuer Registry charge **Why the fee exists.** Registering an RP or an Issuer Schema triggers the initialization of an OPRF key via a multi-round distributed key generation ceremony across the OPRF Nodes. This is a computationally expensive operation with real infrastructure cost. The registration fee is sized to cover the cost of OPRF key generation and storage for at least approximately one year. **How it works.** - - The fee is paid in a configurable ERC-20 token via `safeTransferFrom` at the time of registration, before OPRF key generation begins. **Future: per-request fees.** The registration fee described here covers only the one-time cost of onboarding. A separate per-request fee — enforced by OPRF Nodes as a proof-of-payment requirement during nullifier generation — may be introduced in a future Protocol release (4.1 or 4.2). See *Future Proofing Notes* for details. @@ -275,7 +275,6 @@ rp->>rp: verify proof (checking sessionId == C' in verifier contract) **Recovering `r` for subsequent Session Proofs.** The OPRF is deterministic: the same input and key always produce the same output. This means `r` can be re-derived at any time by calling the OPRF nodes with the original `oprf_seed` (stored in `sessionId`). Caching `r` is an optimization, not a requirement. The OPRF call to derive `r` and the OPRF call to derive the nullifier can be made in parallel. **Session Nullifiers** - - A [`sessionNullifier`](https://docs.rs/world-id-primitives/latest/world_id_primitives/session/struct.SessionNullifier.html) is used for verifying Session Proofs. It must be passed to the verification contract. Internally, the [`sessionNullifier`](https://docs.rs/world-id-primitives/latest/world_id_primitives/session/struct.SessionNullifier.html) implements custom encoding on the Authenticator and on the `WorldIDVerifier` contract. - The raison d'être is simply to allow usage of the same ZK circuit as for Uniqueness Proofs. Reducing the number of circuits is currently a priority because of the size of the circuits needed to be bundled in Authenticator clients. As World ID moves to a different proving system, this type will no longer be required. - Session Proofs use a randomized `action` as circuit input. This randomized `action` ensures the circuit's nullifier output is unique per proof, preserving the one-time use property. It is verified internally within the circuit. It does not affect `r` derivation. @@ -284,16 +283,16 @@ rp->>rp: verify proof (checking sessionId == C' in verifier contract) - A Uniqueness Proof request may include an existing `sessionId`. The proof then carries the session's commitment `C` as its `id_commitment` public signal, proving in-circuit that the session and the nullifier belong to the same World ID. - The Authenticator requires the cached `r` for this; re-deriving `r` is only possible through a session-type request. -- Verifiers MUST check the proof against the session's commitment. A proof whose `id_commitment` public signal is `0` is valid but unbound. On-chain, the dedicated `verifyWithSession()` entry point does this (it rejects `sessionId == 0`). The convenience `verify()` entry point pins the signal to `0` and rejects bound proofs, so binding is explicit in both directions. +- Verifiers MUST check the proof against the session's commitment — with the signal at `0` the proof is valid but unbound. On-chain, the dedicated `verifyWithSession()` entry point does this (it rejects `sessionId == 0`); the convenience `verify()` entry point pins the signal to `0` and rejects bound proofs, so binding is explicit in both directions. - Binding one `sessionId` to Uniqueness Proofs under different actions intentionally links those actions to the same World ID; Authenticators MUST clearly surface this to users. ### Web-based Authenticator Provider -To allow for an improved user experience, a reference browser-based Authenticator provider is being introduced. This app provides (currently limited) World ID functionality but without leaving the browser. +To allow for an improved user experience, a reference browser-based Authenticator provider is being introduced. This app provides (currently limited) World ID functionality but without leaving the browser. 1. At a high-level, it allows **usage** of a World ID. The user can generate proofs in their browser, and this is particularly useful for when working on other devices (such as desktop) or on non-native apps. 2. Whenever an RP requires a user’s World ID proof, they can simply redirect the user to the web app (handled automatically by common SDKs like [ID Kit](https://github.com/worldcoin/idkit)). The user authenticates with their passkey, generates the proof in their browser and passes it back to the RP. -3. Further documentation on the architecture of the reference web-based Authenticator provider will be published in the repository. +3. Further documentation on the architecture of the reference web-based Authenticator provider will be published in the https://github.com/worldcoin/web-authenticator repository. 4. **Credential Enrollment** will not be supported in the initial release, but this may be introduced in the future. ## Migration Considerations @@ -304,7 +303,8 @@ At a high level, every user and RP will need to migrate to the new Protocol. Det - The Protocol, via the Oblivious Nullifier Pool enforces that nullifiers cannot be generated more than once (as long as authenticators are properly implemented), which prevents long running user tracking, increasing the privacy from the previous protocol version. - In adversarial scenarios, these are the most relevant privacy considerations, - + + | Attack scenario | **World ID ≤3.0** | **World ID 4.0 (2025)** | | --- | --- | --- | | Compromised user’s secret | ⚠️ Potentially reveals all past activity if the attacker knows the public app IDs and actions. | ✅ Cannot reveal past activity on its own | @@ -326,6 +326,7 @@ At a high level, every user and RP will need to migrate to the new Protocol. Det - **Authenticator Risk**. Aside from having access to the user’s credentials, an Authenticator must learn of a user’s raw `leafIndex` to be able to generate Proofs. A malicious Authenticator can misuse this to track the user, even though that tracking cannot be correlated to nullifiers provided to RPs on its own. Different strategies to mitigate Authenticator risk are being explored. - **Recovery Agent Risk**. Should a user designate a Recovery Agent, this entity has a special permission that allows it to gain access to the user’s World ID, which could be misused. Beyond the explicit risk of a malicious Recovery Agent compromising a user's World ID, users need to consider the different risks associated with different Recovery Agents based on how they perform authentication. + ## Future Proofing Notes (World ID 4.x future releases and beyond) This is not a comprehensive list, but it outlines general topics that may be the target of upcoming Protocol releases which are not currently covered on this release. From 74d15fe2f4e7be014247c2963772690f3828e629 Mon Sep 17 00:00:00 2001 From: kilianglas Date: Mon, 13 Jul 2026 20:04:07 +0200 Subject: [PATCH 11/36] docs: reword session binding verifier requirement --- docs/world-id-4-specs/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/world-id-4-specs/README.md b/docs/world-id-4-specs/README.md index f40509f36..ea5857234 100644 --- a/docs/world-id-4-specs/README.md +++ b/docs/world-id-4-specs/README.md @@ -283,7 +283,7 @@ rp->>rp: verify proof (checking sessionId == C' in verifier contract) - A Uniqueness Proof request may include an existing `sessionId`. The proof then carries the session's commitment `C` as its `id_commitment` public signal, proving in-circuit that the session and the nullifier belong to the same World ID. - The Authenticator requires the cached `r` for this; re-deriving `r` is only possible through a session-type request. -- Verifiers MUST check the proof against the session's commitment — with the signal at `0` the proof is valid but unbound. On-chain, the dedicated `verifyWithSession()` entry point does this (it rejects `sessionId == 0`); the convenience `verify()` entry point pins the signal to `0` and rejects bound proofs, so binding is explicit in both directions. +- Verifiers MUST check the proof against the session's commitment. With the session commitment set to `0` the proof is valid but unbound. On-chain, the dedicated `verifyWithSession()` entry point does this (it rejects `sessionId == 0`). The convenience `verify()` entry point pins the signal to `0` and rejects bound proofs, so binding is explicit in both directions. - Binding one `sessionId` to Uniqueness Proofs under different actions intentionally links those actions to the same World ID; Authenticators MUST clearly surface this to users. ### Web-based Authenticator Provider From b594803c925278be6311a6292b9e5f46b27d2d06 Mon Sep 17 00:00:00 2001 From: kilianglas Date: Tue, 14 Jul 2026 15:18:53 +0200 Subject: [PATCH 12/36] refactor!: model session creation through SessionRef Replace the dedicated create-session proof type with a three-state session reference so request semantics are explicit on the wire. Co-authored-by: Cursor --- crates/authenticator/src/prove.rs | 64 ++-- crates/core/tests/generate_proof.rs | 7 +- crates/primitives/src/lib.rs | 2 +- crates/primitives/src/request/mod.rs | 341 +++++++++++++----- crates/primitives/src/session.rs | 226 ++++++++++++ crates/proof/src/oprf_query.rs | 2 +- docs/world-id-4-specs/README.md | 4 +- .../src/bin/world-id-dev-client-rp.rs | 6 +- tools/generate-solidity-fixtures/src/main.rs | 8 +- 9 files changed, 521 insertions(+), 139 deletions(-) diff --git a/crates/authenticator/src/prove.rs b/crates/authenticator/src/prove.rs index b180836c0..6aef15ae2 100644 --- a/crates/authenticator/src/prove.rs +++ b/crates/authenticator/src/prove.rs @@ -1,7 +1,7 @@ use secrecy::ExposeSecret; use world_id_primitives::{ Credential, FieldElement, ProofRequest, ProofResponse, ProofType, RequestItem, ResponseItem, - SessionId, SessionNullifier, ZeroKnowledgeProof, + SessionId, SessionNullifier, SessionRef, ZeroKnowledgeProof, }; use world_id_proof::{ AuthenticatorProofInput, FullOprfOutput, OprfEntrypoint, ProofCompression, @@ -212,7 +212,7 @@ impl Authenticator { return Err(AuthenticatorError::PrimitiveError( world_id_primitives::PrimitiveError::InvalidInput { attribute: "proof_type".to_string(), - reason: "must be create_session or session".to_string(), + reason: "session ids can only be built for session proof requests".to_string(), }, )); } @@ -220,8 +220,8 @@ impl Authenticator { let mut rng = rand::rngs::OsRng; let oprf_seed = match proof_request.session_id { - Some(session_id) => session_id.oprf_seed, - None => SessionId::generate_oprf_seed(&mut rng), + SessionRef::Existing(session_id) => session_id.oprf_seed, + SessionRef::Create | SessionRef::None => SessionId::generate_oprf_seed(&mut rng), }; let resolved_session_id_r_seed = match session_id_r_seed { @@ -244,7 +244,7 @@ impl Authenticator { let session_id = SessionId::from_r_seed(self.leaf_index(), resolved_session_id_r_seed, oprf_seed)?; - if let Some(request_session_id) = proof_request.session_id { + if let SessionRef::Existing(request_session_id) = proof_request.session_id { self.validate_cached_session_r_seed(resolved_session_id_r_seed, request_session_id)?; } @@ -307,39 +307,45 @@ impl Authenticator { .ok_or(AuthenticatorError::UnfullfilableRequest)?; // 2. Resolve session seed - let (resolved_session_id, resolved_session_seed) = match proof_request.proof_type { - ProofType::Uniqueness => match proof_request.session_id { + let (resolved_session_id, resolved_session_seed) = + match (proof_request.proof_type, proof_request.session_id) { + (ProofType::Uniqueness, SessionRef::None) => (None, None), // Bind the proof to the existing session. Requires the cached `r`. - Some(session_id) => { + (ProofType::Uniqueness, SessionRef::Existing(session_id)) => { let seed = session_id_r_seed.ok_or(AuthenticatorError::SessionSeedRequired)?; self.validate_cached_session_r_seed(seed, session_id)?; (Some(session_id), Some(seed)) } - None => (None, None), - }, - ProofType::CreateSession => { - let (session_id, seed) = self - .build_session_id(proof_request, None, account_inclusion_proof) - .await?; - (Some(session_id), Some(seed)) - } - ProofType::Session => { - let session_id = proof_request - .session_id - .expect("session proof must have session_id"); - if let Some(seed) = session_id_r_seed { - self.validate_cached_session_r_seed(seed, session_id)?; - (Some(session_id), Some(seed)) - } else { - // Re-derive the same `r` from the existing session's `oprf_seed` when the - // caller did not provide a cached seed. - let (_session_id, seed) = self + (ProofType::Session, SessionRef::Create) => { + let (session_id, seed) = self .build_session_id(proof_request, None, account_inclusion_proof) .await?; (Some(session_id), Some(seed)) } - } - }; + (ProofType::Session, SessionRef::Existing(session_id)) => { + if let Some(seed) = session_id_r_seed { + self.validate_cached_session_r_seed(seed, session_id)?; + (Some(session_id), Some(seed)) + } else { + // Re-derive the same `r` from the existing session's `oprf_seed` when the + // caller did not provide a cached seed. + let (_session_id, seed) = self + .build_session_id(proof_request, None, account_inclusion_proof) + .await?; + (Some(session_id), Some(seed)) + } + } + // Rejected by validate_proof_type() above; kept explicit to stay exhaustive. + (ProofType::Uniqueness, SessionRef::Create) + | (ProofType::Session, SessionRef::None) => { + return Err(AuthenticatorError::PrimitiveError( + world_id_primitives::PrimitiveError::InvalidInput { + attribute: "session_id".to_string(), + reason: "invalid proof_type/session_id combination".to_string(), + }, + )); + } + }; let nullifier_material = self .zk_artifact_source diff --git a/crates/core/tests/generate_proof.rs b/crates/core/tests/generate_proof.rs index 0cb726390..4d28e1b75 100644 --- a/crates/core/tests/generate_proof.rs +++ b/crates/core/tests/generate_proof.rs @@ -31,7 +31,8 @@ use world_id_gateway::{ spawn_gateway_for_tests, }; use world_id_primitives::{ - Config, FieldElement, ServiceEndpoint, SessionId, TREE_DEPTH, merkle::AccountInclusionProof, + Config, FieldElement, ServiceEndpoint, SessionId, SessionRef, TREE_DEPTH, + merkle::AccountInclusionProof, }; use world_id_test_utils::{ anvil::WorldIDVerifierV2, @@ -305,7 +306,7 @@ async fn e2e_authenticator_generate_proof() -> Result<()> { expires_at: rp_fixture.expiration_timestamp, rp_id: rp_fixture.world_rp_id, oprf_key_id: rp_fixture.oprf_key_id, - session_id: None, + session_id: SessionRef::None, action: Some(rp_fixture.action.into()), signature: rp_fixture.signature, nonce: rp_fixture.nonce.into(), @@ -375,7 +376,7 @@ async fn e2e_authenticator_generate_proof() -> Result<()> { SessionId::generate_oprf_seed(&mut rng), )?; let bound_request = ProofRequest { - session_id: Some(session_id), + session_id: SessionRef::Existing(session_id), ..proof_request.clone() }; diff --git a/crates/primitives/src/lib.rs b/crates/primitives/src/lib.rs index ac57cacd8..a6d2b6131 100644 --- a/crates/primitives/src/lib.rs +++ b/crates/primitives/src/lib.rs @@ -53,7 +53,7 @@ pub use nullifier::Nullifier; /// Contains types relevant for Session Proofs. mod session; -pub use session::{SessionFeType, SessionFieldElement, SessionId, SessionNullifier}; +pub use session::{SessionFeType, SessionFieldElement, SessionId, SessionNullifier, SessionRef}; /// Contains the quintessential zero-knowledge proof type. pub mod proof; diff --git a/crates/primitives/src/request/mod.rs b/crates/primitives/src/request/mod.rs index 9638354eb..3f8b591f9 100644 --- a/crates/primitives/src/request/mod.rs +++ b/crates/primitives/src/request/mod.rs @@ -6,8 +6,8 @@ mod constraints; pub use constraints::{ConstraintExpr, ConstraintKind, ConstraintNode, MAX_CONSTRAINT_NODES}; use crate::{ - FieldElement, Nullifier, PrimitiveError, SessionId, SessionNullifier, ZeroKnowledgeProof, - rp::RpId, + FieldElement, Nullifier, PrimitiveError, SessionId, SessionNullifier, SessionRef, + ZeroKnowledgeProof, rp::RpId, }; use serde::{Deserialize, Serialize, de::Error as _}; use std::collections::HashSet; @@ -47,10 +47,6 @@ impl<'de> serde::Deserialize<'de> for RequestVersion { } /// The high-level proof flow requested by an RP. -/// -/// Explicit discriminants reserve a stable one-byte protocol encoding for future -/// signed request payloads. JSON serialization remains the snake_case variant name. -#[repr(u8)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ProofType { @@ -59,11 +55,10 @@ pub enum ProofType { /// May carry a `session_id` to bind the proof to an existing session, /// see [`ProofRequest::binds_session`]. #[default] - Uniqueness = 0x00, - /// Create a new RP-scoped `session_id` and prove it in the same response. - CreateSession = 0x01, - /// Prove ownership of an existing RP-scoped `session_id`. - Session = 0x02, + Uniqueness, + /// Prove an RP-scoped session — either minting a fresh one + /// (`session_id: "create"`) or an existing one (`session_id: "session_"`). + Session, } impl ProofType { @@ -76,7 +71,7 @@ impl ProofType { /// Returns true for proof flows that produce a session proof response item. #[must_use] pub const fn is_session(&self) -> bool { - matches!(self, Self::CreateSession | Self::Session) + matches!(self, Self::Session) } } @@ -104,12 +99,14 @@ pub struct ProofRequest { pub oprf_key_id: OprfKeyId, /// Session identifier that links proofs for the same user/RP pair across requests. /// - /// Required for [`ProofType::Session`], forbidden for [`ProofType::CreateSession`], - /// optional for [`ProofType::Uniqueness`] to bind the proof to an existing session - /// (see [`Self::binds_session`]). + /// Three states: absent/`null` (no session), `"create"` (mint a fresh session, + /// [`ProofType::Session`] only), or an existing `"session_"`-prefixed id — + /// required for [`ProofType::Session`], optional for [`ProofType::Uniqueness`] + /// to bind the proof to that session (see [`Self::binds_session`]). /// The proof will only be valid if the session ID is meant for this context and /// this particular World ID holder. - pub session_id: Option, + #[serde(default)] + pub session_id: SessionRef, /// An RP-defined context that scopes what the user is proving uniqueness on. /// /// This parameter expects a field element. When dealing with strings or bytes, @@ -465,39 +462,29 @@ impl ProofRequest { /// Returns [`PrimitiveError::InvalidInput`] when the request has an invalid /// combination of `proof_type`, `session_id`, and `action`. pub fn validate_proof_type(&self) -> Result<(), PrimitiveError> { - match self.proof_type { - // `session_id` is allowed for session binding, see `Self::binds_session` - ProofType::Uniqueness => {} - ProofType::CreateSession => { - if self.session_id.is_some() { - return Err(PrimitiveError::InvalidInput { - attribute: "session_id".to_string(), - reason: "must be omitted when creating a session".to_string(), - }); - } - if self.action.is_some() { - return Err(PrimitiveError::InvalidInput { - attribute: "action".to_string(), - reason: "must be omitted for session proofs".to_string(), - }); - } - } - ProofType::Session => { - if self.session_id.is_none() { - return Err(PrimitiveError::InvalidInput { - attribute: "session_id".to_string(), - reason: "must be provided when proving a session".to_string(), - }); - } + match (self.proof_type, self.session_id) { + // No session, or bound to an existing one — see `Self::binds_session` + (ProofType::Uniqueness, SessionRef::None | SessionRef::Existing(_)) => Ok(()), + // Enabled in a future protocol change together with signed session modes. + (ProofType::Uniqueness, SessionRef::Create) => Err(PrimitiveError::InvalidInput { + attribute: "session_id".to_string(), + reason: "session creation is not yet supported for uniqueness proofs".to_string(), + }), + (ProofType::Session, SessionRef::None) => Err(PrimitiveError::InvalidInput { + attribute: "session_id".to_string(), + reason: "must be \"create\" or an existing session id for session proofs" + .to_string(), + }), + (ProofType::Session, SessionRef::Create | SessionRef::Existing(_)) => { if self.action.is_some() { return Err(PrimitiveError::InvalidInput { attribute: "action".to_string(), reason: "must be omitted for session proofs".to_string(), }); } + Ok(()) } } - Ok(()) } /// Returns true if this request produces a Session proof. @@ -514,13 +501,7 @@ impl ProofRequest { /// but unbound. #[must_use] pub const fn binds_session(&self) -> bool { - self.proof_type.is_uniqueness() && self.session_id.is_some() - } - - /// Returns true if this request creates a new session. - #[must_use] - pub const fn is_create_session(&self) -> bool { - matches!(self.proof_type, ProofType::CreateSession) + self.proof_type.is_uniqueness() && self.session_id.existing().is_some() } /// Validates the structural integrity of the constraint expression. @@ -569,26 +550,34 @@ impl ProofRequest { return Err(ValidationError::ProofGenerationFailed(error.clone())); } - match self.proof_type { - ProofType::Uniqueness => { - if self.binds_session() { - if self.session_id != response.session_id { - return Err(ValidationError::SessionIdMismatch); - } - } else if response.session_id.is_some() { + match (self.proof_type, self.session_id) { + (ProofType::Uniqueness, SessionRef::Existing(session_id)) => { + if response.session_id != Some(session_id) { + return Err(ValidationError::SessionIdMismatch); + } + } + (ProofType::Uniqueness, _) => { + if response.session_id.is_some() { return Err(ValidationError::UnexpectedSessionId); } } - ProofType::CreateSession => { + (ProofType::Session, SessionRef::Create) => { + // No request-side id to compare — the freshly minted id must be present. if response.session_id.is_none() { return Err(ValidationError::MissingSessionId); } } - ProofType::Session => { - if self.session_id != response.session_id { + (ProofType::Session, SessionRef::Existing(session_id)) => { + if response.session_id != Some(session_id) { return Err(ValidationError::SessionIdMismatch); } } + // Rejected by validate_proof_type() above; kept explicit to stay exhaustive. + (ProofType::Session, SessionRef::None) => { + return Err(ValidationError::InvalidProofRequest( + "session proof without session_id".to_string(), + )); + } } // Validate response items correspond to request items and are unique. @@ -759,7 +748,7 @@ pub enum ValidationError { /// Session ID doesn't match between request and response #[error("Session ID doesn't match between request and response")] SessionIdMismatch, - /// Session ID missing from a create-session response. + /// Session ID missing from a session-create response. #[error("Session ID missing from session response")] MissingSessionId, /// Session ID present in a uniqueness response. @@ -977,7 +966,7 @@ mod tests { id: "test_request".into(), version: RequestVersion::V1, proof_type: ProofType::Uniqueness, - session_id: None, + session_id: SessionRef::None, action: Some(FieldElement::ZERO), created_at: 1_700_000_000, expires_at: 1_700_100_000, @@ -1018,7 +1007,7 @@ mod tests { id: "test".into(), version: RequestVersion::V1, proof_type: ProofType::Uniqueness, - session_id: None, + session_id: SessionRef::None, action: Some(FieldElement::ZERO), created_at: 1_700_000_000, expires_at: 1_700_100_000, @@ -1060,7 +1049,7 @@ mod tests { id: "req_1".into(), version: RequestVersion::V1, proof_type: ProofType::Uniqueness, - session_id: None, + session_id: SessionRef::None, action: Some(FieldElement::ZERO), created_at: 1_735_689_600, expires_at: 1_735_689_600, // 2025-01-01 @@ -1206,7 +1195,7 @@ mod tests { id: "req_2".into(), version: RequestVersion::V1, proof_type: ProofType::Uniqueness, - session_id: None, + session_id: SessionRef::None, action: Some(test_field_element(1)), created_at: 1_735_689_600, expires_at: 1_735_689_600, @@ -1274,7 +1263,7 @@ mod tests { id: "req_nodes_ok".into(), version: RequestVersion::V1, proof_type: ProofType::Uniqueness, - session_id: None, + session_id: SessionRef::None, action: Some(test_field_element(5)), created_at: 1_735_689_600, expires_at: 1_735_689_600, @@ -1417,7 +1406,7 @@ mod tests { id: "req_nodes_too_many".into(), version: RequestVersion::V1, proof_type: ProofType::Uniqueness, - session_id: None, + session_id: SessionRef::None, action: Some(test_field_element(1)), created_at: 1_735_689_600, expires_at: 1_735_689_600, @@ -1525,7 +1514,7 @@ mod tests { id: "req_18c0f7f03e7d".into(), version: RequestVersion::V1, proof_type: ProofType::Session, - session_id: Some(SessionId::default()), + session_id: SessionRef::Existing(SessionId::default()), action: None, created_at: 1_725_381_192, expires_at: 1_725_381_492, @@ -1569,7 +1558,7 @@ mod tests { id: "req_18c0f7f03e7d".into(), version: RequestVersion::V1, proof_type: ProofType::Uniqueness, - session_id: None, + session_id: SessionRef::None, action: Some(test_field_element(1)), created_at: 1_725_381_192, expires_at: 1_725_381_492, @@ -1626,7 +1615,7 @@ mod tests { id: "req_18c0f7f03e7d".into(), version: RequestVersion::V1, proof_type: ProofType::Uniqueness, - session_id: None, + session_id: SessionRef::None, action: Some(test_field_element(1)), created_at: 1_725_381_192, expires_at: 1_725_381_492, @@ -1703,7 +1692,7 @@ mod tests { id: "req_enum".into(), version: RequestVersion::V1, proof_type: ProofType::Uniqueness, - session_id: None, + session_id: SessionRef::None, action: Some(test_field_element(1)), created_at: 1_725_381_192, expires_at: 1_725_381_492, @@ -1877,7 +1866,7 @@ mod tests { id: "req_dup".into(), version: RequestVersion::V1, proof_type: ProofType::Uniqueness, - session_id: None, + session_id: SessionRef::None, action: Some(test_field_element(5)), created_at: 1_725_381_192, expires_at: 1_725_381_492, @@ -1920,7 +1909,7 @@ mod tests { id: "req_error".into(), version: RequestVersion::V1, proof_type: ProofType::Uniqueness, - session_id: None, + session_id: SessionRef::None, action: Some(FieldElement::ZERO), created_at: 1_735_689_600, expires_at: 1_735_689_600, @@ -1986,7 +1975,7 @@ mod tests { id: "req".into(), version: RequestVersion::V1, proof_type: ProofType::Uniqueness, - session_id: None, + session_id: SessionRef::None, action: Some(test_field_element(5)), created_at: 1_735_689_600, expires_at: 1_735_689_600, // 2025-01-01 00:00:00 UTC @@ -2034,7 +2023,7 @@ mod tests { id: "req".into(), version: RequestVersion::V1, proof_type: ProofType::Uniqueness, - session_id: None, + session_id: SessionRef::None, action: Some(test_field_element(1)), created_at: 1_735_689_600, expires_at: 1_735_689_600, // 2025-01-01 00:00:00 UTC @@ -2107,7 +2096,7 @@ mod tests { id: "req".into(), version: RequestVersion::V1, proof_type: ProofType::Uniqueness, - session_id: None, + session_id: SessionRef::None, action: Some(test_field_element(1)), created_at: 1_735_689_600, expires_at: 1_735_689_600, @@ -2174,7 +2163,7 @@ mod tests { id: "req".into(), version: RequestVersion::V1, proof_type: ProofType::Uniqueness, - session_id: None, + session_id: SessionRef::None, action: Some(test_field_element(1)), created_at: 1_735_689_600, expires_at: 1_735_689_600, @@ -2283,7 +2272,7 @@ mod tests { id: "req_expires_test".into(), version: RequestVersion::V1, proof_type: ProofType::Uniqueness, - session_id: None, + session_id: SessionRef::None, action: Some(test_field_element(1)), created_at: request_created_at, expires_at: request_created_at + 300, @@ -2410,7 +2399,7 @@ mod tests { id: "req_bound_uniqueness".into(), version: RequestVersion::V1, proof_type: ProofType::Uniqueness, - session_id: Some(test_session_id(1)), + session_id: SessionRef::Existing(test_session_id(1)), action: None, created_at: 1_735_689_600, expires_at: 1_735_689_900, @@ -2434,30 +2423,61 @@ mod tests { assert!(!uniqueness_with_session.is_session_proof()); let plain_uniqueness = ProofRequest { - session_id: None, + session_id: SessionRef::None, ..uniqueness_with_session.clone() }; assert!(plain_uniqueness.validate_proof_type().is_ok()); assert!(!plain_uniqueness.binds_session()); - let create_session_with_session = ProofRequest { - proof_type: ProofType::CreateSession, + // uniqueness + "create" is reserved for a future protocol change + let uniqueness_with_create = ProofRequest { + session_id: SessionRef::Create, ..uniqueness_with_session.clone() }; assert!(matches!( - create_session_with_session.validate_proof_type(), + uniqueness_with_create.validate_proof_type(), Err(PrimitiveError::InvalidInput { attribute, .. }) if attribute == "session_id" )); let session_without_session = ProofRequest { proof_type: ProofType::Session, - session_id: None, - ..uniqueness_with_session + session_id: SessionRef::None, + ..uniqueness_with_session.clone() }; assert!(matches!( session_without_session.validate_proof_type(), Err(PrimitiveError::InvalidInput { attribute, .. }) if attribute == "session_id" )); + + // session proofs accept both "create" and an existing session id + let session_create = ProofRequest { + proof_type: ProofType::Session, + session_id: SessionRef::Create, + ..uniqueness_with_session.clone() + }; + assert!(session_create.validate_proof_type().is_ok()); + assert!(session_create.is_session_proof()); + assert!(!session_create.binds_session()); + + let session_existing = ProofRequest { + proof_type: ProofType::Session, + ..uniqueness_with_session.clone() + }; + assert!(session_existing.validate_proof_type().is_ok()); + + // action is forbidden for both session sub-states + for session_id in [SessionRef::Create, SessionRef::Existing(test_session_id(1))] { + let session_with_action = ProofRequest { + proof_type: ProofType::Session, + session_id, + action: Some(FieldElement::ZERO), + ..uniqueness_with_session.clone() + }; + assert!(matches!( + session_with_action.validate_proof_type(), + Err(PrimitiveError::InvalidInput { attribute, .. }) if attribute == "action" + )); + } } #[test] @@ -2466,7 +2486,7 @@ mod tests { id: "req_bound".into(), version: RequestVersion::V1, proof_type: ProofType::Uniqueness, - session_id: Some(test_session_id(1)), + session_id: SessionRef::Existing(test_session_id(1)), action: Some(FieldElement::ZERO), created_at: 1_735_689_600, expires_at: 1_735_689_900, @@ -2493,6 +2513,119 @@ mod tests { assert!(parsed.binds_session()); } + #[test] + fn test_request_with_create_session_proof_type_fails_loudly() { + let request = ProofRequest { + id: "req_legacy".into(), + version: RequestVersion::V1, + proof_type: ProofType::Session, + session_id: SessionRef::Create, + action: None, + created_at: 1_735_689_600, + expires_at: 1_735_689_900, + rp_id: RpId::new(1), + oprf_key_id: OprfKeyId::new(uint!(1_U160)), + signature: test_signature(), + nonce: test_nonce(), + requests: vec![RequestItem { + identifier: "orb".into(), + issuer_schema_id: 1, + signal: None, + genesis_issued_at_min: None, + expires_at_min: None, + }], + constraints: None, + }; + + // the collapsed legacy proof type must be rejected at the parse boundary + let mut value: serde_json::Value = + serde_json::from_str(&request.to_json().unwrap()).unwrap(); + value["proof_type"] = "create_session".into(); + value["session_id"] = serde_json::Value::Null; + let err = ProofRequest::from_json(&value.to_string()).unwrap_err(); + assert!(err.to_string().contains("create_session")); + } + + #[test] + fn test_request_session_create_parses_and_validates() { + let request = ProofRequest { + id: "req_create".into(), + version: RequestVersion::V1, + proof_type: ProofType::Session, + session_id: SessionRef::Create, + action: None, + created_at: 1_735_689_600, + expires_at: 1_735_689_900, + rp_id: RpId::new(1), + oprf_key_id: OprfKeyId::new(uint!(1_U160)), + signature: test_signature(), + nonce: test_nonce(), + requests: vec![RequestItem { + identifier: "orb".into(), + issuer_schema_id: 1, + signal: None, + genesis_issued_at_min: None, + expires_at_min: None, + }], + constraints: None, + }; + + let json = request.to_json().unwrap(); + let value: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(value["session_id"], "create"); + + let parsed = ProofRequest::from_json(&json).unwrap(); + assert!(parsed.session_id.is_create()); + assert!(parsed.is_session_proof()); + assert!(!parsed.binds_session()); + + // session proofs without a session reference stay rejected + let mut without_session: serde_json::Value = serde_json::from_str(&json).unwrap(); + without_session["session_id"] = serde_json::Value::Null; + assert!(ProofRequest::from_json(&without_session.to_string()).is_err()); + + // uniqueness × "create" is rejected at the parse boundary + let mut uniqueness_create: serde_json::Value = serde_json::from_str(&json).unwrap(); + uniqueness_create["proof_type"] = "uniqueness".into(); + assert!(ProofRequest::from_json(&uniqueness_create.to_string()).is_err()); + } + + #[test] + fn test_request_absent_session_id_defaults_to_none() { + let request = ProofRequest { + id: "req_plain".into(), + version: RequestVersion::V1, + proof_type: ProofType::Uniqueness, + session_id: SessionRef::None, + action: Some(FieldElement::ZERO), + created_at: 1_735_689_600, + expires_at: 1_735_689_900, + rp_id: RpId::new(1), + oprf_key_id: OprfKeyId::new(uint!(1_U160)), + signature: test_signature(), + nonce: test_nonce(), + requests: vec![RequestItem { + identifier: "orb".into(), + issuer_schema_id: 1, + signal: None, + genesis_issued_at_min: None, + expires_at_min: None, + }], + constraints: None, + }; + + // None serializes as null (unchanged wire shape) ... + let json = request.to_json().unwrap(); + let mut value: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert!(value["session_id"].is_null()); + + // ... and an absent key also parses to None via #[serde(default)] + value.as_object_mut().unwrap().remove("session_id"); + let parsed = ProofRequest::from_json(&value.to_string()).unwrap(); + assert_eq!(parsed.session_id, SessionRef::None); + assert!(!parsed.binds_session()); + } + #[test] fn test_validate_response_bound_uniqueness_echoes_session_id() { let session_id = test_session_id(7); @@ -2500,7 +2633,7 @@ mod tests { id: "req_bound".into(), version: RequestVersion::V1, proof_type: ProofType::Uniqueness, - session_id: Some(session_id), + session_id: SessionRef::Existing(session_id), action: Some(FieldElement::ZERO), created_at: 1_735_689_600, expires_at: 1_735_689_900, @@ -2556,7 +2689,7 @@ mod tests { // plain uniqueness requests still reject any session id in the response let plain_request = ProofRequest { - session_id: None, + session_id: SessionRef::None, ..request }; assert!(matches!( @@ -2566,19 +2699,35 @@ mod tests { } #[test] - fn proof_type_protocol_encoding_is_stable() { - assert_eq!(ProofType::Uniqueness as u8, 0x00); - assert_eq!(ProofType::CreateSession as u8, 0x01); - assert_eq!(ProofType::Session as u8, 0x02); + fn proof_type_wire_encoding_is_stable() { + assert_eq!( + serde_json::to_string(&ProofType::Uniqueness).unwrap(), + "\"uniqueness\"" + ); + assert_eq!( + serde_json::to_string(&ProofType::Session).unwrap(), + "\"session\"" + ); + assert_eq!( + serde_json::from_str::("\"uniqueness\"").unwrap(), + ProofType::Uniqueness + ); + assert_eq!( + serde_json::from_str::("\"session\"").unwrap(), + ProofType::Session + ); + // the collapsed legacy variant must fail loudly + let err = serde_json::from_str::("\"create_session\"").unwrap_err(); + assert!(err.to_string().contains("create_session")); } #[test] - fn test_validate_response_accepts_create_session_response() { + fn test_validate_response_session_create_requires_minted_session_id() { let request = ProofRequest { id: "req_create_session".into(), version: RequestVersion::V1, - proof_type: ProofType::CreateSession, - session_id: None, + proof_type: ProofType::Session, + session_id: SessionRef::Create, action: None, created_at: 1_735_689_600, expires_at: 1_735_689_900, @@ -2628,7 +2777,7 @@ mod tests { id: "req_session".into(), version: RequestVersion::V1, proof_type: ProofType::Session, - session_id: Some(SessionId::default()), + session_id: SessionRef::Existing(SessionId::default()), action: None, created_at: 1_735_689_600, expires_at: 1_735_689_900, @@ -2674,7 +2823,7 @@ mod tests { id: "req_session".into(), version: RequestVersion::V1, proof_type: ProofType::Session, - session_id: Some(SessionId::default()), + session_id: SessionRef::Existing(SessionId::default()), action: None, created_at: 1_735_689_600, expires_at: 1_735_689_900, @@ -2723,7 +2872,7 @@ mod tests { id: "req_uniqueness".into(), version: RequestVersion::V1, proof_type: ProofType::Uniqueness, - session_id: None, + session_id: SessionRef::None, action: Some(test_field_element(42)), created_at: 1_735_689_600, expires_at: 1_735_689_900, diff --git a/crates/primitives/src/session.rs b/crates/primitives/src/session.rs index fd83ffa2b..fb03023bb 100644 --- a/crates/primitives/src/session.rs +++ b/crates/primitives/src/session.rs @@ -245,6 +245,135 @@ impl<'de> Deserialize<'de> for SessionId { } } +/// How a proof request refers to a session. +/// +/// Wire encoding (the request's `session_id` field): absent or `null` → [`Self::None`], +/// `"create"` → [`Self::Create`], a `"session_"`-prefixed id → [`Self::Existing`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum SessionRef { + /// No session involvement. + #[default] + None, + /// Mint a fresh session and prove it in the same response. + Create, + /// Refer to an existing session. + Existing(SessionId), +} + +impl SessionRef { + const CREATE_TOKEN: &str = "create"; + + /// Returns true if the request involves no session. + #[must_use] + pub const fn is_none(&self) -> bool { + matches!(self, Self::None) + } + + /// Returns true if the request asks to mint a fresh session. + #[must_use] + pub const fn is_create(&self) -> bool { + matches!(self, Self::Create) + } + + /// Returns the referenced existing session id, if any. + #[must_use] + pub const fn existing(&self) -> Option { + match self { + Self::Existing(id) => Some(*id), + _ => None, + } + } +} + +impl From for SessionRef { + fn from(id: SessionId) -> Self { + Self::Existing(id) + } +} + +impl Serialize for SessionRef { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::None => serializer.serialize_none(), + Self::Create => { + if serializer.is_human_readable() { + serializer.serialize_str(Self::CREATE_TOKEN) + } else { + // Binary: 6-byte token, cannot collide with the 64-byte `SessionId` encoding + serializer.serialize_bytes(Self::CREATE_TOKEN.as_bytes()) + } + } + Self::Existing(id) => id.serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for SessionRef { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct SessionRefVisitor; + + impl<'de> serde::de::Visitor<'de> for SessionRefVisitor { + type Value = SessionRef; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + write!( + formatter, + "null, \"{}\", or a '{}'-prefixed session id", + SessionRef::CREATE_TOKEN, + SessionId::JSON_PREFIX + ) + } + + fn visit_none(self) -> Result { + Ok(SessionRef::None) + } + + fn visit_unit(self) -> Result { + Ok(SessionRef::None) + } + + fn visit_some(self, deserializer: D2) -> Result + where + D2: Deserializer<'de>, + { + if deserializer.is_human_readable() { + let value = String::deserialize(deserializer)?; + if value == SessionRef::CREATE_TOKEN { + return Ok(SessionRef::Create); + } + let hex_str = value.strip_prefix(SessionId::JSON_PREFIX).ok_or_else(|| { + D2::Error::custom(format!( + "session_id must be \"{}\" or start with '{}'", + SessionRef::CREATE_TOKEN, + SessionId::JSON_PREFIX + )) + })?; + let bytes = hex::decode(hex_str).map_err(D2::Error::custom)?; + SessionId::from_compressed_bytes(&bytes) + .map(SessionRef::Existing) + .map_err(D2::Error::custom) + } else { + let bytes = Vec::::deserialize(deserializer)?; + if bytes == SessionRef::CREATE_TOKEN.as_bytes() { + return Ok(SessionRef::Create); + } + SessionId::from_compressed_bytes(&bytes) + .map(SessionRef::Existing) + .map_err(D2::Error::custom) + } + } + } + + deserializer.deserialize_option(SessionRefVisitor) + } +} + /// A session nullifier for World ID Session proofs. It is analogous to a request nonce, /// it **does NOT guarantee uniqueness of a World ID** as a `Nullifier` does. /// @@ -571,6 +700,103 @@ mod session_id_tests { } } +#[cfg(test)] +mod session_ref_tests { + use super::*; + use ruint::uint; + + fn test_session_id() -> SessionId { + let oprf_seed = U256::from(42u64) + | uint!(0x0100000000000000000000000000000000000000000000000000000000000000_U256); + SessionId::new( + FieldElement::from(1001u64), + FieldElement::try_from(oprf_seed).expect("test value fits in field"), + ) + .expect("valid session id") + } + + #[test] + fn test_default_is_none() { + assert_eq!(SessionRef::default(), SessionRef::None); + assert!(SessionRef::None.is_none()); + assert!(SessionRef::Create.is_create()); + assert_eq!( + SessionRef::Existing(test_session_id()).existing(), + Some(test_session_id()) + ); + assert_eq!( + SessionRef::from(test_session_id()).existing(), + Some(test_session_id()) + ); + } + + #[test] + fn test_deserialize_create_token() { + let parsed: SessionRef = serde_json::from_str("\"create\"").unwrap(); + assert_eq!(parsed, SessionRef::Create); + } + + #[test] + fn test_deserialize_existing_matches_session_id_parse() { + let id = test_session_id(); + let json = serde_json::to_string(&id).unwrap(); + let parsed: SessionRef = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, SessionRef::Existing(id)); + } + + #[test] + fn test_deserialize_null_is_none() { + let parsed: SessionRef = serde_json::from_str("null").unwrap(); + assert_eq!(parsed, SessionRef::None); + } + + #[test] + fn test_rejects_unknown_strings() { + for input in ["\"Create\"", "\"creat\"", "\"snil_00\"", "\"\""] { + let result = serde_json::from_str::(input); + let err = result.expect_err(input).to_string(); + assert!( + err.contains("create") || err.contains("session_"), + "error for {input} should name the accepted forms: {err}" + ); + } + } + + #[test] + fn test_json_roundtrip_all_states() { + let cases = [ + (SessionRef::None, "null"), + (SessionRef::Create, "\"create\""), + ]; + for (state, expected_json) in cases { + let json = serde_json::to_string(&state).unwrap(); + assert_eq!(json, expected_json); + let parsed: SessionRef = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, state); + } + + let existing = SessionRef::Existing(test_session_id()); + let json = serde_json::to_string(&existing).unwrap(); + assert!(json.starts_with("\"session_")); + let parsed: SessionRef = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, existing); + } + + #[test] + fn test_cbor_roundtrip_all_states() { + for state in [ + SessionRef::None, + SessionRef::Create, + SessionRef::Existing(test_session_id()), + ] { + let mut buffer = Vec::new(); + ciborium::into_writer(&state, &mut buffer).unwrap(); + let decoded: SessionRef = ciborium::from_reader(&buffer[..]).unwrap(); + assert_eq!(state, decoded); + } + } +} + #[cfg(test)] mod session_nullifier_tests { use super::*; diff --git a/crates/proof/src/oprf_query.rs b/crates/proof/src/oprf_query.rs index 049590d71..992e2a78c 100644 --- a/crates/proof/src/oprf_query.rs +++ b/crates/proof/src/oprf_query.rs @@ -288,7 +288,7 @@ impl<'a> OprfEntrypoint<'a> { .map_err(|err| ProofError::GenerationError(err.to_string()))?; if !proof_request.is_session_proof() { return Err(ProofError::GenerationError( - "proof_type must be create_session or prove_session".to_string(), + "proof_type must be session".to_string(), )); } diff --git a/docs/world-id-4-specs/README.md b/docs/world-id-4-specs/README.md index ea5857234..556990f0d 100644 --- a/docs/world-id-4-specs/README.md +++ b/docs/world-id-4-specs/README.md @@ -240,10 +240,10 @@ Session Proofs use the same zero-knowledge circuits as Uniqueness Proofs, but au Session Proofs work in the following manner: -- An RP requests an authenticator to create a session. +- An RP requests an authenticator to create a session. On the wire this is a proof request with `"proof_type": "session"` and `"session_id": "create"`; the session is created and proven in the same response. - The authenticator provides a `sessionId`. A unique identifier bound to the user's World ID for that RP. - The RP stores this `sessionId` alongside their account for the user. -- For subsequent interactions, the RP includes the `sessionId` in proof requests. The user can then generate a Session Proof to prove they have the same World ID. Different proofs over time with the same `sessionId` may use different credentials. +- For subsequent interactions, the RP includes the stored `sessionId` (a `session_`-prefixed string) as `session_id` in proof requests with `"proof_type": "session"`. The user can then generate a Session Proof to prove they have the same World ID. Different proofs over time with the same `sessionId` may use different credentials. - The `sessionId` is generated as outlined below, where `r` is computationally indistinguishable from random. ```mermaid diff --git a/services/oprf-dev-client/src/bin/world-id-dev-client-rp.rs b/services/oprf-dev-client/src/bin/world-id-dev-client-rp.rs index 4de270ce0..62141a8be 100644 --- a/services/oprf-dev-client/src/bin/world-id-dev-client-rp.rs +++ b/services/oprf-dev-client/src/bin/world-id-dev-client-rp.rs @@ -23,7 +23,7 @@ use world_id_core::{ use world_id_oprf_dev_client::{SharedDevClientComponents, WorldDevClientConfig}; use world_id_primitives::{ AuthenticatorPublicKeySet, ProofRequest, ProofType, RequestItem, RequestVersion, SessionFeType, - SessionFieldElement as _, SessionId, TREE_DEPTH, + SessionFieldElement as _, SessionId, SessionRef, TREE_DEPTH, merkle::MerkleInclusionProof, oprf::{NullifierOprfRequestAuthV1, OprfModule}, rp::RpId, @@ -275,7 +275,7 @@ fn create_proof_request( rng.fill(&mut bytes[1..]); bytes[0] = 0x00; let a = FieldElement::from_be_bytes(&bytes).expect("Works"); - (ProofType::Uniqueness, Some(*a), None) + (ProofType::Uniqueness, Some(*a), SessionRef::None) } OprfModule::Session => { // Session RP signature does NOT include action @@ -285,7 +285,7 @@ fn create_proof_request( FieldElement::random_for_session(rng, SessionFeType::OprfSeed), ) .context("while building SessionId")?; - (ProofType::Session, None, Some(session_id)) + (ProofType::Session, None, SessionRef::Existing(session_id)) } _ => unreachable!("only have session and nullifier modules here"), }; diff --git a/tools/generate-solidity-fixtures/src/main.rs b/tools/generate-solidity-fixtures/src/main.rs index 28950ab43..1a0b652c8 100644 --- a/tools/generate-solidity-fixtures/src/main.rs +++ b/tools/generate-solidity-fixtures/src/main.rs @@ -38,7 +38,7 @@ use world_id_gateway::{ spawn_gateway_for_tests, }; use world_id_primitives::{ - Config, FieldElement, ServiceEndpoint, SessionFieldElement, SessionId, TREE_DEPTH, + Config, FieldElement, ServiceEndpoint, SessionFieldElement, SessionId, SessionRef, TREE_DEPTH, merkle::AccountInclusionProof, }; use world_id_test_utils::{ @@ -272,7 +272,7 @@ async fn main() -> Result<()> { expires_at: rp_fixture.expiration_timestamp, rp_id: rp_fixture.world_rp_id, oprf_key_id: rp_fixture.oprf_key_id, - session_id: None, + session_id: SessionRef::None, action: Some(rp_fixture.action.into()), signature: rp_fixture.signature, nonce: rp_fixture.nonce.into(), @@ -363,7 +363,7 @@ async fn main() -> Result<()> { .sign_message_sync(&session_msg)?; let session_request = ProofRequest { proof_type: ProofType::Session, - session_id: Some(session_id), + session_id: SessionRef::Existing(session_id), action: None, nonce: session_nonce, signature: session_signature, @@ -414,7 +414,7 @@ async fn main() -> Result<()> { // ── SESSION-BOUND UNIQUENESS PROOF (same action, bound to the session above) ── let bound_request = ProofRequest { proof_type: ProofType::Uniqueness, - session_id: Some(session_id), + session_id: SessionRef::Existing(session_id), ..uniqueness_request.clone() }; From a54cb50c9b495fe7b05ef356bac168d0bff1786c Mon Sep 17 00:00:00 2001 From: kilianglas Date: Tue, 14 Jul 2026 15:19:08 +0200 Subject: [PATCH 13/36] feat(node): authorize signed session-seed queries Carry the RP-signed uniqueness action separately from the session OPRF seed so nodes can safely authorize atomic session creation. Co-authored-by: Cursor --- crates/primitives/src/oprf.rs | 117 +++++++++++++- crates/proof/src/oprf_query.rs | 20 ++- .../src/bin/world-id-dev-client-rp.rs | 1 + services/oprf-node/src/auth/rp_module.rs | 50 +++++- .../oprf-node/src/auth/rp_module/tests.rs | 146 ++++++++++++++++++ services/oprf-node/src/metrics.rs | 11 ++ 6 files changed, 338 insertions(+), 7 deletions(-) diff --git a/crates/primitives/src/oprf.rs b/crates/primitives/src/oprf.rs index b883f27a1..01cdf2824 100644 --- a/crates/primitives/src/oprf.rs +++ b/crates/primitives/src/oprf.rs @@ -5,7 +5,7 @@ use circom_types::groth16::Proof; use serde::{Deserialize, Serialize}; use taceo_oprf::types::api::{CloseFrameMessage, OprfRequestAuthenticatorError}; -use crate::rp::RpId; +use crate::{FieldElement, rp::RpId}; #[expect(unused_imports, reason = "used in doc comments")] use crate::SessionFeType; @@ -69,6 +69,14 @@ pub struct NullifierOprfRequestAuthV1 { with = "serde_utils::hex_bytes_opt" )] pub wip101_data: Option>, + /// The RP-signed uniqueness action (MSB `0x00`) for create-and-bind session-seed queries. + /// + /// Only valid on session-seed queries (see [`SessionFeType::OprfSeed`]) from EOA-backed RPs. + /// When present, the OPRF node verifies the RP signature over the action-inclusive message + /// (see `compute_rp_signature_msg`) instead of the action-less one, so a single RP signature + /// can authorize creating a session and binding a Uniqueness Proof to it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signed_action: Option, } /// A request sent by a client for OPRF credential blinding factor authentication. @@ -171,6 +179,14 @@ pub enum WorldIdRequestAuthError { /// prefixes. #[error("invalid_action_for_session")] InvalidActionSession, + /// The provided signed action is not a valid nullifier action. Signed actions must + /// start with `0x00` (MSB). + #[error("invalid_signed_action")] + InvalidSignedAction, + /// A signed action was provided on a request that does not support one. Signed + /// actions are only allowed on session-seed queries from EOA-backed RPs. + #[error("signed_action_not_allowed")] + SignedActionNotAllowed, /// The RP signer is a contract but does not implement the WIP101 interface. #[error("wip101_incompatible_rp_signer")] Wip101IncompatibleRpSigner, @@ -235,6 +251,7 @@ impl WorldIdRequestAuthError { | Self::InvalidRpSignature | Self::DuplicateNonce | Self::InvalidActionNullifier + | Self::InvalidSignedAction | Self::Wip101IncompatibleRpSigner | Self::Wip101VerificationFailed(_) | Self::Wip101CustomRevert @@ -247,6 +264,7 @@ impl WorldIdRequestAuthError { | Self::InvalidQueryProof | Self::InvalidActionSchemaIssuer | Self::InvalidActionSession + | Self::SignedActionNotAllowed | Self::RpSignatureMissing => ErrorActor::Authenticator, Self::Internal | Self::Unknown(_) => ErrorActor::OprfNode, } @@ -268,6 +286,8 @@ impl From for WorldIdRequestAuthError { error_codes::UNKNOWN_SCHEMA_ISSUER => Self::UnknownSchemaIssuerId, error_codes::INVALID_ACTION_NULLIFIER => Self::InvalidActionNullifier, error_codes::INVALID_ACTION_SESSION => Self::InvalidActionSession, + error_codes::INVALID_SIGNED_ACTION => Self::InvalidSignedAction, + error_codes::SIGNED_ACTION_NOT_ALLOWED => Self::SignedActionNotAllowed, error_codes::RP_SIGNATURE_EXPIRED => Self::RpSignatureExpired, error_codes::RP_SIGNATURE_MISSING => Self::RpSignatureMissing, error_codes::INVALID_TIMESTAMP => Self::InvalidTimestamp, @@ -309,6 +329,10 @@ impl From for u16 { error_codes::INVALID_ACTION_NULLIFIER } WorldIdRequestAuthError::InvalidActionSession => error_codes::INVALID_ACTION_SESSION, + WorldIdRequestAuthError::InvalidSignedAction => error_codes::INVALID_SIGNED_ACTION, + WorldIdRequestAuthError::SignedActionNotAllowed => { + error_codes::SIGNED_ACTION_NOT_ALLOWED + } WorldIdRequestAuthError::RpSignatureExpired => error_codes::RP_SIGNATURE_EXPIRED, WorldIdRequestAuthError::CreatedAtTooFarInFuture => { error_codes::CREATED_AT_TOO_FAR_IN_FUTURE @@ -386,6 +410,10 @@ pub mod error_codes { pub const BLOCKED_RP: u16 = 4522; /// Error code for [`super::WorldIdRequestAuthError::ExpiresAtTooFarInFuture`]. pub const EXPIRES_AT_TOO_FAR_IN_FUTURE: u16 = 4523; + /// Error code for [`super::WorldIdRequestAuthError::InvalidSignedAction`]. + pub const INVALID_SIGNED_ACTION: u16 = 4524; + /// Error code for [`super::WorldIdRequestAuthError::SignedActionNotAllowed`]. + pub const SIGNED_ACTION_NOT_ALLOWED: u16 = 4525; /// Error code for [`super::WorldIdRequestAuthError::Internal`]. pub const INTERNAL: u16 = 1011; } @@ -468,6 +496,16 @@ impl From for OprfRequestAuthenticatorError { // this should never truncate as code is a U256 encoded as hex CloseFrameMessage::new_truncate(format!("{:#x}", code)) } + WorldIdRequestAuthError::InvalidSignedAction => { + taceo_oprf::types::close_frame_message!( + "Invalid signed action - must be a valid nullifier action (MSB 0x00)" + ) + } + WorldIdRequestAuthError::SignedActionNotAllowed => { + taceo_oprf::types::close_frame_message!( + "Signed actions are only allowed on session-seed queries from EOA-backed RPs" + ) + } WorldIdRequestAuthError::Wip101AuxDataOnEoa => taceo_oprf::types::close_frame_message!( "Auxiliary data must be empty with EOA backed signer" ), @@ -496,6 +534,81 @@ impl From for OprfRequestAuthenticatorError { mod tests { use super::*; + /// A structurally valid Groth16 proof (BN254 generator points) for serde tests. + fn test_proof() -> Proof { + serde_json::from_value(serde_json::json!({ + "pi_a": ["1", "2", "1"], + "pi_b": [ + [ + "10857046999023057135944570762232829481370756359578518086990519993285655852781", + "11559732032986387107991004021392285783925812861821192530917403151452391805634" + ], + [ + "8495653923123431417604973247489272438418190587263600148770280649306958101930", + "4082367875863433681332203403145435568316851327593401208105741076214120093531" + ], + ["1", "0"] + ], + "pi_c": ["1", "2", "1"], + "protocol": "groth16", + "curve": "bn128" + })) + .expect("valid test proof") + } + + fn test_auth(signed_action: Option) -> NullifierOprfRequestAuthV1 { + NullifierOprfRequestAuthV1 { + proof: test_proof(), + action: ark_babyjubjub::Fq::from(1u64), + nonce: ark_babyjubjub::Fq::from(2u64), + merkle_root: ark_babyjubjub::Fq::from(3u64), + created_at: 4, + expires_at: 5, + signature: None, + rp_id: RpId::new(6), + wip101_data: None, + signed_action, + } + } + + #[test] + fn nullifier_auth_signed_action_none_is_omitted() { + let value = serde_json::to_value(test_auth(None)).unwrap(); + // Forward compat: unused, the field never appears on the wire. + assert!(value.get("signed_action").is_none()); + // Backward compat: payloads without the field deserialize to `None`. + let parsed: NullifierOprfRequestAuthV1 = serde_json::from_value(value).unwrap(); + assert!(parsed.signed_action.is_none()); + } + + #[test] + fn nullifier_auth_signed_action_json_roundtrip() { + let signed_action = FieldElement::from(42u64); + let auth = test_auth(Some(signed_action)); + let json = serde_json::to_string(&auth).unwrap(); + let parsed: NullifierOprfRequestAuthV1 = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.signed_action, Some(signed_action)); + } + + #[test] + fn nullifier_auth_signed_action_cbor_roundtrip() { + let signed_action = FieldElement::from(42u64); + let auth = test_auth(Some(signed_action)); + let mut bytes = Vec::new(); + ciborium::into_writer(&auth, &mut bytes).unwrap(); + let parsed: NullifierOprfRequestAuthV1 = ciborium::from_reader(bytes.as_slice()).unwrap(); + assert_eq!(parsed.signed_action, Some(signed_action)); + } + + #[test] + fn nullifier_auth_ignores_unknown_fields() { + // Old nodes must ignore fields added later (no `deny_unknown_fields`). + let mut value = serde_json::to_value(test_auth(None)).unwrap(); + value["some_future_field"] = serde_json::json!("ignored"); + let parsed = serde_json::from_value::(value); + assert!(parsed.is_ok()); + } + #[test] fn error_code_roundtrip() { let codes: &[u16] = &[ @@ -511,6 +624,8 @@ mod tests { error_codes::UNKNOWN_SCHEMA_ISSUER, error_codes::INVALID_ACTION_NULLIFIER, error_codes::INVALID_ACTION_SESSION, + error_codes::INVALID_SIGNED_ACTION, + error_codes::SIGNED_ACTION_NOT_ALLOWED, error_codes::INACTIVE_RP, error_codes::RP_SIGNATURE_EXPIRED, error_codes::INVALID_TIMESTAMP, diff --git a/crates/proof/src/oprf_query.rs b/crates/proof/src/oprf_query.rs index 992e2a78c..fce36a916 100644 --- a/crates/proof/src/oprf_query.rs +++ b/crates/proof/src/oprf_query.rs @@ -21,7 +21,8 @@ use taceo_oprf::{ }; use world_id_primitives::{ - FieldElement, ProofRequest, SessionFeType, SessionFieldElement, TREE_DEPTH, + FieldElement, ProofRequest, ProofType, SessionFeType, SessionFieldElement, SessionRef, + TREE_DEPTH, oprf::{CredentialBlindingFactorOprfRequestAuthV1, NullifierOprfRequestAuthV1, OprfModule}, }; @@ -258,6 +259,7 @@ impl<'a> OprfEntrypoint<'a> { signature: Some(proof_request.signature), rp_id: proof_request.rp_id, wip101_data: None, + signed_action: None, }; let verifiable_oprf_output = Self::execute_distributed_oprf( @@ -286,9 +288,15 @@ impl<'a> OprfEntrypoint<'a> { proof_request .validate_proof_type() .map_err(|err| ProofError::GenerationError(err.to_string()))?; - if !proof_request.is_session_proof() { + if !proof_request.is_session_proof() + && !matches!( + (proof_request.proof_type, proof_request.session_id), + (ProofType::Uniqueness, SessionRef::Create) + ) + { return Err(ProofError::GenerationError( - "proof_type must be session".to_string(), + "session randomness can only be derived for session proofs or uniqueness session creation" + .to_string(), )); } @@ -301,6 +309,11 @@ impl<'a> OprfEntrypoint<'a> { rng, )?; + let signed_action = match (proof_request.proof_type, proof_request.session_id) { + (ProofType::Uniqueness, SessionRef::Create) => proof_request.action, + _ => None, + }; + let auth = NullifierOprfRequestAuthV1 { proof: result.proof.into(), action: *oprf_seed, @@ -311,6 +324,7 @@ impl<'a> OprfEntrypoint<'a> { signature: Some(proof_request.signature), rp_id: proof_request.rp_id, wip101_data: None, + signed_action, }; let verifiable_oprf_output = Self::execute_distributed_oprf( diff --git a/services/oprf-dev-client/src/bin/world-id-dev-client-rp.rs b/services/oprf-dev-client/src/bin/world-id-dev-client-rp.rs index 62141a8be..745f449c7 100644 --- a/services/oprf-dev-client/src/bin/world-id-dev-client-rp.rs +++ b/services/oprf-dev-client/src/bin/world-id-dev-client-rp.rs @@ -360,6 +360,7 @@ fn generate_oprf_auth_request( signature: Some(proof_request.signature), rp_id: proof_request.rp_id, wip101_data: None, + signed_action: None, }; Ok(auth) diff --git a/services/oprf-node/src/auth/rp_module.rs b/services/oprf-node/src/auth/rp_module.rs index 5ec53193b..96565a31a 100644 --- a/services/oprf-node/src/auth/rp_module.rs +++ b/services/oprf-node/src/auth/rp_module.rs @@ -3,11 +3,22 @@ //! Both the session and uniqueness modules share identical struct fields, init //! logic, and query-proof verification. They differ only in: //! - how the action field is validated (`MSB == 0x00` for uniqueness vs `0x01/0x02` for sessions depending on the [`SessionFeType`]) -//! - whether the action is included in the RP signature (`Some` for uniqueness, `None` for session) +//! - whether the action is included in the RP signature (`Some` for uniqueness, `None` for +//! session — unless the request carries a `signed_action`, see below) //! - which [`WorldIdRequestAuthError`] variant is returned for an invalid action //! //! [`RpModuleKind`] captures these differences; [`RpModuleAuth`] holds the shared //! state and branches on the kind at runtime. +//! +//! # Signed actions on session-seed queries (create-and-bind) +//! +//! A session-seed query may carry an optional `signed_action` (a nullifier action, MSB +//! `0x00`). When present, the session module verifies the RP signature over the +//! action-inclusive message instead of the action-less one. This lets a single RP +//! signature authorize both creating a session and binding a Uniqueness Proof to it. +//! `signed_action` is rejected everywhere else: on session-action queries, on the +//! uniqueness module, and for WIP101 contract-backed RPs (which do not support session +//! queries yet). use crate::{ accountant_batcher::AccountantBatcherHandle, @@ -42,7 +53,9 @@ pub(crate) mod wip101; /// Distinguishes the two RP-authenticated OPRF modules. #[derive(Clone)] pub(crate) enum RpModuleKind { - /// Session module: action MSB must be `0x01` (seed) or `0x02` (action); action is NOT signed. + /// Session module: action MSB must be `0x01` (seed) or `0x02` (action); action is NOT + /// signed. Seed queries may carry a `signed_action` (MSB `0x00`), in which case the RP + /// signature is verified over the action-inclusive message (create-and-bind). Session, /// Uniqueness module: action MSB must be `0x00`; action IS signed. Uniqueness(AccountantBatcherHandle), @@ -73,6 +86,10 @@ pub(crate) enum RpModuleError { #[error("Invalid action for uniqueness (action MSB must be 0x00): {action}")] InvalidActionUniqueness { action: FieldElement }, + #[error("Invalid signed action (MSB must be 0x00): {signed_action}")] + InvalidSignedAction { signed_action: FieldElement }, + #[error("Signed action not allowed: {context}")] + SignedActionNotAllowed { context: &'static str }, #[error("Could not verify query proof")] InvalidQueryProof, #[error(transparent)] @@ -131,6 +148,8 @@ impl From<&RpModuleError> for WorldIdRequestAuthError { match value { RpModuleError::InvalidActionSession { .. } => Self::InvalidActionSession, RpModuleError::InvalidActionUniqueness { .. } => Self::InvalidActionNullifier, + RpModuleError::InvalidSignedAction { .. } => Self::InvalidSignedAction, + RpModuleError::SignedActionNotAllowed { .. } => Self::SignedActionNotAllowed, RpModuleError::InvalidQueryProof => Self::InvalidQueryProof, RpModuleError::MerkleWatcher(e) => Self::from(e.as_ref()), RpModuleError::RpRegistry(e) => Self::from(e.as_ref()), @@ -321,12 +340,19 @@ impl RpModuleAuth { tracing::trace!("RP signer is EOA"); let action = match self.kind { RpModuleKind::Uniqueness(_) => Some(action), - RpModuleKind::Session => None, + // Session RP signatures do not include the action, unless the request + // carries a `signed_action` (create-and-bind seed queries). + RpModuleKind::Session => request.auth.signed_action.map(|a| *a), }; rp.verify_eoa(action, request) } RpAccountType::Contract => { // TODO(session-proofs): WIP-101 does not currently support session proofs. + if request.auth.signed_action.is_some() { + return Err(RpModuleError::SignedActionNotAllowed { + context: "not supported for WIP101 contract-backed RPs", + }); + } Ok(rp .verify_wip101( action, @@ -378,12 +404,25 @@ impl RpModuleAuth { let action = FieldElement::from(request.auth.action); // Validate the action per kind and derive the nonce scope it consumes. + // A `signed_action` (a nullifier action the RP signature covers) is only valid on + // session-seed queries; see the module docs for the create-and-bind flow. let nonce_scope = match self.kind { RpModuleKind::Session => { metrics::auth_module::inc_session(); if action.is_valid_for_session(SessionFeType::OprfSeed) { + if let Some(signed_action) = request.auth.signed_action { + if signed_action.to_be_bytes()[0] != 0 { + return Err(RpModuleError::InvalidSignedAction { signed_action }); + } + metrics::auth_module::inc_session_signed_action(); + } NonceScope::SessionOprfSeed } else if action.is_valid_for_session(SessionFeType::Action) { + if request.auth.signed_action.is_some() { + return Err(RpModuleError::SignedActionNotAllowed { + context: "only allowed on session-seed queries", + }); + } NonceScope::SessionAction } else { return Err(RpModuleError::InvalidActionSession { action }); @@ -391,6 +430,11 @@ impl RpModuleAuth { } RpModuleKind::Uniqueness(_) => { metrics::auth_module::inc_nullifier(); + if request.auth.signed_action.is_some() { + return Err(RpModuleError::SignedActionNotAllowed { + context: "only allowed on the session module", + }); + } if action.to_be_bytes()[0] != 0 { return Err(RpModuleError::InvalidActionUniqueness { action }); } diff --git a/services/oprf-node/src/auth/rp_module/tests.rs b/services/oprf-node/src/auth/rp_module/tests.rs index beac1afe7..930fd5a7e 100644 --- a/services/oprf-node/src/auth/rp_module/tests.rs +++ b/services/oprf-node/src/auth/rp_module/tests.rs @@ -76,6 +76,45 @@ impl RpModuleTestSetup { signature: Some(signature), rp_id: infra.setup.rp_fixture.world_rp_id, wip101_data: None, + signed_action: None, + }; + + Ok(Self { + setup: infra.setup, + request_authenticator, + request: OprfRequest { + request_id: Uuid::new_v4(), + blinded_query: bundle.blinded_query, + auth, + }, + }) + } + + /// Constructs a valid session-seed test setup whose RP signature covers the + /// fixture's uniqueness action, carried in `signed_action` (create-and-bind). + pub(crate) async fn new_session_bound_seed() -> eyre::Result { + let mut rng = rand::thread_rng(); + let infra = AuthModulesTestSetup::new(SetupKind::RpModule).await?; + + let request_authenticator = RpModuleAuth::new_session(infra.rp_module_args()); + + let session_action = FieldElement::random_for_session(&mut rng, SessionFeType::OprfSeed); + let bundle = infra + .generate_query_proof(session_action, infra.setup.rp_fixture.world_rp_id.into())?; + + // The fixture signature is computed over the action-inclusive message, matching + // the `signed_action` below. + let auth = NullifierOprfRequestAuthV1 { + proof: bundle.proof, + action: *session_action, + nonce: bundle.nonce, + merkle_root: *infra.setup.merkle_inclusion_proof.root, + created_at: infra.setup.rp_fixture.current_timestamp, + expires_at: infra.setup.rp_fixture.expiration_timestamp, + signature: Some(infra.setup.rp_fixture.signature), + rp_id: infra.setup.rp_fixture.world_rp_id, + wip101_data: None, + signed_action: Some(infra.setup.rp_fixture.action.into()), }; Ok(Self { @@ -111,6 +150,7 @@ impl RpModuleTestSetup { signature: Some(infra.setup.rp_fixture.signature), rp_id: infra.setup.rp_fixture.world_rp_id, wip101_data: None, + signed_action: None, }; Ok(Self { @@ -643,6 +683,112 @@ async fn test_session_invalid_action_random_prefix() -> eyre::Result<()> { .await } +// ── Signed-action (create-and-bind) tests ──────────────────────────────── +// +// A session-seed query may carry a `signed_action` covered by the RP signature. +// The happy path verifies the action-inclusive message; everything else must +// fail loudly — most importantly the two signature/field mismatch directions, +// which are exactly what an old node (ignoring the field) would hit. + +#[tokio::test] +async fn test_session_seed_signed_action_success() -> eyre::Result<()> { + check_success(RpModuleTestSetup::new_session_bound_seed().await?).await +} + +#[tokio::test] +async fn test_session_seed_signed_action_missing_field() -> eyre::Result<()> { + // Old-node simulation: the signature covers the action, but the field is absent, + // so the node reconstructs the action-less message. Must fail closed. + let mut setup = RpModuleTestSetup::new_session_bound_seed().await?; + setup.request.auth.signed_action = None; + setup + .assert_auth_err( + error_codes::INVALID_RP_SIGNATURE, + "signature from RP cannot be verified", + ) + .await +} + +#[tokio::test] +async fn test_session_seed_signed_action_actionless_signature() -> eyre::Result<()> { + // Inverse mismatch: field present, but the signature was made over the + // action-less message. + let mut setup = RpModuleTestSetup::new_session().await?; + setup.request.auth.signed_action = Some(setup.setup.rp_fixture.action.into()); + setup + .assert_auth_err( + error_codes::INVALID_RP_SIGNATURE, + "signature from RP cannot be verified", + ) + .await +} + +#[tokio::test] +async fn test_session_seed_signed_action_tampered() -> eyre::Result<()> { + let mut setup = RpModuleTestSetup::new_session_bound_seed().await?; + setup.request.auth.signed_action = Some(action_with_msb(0x00).into()); + setup + .assert_auth_err( + error_codes::INVALID_RP_SIGNATURE, + "signature from RP cannot be verified", + ) + .await +} + +#[tokio::test] +async fn test_session_seed_signed_action_invalid_prefix() -> eyre::Result<()> { + // A signed action must be a nullifier action (MSB 0x00); session prefixes are invalid. + let mut setup = RpModuleTestSetup::new_session_bound_seed().await?; + setup.request.auth.signed_action = Some(action_with_msb(0x01).into()); + setup + .assert_auth_err( + error_codes::INVALID_SIGNED_ACTION, + "Invalid signed action - must be a valid nullifier action (MSB 0x00)", + ) + .await +} + +#[tokio::test] +async fn test_session_action_query_rejects_signed_action() -> eyre::Result<()> { + // Only seed queries (0x01) may carry a signed action, not session-action queries (0x02). + let mut setup = RpModuleTestSetup::new_session_with_fe_type(SessionFeType::Action).await?; + setup.request.auth.signed_action = Some(setup.setup.rp_fixture.action.into()); + setup + .assert_auth_err( + error_codes::SIGNED_ACTION_NOT_ALLOWED, + "Signed actions are only allowed on session-seed queries from EOA-backed RPs", + ) + .await +} + +#[tokio::test] +async fn test_uniqueness_rejects_signed_action() -> eyre::Result<()> { + // The uniqueness module signs the regular action; a signed_action is meaningless there. + let mut setup = RpModuleTestSetup::new_uniqueness().await?; + setup.request.auth.signed_action = Some(setup.setup.rp_fixture.action.into()); + setup + .assert_auth_err( + error_codes::SIGNED_ACTION_NOT_ALLOWED, + "Signed actions are only allowed on session-seed queries from EOA-backed RPs", + ) + .await +} + +#[tokio::test] +async fn test_session_wip101_rejects_signed_action() -> eyre::Result<()> { + // WIP101 contract-backed RPs do not support session queries with signed actions. + let mut setup = RpModuleTestSetup::new_session_bound_seed().await?; + let addr = deploy!(WIP101Correct, setup); + setup.set_contract_signer(addr, None).await; + setup.request.auth.signed_action = Some(setup.setup.rp_fixture.action.into()); + setup + .assert_auth_err( + error_codes::SIGNED_ACTION_NOT_ALLOWED, + "Signed actions are only allowed on session-seed queries from EOA-backed RPs", + ) + .await +} + // ── Uniqueness-specific tests ──────────────────────────────────────────── #[tokio::test] diff --git a/services/oprf-node/src/metrics.rs b/services/oprf-node/src/metrics.rs index 7feb07fe4..71dd346c3 100644 --- a/services/oprf-node/src/metrics.rs +++ b/services/oprf-node/src/metrics.rs @@ -51,6 +51,8 @@ pub(crate) mod accountant_batcher { pub(crate) mod auth_module { const METRICS_ID_AUTHENTICATION_COUNTER: &str = "taceo.oprf.node.auth"; + const METRICS_ID_SESSION_SIGNED_ACTION_COUNTER: &str = + "taceo.oprf.node.auth.session_signed_action"; const METRICS_ATTRID_AUTH_MODULE: &str = "auth_module"; const METRICS_ATTR_NULLIFIER_MODULE: &str = "nullifier"; const METRICS_ATTR_SESSION_MODULE: &str = "session"; @@ -62,6 +64,11 @@ pub(crate) mod auth_module { metrics::Unit::Count, "Number of times the authentication modules were hit." ); + metrics::describe_counter!( + METRICS_ID_SESSION_SIGNED_ACTION_COUNTER, + metrics::Unit::Count, + "Number of session-seed authentications carrying an RP-signed action (create-and-bind)." + ); } pub(crate) fn inc_nullifier() { @@ -72,6 +79,10 @@ pub(crate) mod auth_module { metrics::counter!(METRICS_ID_AUTHENTICATION_COUNTER, METRICS_ATTRID_AUTH_MODULE => METRICS_ATTR_SESSION_MODULE).increment(1); } + pub(crate) fn inc_session_signed_action() { + metrics::counter!(METRICS_ID_SESSION_SIGNED_ACTION_COUNTER).increment(1); + } + pub(crate) fn inc_issuer_blinding() { metrics::counter!(METRICS_ID_AUTHENTICATION_COUNTER, METRICS_ATTRID_AUTH_MODULE => METRICS_ATTR_CREDENTIAL_BLINDING).increment(1); } From f4b147b12705993f25531d8f8a9772fcb3369a46 Mon Sep 17 00:00:00 2001 From: kilianglas Date: Tue, 14 Jul 2026 15:19:19 +0200 Subject: [PATCH 14/36] feat: create sessions from uniqueness proofs Accept create and existing session references on uniqueness requests, mint sessions through the authenticator, and validate the corresponding response semantics. Co-authored-by: Cursor --- crates/authenticator/src/authenticator.rs | 5 +- crates/authenticator/src/prove.rs | 23 ++-- crates/primitives/src/request/mod.rs | 125 ++++++++++++++++------ crates/primitives/src/session.rs | 4 +- 4 files changed, 111 insertions(+), 46 deletions(-) diff --git a/crates/authenticator/src/authenticator.rs b/crates/authenticator/src/authenticator.rs index efee8ddde..198437986 100644 --- a/crates/authenticator/src/authenticator.rs +++ b/crates/authenticator/src/authenticator.rs @@ -59,9 +59,10 @@ pub struct CredentialInput { /// those are SDK concerns. #[derive(Debug)] pub struct ProofResult { - /// The session_id_r_seed (`r`), if a session proof was generated. + /// The session_id_r_seed (`r`), when a session was created or proven. /// - /// The SDK should cache this keyed by [`SessionId::oprf_seed`]. + /// Returned for session proofs and for uniqueness proofs that create or bind + /// a session. The SDK should cache this keyed by [`SessionId::oprf_seed`]. pub session_id_r_seed: Option, /// The response to deliver to an RP. diff --git a/crates/authenticator/src/prove.rs b/crates/authenticator/src/prove.rs index 6aef15ae2..37414f732 100644 --- a/crates/authenticator/src/prove.rs +++ b/crates/authenticator/src/prove.rs @@ -208,11 +208,17 @@ impl Authenticator { account_inclusion_proof: Option>, ) -> Result<(SessionId, FieldElement), AuthenticatorError> { proof_request.validate_proof_type()?; - if !proof_request.is_session_proof() { + if !proof_request.is_session_proof() + && !matches!( + (proof_request.proof_type, proof_request.session_id), + (ProofType::Uniqueness, SessionRef::Create) + ) + { return Err(AuthenticatorError::PrimitiveError( world_id_primitives::PrimitiveError::InvalidInput { - attribute: "proof_type".to_string(), - reason: "session ids can only be built for session proof requests".to_string(), + attribute: "session_id".to_string(), + reason: "session ids can only be built for session proofs or uniqueness session creation" + .to_string(), }, )); } @@ -275,8 +281,10 @@ impl Authenticator { /// matched to request items by `issuer_schema_id`. /// - `account_inclusion_proof` — a cached inclusion proof if available (a fresh one will be fetched otherwise) /// - `session_id_r_seed` — a cached session `r` seed. For Session Proofs it is re-computed - /// if unavailable; for session-bound Uniqueness Proofs ([`ProofRequest::binds_session`]) - /// it is required and the call fails with [`AuthenticatorError::SessionSeedRequired`] otherwise. + /// if unavailable; for session-bound Uniqueness Proofs with an existing session id + /// ([`ProofRequest::binds_session`]) it is required and the call fails with + /// [`AuthenticatorError::SessionSeedRequired`] otherwise. Create flows mint a fresh + /// session and return the new `session_id_r_seed` for caching. /// /// # Caller Responsibilities /// 1. The caller must ensure the request can be fulfilled with the credentials which the user has available, @@ -316,7 +324,7 @@ impl Authenticator { self.validate_cached_session_r_seed(seed, session_id)?; (Some(session_id), Some(seed)) } - (ProofType::Session, SessionRef::Create) => { + (ProofType::Uniqueness | ProofType::Session, SessionRef::Create) => { let (session_id, seed) = self .build_session_id(proof_request, None, account_inclusion_proof) .await?; @@ -336,8 +344,7 @@ impl Authenticator { } } // Rejected by validate_proof_type() above; kept explicit to stay exhaustive. - (ProofType::Uniqueness, SessionRef::Create) - | (ProofType::Session, SessionRef::None) => { + (ProofType::Session, SessionRef::None) => { return Err(AuthenticatorError::PrimitiveError( world_id_primitives::PrimitiveError::InvalidInput { attribute: "session_id".to_string(), diff --git a/crates/primitives/src/request/mod.rs b/crates/primitives/src/request/mod.rs index 3f8b591f9..ff292ce64 100644 --- a/crates/primitives/src/request/mod.rs +++ b/crates/primitives/src/request/mod.rs @@ -52,8 +52,9 @@ impl<'de> serde::Deserialize<'de> for RequestVersion { pub enum ProofType { /// A uniqueness proof scoped by the RP-provided action. /// - /// May carry a `session_id` to bind the proof to an existing session, - /// see [`ProofRequest::binds_session`]. + /// May carry a `session_id` to mint a fresh session, bind the proof to an + /// existing session, or omit session involvement entirely — see + /// [`ProofRequest::binds_session`]. #[default] Uniqueness, /// Prove an RP-scoped session — either minting a fresh one @@ -99,10 +100,10 @@ pub struct ProofRequest { pub oprf_key_id: OprfKeyId, /// Session identifier that links proofs for the same user/RP pair across requests. /// - /// Three states: absent/`null` (no session), `"create"` (mint a fresh session, - /// [`ProofType::Session`] only), or an existing `"session_"`-prefixed id — - /// required for [`ProofType::Session`], optional for [`ProofType::Uniqueness`] - /// to bind the proof to that session (see [`Self::binds_session`]). + /// Three states: absent/`null` (no session), `"create"` (mint a fresh session), + /// or an existing `"session_"`-prefixed id. For [`ProofType::Uniqueness`], all + /// three are valid; for [`ProofType::Session`], `"create"` or an existing id is + /// required (see [`Self::binds_session`]). /// The proof will only be valid if the session ID is meant for this context and /// this particular World ID holder. #[serde(default)] @@ -234,8 +235,9 @@ pub struct ProofResponse { /// the newly generated `SessionId`. For subsequent Session Proofs, this /// echoes back the `SessionId` from the request for convenience. /// - /// For Uniqueness Proofs this is only present when the request asked for - /// session binding ([`ProofRequest::binds_session`]), echoing back the bound `SessionId`. + /// For Uniqueness Proofs this is present when the request asked to create or + /// bind a session ([`ProofRequest::binds_session`]). Create responses carry + /// the newly minted `SessionId`; existing-session responses echo the bound id. #[serde(skip_serializing_if = "Option::is_none")] pub session_id: Option, /// Error message if the entire proof request failed. @@ -463,13 +465,7 @@ impl ProofRequest { /// combination of `proof_type`, `session_id`, and `action`. pub fn validate_proof_type(&self) -> Result<(), PrimitiveError> { match (self.proof_type, self.session_id) { - // No session, or bound to an existing one — see `Self::binds_session` - (ProofType::Uniqueness, SessionRef::None | SessionRef::Existing(_)) => Ok(()), - // Enabled in a future protocol change together with signed session modes. - (ProofType::Uniqueness, SessionRef::Create) => Err(PrimitiveError::InvalidInput { - attribute: "session_id".to_string(), - reason: "session creation is not yet supported for uniqueness proofs".to_string(), - }), + (ProofType::Uniqueness, _) => Ok(()), (ProofType::Session, SessionRef::None) => Err(PrimitiveError::InvalidInput { attribute: "session_id".to_string(), reason: "must be \"create\" or an existing session id for session proofs" @@ -493,15 +489,15 @@ impl ProofRequest { self.proof_type.is_session() } - /// Returns true if this request asks for a Uniqueness Proof bound to an existing session. + /// Returns true if this request asks for a Uniqueness Proof committed to a session. /// - /// A bound proof carries [`SessionId::commitment`] as its `id_commitment` public signal, - /// proving in-circuit that session and nullifier belong to the same World ID. RPs MUST - /// verify the proof against that commitment — with a zero commitment the proof is valid - /// but unbound. + /// A committed proof carries [`SessionId::commitment`] as its `id_commitment` public + /// signal, proving in-circuit that session and nullifier belong to the same World ID. + /// RPs MUST verify the proof against that commitment — with a zero commitment the + /// proof is valid but unbound. #[must_use] pub const fn binds_session(&self) -> bool { - self.proof_type.is_uniqueness() && self.session_id.existing().is_some() + self.proof_type.is_uniqueness() && !self.session_id.is_none() } /// Validates the structural integrity of the constraint expression. @@ -551,16 +547,21 @@ impl ProofRequest { } match (self.proof_type, self.session_id) { + (ProofType::Uniqueness, SessionRef::None) => { + if response.session_id.is_some() { + return Err(ValidationError::UnexpectedSessionId); + } + } + (ProofType::Uniqueness, SessionRef::Create) => { + if response.session_id.is_none() { + return Err(ValidationError::MissingSessionId); + } + } (ProofType::Uniqueness, SessionRef::Existing(session_id)) => { if response.session_id != Some(session_id) { return Err(ValidationError::SessionIdMismatch); } } - (ProofType::Uniqueness, _) => { - if response.session_id.is_some() { - return Err(ValidationError::UnexpectedSessionId); - } - } (ProofType::Session, SessionRef::Create) => { // No request-side id to compare — the freshly minted id must be present. if response.session_id.is_none() { @@ -2429,15 +2430,13 @@ mod tests { assert!(plain_uniqueness.validate_proof_type().is_ok()); assert!(!plain_uniqueness.binds_session()); - // uniqueness + "create" is reserved for a future protocol change + // uniqueness + "create" mints and binds a session let uniqueness_with_create = ProofRequest { session_id: SessionRef::Create, ..uniqueness_with_session.clone() }; - assert!(matches!( - uniqueness_with_create.validate_proof_type(), - Err(PrimitiveError::InvalidInput { attribute, .. }) if attribute == "session_id" - )); + assert!(uniqueness_with_create.validate_proof_type().is_ok()); + assert!(uniqueness_with_create.binds_session()); let session_without_session = ProofRequest { proof_type: ProofType::Session, @@ -2584,10 +2583,17 @@ mod tests { without_session["session_id"] = serde_json::Value::Null; assert!(ProofRequest::from_json(&without_session.to_string()).is_err()); - // uniqueness × "create" is rejected at the parse boundary - let mut uniqueness_create: serde_json::Value = serde_json::from_str(&json).unwrap(); - uniqueness_create["proof_type"] = "uniqueness".into(); - assert!(ProofRequest::from_json(&uniqueness_create.to_string()).is_err()); + // uniqueness × "create" is valid at the parse boundary + let uniqueness_create_request = ProofRequest { + proof_type: ProofType::Uniqueness, + session_id: SessionRef::Create, + action: Some(FieldElement::ZERO), + ..request.clone() + }; + let parsed = + ProofRequest::from_json(&uniqueness_create_request.to_json().unwrap()).unwrap(); + assert!(parsed.session_id.is_create()); + assert!(parsed.binds_session()); } #[test] @@ -2770,6 +2776,55 @@ mod tests { assert!(request.validate_response(&valid_response).is_ok()); } + #[test] + fn test_validate_response_uniqueness_create_requires_minted_session_id() { + let request = ProofRequest { + id: "req_uniqueness_create".into(), + version: RequestVersion::V1, + proof_type: ProofType::Uniqueness, + session_id: SessionRef::Create, + action: Some(FieldElement::ZERO), + created_at: 1_735_689_600, + expires_at: 1_735_689_900, + rp_id: RpId::new(1), + oprf_key_id: OprfKeyId::new(uint!(1_U160)), + signature: test_signature(), + nonce: test_nonce(), + requests: vec![RequestItem { + identifier: "orb".into(), + issuer_schema_id: 1, + signal: None, + genesis_issued_at_min: None, + expires_at_min: None, + }], + constraints: None, + }; + + let missing_session = ProofResponse { + id: request.id.clone(), + version: RequestVersion::V1, + session_id: None, + error: None, + responses: vec![ResponseItem::new_uniqueness( + "orb".into(), + 1, + ZeroKnowledgeProof::default(), + Nullifier::new(test_field_element(1001)), + 1_735_689_600, + )], + }; + assert!(matches!( + request.validate_response(&missing_session), + Err(ValidationError::MissingSessionId) + )); + + let valid_response = ProofResponse { + session_id: Some(SessionId::default()), + ..missing_session + }; + assert!(request.validate_response(&valid_response).is_ok()); + } + #[test] fn test_validate_response_requires_session_id_in_response() { // Request with session_id should require response to also have session_id diff --git a/crates/primitives/src/session.rs b/crates/primitives/src/session.rs index fb03023bb..627651574 100644 --- a/crates/primitives/src/session.rs +++ b/crates/primitives/src/session.rs @@ -254,7 +254,9 @@ pub enum SessionRef { /// No session involvement. #[default] None, - /// Mint a fresh session and prove it in the same response. + /// Mint a fresh session. For [`crate::ProofType::Session`] this proves the new + /// session in the same response; for [`crate::ProofType::Uniqueness`] this + /// returns a uniqueness proof committed to the newly minted session. Create, /// Refer to an existing session. Existing(SessionId), From 28dafed1df0ad50145b36e36ef304af9e2b8502f Mon Sep 17 00:00:00 2001 From: kilianglas Date: Tue, 14 Jul 2026 15:19:34 +0200 Subject: [PATCH 15/36] test: cover atomic uniqueness session creation Exercise the signed-action create flow end to end and generate verifier fixtures from a session minted by the uniqueness request. Co-authored-by: Cursor --- crates/core/tests/generate_proof.rs | 95 ++++++++++++- tools/generate-solidity-fixtures/src/main.rs | 135 ++++++++++--------- 2 files changed, 165 insertions(+), 65 deletions(-) diff --git a/crates/core/tests/generate_proof.rs b/crates/core/tests/generate_proof.rs index 4d28e1b75..ad02bd84e 100644 --- a/crates/core/tests/generate_proof.rs +++ b/crates/core/tests/generate_proof.rs @@ -8,7 +8,7 @@ use std::{ use alloy::{ primitives::{U160, U256}, - signers::local::LocalSigner, + signers::{SignerSync as _, local::LocalSigner}, }; use eyre::{Context as _, Result, eyre}; use taceo_oprf::{ @@ -367,7 +367,98 @@ async fn e2e_authenticator_generate_proof() -> Result<()> { .await?; info!("on-chain proof verification succeeded"); - // ── SESSION-BOUND UNIQUENESS PROOF ── + // ── UNIQUENESS + CREATE (atomic session mint and bound uniqueness proof) ── + let mut rng = rand::thread_rng(); + let create_nonce = FieldElement::random(&mut rng); + let create_msg = world_id_primitives::rp::compute_rp_signature_msg( + *create_nonce, + rp_fixture.current_timestamp, + rp_fixture.expiration_timestamp, + Some(rp_fixture.action), + ); + let create_signature = LocalSigner::from_signing_key(rp_fixture.signing_key.clone()) + .sign_message_sync(&create_msg)?; + let create_request = ProofRequest { + id: "test_uniqueness_create".to_string(), + session_id: SessionRef::Create, + action: Some(rp_fixture.action.into()), + nonce: create_nonce, + signature: create_signature, + ..proof_request.clone() + }; + let create_nullifier = authenticator + .generate_nullifier(&create_request, None) + .await?; + let create_result = authenticator + .generate_proof(&create_request, create_nullifier, &credentials, None, None) + .await?; + let created_session_id = create_result + .proof_response + .session_id + .expect("uniqueness create must mint a session id"); + let created_session_seed = create_result + .session_id_r_seed + .expect("uniqueness create must return session seed"); + let create_item = &create_result.proof_response.responses[0]; + assert!(create_item.nullifier.is_some()); + assert!(create_item.session_nullifier.is_none()); + assert_eq!( + SessionId::from_r_seed( + leaf_index, + created_session_seed, + created_session_id.oprf_seed + )?, + created_session_id + ); + + let create_nullifier = create_item + .nullifier + .expect("create uniqueness proof should have nullifier"); + let unbound_verify = world_id_verifier + .verify( + create_nullifier.into(), + rp_fixture.action.into(), + rp_fixture.world_rp_id.into_inner(), + create_nonce.into(), + request_item.signal_hash().into(), + create_item.expires_at_min, + issuer_schema_id, + request_item + .genesis_issued_at_min + .unwrap_or_default() + .try_into() + .expect("u64 fits into U256"), + create_item.proof.as_ethereum_representation(), + ) + .call() + .await; + assert!( + unbound_verify.is_err(), + "create-bound proof must not verify with sessionId = 0" + ); + + world_id_verifier + .verifyWithSession( + create_nullifier.into(), + rp_fixture.action.into(), + rp_fixture.world_rp_id.into_inner(), + create_nonce.into(), + request_item.signal_hash().into(), + create_item.expires_at_min, + issuer_schema_id, + request_item + .genesis_issued_at_min + .unwrap_or_default() + .try_into() + .expect("u64 fits into U256"), + created_session_id.commitment.into(), + create_item.proof.as_ethereum_representation(), + ) + .call() + .await?; + info!("uniqueness create proof verified via verifyWithSession"); + + // ── SESSION-BOUND UNIQUENESS PROOF (existing session) ── // Note: We mock a cached r here. This would be initially obtained from an OPRF query. let session_id_r_seed = FieldElement::random(&mut rng); let session_id = SessionId::from_r_seed( diff --git a/tools/generate-solidity-fixtures/src/main.rs b/tools/generate-solidity-fixtures/src/main.rs index 1a0b652c8..5dc327bca 100644 --- a/tools/generate-solidity-fixtures/src/main.rs +++ b/tools/generate-solidity-fixtures/src/main.rs @@ -298,10 +298,6 @@ async fn main() -> Result<()> { .generate_nullifier(&uniqueness_request, None) .await?; - // Clone the nullifier data before it's consumed — we reuse it for the - // session-bound uniqueness proof. - let nullifier_data_for_bound = nullifier_data.clone(); - let uniqueness_result = authenticator .generate_proof( &uniqueness_request, @@ -341,14 +337,78 @@ async fn main() -> Result<()> { .await?; info!("Uniqueness proof verified ✓"); - // ── CREATE SESSION - let session_id_r_seed = FieldElement::random(&mut rng); // TODO: Create through OPRF - let session_id = SessionId::from_r_seed( - leaf_index, - session_id_r_seed, - FieldElement::random_for_session(&mut rng, world_id_primitives::SessionFeType::OprfSeed), - ) - .unwrap(); + // ── UNIQUENESS + CREATE (atomic session mint and bound uniqueness proof) ── + let create_nonce = FieldElement::random(&mut rng); + let create_msg = world_id_primitives::rp::compute_rp_signature_msg( + *create_nonce, + rp_fixture.current_timestamp, + rp_fixture.expiration_timestamp, + Some(rp_fixture.action), + ); + let create_signature = LocalSigner::from_signing_key(rp_fixture.signing_key.clone()) + .sign_message_sync(&create_msg)?; + let bound_create_request = ProofRequest { + id: "fixture_uniqueness_create".to_string(), + proof_type: ProofType::Uniqueness, + session_id: SessionRef::Create, + action: Some(rp_fixture.action.into()), + nonce: create_nonce, + signature: create_signature, + ..uniqueness_request.clone() + }; + + let bound_create_nullifier = authenticator + .generate_nullifier(&bound_create_request, None) + .await?; + + let bound_create_result = authenticator + .generate_proof( + &bound_create_request, + bound_create_nullifier, + &credentials, + None, + None, + ) + .await?; + let session_id = bound_create_result + .proof_response + .session_id + .expect("uniqueness create must mint a session id"); + let session_id_r_seed = bound_create_result + .session_id_r_seed + .expect("uniqueness create must return session seed"); + let bound_response = &bound_create_result.proof_response.responses[0]; + let bound_nullifier = bound_response + .nullifier + .expect("bound uniqueness proof should have nullifier"); + assert_ne!( + bound_nullifier, + uniqueness_response + .nullifier + .expect("uniqueness proof has nullifier") + ); + + info!("Verifying session-bound uniqueness proof on-chain..."); + verifier_instance + .verifyWithSession( + bound_nullifier.into(), + rp_fixture.action.into(), + rp_fixture.world_rp_id.into_inner(), + create_nonce.into(), + request_item.signal_hash().into(), + bound_response.expires_at_min, + issuer_schema_id, + request_item + .genesis_issued_at_min + .unwrap_or_default() + .try_into() + .expect("u64 fits into U256"), + session_id.commitment.into(), + bound_response.proof.as_ethereum_representation(), + ) + .call() + .await?; + info!("Session-bound uniqueness proof verified ✓"); // ── SESSION PROOF (own OPRF round: session queries use an internal random action // generated at query time, and the RP signature does not cover an action) ── @@ -411,57 +471,6 @@ async fn main() -> Result<()> { .await?; info!("Session proof verified ✓"); - // ── SESSION-BOUND UNIQUENESS PROOF (same action, bound to the session above) ── - let bound_request = ProofRequest { - proof_type: ProofType::Uniqueness, - session_id: SessionRef::Existing(session_id), - ..uniqueness_request.clone() - }; - - let bound_result = authenticator - .generate_proof( - &bound_request, - nullifier_data_for_bound, - &credentials, - None, - Some(session_id_r_seed), - ) - .await?; - let bound_response = &bound_result.proof_response.responses[0]; - let bound_nullifier = bound_response - .nullifier - .expect("bound uniqueness proof should have nullifier"); - // Same RP/action => same deterministic nullifier as the unbound proof. - assert_eq!( - bound_nullifier, - uniqueness_response - .nullifier - .expect("uniqueness proof has nullifier") - ); - - // Verify bound proof on-chain. - info!("Verifying session-bound uniqueness proof on-chain..."); - verifier_instance - .verifyWithSession( - bound_nullifier.into(), - rp_fixture.action.into(), - rp_fixture.world_rp_id.into_inner(), - rp_fixture.nonce.into(), - request_item.signal_hash().into(), - bound_response.expires_at_min, - issuer_schema_id, - request_item - .genesis_issued_at_min - .unwrap_or_default() - .try_into() - .expect("u64 fits into U256"), - session_id.commitment.into(), - bound_response.proof.as_ethereum_representation(), - ) - .call() - .await?; - info!("Session-bound uniqueness proof verified ✓"); - // ── PRINT SOLIDITY FIXTURE ── let u_proof = uniqueness_response.proof.as_ethereum_representation(); From f651855fd87381cc17e2ce91dc93e90baef14621 Mon Sep 17 00:00:00 2001 From: kilianglas Date: Tue, 14 Jul 2026 15:19:34 +0200 Subject: [PATCH 16/36] docs: describe uniqueness session creation Document create, existing, and unbound uniqueness modes together with the signed-action OPRF flow and deployment order. Co-authored-by: Cursor --- .../core/interfaces/IWorldIDVerifierV2.sol | 4 +- docs/world-id-4-specs/README.md | 129 +++++++++--------- 2 files changed, 67 insertions(+), 66 deletions(-) diff --git a/contracts/src/core/interfaces/IWorldIDVerifierV2.sol b/contracts/src/core/interfaces/IWorldIDVerifierV2.sol index 7c673c9f0..d0c0ca783 100644 --- a/contracts/src/core/interfaces/IWorldIDVerifierV2.sol +++ b/contracts/src/core/interfaces/IWorldIDVerifierV2.sol @@ -9,7 +9,7 @@ import {IWorldIDVerifier} from "./IWorldIDVerifier.sol"; * @notice Interface for verifying World ID proofs (Uniqueness and Session proofs). * @dev V2 enforces the action-prefix convention on the convenience entry points (`verify` * requires the action's most significant byte to be `0x00`, `verifySession` requires `0x02`) - * and adds `verifyWithSession` for Uniqueness Proofs bound to an existing session. + * and adds `verifyWithSession` for Uniqueness Proofs bound to a session commitment. */ interface IWorldIDVerifierV2 is IWorldIDVerifier { //////////////////////////////////////////////////////////// @@ -34,7 +34,7 @@ interface IWorldIDVerifierV2 is IWorldIDVerifier { //////////////////////////////////////////////////////////// /** - * @notice Verifies a Uniqueness Proof that is bound to an existing session. + * @notice Verifies a Uniqueness Proof that is bound to a session commitment. * @dev Same as `verify`, except the proof's `session_id` public signal is checked against the * provided session commitment instead of being pinned to 0. Bound proofs are rejected by * `verify` and unbound proofs are rejected here. Hence, binding is explicit in both directions. diff --git a/docs/world-id-4-specs/README.md b/docs/world-id-4-specs/README.md index 556990f0d..fb183162e 100644 --- a/docs/world-id-4-specs/README.md +++ b/docs/world-id-4-specs/README.md @@ -30,29 +30,29 @@ Stemming from the enablement of other Authenticators to exist, a reference open- This notes the key **new** features or functionality for this **release** of World ID (v4.0): - Multi-key support: A World ID is not bound to a single key. A user can generate proofs on multiple valid authenticators (e.g. devices, platforms). With the important exception of security properties of the Authenticator, a proof proves the same thing to an RP regardless of which authenticator was used. - - A user can add or remove different valid authenticators to manage their World ID (Portability). - - **Motivation:** Allowing multiple authenticators serves to the Decentralization of the Protocol, with no reliance on a single actor (such as a single Authenticator provider, e.g. World App). Furthermore, abstracting a World ID into a conceptual record vs. a single secret enables the secure and practical existence of multiple Authenticators as well as enabling Recovery in case of loss and rotation in case of compromise. + - A user can add or remove different valid authenticators to manage their World ID (Portability). + - **Motivation:** Allowing multiple authenticators serves to the Decentralization of the Protocol, with no reliance on a single actor (such as a single Authenticator provider, e.g. World App). Furthermore, abstracting a World ID into a conceptual record vs. a single secret enables the secure and practical existence of multiple Authenticators as well as enabling Recovery in case of loss and rotation in case of compromise. - Recovery: Regain access to the same World ID through Recovery Agents. - - **Motivation:** Recovery is a fundamental building block of a Proof-of-Human Protocol (see [Whitepaper](https://whitepaper.world.org/#recovery) on why). While we expect authenticator providers to offer robust backup mechanisms, the user must be able to recover their World ID and related state in a contingency scenario. + - **Motivation:** Recovery is a fundamental building block of a Proof-of-Human Protocol (see [Whitepaper](https://whitepaper.world.org/#recovery) on why). While we expect authenticator providers to offer robust backup mechanisms, the user must be able to recover their World ID and related state in a contingency scenario. - Web-based Authenticator Provider: A limited authenticator that allows usage of World ID in the web browser. This serves both as a reference of an authenticator and also for improved UX for certain RP flows. Functionality is limited as enrollment of credentials is out of the scope for this initial release. - - **Motivation**: A lightweight web-based authenticator enables simpler usage of World ID which provides a better user experience and will enable more growth as interacting with RPs will be significantly simpler. + - **Motivation**: A lightweight web-based authenticator enables simpler usage of World ID which provides a better user experience and will enable more growth as interacting with RPs will be significantly simpler. - Trusted RPs. An authenticator can identify a request comes from a valid RP. - - **Motivation:** Authenticators need to be able to identify that they’re generating proofs for the right recipient to reduce potential for proof phishing (e.g. a malicious actor asking you for a proof meant for a different RP to know if you’ve performed that action). In addition, this enables future introduction of Protocol fees. + - **Motivation:** Authenticators need to be able to identify that they’re generating proofs for the right recipient to reduce potential for proof phishing (e.g. a malicious actor asking you for a proof meant for a different RP to know if you’ve performed that action). In addition, this enables future introduction of Protocol fees. ## Non-Functional Requirements - Privacy. - - Assuming **non-collusion** of nodes of each multi-party system, the user’s privacy cannot be compromised by any single party, neither correlation of multiple actions nor direct identification of a user. For example, it’s impossible to know that a specific user performed a specific action (identification) or that two different Actions were performed by the same user (correlation). - - Addressing the attack vector of collusion of a threshold (or all) nodes is covered in the [Other Risk Considerations](#other-risk-considerations) section. - - Strict requirements for the identifiers handed off by the Protocol are introduced. See details in Tech Specs. - - No human super-cookies. Permanent state or linkable state is as privacy-preserving as possible and is protocol-enforced. Privacy preserving in this context means that it’s not possible to identify a single person, even pseudonymously, across a long period of time without ongoing consent. Any exposed long-living / constant IDs should be protected as secrets. + - Assuming **non-collusion** of nodes of each multi-party system, the user’s privacy cannot be compromised by any single party, neither correlation of multiple actions nor direct identification of a user. For example, it’s impossible to know that a specific user performed a specific action (identification) or that two different Actions were performed by the same user (correlation). + - Addressing the attack vector of collusion of a threshold (or all) nodes is covered in the [Other Risk Considerations](#other-risk-considerations) section. + - Strict requirements for the identifiers handed off by the Protocol are introduced. See details in Tech Specs. + - No human super-cookies. Permanent state or linkable state is as privacy-preserving as possible and is protocol-enforced. Privacy preserving in this context means that it’s not possible to identify a single person, even pseudonymously, across a long period of time without ongoing consent. Any exposed long-living / constant IDs should be protected as secrets. - Security. - - A World ID is not a single secret that needs to be shared or can’t be rotated. - - A World ID cannot be recovered or an authenticator added without verifiable user intent (through knowledge of a secret key of an authorized authenticator). - - Collusion of **all** nodes with an multi-party system (MPC) does not allow performing actions on the user’s behalf. - - User Auditability — each user needs to be able to see account management events that have been authorized with their World ID, for example things like adding / removing of authenticators. + - A World ID is not a single secret that needs to be shared or can’t be rotated. + - A World ID cannot be recovered or an authenticator added without verifiable user intent (through knowledge of a secret key of an authorized authenticator). + - Collusion of **all** nodes with an multi-party system (MPC) does not allow performing actions on the user’s behalf. + - User Auditability — each user needs to be able to see account management events that have been authorized with their World ID, for example things like adding / removing of authenticators. - Migration Path. - - There needs to be a clear migration path for all currently active and relevant use cases to the new version of the Protocol. + - There needs to be a clear migration path for all currently active and relevant use cases to the new version of the Protocol. ## User Flows (Authenticator) @@ -72,14 +72,14 @@ This notes the key **new** features or functionality for this **release** of Wor ## Summary: What is Changing? - A World ID is now a record on an on-chain registry and more importantly a single World ID can have multiple public keys. - - This also means identity commitments (`identityCommitment`) no longer exist. Instead, the identification mechanism is the knowledge of a secret key corresponding to a public key registered in a specific leaf index in the `WorldIDRegistry`. - - Also implies that the on-chain trees of identity commitments is gone in favor of a single `WorldIDRegistry`. + - This also means identity commitments (`identityCommitment`) no longer exist. Instead, the identification mechanism is the knowledge of a secret key corresponding to a public key registered in a specific leaf index in the `WorldIDRegistry`. + - Also implies that the on-chain trees of identity commitments is gone in favor of a single `WorldIDRegistry`. - Creating a World ID now occurs through on-chain registration (vs. as an offline keypair generation previously), and issuing Credentials is now done without on-chain interaction. Credentials are now issued by the Issuer signing them. Previously, the Issuer would add the user’s identity commitment to the relevant on-chain tree. - Nullifiers are enforced one-time use. Previously there was no enforcement of nullifiers being one-time use and they could become pseudonymous identifiers for an RP, now Authenticators will not issue a nullifier more than once. - [**For RPs only**]. When RPs require users to prove they are still the same World ID that originally performed an action, they will be able to store an identifier (a `sessionId`) and provide it to the user for subsequent proofs. With Proof of Human, this allows RPs to establish they are interacting with the same World ID, potentially with different credentials too. See *Session Proofs* for further details. - [**For Issuers only**]. Authentication based on using nullifiers from ZKPs as identifiers is no longer supported. A new authentication mechanism is introduced for issuers. - Access to a World ID can be recovered. A user can designate a *Recovery Agent* for their account which will allow for recovery in case of access to all Authenticators is lost. - - [**Recovery Agent Scope**]. Users may designate the *PoH AMPC* system as their Recovery Agent to recover their World ID. In the future, other Recovery Agents are expected to be available. + - [**Recovery Agent Scope**]. Users may designate the *PoH AMPC* system as their Recovery Agent to recover their World ID. In the future, other Recovery Agents are expected to be available. ## High level overview @@ -90,14 +90,13 @@ Diagram of components for the World ID 4.0 Protocol. 2. Similarly, a **Relying Party Registry** is introduced. This registry contains a list of authorized Relying Parties with their accompanying authorized public keys. The registry permits RPs to authenticate requests for proofs to Authenticators. 3. The multi-party set of **OPRF Nodes** is introduced. This set of nodes are now responsible for generating the nullifiers that users present to RPs to prove uniqueness. The nullifiers are generated through a *Verified Threshold* *Oblivious Pseudorandom Function* (vOPRF) with participation of the OPRF nodes. Nodes verify requests for nullifiers are properly validated by both RPs and users (see *Uniqueness Proofs*), and only then will generate the required output to compute the user’s nullifier. The users then construct the final nullifier and prove its computation in the proof they present to RPs. 1. A multi-party OPRF is necessary because it prevents nullifiers from being guessable, i.e. nullifiers are deterministic but appear random (recall that PRF outputs under a uniformly random key are computationally indistinguishable from a uniformly random function). This could theoretically be accomplished with a regular hash function, but then nullifiers could be brute forced by computing the hash for all possible `leafIndex`es (which are public on-chain). To prevent this, secret entropy is required (in World ID ≤ 3.0, the user provided this entropy). Since this is not available anymore, the entropy now comes from the OPRF nodes. - 2. Additionally, to prevent brute forcing even with involvement of OPRF nodes, OPRF nodes require authentication before computing each hash. They authenticate the user through a ZKP that proves knowledge of an Authenticator secret key authorized in the `WorldIDRegistry` for the particular `leafIndex` for which they are generating a nullifier. + 2. Additionally, to prevent brute forcing even with involvement of OPRF nodes, OPRF nodes require authentication before computing each hash. They authenticate the user through a ZKP that proves knowledge of an Authenticator secret key authorized in the `WorldIDRegistry` for the particular `leafIndex` for which they are generating a nullifier. 3. Importantly, the OPRF nodes compute the keyed-hash function $H_k(x')$ on a blinded input, hence they cannot learn which user is actually performing a request. Furthermore, the OPRF nodes output a proof that attests to the proper computation of $H_k$ given a committed $k_{pk}$, so neither users nor RPs need to blindly trust the OPRF nodes. 4. Similar to how OPRF Nodes are used to generate the nullifiers presented to RPs, these nodes also generate a blinding factor for each credential so there cannot be correlation of World IDs from malicious issuers. 5. More information on the OPRF Nodes can be found in the paper: *“[A Nullifier Protocol based on a Verifiable, Threshold OPRF](https://github.com/TaceoLabs/oprf-service/blob/main/docs/oprf.pdf)”*. - 6. Details about the nature, number, and diversity requirements of OPRF nodes must be established before the production network is live. + 6. Details about the nature, number, and diversity requirements of OPRF nodes must be established before the production network is live. 4. Protocol differences at a glance: - - + | | **World ID ≤3.0** | **World ID 4.0 (2025)** | | --- | --- | --- | | What is a World ID? | A secret. | An entry in public registry. | @@ -147,12 +146,11 @@ RP ->> RP: Verify nullifier uniqueness ``` - The nullifier is computed by the OPRF Nodes. Computing it requires output from a threshold number of nodes to be valid. - - Importantly, the input to the OPRF Nodes is blinded so that no OPRF node can see the raw `leafIndex` (i.e. OPRF nodes only know that the request is from an authorized authenticator). - - Importantly, the nullifier is credential *independent*, so the action can only be performed once regardless of which credentials are available at the time. - - Further information on how the nullifier is computed can be found in the [TACEO OPRF Whitepaper](https://github.com/TaceoLabs/nullifier-oracle-service/blob/main/docs/oprf.pdf). + - Importantly, the input to the OPRF Nodes is blinded so that no OPRF node can see the raw `leafIndex` (i.e. OPRF nodes only know that the request is from an authorized authenticator). + - Importantly, the nullifier is credential *independent*, so the action can only be performed once regardless of which credentials are available at the time. + - Further information on how the nullifier is computed can be found in the [TACEO OPRF Whitepaper](https://github.com/TaceoLabs/nullifier-oracle-service/blob/main/docs/oprf.pdf). - Nullifiers have the following properties, which in combination make them amenable for use by an RP to enforce anonymous per-action uniqueness: - - + | **Property** | **Description** | | --- | --- | | Deterministic | Given the same context (`leafIndex` [blinded], `rpId`, `action`), the nullifier is always the same. Assuming honest behavior of OPRF nodes never rotating their base key. *Note that the credential is intentionally not included in this context. This means that the action can be performed only once, regardless of which credentials are available at the time.* | @@ -161,45 +159,46 @@ RP ->> RP: Verify nullifier uniqueness | Anonymous | A nullifier hides which user generated it. To preserve anonymity, each nullifier must only be used once (otherwise repeated use makes it pseudonymous). This is the responsibility of Authenticators. | | Unlinkable | For any two nullifiers with different contexts, the probability that an adversary can correctly distinguish whether they were derived from the same user is at most negligibly better than random guessing. | | Pre-image resistance | For any given nullifier, and knowing the public context (`rpId`, `action`), it is computationally infeasible to find the pre-image or the `leafIndex`. | + - The authenticator generates two types of different zero-knowledge proofs to be able to deliver a Uniqueness Proof to an RP, - - The query proof $\pi_1$ which proves to the OPRF Nodes that the request is properly authorized by the user. This ZKP proves the request is signed by a public key which is registered for the particularly provided blinded `leafIndex` in the `WorldIDRegistry`. - - A final Uniqueness Proof $\pi_2$ which ensures at least the following constraints: - - *The same constraints of the query proof are evaluated.* - - Correct OPRF evaluation on `leafIndex`, i.e. the generated nullifier is correct for the committed public keys from each OPRF node. - - Request is signed by a public key that is registered for the `leafIndex` in the `WorldIDRegistry` (user authentication). - - The Credential was issued for this World ID, i.e. the Credential’s `sub` matches the blinded `leafIndex` of the user. - - The Credential used in the proof is signed by the Issuer (through the committed key in the `CredentialSchemaIssuerRegistry`). - - Credential is not expired. - - Credential meets the minimum genesis_issued_at constraint provided by the RP. - - Signal and nonce provided by the RP as public inputs are committed. - - *Potential future constraints may include: integrity attestation of device, enforcing the expiration of actions, credential specific checks, etc.* + - The query proof $\pi_1$ which proves to the OPRF Nodes that the request is properly authorized by the user. This ZKP proves the request is signed by a public key which is registered for the particularly provided blinded `leafIndex` in the `WorldIDRegistry`. + - A final Uniqueness Proof $\pi_2$ which ensures at least the following constraints: + - *The same constraints of the query proof are evaluated.* + - Correct OPRF evaluation on `leafIndex`, i.e. the generated nullifier is correct for the committed public keys from each OPRF node. + - Request is signed by a public key that is registered for the `leafIndex` in the `WorldIDRegistry` (user authentication). + - The Credential was issued for this World ID, i.e. the Credential’s `sub` matches the blinded `leafIndex` of the user. + - The Credential used in the proof is signed by the Issuer (through the committed key in the `CredentialSchemaIssuerRegistry`). + - Credential is not expired. + - Credential meets the minimum genesis_issued_at constraint provided by the RP. + - Signal and nonce provided by the RP as public inputs are committed. + - *Potential future constraints may include: integrity attestation of device, enforcing the expiration of actions, credential specific checks, etc.* - **Oblivious Nullifier Pool**. The Oblivious Nullifier Pool is a separate service which offers *Private Intersection Retrieval* and keeps track of used nullifiers. Its function is simply to keep a flat list of used nullifiers such that an authenticator can query if a nullifier has been used before sharing it (and the related $\pi_2$) with an RP if it has been used before. The list is flat (as the nullifier is already unique per-RP-per-action-per-user) relying on the collision-resistance property of the hash function used in the Protocol. - - This system ensures that nullifiers can’t be misused to create long running identifiers. As their name suggests, a nullifier is one-time use. - - The term *oblivious* is used to refer to the fact that this map is queried in a way where the servers serving such requests cannot learn which records where accessed and hence be able to compromise the user’s privacy. - - The main limitation of the nullifier pool is performance at scale. One option is to shard the pool, making trade-offs of anonymity set size vs. performance. This is still in research. - - Initially, this pool will only be used for actions that have a running period longer than a predefined threshold. This is to solve for scaling issues as this system grows. + - This system ensures that nullifiers can’t be misused to create long running identifiers. As their name suggests, a nullifier is one-time use. + - The term *oblivious* is used to refer to the fact that this map is queried in a way where the servers serving such requests cannot learn which records where accessed and hence be able to compromise the user’s privacy. + - The main limitation of the nullifier pool is performance at scale. One option is to shard the pool, making trade-offs of anonymity set size vs. performance. This is still in research. + - Initially, this pool will only be used for actions that have a running period longer than a predefined threshold. This is to solve for scaling issues as this system grows. - **Blinded subjects**. To prevent correlation of users even among issuers, or in case of leaked credentials, the subjects of the credentials are blinded. - - When requesting a new credential from an issuer, the user generates a blinding factor using the OPRF nodes, $\texttt{subjectBlindingFactor}=H_k(\texttt{issuerSchemaId} \mid\mid \texttt{leafIndex})$. - - The user then hashes the blinding factor with their `leafIndex` to compute the `sub` claim of the credential. This value is what issuers include in the credential. - - When a proof is presented, the `subjectBlindingFactor` is used within the Uniqueness Proof circuit to ensure the credential is issued to the right user. The blinding factor acts as entropy to prevent correlation, but the right `leafIndex` as provided in the circuit input must match correctly. + - When requesting a new credential from an issuer, the user generates a blinding factor using the OPRF nodes, $\texttt{subjectBlindingFactor}=H_k(\texttt{issuerSchemaId} \mid\mid \texttt{leafIndex})$. + - The user then hashes the blinding factor with their `leafIndex` to compute the `sub` claim of the credential. This value is what issuers include in the credential. + - When a proof is presented, the `subjectBlindingFactor` is used within the Uniqueness Proof circuit to ensure the credential is issued to the right user. The blinding factor acts as entropy to prevent correlation, but the right `leafIndex` as provided in the circuit input must match correctly. ### Registries - **World ID Registry** - - Each user can grant access to multiple different keys to interact with their World ID. The Authenticator proves control inside a ZKP to prevent long-lived identifiers. - - In order to not leak the user, this needs to be in some structure that allows inclusion proofs. This is accomplished with [Incremental Merkle Trees](https://github.com/zk-kit/zk-kit.solidity/blob/main/packages/imt/contracts/BinaryIMT.sol). - - Each Authenticator registers two keys in the registry. This is done to enable performant operations both on-chain and on zero-knowledge circuits. - - An on-chain key which is an elliptic curve key on the `secp256k1` curve is used to authorize on-chain operations on the contract (e.g. adding an authenticator, removing an authenticator, etc.). The public key is simply represented as an Ethereum address. - - An off-chain key which is an elliptic curve key on the `BabyJubJub` curve is used to sign requests for zero-knowledge proofs. The public key (represented as a curve point) is emitted on-chain and committed to in the contract. + - Each user can grant access to multiple different keys to interact with their World ID. The Authenticator proves control inside a ZKP to prevent long-lived identifiers. + - In order to not leak the user, this needs to be in some structure that allows inclusion proofs. This is accomplished with [Incremental Merkle Trees](https://github.com/zk-kit/zk-kit.solidity/blob/main/packages/imt/contracts/BinaryIMT.sol). + - Each Authenticator registers two keys in the registry. This is done to enable performant operations both on-chain and on zero-knowledge circuits. + - An on-chain key which is an elliptic curve key on the `secp256k1` curve is used to authorize on-chain operations on the contract (e.g. adding an authenticator, removing an authenticator, etc.). The public key is simply represented as an Ethereum address. + - An off-chain key which is an elliptic curve key on the `BabyJubJub` curve is used to sign requests for zero-knowledge proofs. The public key (represented as a curve point) is emitted on-chain and committed to in the contract. - **Relying Party Registry** - - Each RP needs to commit to their authorized public key on the public registry, such that this can be verified in the request proof $\pi_1$ by each queried OPRF node. - - Registering an RP is a public action that anyone can take, but this requires paying a one-time registration fee (see *Registration Fees* below). - - In order to allow for decentralized application creation and registration, the RP Registry will be extended and restrictions further lifted in the future, but for this initial version the following applies: - - At launch, only one authorized key is allowed per RP. This will be extended in the future. + - Each RP needs to commit to their authorized public key on the public registry, such that this can be verified in the request proof $\pi_1$ by each queried OPRF node. + - Registering an RP is a public action that anyone can take, but this requires paying a one-time registration fee (see *Registration Fees* below). + - In order to allow for decentralized application creation and registration, the RP Registry will be extended and restrictions further lifted in the future, but for this initial version the following applies: + - At launch, only one authorized key is allowed per RP. This will be extended in the future. - **Credential Schema Issuer Registry** - - It's a simple registry where Issuers register for each of their credential types a schema and an authorized signatory and get issued an `issuerSchemaId`. This ID represents the combination of an (issuer, schema). For example: (Tools For Humanity, Orb credential). - - The `issuerSchemaId` is included in the credential and is verified as part of all Proofs. When generating and verifying proofs, the signature of a credential is verified against the public key registered in the contract. - - Registering an Issuer Schema also requires paying a one-time registration fee (see *Registration Fees* below). + - It's a simple registry where Issuers register for each of their credential types a schema and an authorized signatory and get issued an `issuerSchemaId`. This ID represents the combination of an (issuer, schema). For example: (Tools For Humanity, Orb credential). + - The `issuerSchemaId` is included in the credential and is verified as part of all Proofs. When generating and verifying proofs, the signature of a credential is verified against the public key registered in the contract. + - Registering an Issuer Schema also requires paying a one-time registration fee (see *Registration Fees* below). ### Registration Fees @@ -208,6 +207,7 @@ Both the Relying Party Registry and the Credential Schema Issuer Registry charge **Why the fee exists.** Registering an RP or an Issuer Schema triggers the initialization of an OPRF key via a multi-round distributed key generation ceremony across the OPRF Nodes. This is a computationally expensive operation with real infrastructure cost. The registration fee is sized to cover the cost of OPRF key generation and storage for at least approximately one year. **How it works.** + - The fee is paid in a configurable ERC-20 token via `safeTransferFrom` at the time of registration, before OPRF key generation begins. **Future: per-request fees.** The registration fee described here covers only the one-time cost of onboarding. A separate per-request fee — enforced by OPRF Nodes as a proof-of-payment requirement during nullifier generation — may be introduced in a future Protocol release (4.1 or 4.2). See *Future Proofing Notes* for details. @@ -275,24 +275,27 @@ rp->>rp: verify proof (checking sessionId == C' in verifier contract) **Recovering `r` for subsequent Session Proofs.** The OPRF is deterministic: the same input and key always produce the same output. This means `r` can be re-derived at any time by calling the OPRF nodes with the original `oprf_seed` (stored in `sessionId`). Caching `r` is an optimization, not a requirement. The OPRF call to derive `r` and the OPRF call to derive the nullifier can be made in parallel. **Session Nullifiers** + - A [`sessionNullifier`](https://docs.rs/world-id-primitives/latest/world_id_primitives/session/struct.SessionNullifier.html) is used for verifying Session Proofs. It must be passed to the verification contract. Internally, the [`sessionNullifier`](https://docs.rs/world-id-primitives/latest/world_id_primitives/session/struct.SessionNullifier.html) implements custom encoding on the Authenticator and on the `WorldIDVerifier` contract. - The raison d'être is simply to allow usage of the same ZK circuit as for Uniqueness Proofs. Reducing the number of circuits is currently a priority because of the size of the circuits needed to be bundled in Authenticator clients. As World ID moves to a different proving system, this type will no longer be required. - Session Proofs use a randomized `action` as circuit input. This randomized `action` ensures the circuit's nullifier output is unique per proof, preserving the one-time use property. It is verified internally within the circuit. It does not affect `r` derivation. **Binding Uniqueness Proofs to a Session** -- A Uniqueness Proof request may include an existing `sessionId`. The proof then carries the session's commitment `C` as its `id_commitment` public signal, proving in-circuit that the session and the nullifier belong to the same World ID. -- The Authenticator requires the cached `r` for this; re-deriving `r` is only possible through a session-type request. -- Verifiers MUST check the proof against the session's commitment. With the session commitment set to `0` the proof is valid but unbound. On-chain, the dedicated `verifyWithSession()` entry point does this (it rejects `sessionId == 0`). The convenience `verify()` entry point pins the signal to `0` and rejects bound proofs, so binding is explicit in both directions. +- A Uniqueness Proof request may omit `session_id` (unbound), use `"create"` to atomically mint a session and bind the proof to it, or include an existing `sessionId` to bind to a previously established session. Bound proofs carry the session's commitment `C` as their `id_commitment` public signal, proving in-circuit that the session and the nullifier belong to the same World ID. +- `proof_type: "uniqueness", session_id: "create"` performs two OPRF rounds: the normal uniqueness-nullifier query and a session-seed query. The latter carries the RP-signed uniqueness action as `signed_action`, allowing the same RP signature to authorize the session module. +- Binding to an existing `sessionId` requires the Authenticator's cached `r`; re-deriving `r` is only possible through a session-type request. +- Verifiers MUST check bound proofs against the session's commitment. With the session commitment set to `0` the proof is valid but unbound. On-chain, the dedicated `verifyWithSession()` entry point does this (it rejects `sessionId == 0`). The convenience `verify()` entry point pins the signal to `0` and rejects bound proofs, so binding is explicit in both directions. - Binding one `sessionId` to Uniqueness Proofs under different actions intentionally links those actions to the same World ID; Authenticators MUST clearly surface this to users. +- OPRF nodes must support `signed_action` on session-seed queries before authenticators begin sending it. The field is additive: requests without it behave identically to before. ### Web-based Authenticator Provider -To allow for an improved user experience, a reference browser-based Authenticator provider is being introduced. This app provides (currently limited) World ID functionality but without leaving the browser. +To allow for an improved user experience, a reference browser-based Authenticator provider is being introduced. This app provides (currently limited) World ID functionality but without leaving the browser. 1. At a high-level, it allows **usage** of a World ID. The user can generate proofs in their browser, and this is particularly useful for when working on other devices (such as desktop) or on non-native apps. 2. Whenever an RP requires a user’s World ID proof, they can simply redirect the user to the web app (handled automatically by common SDKs like [ID Kit](https://github.com/worldcoin/idkit)). The user authenticates with their passkey, generates the proof in their browser and passes it back to the RP. -3. Further documentation on the architecture of the reference web-based Authenticator provider will be published in the https://github.com/worldcoin/web-authenticator repository. +3. Further documentation on the architecture of the reference web-based Authenticator provider will be published in the repository. 4. **Credential Enrollment** will not be supported in the initial release, but this may be introduced in the future. ## Migration Considerations @@ -303,8 +306,7 @@ At a high level, every user and RP will need to migrate to the new Protocol. Det - The Protocol, via the Oblivious Nullifier Pool enforces that nullifiers cannot be generated more than once (as long as authenticators are properly implemented), which prevents long running user tracking, increasing the privacy from the previous protocol version. - In adversarial scenarios, these are the most relevant privacy considerations, - - + | Attack scenario | **World ID ≤3.0** | **World ID 4.0 (2025)** | | --- | --- | --- | | Compromised user’s secret | ⚠️ Potentially reveals all past activity if the attacker knows the public app IDs and actions. | ✅ Cannot reveal past activity on its own | @@ -326,7 +328,6 @@ At a high level, every user and RP will need to migrate to the new Protocol. Det - **Authenticator Risk**. Aside from having access to the user’s credentials, an Authenticator must learn of a user’s raw `leafIndex` to be able to generate Proofs. A malicious Authenticator can misuse this to track the user, even though that tracking cannot be correlated to nullifiers provided to RPs on its own. Different strategies to mitigate Authenticator risk are being explored. - **Recovery Agent Risk**. Should a user designate a Recovery Agent, this entity has a special permission that allows it to gain access to the user’s World ID, which could be misused. Beyond the explicit risk of a malicious Recovery Agent compromising a user's World ID, users need to consider the different risks associated with different Recovery Agents based on how they perform authentication. - ## Future Proofing Notes (World ID 4.x future releases and beyond) This is not a comprehensive list, but it outlines general topics that may be the target of upcoming Protocol releases which are not currently covered on this release. From b7c197790555a11f0c03ccc89fef35aeecc1369e Mon Sep 17 00:00:00 2001 From: kilianglas Date: Tue, 14 Jul 2026 15:45:29 +0200 Subject: [PATCH 17/36] chore: remove unnecessary tests --- crates/primitives/src/request/mod.rs | 87 ---------------------------- 1 file changed, 87 deletions(-) diff --git a/crates/primitives/src/request/mod.rs b/crates/primitives/src/request/mod.rs index ff292ce64..bfb37cc64 100644 --- a/crates/primitives/src/request/mod.rs +++ b/crates/primitives/src/request/mod.rs @@ -2512,90 +2512,6 @@ mod tests { assert!(parsed.binds_session()); } - #[test] - fn test_request_with_create_session_proof_type_fails_loudly() { - let request = ProofRequest { - id: "req_legacy".into(), - version: RequestVersion::V1, - proof_type: ProofType::Session, - session_id: SessionRef::Create, - action: None, - created_at: 1_735_689_600, - expires_at: 1_735_689_900, - rp_id: RpId::new(1), - oprf_key_id: OprfKeyId::new(uint!(1_U160)), - signature: test_signature(), - nonce: test_nonce(), - requests: vec![RequestItem { - identifier: "orb".into(), - issuer_schema_id: 1, - signal: None, - genesis_issued_at_min: None, - expires_at_min: None, - }], - constraints: None, - }; - - // the collapsed legacy proof type must be rejected at the parse boundary - let mut value: serde_json::Value = - serde_json::from_str(&request.to_json().unwrap()).unwrap(); - value["proof_type"] = "create_session".into(); - value["session_id"] = serde_json::Value::Null; - let err = ProofRequest::from_json(&value.to_string()).unwrap_err(); - assert!(err.to_string().contains("create_session")); - } - - #[test] - fn test_request_session_create_parses_and_validates() { - let request = ProofRequest { - id: "req_create".into(), - version: RequestVersion::V1, - proof_type: ProofType::Session, - session_id: SessionRef::Create, - action: None, - created_at: 1_735_689_600, - expires_at: 1_735_689_900, - rp_id: RpId::new(1), - oprf_key_id: OprfKeyId::new(uint!(1_U160)), - signature: test_signature(), - nonce: test_nonce(), - requests: vec![RequestItem { - identifier: "orb".into(), - issuer_schema_id: 1, - signal: None, - genesis_issued_at_min: None, - expires_at_min: None, - }], - constraints: None, - }; - - let json = request.to_json().unwrap(); - let value: serde_json::Value = serde_json::from_str(&json).unwrap(); - assert_eq!(value["session_id"], "create"); - - let parsed = ProofRequest::from_json(&json).unwrap(); - assert!(parsed.session_id.is_create()); - assert!(parsed.is_session_proof()); - assert!(!parsed.binds_session()); - - // session proofs without a session reference stay rejected - let mut without_session: serde_json::Value = serde_json::from_str(&json).unwrap(); - without_session["session_id"] = serde_json::Value::Null; - assert!(ProofRequest::from_json(&without_session.to_string()).is_err()); - - // uniqueness × "create" is valid at the parse boundary - let uniqueness_create_request = ProofRequest { - proof_type: ProofType::Uniqueness, - session_id: SessionRef::Create, - action: Some(FieldElement::ZERO), - ..request.clone() - }; - let parsed = - ProofRequest::from_json(&uniqueness_create_request.to_json().unwrap()).unwrap(); - assert!(parsed.session_id.is_create()); - assert!(parsed.binds_session()); - } - #[test] fn test_request_absent_session_id_defaults_to_none() { let request = ProofRequest { @@ -2722,9 +2638,6 @@ mod tests { serde_json::from_str::("\"session\"").unwrap(), ProofType::Session ); - // the collapsed legacy variant must fail loudly - let err = serde_json::from_str::("\"create_session\"").unwrap_err(); - assert!(err.to_string().contains("create_session")); } #[test] From 816e9cfa4b29a423c85f0950612b4efb9689e072 Mon Sep 17 00:00:00 2001 From: kilianglas Date: Wed, 15 Jul 2026 11:43:27 +0200 Subject: [PATCH 18/36] feat: rp_signature_verification field --- crates/primitives/src/oprf.rs | 56 ++++++++++----- crates/proof/src/oprf_query.rs | 19 +++-- docs/world-id-4-specs/README.md | 4 +- .../src/bin/world-id-dev-client-rp.rs | 2 +- services/oprf-node/src/auth/rp_module.rs | 45 +++++++----- .../oprf-node/src/auth/rp_module/tests.rs | 71 ++++++++++++------- 6 files changed, 127 insertions(+), 70 deletions(-) diff --git a/crates/primitives/src/oprf.rs b/crates/primitives/src/oprf.rs index 01cdf2824..600e1edc2 100644 --- a/crates/primitives/src/oprf.rs +++ b/crates/primitives/src/oprf.rs @@ -21,6 +21,20 @@ pub enum OprfModule { Session, } +/// Additional data needed to reconstruct the message covered by an RP signature. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RpSignatureVerification { + /// A uniqueness action covered by the RP signature. + /// + /// This is used on create-and-bind session-seed queries, whose OPRF action is the + /// session seed rather than the uniqueness action included in the signed message. + UniquenessAction { + /// The RP-signed uniqueness action (MSB `0x00`). + action: FieldElement, + }, +} + impl std::fmt::Display for OprfModule { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -69,14 +83,12 @@ pub struct NullifierOprfRequestAuthV1 { with = "serde_utils::hex_bytes_opt" )] pub wip101_data: Option>, - /// The RP-signed uniqueness action (MSB `0x00`) for create-and-bind session-seed queries. + /// Additional data needed to reconstruct the RP-signed message. /// - /// Only valid on session-seed queries (see [`SessionFeType::OprfSeed`]) from EOA-backed RPs. - /// When present, the OPRF node verifies the RP signature over the action-inclusive message - /// (see `compute_rp_signature_msg`) instead of the action-less one, so a single RP signature - /// can authorize creating a session and binding a Uniqueness Proof to it. + /// Currently only valid on create-and-bind session-seed queries (see + /// [`SessionFeType::OprfSeed`]) from EOA-backed RPs. #[serde(default, skip_serializing_if = "Option::is_none")] - pub signed_action: Option, + pub rp_signature_verification: Option, } /// A request sent by a client for OPRF credential blinding factor authentication. @@ -556,7 +568,9 @@ mod tests { .expect("valid test proof") } - fn test_auth(signed_action: Option) -> NullifierOprfRequestAuthV1 { + fn test_auth( + rp_signature_verification: Option, + ) -> NullifierOprfRequestAuthV1 { NullifierOprfRequestAuthV1 { proof: test_proof(), action: ark_babyjubjub::Fq::from(1u64), @@ -567,37 +581,41 @@ mod tests { signature: None, rp_id: RpId::new(6), wip101_data: None, - signed_action, + rp_signature_verification, } } #[test] - fn nullifier_auth_signed_action_none_is_omitted() { + fn nullifier_auth_rp_signature_verification_none_is_omitted() { let value = serde_json::to_value(test_auth(None)).unwrap(); // Forward compat: unused, the field never appears on the wire. - assert!(value.get("signed_action").is_none()); + assert!(value.get("rp_signature_verification").is_none()); // Backward compat: payloads without the field deserialize to `None`. let parsed: NullifierOprfRequestAuthV1 = serde_json::from_value(value).unwrap(); - assert!(parsed.signed_action.is_none()); + assert!(parsed.rp_signature_verification.is_none()); } #[test] - fn nullifier_auth_signed_action_json_roundtrip() { - let signed_action = FieldElement::from(42u64); - let auth = test_auth(Some(signed_action)); + fn nullifier_auth_rp_signature_verification_json_roundtrip() { + let verification = RpSignatureVerification::UniquenessAction { + action: FieldElement::from(42u64), + }; + let auth = test_auth(Some(verification)); let json = serde_json::to_string(&auth).unwrap(); let parsed: NullifierOprfRequestAuthV1 = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.signed_action, Some(signed_action)); + assert_eq!(parsed.rp_signature_verification, Some(verification)); } #[test] - fn nullifier_auth_signed_action_cbor_roundtrip() { - let signed_action = FieldElement::from(42u64); - let auth = test_auth(Some(signed_action)); + fn nullifier_auth_rp_signature_verification_cbor_roundtrip() { + let verification = RpSignatureVerification::UniquenessAction { + action: FieldElement::from(42u64), + }; + let auth = test_auth(Some(verification)); let mut bytes = Vec::new(); ciborium::into_writer(&auth, &mut bytes).unwrap(); let parsed: NullifierOprfRequestAuthV1 = ciborium::from_reader(bytes.as_slice()).unwrap(); - assert_eq!(parsed.signed_action, Some(signed_action)); + assert_eq!(parsed.rp_signature_verification, Some(verification)); } #[test] diff --git a/crates/proof/src/oprf_query.rs b/crates/proof/src/oprf_query.rs index fce36a916..dab2cf47e 100644 --- a/crates/proof/src/oprf_query.rs +++ b/crates/proof/src/oprf_query.rs @@ -23,7 +23,10 @@ use taceo_oprf::{ use world_id_primitives::{ FieldElement, ProofRequest, ProofType, SessionFeType, SessionFieldElement, SessionRef, TREE_DEPTH, - oprf::{CredentialBlindingFactorOprfRequestAuthV1, NullifierOprfRequestAuthV1, OprfModule}, + oprf::{ + CredentialBlindingFactorOprfRequestAuthV1, NullifierOprfRequestAuthV1, OprfModule, + RpSignatureVerification, + }, }; use crate::circuit_inputs::QueryProofCircuitInput; @@ -259,7 +262,7 @@ impl<'a> OprfEntrypoint<'a> { signature: Some(proof_request.signature), rp_id: proof_request.rp_id, wip101_data: None, - signed_action: None, + rp_signature_verification: None, }; let verifiable_oprf_output = Self::execute_distributed_oprf( @@ -309,8 +312,14 @@ impl<'a> OprfEntrypoint<'a> { rng, )?; - let signed_action = match (proof_request.proof_type, proof_request.session_id) { - (ProofType::Uniqueness, SessionRef::Create) => proof_request.action, + let rp_signature_verification = match ( + proof_request.proof_type, + proof_request.session_id, + proof_request.action, + ) { + (ProofType::Uniqueness, SessionRef::Create, Some(action)) => { + Some(RpSignatureVerification::UniquenessAction { action }) + } _ => None, }; @@ -324,7 +333,7 @@ impl<'a> OprfEntrypoint<'a> { signature: Some(proof_request.signature), rp_id: proof_request.rp_id, wip101_data: None, - signed_action, + rp_signature_verification, }; let verifiable_oprf_output = Self::execute_distributed_oprf( diff --git a/docs/world-id-4-specs/README.md b/docs/world-id-4-specs/README.md index fb183162e..395948d64 100644 --- a/docs/world-id-4-specs/README.md +++ b/docs/world-id-4-specs/README.md @@ -283,11 +283,11 @@ rp->>rp: verify proof (checking sessionId == C' in verifier contract) **Binding Uniqueness Proofs to a Session** - A Uniqueness Proof request may omit `session_id` (unbound), use `"create"` to atomically mint a session and bind the proof to it, or include an existing `sessionId` to bind to a previously established session. Bound proofs carry the session's commitment `C` as their `id_commitment` public signal, proving in-circuit that the session and the nullifier belong to the same World ID. -- `proof_type: "uniqueness", session_id: "create"` performs two OPRF rounds: the normal uniqueness-nullifier query and a session-seed query. The latter carries the RP-signed uniqueness action as `signed_action`, allowing the same RP signature to authorize the session module. +- `proof_type: "uniqueness", session_id: "create"` performs two OPRF rounds: the normal uniqueness-nullifier query and a session-seed query. The latter carries the RP-signed uniqueness action as `rp_signature_verification: { "uniqueness_action": { "action": ... } }`, allowing the same RP signature to authorize the session module. - Binding to an existing `sessionId` requires the Authenticator's cached `r`; re-deriving `r` is only possible through a session-type request. - Verifiers MUST check bound proofs against the session's commitment. With the session commitment set to `0` the proof is valid but unbound. On-chain, the dedicated `verifyWithSession()` entry point does this (it rejects `sessionId == 0`). The convenience `verify()` entry point pins the signal to `0` and rejects bound proofs, so binding is explicit in both directions. - Binding one `sessionId` to Uniqueness Proofs under different actions intentionally links those actions to the same World ID; Authenticators MUST clearly surface this to users. -- OPRF nodes must support `signed_action` on session-seed queries before authenticators begin sending it. The field is additive: requests without it behave identically to before. +- OPRF nodes must support `rp_signature_verification` on session-seed queries before authenticators begin sending it. The field is additive: requests without it behave identically to before. ### Web-based Authenticator Provider diff --git a/services/oprf-dev-client/src/bin/world-id-dev-client-rp.rs b/services/oprf-dev-client/src/bin/world-id-dev-client-rp.rs index 745f449c7..c4c72e5e6 100644 --- a/services/oprf-dev-client/src/bin/world-id-dev-client-rp.rs +++ b/services/oprf-dev-client/src/bin/world-id-dev-client-rp.rs @@ -360,7 +360,7 @@ fn generate_oprf_auth_request( signature: Some(proof_request.signature), rp_id: proof_request.rp_id, wip101_data: None, - signed_action: None, + rp_signature_verification: None, }; Ok(auth) diff --git a/services/oprf-node/src/auth/rp_module.rs b/services/oprf-node/src/auth/rp_module.rs index 96565a31a..fce306c41 100644 --- a/services/oprf-node/src/auth/rp_module.rs +++ b/services/oprf-node/src/auth/rp_module.rs @@ -4,21 +4,22 @@ //! logic, and query-proof verification. They differ only in: //! - how the action field is validated (`MSB == 0x00` for uniqueness vs `0x01/0x02` for sessions depending on the [`SessionFeType`]) //! - whether the action is included in the RP signature (`Some` for uniqueness, `None` for -//! session — unless the request carries a `signed_action`, see below) +//! session — unless the request carries RP signature verification data, see below) //! - which [`WorldIdRequestAuthError`] variant is returned for an invalid action //! //! [`RpModuleKind`] captures these differences; [`RpModuleAuth`] holds the shared //! state and branches on the kind at runtime. //! -//! # Signed actions on session-seed queries (create-and-bind) +//! # RP signature verification data on session-seed queries (create-and-bind) //! -//! A session-seed query may carry an optional `signed_action` (a nullifier action, MSB -//! `0x00`). When present, the session module verifies the RP signature over the +//! A session-seed query may carry an optional uniqueness action (MSB `0x00`) as +//! [`RpSignatureVerification::UniquenessAction`]. When present, the session module +//! verifies the RP signature over the //! action-inclusive message instead of the action-less one. This lets a single RP //! signature authorize both creating a session and binding a Uniqueness Proof to it. -//! `signed_action` is rejected everywhere else: on session-action queries, on the -//! uniqueness module, and for WIP101 contract-backed RPs (which do not support session -//! queries yet). +//! RP signature verification data is rejected everywhere else: on session-action queries, +//! on the uniqueness module, and for WIP101 contract-backed RPs (which do not support +//! session queries yet). use crate::{ accountant_batcher::AccountantBatcherHandle, @@ -44,7 +45,7 @@ use taceo_oprf::types::{ use tracing::instrument; use world_id_primitives::{ FieldElement, SessionFeType, SessionFieldElement as _, - oprf::{NullifierOprfRequestAuthV1, WorldIdRequestAuthError}, + oprf::{NullifierOprfRequestAuthV1, RpSignatureVerification, WorldIdRequestAuthError}, rp::RpId, }; @@ -54,8 +55,9 @@ pub(crate) mod wip101; #[derive(Clone)] pub(crate) enum RpModuleKind { /// Session module: action MSB must be `0x01` (seed) or `0x02` (action); action is NOT - /// signed. Seed queries may carry a `signed_action` (MSB `0x00`), in which case the RP - /// signature is verified over the action-inclusive message (create-and-bind). + /// signed. Seed queries may carry a uniqueness action as RP signature verification data, + /// in which case the signature is verified over the action-inclusive message + /// (create-and-bind). Session, /// Uniqueness module: action MSB must be `0x00`; action IS signed. Uniqueness(AccountantBatcherHandle), @@ -341,14 +343,18 @@ impl RpModuleAuth { let action = match self.kind { RpModuleKind::Uniqueness(_) => Some(action), // Session RP signatures do not include the action, unless the request - // carries a `signed_action` (create-and-bind seed queries). - RpModuleKind::Session => request.auth.signed_action.map(|a| *a), + // carries a uniqueness action as verification data (create-and-bind). + RpModuleKind::Session => request.auth.rp_signature_verification.map( + |verification| match verification { + RpSignatureVerification::UniquenessAction { action } => *action, + }, + ), }; rp.verify_eoa(action, request) } RpAccountType::Contract => { // TODO(session-proofs): WIP-101 does not currently support session proofs. - if request.auth.signed_action.is_some() { + if request.auth.rp_signature_verification.is_some() { return Err(RpModuleError::SignedActionNotAllowed { context: "not supported for WIP101 contract-backed RPs", }); @@ -404,13 +410,16 @@ impl RpModuleAuth { let action = FieldElement::from(request.auth.action); // Validate the action per kind and derive the nonce scope it consumes. - // A `signed_action` (a nullifier action the RP signature covers) is only valid on - // session-seed queries; see the module docs for the create-and-bind flow. + // RP signature verification data is only valid on session-seed queries; see the + // module docs for the create-and-bind flow. let nonce_scope = match self.kind { RpModuleKind::Session => { metrics::auth_module::inc_session(); if action.is_valid_for_session(SessionFeType::OprfSeed) { - if let Some(signed_action) = request.auth.signed_action { + if let Some(RpSignatureVerification::UniquenessAction { + action: signed_action, + }) = request.auth.rp_signature_verification + { if signed_action.to_be_bytes()[0] != 0 { return Err(RpModuleError::InvalidSignedAction { signed_action }); } @@ -418,7 +427,7 @@ impl RpModuleAuth { } NonceScope::SessionOprfSeed } else if action.is_valid_for_session(SessionFeType::Action) { - if request.auth.signed_action.is_some() { + if request.auth.rp_signature_verification.is_some() { return Err(RpModuleError::SignedActionNotAllowed { context: "only allowed on session-seed queries", }); @@ -430,7 +439,7 @@ impl RpModuleAuth { } RpModuleKind::Uniqueness(_) => { metrics::auth_module::inc_nullifier(); - if request.auth.signed_action.is_some() { + if request.auth.rp_signature_verification.is_some() { return Err(RpModuleError::SignedActionNotAllowed { context: "only allowed on the session module", }); diff --git a/services/oprf-node/src/auth/rp_module/tests.rs b/services/oprf-node/src/auth/rp_module/tests.rs index 930fd5a7e..aec52230f 100644 --- a/services/oprf-node/src/auth/rp_module/tests.rs +++ b/services/oprf-node/src/auth/rp_module/tests.rs @@ -12,7 +12,7 @@ use taceo_oprf::types::api::{OprfRequest, OprfRequestAuthenticator as _}; use uuid::Uuid; use world_id_primitives::{ FieldElement, SessionFeType, SessionFieldElement as _, - oprf::{NullifierOprfRequestAuthV1, error_codes}, + oprf::{NullifierOprfRequestAuthV1, RpSignatureVerification, error_codes}, rp::RpId, }; @@ -76,7 +76,7 @@ impl RpModuleTestSetup { signature: Some(signature), rp_id: infra.setup.rp_fixture.world_rp_id, wip101_data: None, - signed_action: None, + rp_signature_verification: None, }; Ok(Self { @@ -91,7 +91,8 @@ impl RpModuleTestSetup { } /// Constructs a valid session-seed test setup whose RP signature covers the - /// fixture's uniqueness action, carried in `signed_action` (create-and-bind). + /// fixture's uniqueness action, carried as RP signature verification data + /// (create-and-bind). pub(crate) async fn new_session_bound_seed() -> eyre::Result { let mut rng = rand::thread_rng(); let infra = AuthModulesTestSetup::new(SetupKind::RpModule).await?; @@ -103,7 +104,7 @@ impl RpModuleTestSetup { .generate_query_proof(session_action, infra.setup.rp_fixture.world_rp_id.into())?; // The fixture signature is computed over the action-inclusive message, matching - // the `signed_action` below. + // the verification data below. let auth = NullifierOprfRequestAuthV1 { proof: bundle.proof, action: *session_action, @@ -114,7 +115,9 @@ impl RpModuleTestSetup { signature: Some(infra.setup.rp_fixture.signature), rp_id: infra.setup.rp_fixture.world_rp_id, wip101_data: None, - signed_action: Some(infra.setup.rp_fixture.action.into()), + rp_signature_verification: Some(RpSignatureVerification::UniquenessAction { + action: infra.setup.rp_fixture.action.into(), + }), }; Ok(Self { @@ -150,7 +153,7 @@ impl RpModuleTestSetup { signature: Some(infra.setup.rp_fixture.signature), rp_id: infra.setup.rp_fixture.world_rp_id, wip101_data: None, - signed_action: None, + rp_signature_verification: None, }; Ok(Self { @@ -683,24 +686,24 @@ async fn test_session_invalid_action_random_prefix() -> eyre::Result<()> { .await } -// ── Signed-action (create-and-bind) tests ──────────────────────────────── +// ── RP signature verification (create-and-bind) tests ─────────────────── // -// A session-seed query may carry a `signed_action` covered by the RP signature. +// A session-seed query may carry a uniqueness action covered by the RP signature. // The happy path verifies the action-inclusive message; everything else must // fail loudly — most importantly the two signature/field mismatch directions, // which are exactly what an old node (ignoring the field) would hit. #[tokio::test] -async fn test_session_seed_signed_action_success() -> eyre::Result<()> { +async fn test_session_seed_rp_signature_verification_success() -> eyre::Result<()> { check_success(RpModuleTestSetup::new_session_bound_seed().await?).await } #[tokio::test] -async fn test_session_seed_signed_action_missing_field() -> eyre::Result<()> { +async fn test_session_seed_rp_signature_verification_missing_field() -> eyre::Result<()> { // Old-node simulation: the signature covers the action, but the field is absent, // so the node reconstructs the action-less message. Must fail closed. let mut setup = RpModuleTestSetup::new_session_bound_seed().await?; - setup.request.auth.signed_action = None; + setup.request.auth.rp_signature_verification = None; setup .assert_auth_err( error_codes::INVALID_RP_SIGNATURE, @@ -710,11 +713,14 @@ async fn test_session_seed_signed_action_missing_field() -> eyre::Result<()> { } #[tokio::test] -async fn test_session_seed_signed_action_actionless_signature() -> eyre::Result<()> { +async fn test_session_seed_rp_signature_verification_actionless_signature() -> eyre::Result<()> { // Inverse mismatch: field present, but the signature was made over the // action-less message. let mut setup = RpModuleTestSetup::new_session().await?; - setup.request.auth.signed_action = Some(setup.setup.rp_fixture.action.into()); + setup.request.auth.rp_signature_verification = + Some(RpSignatureVerification::UniquenessAction { + action: setup.setup.rp_fixture.action.into(), + }); setup .assert_auth_err( error_codes::INVALID_RP_SIGNATURE, @@ -724,9 +730,12 @@ async fn test_session_seed_signed_action_actionless_signature() -> eyre::Result< } #[tokio::test] -async fn test_session_seed_signed_action_tampered() -> eyre::Result<()> { +async fn test_session_seed_rp_signature_verification_tampered() -> eyre::Result<()> { let mut setup = RpModuleTestSetup::new_session_bound_seed().await?; - setup.request.auth.signed_action = Some(action_with_msb(0x00).into()); + setup.request.auth.rp_signature_verification = + Some(RpSignatureVerification::UniquenessAction { + action: action_with_msb(0x00).into(), + }); setup .assert_auth_err( error_codes::INVALID_RP_SIGNATURE, @@ -736,10 +745,13 @@ async fn test_session_seed_signed_action_tampered() -> eyre::Result<()> { } #[tokio::test] -async fn test_session_seed_signed_action_invalid_prefix() -> eyre::Result<()> { +async fn test_session_seed_rp_signature_verification_invalid_prefix() -> eyre::Result<()> { // A signed action must be a nullifier action (MSB 0x00); session prefixes are invalid. let mut setup = RpModuleTestSetup::new_session_bound_seed().await?; - setup.request.auth.signed_action = Some(action_with_msb(0x01).into()); + setup.request.auth.rp_signature_verification = + Some(RpSignatureVerification::UniquenessAction { + action: action_with_msb(0x01).into(), + }); setup .assert_auth_err( error_codes::INVALID_SIGNED_ACTION, @@ -749,10 +761,13 @@ async fn test_session_seed_signed_action_invalid_prefix() -> eyre::Result<()> { } #[tokio::test] -async fn test_session_action_query_rejects_signed_action() -> eyre::Result<()> { +async fn test_session_action_query_rejects_rp_signature_verification() -> eyre::Result<()> { // Only seed queries (0x01) may carry a signed action, not session-action queries (0x02). let mut setup = RpModuleTestSetup::new_session_with_fe_type(SessionFeType::Action).await?; - setup.request.auth.signed_action = Some(setup.setup.rp_fixture.action.into()); + setup.request.auth.rp_signature_verification = + Some(RpSignatureVerification::UniquenessAction { + action: setup.setup.rp_fixture.action.into(), + }); setup .assert_auth_err( error_codes::SIGNED_ACTION_NOT_ALLOWED, @@ -762,10 +777,13 @@ async fn test_session_action_query_rejects_signed_action() -> eyre::Result<()> { } #[tokio::test] -async fn test_uniqueness_rejects_signed_action() -> eyre::Result<()> { - // The uniqueness module signs the regular action; a signed_action is meaningless there. +async fn test_uniqueness_rejects_rp_signature_verification() -> eyre::Result<()> { + // The uniqueness module signs the regular action; extra verification is meaningless there. let mut setup = RpModuleTestSetup::new_uniqueness().await?; - setup.request.auth.signed_action = Some(setup.setup.rp_fixture.action.into()); + setup.request.auth.rp_signature_verification = + Some(RpSignatureVerification::UniquenessAction { + action: setup.setup.rp_fixture.action.into(), + }); setup .assert_auth_err( error_codes::SIGNED_ACTION_NOT_ALLOWED, @@ -775,12 +793,15 @@ async fn test_uniqueness_rejects_signed_action() -> eyre::Result<()> { } #[tokio::test] -async fn test_session_wip101_rejects_signed_action() -> eyre::Result<()> { - // WIP101 contract-backed RPs do not support session queries with signed actions. +async fn test_session_wip101_rejects_rp_signature_verification() -> eyre::Result<()> { + // WIP101 contract-backed RPs do not support session queries with verification data. let mut setup = RpModuleTestSetup::new_session_bound_seed().await?; let addr = deploy!(WIP101Correct, setup); setup.set_contract_signer(addr, None).await; - setup.request.auth.signed_action = Some(setup.setup.rp_fixture.action.into()); + setup.request.auth.rp_signature_verification = + Some(RpSignatureVerification::UniquenessAction { + action: setup.setup.rp_fixture.action.into(), + }); setup .assert_auth_err( error_codes::SIGNED_ACTION_NOT_ALLOWED, From 89868726af5a6d389e28275736ee58204bd51830 Mon Sep 17 00:00:00 2001 From: kilianglas Date: Wed, 15 Jul 2026 13:39:12 +0200 Subject: [PATCH 19/36] refactor: single RpSignatureVerifcaton erro --- crates/primitives/src/oprf.rs | 45 +++++++------------ services/oprf-node/src/auth/rp_module.rs | 35 ++++++--------- .../oprf-node/src/auth/rp_module/tests.rs | 18 ++++---- 3 files changed, 37 insertions(+), 61 deletions(-) diff --git a/crates/primitives/src/oprf.rs b/crates/primitives/src/oprf.rs index 600e1edc2..5d758df08 100644 --- a/crates/primitives/src/oprf.rs +++ b/crates/primitives/src/oprf.rs @@ -191,14 +191,12 @@ pub enum WorldIdRequestAuthError { /// prefixes. #[error("invalid_action_for_session")] InvalidActionSession, - /// The provided signed action is not a valid nullifier action. Signed actions must - /// start with `0x00` (MSB). - #[error("invalid_signed_action")] - InvalidSignedAction, - /// A signed action was provided on a request that does not support one. Signed - /// actions are only allowed on session-seed queries from EOA-backed RPs. - #[error("signed_action_not_allowed")] - SignedActionNotAllowed, + /// The provided RP signature verification data is invalid or not allowed on this query. + /// + /// Verification data is only valid on create-and-bind session-seed queries from + /// EOA-backed RPs and must carry a uniqueness action (MSB `0x00`). + #[error("invalid_rp_signature_verification")] + InvalidRpSignatureVerification, /// The RP signer is a contract but does not implement the WIP101 interface. #[error("wip101_incompatible_rp_signer")] Wip101IncompatibleRpSigner, @@ -263,7 +261,6 @@ impl WorldIdRequestAuthError { | Self::InvalidRpSignature | Self::DuplicateNonce | Self::InvalidActionNullifier - | Self::InvalidSignedAction | Self::Wip101IncompatibleRpSigner | Self::Wip101VerificationFailed(_) | Self::Wip101CustomRevert @@ -276,7 +273,7 @@ impl WorldIdRequestAuthError { | Self::InvalidQueryProof | Self::InvalidActionSchemaIssuer | Self::InvalidActionSession - | Self::SignedActionNotAllowed + | Self::InvalidRpSignatureVerification | Self::RpSignatureMissing => ErrorActor::Authenticator, Self::Internal | Self::Unknown(_) => ErrorActor::OprfNode, } @@ -298,8 +295,7 @@ impl From for WorldIdRequestAuthError { error_codes::UNKNOWN_SCHEMA_ISSUER => Self::UnknownSchemaIssuerId, error_codes::INVALID_ACTION_NULLIFIER => Self::InvalidActionNullifier, error_codes::INVALID_ACTION_SESSION => Self::InvalidActionSession, - error_codes::INVALID_SIGNED_ACTION => Self::InvalidSignedAction, - error_codes::SIGNED_ACTION_NOT_ALLOWED => Self::SignedActionNotAllowed, + error_codes::INVALID_RP_SIGNATURE_VERIFICATION => Self::InvalidRpSignatureVerification, error_codes::RP_SIGNATURE_EXPIRED => Self::RpSignatureExpired, error_codes::RP_SIGNATURE_MISSING => Self::RpSignatureMissing, error_codes::INVALID_TIMESTAMP => Self::InvalidTimestamp, @@ -341,9 +337,8 @@ impl From for u16 { error_codes::INVALID_ACTION_NULLIFIER } WorldIdRequestAuthError::InvalidActionSession => error_codes::INVALID_ACTION_SESSION, - WorldIdRequestAuthError::InvalidSignedAction => error_codes::INVALID_SIGNED_ACTION, - WorldIdRequestAuthError::SignedActionNotAllowed => { - error_codes::SIGNED_ACTION_NOT_ALLOWED + WorldIdRequestAuthError::InvalidRpSignatureVerification => { + error_codes::INVALID_RP_SIGNATURE_VERIFICATION } WorldIdRequestAuthError::RpSignatureExpired => error_codes::RP_SIGNATURE_EXPIRED, WorldIdRequestAuthError::CreatedAtTooFarInFuture => { @@ -422,10 +417,8 @@ pub mod error_codes { pub const BLOCKED_RP: u16 = 4522; /// Error code for [`super::WorldIdRequestAuthError::ExpiresAtTooFarInFuture`]. pub const EXPIRES_AT_TOO_FAR_IN_FUTURE: u16 = 4523; - /// Error code for [`super::WorldIdRequestAuthError::InvalidSignedAction`]. - pub const INVALID_SIGNED_ACTION: u16 = 4524; - /// Error code for [`super::WorldIdRequestAuthError::SignedActionNotAllowed`]. - pub const SIGNED_ACTION_NOT_ALLOWED: u16 = 4525; + /// Error code for [`super::WorldIdRequestAuthError::InvalidRpSignatureVerification`]. + pub const INVALID_RP_SIGNATURE_VERIFICATION: u16 = 4524; /// Error code for [`super::WorldIdRequestAuthError::Internal`]. pub const INTERNAL: u16 = 1011; } @@ -508,15 +501,8 @@ impl From for OprfRequestAuthenticatorError { // this should never truncate as code is a U256 encoded as hex CloseFrameMessage::new_truncate(format!("{:#x}", code)) } - WorldIdRequestAuthError::InvalidSignedAction => { - taceo_oprf::types::close_frame_message!( - "Invalid signed action - must be a valid nullifier action (MSB 0x00)" - ) - } - WorldIdRequestAuthError::SignedActionNotAllowed => { - taceo_oprf::types::close_frame_message!( - "Signed actions are only allowed on session-seed queries from EOA-backed RPs" - ) + WorldIdRequestAuthError::InvalidRpSignatureVerification => { + taceo_oprf::types::close_frame_message!("Invalid RP signature verification data") } WorldIdRequestAuthError::Wip101AuxDataOnEoa => taceo_oprf::types::close_frame_message!( "Auxiliary data must be empty with EOA backed signer" @@ -642,8 +628,7 @@ mod tests { error_codes::UNKNOWN_SCHEMA_ISSUER, error_codes::INVALID_ACTION_NULLIFIER, error_codes::INVALID_ACTION_SESSION, - error_codes::INVALID_SIGNED_ACTION, - error_codes::SIGNED_ACTION_NOT_ALLOWED, + error_codes::INVALID_RP_SIGNATURE_VERIFICATION, error_codes::INACTIVE_RP, error_codes::RP_SIGNATURE_EXPIRED, error_codes::INVALID_TIMESTAMP, diff --git a/services/oprf-node/src/auth/rp_module.rs b/services/oprf-node/src/auth/rp_module.rs index fce306c41..affd2dee0 100644 --- a/services/oprf-node/src/auth/rp_module.rs +++ b/services/oprf-node/src/auth/rp_module.rs @@ -4,22 +4,12 @@ //! logic, and query-proof verification. They differ only in: //! - how the action field is validated (`MSB == 0x00` for uniqueness vs `0x01/0x02` for sessions depending on the [`SessionFeType`]) //! - whether the action is included in the RP signature (`Some` for uniqueness, `None` for -//! session — unless the request carries RP signature verification data, see below) +//! session), except for session-seed queries carrying a uniqueness action in +//! `rp_signature_verification` //! - which [`WorldIdRequestAuthError`] variant is returned for an invalid action //! //! [`RpModuleKind`] captures these differences; [`RpModuleAuth`] holds the shared //! state and branches on the kind at runtime. -//! -//! # RP signature verification data on session-seed queries (create-and-bind) -//! -//! A session-seed query may carry an optional uniqueness action (MSB `0x00`) as -//! [`RpSignatureVerification::UniquenessAction`]. When present, the session module -//! verifies the RP signature over the -//! action-inclusive message instead of the action-less one. This lets a single RP -//! signature authorize both creating a session and binding a Uniqueness Proof to it. -//! RP signature verification data is rejected everywhere else: on session-action queries, -//! on the uniqueness module, and for WIP101 contract-backed RPs (which do not support -//! session queries yet). use crate::{ accountant_batcher::AccountantBatcherHandle, @@ -88,10 +78,8 @@ pub(crate) enum RpModuleError { #[error("Invalid action for uniqueness (action MSB must be 0x00): {action}")] InvalidActionUniqueness { action: FieldElement }, - #[error("Invalid signed action (MSB must be 0x00): {signed_action}")] - InvalidSignedAction { signed_action: FieldElement }, - #[error("Signed action not allowed: {context}")] - SignedActionNotAllowed { context: &'static str }, + #[error("Invalid RP signature verification data: {context}")] + InvalidRpSignatureVerification { context: &'static str }, #[error("Could not verify query proof")] InvalidQueryProof, #[error(transparent)] @@ -150,8 +138,9 @@ impl From<&RpModuleError> for WorldIdRequestAuthError { match value { RpModuleError::InvalidActionSession { .. } => Self::InvalidActionSession, RpModuleError::InvalidActionUniqueness { .. } => Self::InvalidActionNullifier, - RpModuleError::InvalidSignedAction { .. } => Self::InvalidSignedAction, - RpModuleError::SignedActionNotAllowed { .. } => Self::SignedActionNotAllowed, + RpModuleError::InvalidRpSignatureVerification { .. } => { + Self::InvalidRpSignatureVerification + } RpModuleError::InvalidQueryProof => Self::InvalidQueryProof, RpModuleError::MerkleWatcher(e) => Self::from(e.as_ref()), RpModuleError::RpRegistry(e) => Self::from(e.as_ref()), @@ -355,7 +344,7 @@ impl RpModuleAuth { RpAccountType::Contract => { // TODO(session-proofs): WIP-101 does not currently support session proofs. if request.auth.rp_signature_verification.is_some() { - return Err(RpModuleError::SignedActionNotAllowed { + return Err(RpModuleError::InvalidRpSignatureVerification { context: "not supported for WIP101 contract-backed RPs", }); } @@ -421,14 +410,16 @@ impl RpModuleAuth { }) = request.auth.rp_signature_verification { if signed_action.to_be_bytes()[0] != 0 { - return Err(RpModuleError::InvalidSignedAction { signed_action }); + return Err(RpModuleError::InvalidRpSignatureVerification { + context: "uniqueness action MSB must be 0x00", + }); } metrics::auth_module::inc_session_signed_action(); } NonceScope::SessionOprfSeed } else if action.is_valid_for_session(SessionFeType::Action) { if request.auth.rp_signature_verification.is_some() { - return Err(RpModuleError::SignedActionNotAllowed { + return Err(RpModuleError::InvalidRpSignatureVerification { context: "only allowed on session-seed queries", }); } @@ -440,7 +431,7 @@ impl RpModuleAuth { RpModuleKind::Uniqueness(_) => { metrics::auth_module::inc_nullifier(); if request.auth.rp_signature_verification.is_some() { - return Err(RpModuleError::SignedActionNotAllowed { + return Err(RpModuleError::InvalidRpSignatureVerification { context: "only allowed on the session module", }); } diff --git a/services/oprf-node/src/auth/rp_module/tests.rs b/services/oprf-node/src/auth/rp_module/tests.rs index aec52230f..2a8fe9c96 100644 --- a/services/oprf-node/src/auth/rp_module/tests.rs +++ b/services/oprf-node/src/auth/rp_module/tests.rs @@ -754,15 +754,15 @@ async fn test_session_seed_rp_signature_verification_invalid_prefix() -> eyre::R }); setup .assert_auth_err( - error_codes::INVALID_SIGNED_ACTION, - "Invalid signed action - must be a valid nullifier action (MSB 0x00)", + error_codes::INVALID_RP_SIGNATURE_VERIFICATION, + "Invalid RP signature verification data", ) .await } #[tokio::test] async fn test_session_action_query_rejects_rp_signature_verification() -> eyre::Result<()> { - // Only seed queries (0x01) may carry a signed action, not session-action queries (0x02). + // Only seed queries (0x01) may carry verification data, not session-action queries (0x02). let mut setup = RpModuleTestSetup::new_session_with_fe_type(SessionFeType::Action).await?; setup.request.auth.rp_signature_verification = Some(RpSignatureVerification::UniquenessAction { @@ -770,8 +770,8 @@ async fn test_session_action_query_rejects_rp_signature_verification() -> eyre:: }); setup .assert_auth_err( - error_codes::SIGNED_ACTION_NOT_ALLOWED, - "Signed actions are only allowed on session-seed queries from EOA-backed RPs", + error_codes::INVALID_RP_SIGNATURE_VERIFICATION, + "Invalid RP signature verification data", ) .await } @@ -786,8 +786,8 @@ async fn test_uniqueness_rejects_rp_signature_verification() -> eyre::Result<()> }); setup .assert_auth_err( - error_codes::SIGNED_ACTION_NOT_ALLOWED, - "Signed actions are only allowed on session-seed queries from EOA-backed RPs", + error_codes::INVALID_RP_SIGNATURE_VERIFICATION, + "Invalid RP signature verification data", ) .await } @@ -804,8 +804,8 @@ async fn test_session_wip101_rejects_rp_signature_verification() -> eyre::Result }); setup .assert_auth_err( - error_codes::SIGNED_ACTION_NOT_ALLOWED, - "Signed actions are only allowed on session-seed queries from EOA-backed RPs", + error_codes::INVALID_RP_SIGNATURE_VERIFICATION, + "Invalid RP signature verification data", ) .await } From 6db473a3618e62e186952ad63ce27fb63f81252a Mon Sep 17 00:00:00 2001 From: kilianglas Date: Wed, 15 Jul 2026 13:40:39 +0200 Subject: [PATCH 20/36] chore: update comment --- services/oprf-node/src/auth/rp_module.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/services/oprf-node/src/auth/rp_module.rs b/services/oprf-node/src/auth/rp_module.rs index affd2dee0..59f87264f 100644 --- a/services/oprf-node/src/auth/rp_module.rs +++ b/services/oprf-node/src/auth/rp_module.rs @@ -3,9 +3,8 @@ //! Both the session and uniqueness modules share identical struct fields, init //! logic, and query-proof verification. They differ only in: //! - how the action field is validated (`MSB == 0x00` for uniqueness vs `0x01/0x02` for sessions depending on the [`SessionFeType`]) -//! - whether the action is included in the RP signature (`Some` for uniqueness, `None` for -//! session), except for session-seed queries carrying a uniqueness action in -//! `rp_signature_verification` +//! - whether the action is included in the RP signature. Some for uniqueness, none for session. For session-seed queries initiated by an RP request for a uniqueness proof, +//! the action of the uniqueness proof is part of the data the RP signs over and is included in the `rp_signature_verification` field. //! - which [`WorldIdRequestAuthError`] variant is returned for an invalid action //! //! [`RpModuleKind`] captures these differences; [`RpModuleAuth`] holds the shared From 3eb823c237b7515680d8fddd93278b4e3f15dba1 Mon Sep 17 00:00:00 2001 From: kilianglas Date: Wed, 15 Jul 2026 14:02:26 +0200 Subject: [PATCH 21/36] chore: remove unnecessary tests --- crates/primitives/src/oprf.rs | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/crates/primitives/src/oprf.rs b/crates/primitives/src/oprf.rs index 5d758df08..9caaf6573 100644 --- a/crates/primitives/src/oprf.rs +++ b/crates/primitives/src/oprf.rs @@ -571,16 +571,6 @@ mod tests { } } - #[test] - fn nullifier_auth_rp_signature_verification_none_is_omitted() { - let value = serde_json::to_value(test_auth(None)).unwrap(); - // Forward compat: unused, the field never appears on the wire. - assert!(value.get("rp_signature_verification").is_none()); - // Backward compat: payloads without the field deserialize to `None`. - let parsed: NullifierOprfRequestAuthV1 = serde_json::from_value(value).unwrap(); - assert!(parsed.rp_signature_verification.is_none()); - } - #[test] fn nullifier_auth_rp_signature_verification_json_roundtrip() { let verification = RpSignatureVerification::UniquenessAction { @@ -604,15 +594,6 @@ mod tests { assert_eq!(parsed.rp_signature_verification, Some(verification)); } - #[test] - fn nullifier_auth_ignores_unknown_fields() { - // Old nodes must ignore fields added later (no `deny_unknown_fields`). - let mut value = serde_json::to_value(test_auth(None)).unwrap(); - value["some_future_field"] = serde_json::json!("ignored"); - let parsed = serde_json::from_value::(value); - assert!(parsed.is_ok()); - } - #[test] fn error_code_roundtrip() { let codes: &[u16] = &[ From c26e6ad3776d683ac7c673ad80e86e7e0547656b Mon Sep 17 00:00:00 2001 From: kilianglas Date: Wed, 15 Jul 2026 14:02:45 +0200 Subject: [PATCH 22/36] chore: remove new metrics --- services/oprf-node/src/auth/rp_module.rs | 4 +--- services/oprf-node/src/metrics.rs | 11 ----------- 2 files changed, 1 insertion(+), 14 deletions(-) diff --git a/services/oprf-node/src/auth/rp_module.rs b/services/oprf-node/src/auth/rp_module.rs index 59f87264f..19e80060d 100644 --- a/services/oprf-node/src/auth/rp_module.rs +++ b/services/oprf-node/src/auth/rp_module.rs @@ -398,8 +398,7 @@ impl RpModuleAuth { let action = FieldElement::from(request.auth.action); // Validate the action per kind and derive the nonce scope it consumes. - // RP signature verification data is only valid on session-seed queries; see the - // module docs for the create-and-bind flow. + // RP signature verification data is only valid on session-seed queries let nonce_scope = match self.kind { RpModuleKind::Session => { metrics::auth_module::inc_session(); @@ -413,7 +412,6 @@ impl RpModuleAuth { context: "uniqueness action MSB must be 0x00", }); } - metrics::auth_module::inc_session_signed_action(); } NonceScope::SessionOprfSeed } else if action.is_valid_for_session(SessionFeType::Action) { diff --git a/services/oprf-node/src/metrics.rs b/services/oprf-node/src/metrics.rs index 71dd346c3..7feb07fe4 100644 --- a/services/oprf-node/src/metrics.rs +++ b/services/oprf-node/src/metrics.rs @@ -51,8 +51,6 @@ pub(crate) mod accountant_batcher { pub(crate) mod auth_module { const METRICS_ID_AUTHENTICATION_COUNTER: &str = "taceo.oprf.node.auth"; - const METRICS_ID_SESSION_SIGNED_ACTION_COUNTER: &str = - "taceo.oprf.node.auth.session_signed_action"; const METRICS_ATTRID_AUTH_MODULE: &str = "auth_module"; const METRICS_ATTR_NULLIFIER_MODULE: &str = "nullifier"; const METRICS_ATTR_SESSION_MODULE: &str = "session"; @@ -64,11 +62,6 @@ pub(crate) mod auth_module { metrics::Unit::Count, "Number of times the authentication modules were hit." ); - metrics::describe_counter!( - METRICS_ID_SESSION_SIGNED_ACTION_COUNTER, - metrics::Unit::Count, - "Number of session-seed authentications carrying an RP-signed action (create-and-bind)." - ); } pub(crate) fn inc_nullifier() { @@ -79,10 +72,6 @@ pub(crate) mod auth_module { metrics::counter!(METRICS_ID_AUTHENTICATION_COUNTER, METRICS_ATTRID_AUTH_MODULE => METRICS_ATTR_SESSION_MODULE).increment(1); } - pub(crate) fn inc_session_signed_action() { - metrics::counter!(METRICS_ID_SESSION_SIGNED_ACTION_COUNTER).increment(1); - } - pub(crate) fn inc_issuer_blinding() { metrics::counter!(METRICS_ID_AUTHENTICATION_COUNTER, METRICS_ATTRID_AUTH_MODULE => METRICS_ATTR_CREDENTIAL_BLINDING).increment(1); } From de5d29bdd0340f8a484d9ecb22c58026ca68f0af Mon Sep 17 00:00:00 2001 From: kilianglas Date: Wed, 15 Jul 2026 14:24:39 +0200 Subject: [PATCH 23/36] chore: add todo --- services/oprf-node/src/auth/rp_module.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/services/oprf-node/src/auth/rp_module.rs b/services/oprf-node/src/auth/rp_module.rs index 19e80060d..d065855b3 100644 --- a/services/oprf-node/src/auth/rp_module.rs +++ b/services/oprf-node/src/auth/rp_module.rs @@ -407,6 +407,7 @@ impl RpModuleAuth { action: signed_action, }) = request.auth.rp_signature_verification { + // TODO: Move this check to a function or trait on FieldElement. Potentially unify with is_valid_for_session. if signed_action.to_be_bytes()[0] != 0 { return Err(RpModuleError::InvalidRpSignatureVerification { context: "uniqueness action MSB must be 0x00", From 1b4e67321dfc2fe324e9a3b509a88d2f358a528c Mon Sep 17 00:00:00 2001 From: kilianglas Date: Wed, 15 Jul 2026 14:41:27 +0200 Subject: [PATCH 24/36] chore: remove unnecessary tests --- .../oprf-node/src/auth/rp_module/tests.rs | 89 +++---------------- 1 file changed, 11 insertions(+), 78 deletions(-) diff --git a/services/oprf-node/src/auth/rp_module/tests.rs b/services/oprf-node/src/auth/rp_module/tests.rs index 2a8fe9c96..e798e28bb 100644 --- a/services/oprf-node/src/auth/rp_module/tests.rs +++ b/services/oprf-node/src/auth/rp_module/tests.rs @@ -39,11 +39,11 @@ pub(crate) struct RpModuleTestSetup { impl RpModuleTestSetup { pub(crate) async fn new_session() -> eyre::Result { - Self::new_session_with_fe_type(SessionFeType::OprfSeed).await + Self::new_unbound_session_with_fe_type(SessionFeType::OprfSeed).await } /// Constructs a valid session test setup with the given session type. - pub(crate) async fn new_session_with_fe_type( + pub(crate) async fn new_unbound_session_with_fe_type( session_type: SessionFeType, ) -> eyre::Result { let mut rng = rand::thread_rng(); @@ -93,7 +93,7 @@ impl RpModuleTestSetup { /// Constructs a valid session-seed test setup whose RP signature covers the /// fixture's uniqueness action, carried as RP signature verification data /// (create-and-bind). - pub(crate) async fn new_session_bound_seed() -> eyre::Result { + pub(crate) async fn new_bound_session_seed() -> eyre::Result { let mut rng = rand::thread_rng(); let infra = AuthModulesTestSetup::new(SetupKind::RpModule).await?; @@ -656,7 +656,7 @@ async fn test_session_wip101_account_check_timeout() -> eyre::Result<()> { #[tokio::test] async fn test_session_success_action() -> eyre::Result<()> { - let setup = RpModuleTestSetup::new_session_with_fe_type(SessionFeType::Action).await?; + let setup = RpModuleTestSetup::new_unbound_session_with_fe_type(SessionFeType::Action).await?; setup.assert_auth_ok().await } @@ -688,21 +688,20 @@ async fn test_session_invalid_action_random_prefix() -> eyre::Result<()> { // ── RP signature verification (create-and-bind) tests ─────────────────── // -// A session-seed query may carry a uniqueness action covered by the RP signature. -// The happy path verifies the action-inclusive message; everything else must -// fail loudly — most importantly the two signature/field mismatch directions, -// which are exactly what an old node (ignoring the field) would hit. +// Session-seed queries may carry the RP-signed uniqueness action in +// `rp_signature_verification`. Keep coverage minimal: happy path, one +// signature/field mismatch, prefix validation, and one wrong-context rejection. #[tokio::test] async fn test_session_seed_rp_signature_verification_success() -> eyre::Result<()> { - check_success(RpModuleTestSetup::new_session_bound_seed().await?).await + check_success(RpModuleTestSetup::new_bound_session_seed().await?).await } #[tokio::test] async fn test_session_seed_rp_signature_verification_missing_field() -> eyre::Result<()> { // Old-node simulation: the signature covers the action, but the field is absent, // so the node reconstructs the action-less message. Must fail closed. - let mut setup = RpModuleTestSetup::new_session_bound_seed().await?; + let mut setup = RpModuleTestSetup::new_bound_session_seed().await?; setup.request.auth.rp_signature_verification = None; setup .assert_auth_err( @@ -712,42 +711,10 @@ async fn test_session_seed_rp_signature_verification_missing_field() -> eyre::Re .await } -#[tokio::test] -async fn test_session_seed_rp_signature_verification_actionless_signature() -> eyre::Result<()> { - // Inverse mismatch: field present, but the signature was made over the - // action-less message. - let mut setup = RpModuleTestSetup::new_session().await?; - setup.request.auth.rp_signature_verification = - Some(RpSignatureVerification::UniquenessAction { - action: setup.setup.rp_fixture.action.into(), - }); - setup - .assert_auth_err( - error_codes::INVALID_RP_SIGNATURE, - "signature from RP cannot be verified", - ) - .await -} - -#[tokio::test] -async fn test_session_seed_rp_signature_verification_tampered() -> eyre::Result<()> { - let mut setup = RpModuleTestSetup::new_session_bound_seed().await?; - setup.request.auth.rp_signature_verification = - Some(RpSignatureVerification::UniquenessAction { - action: action_with_msb(0x00).into(), - }); - setup - .assert_auth_err( - error_codes::INVALID_RP_SIGNATURE, - "signature from RP cannot be verified", - ) - .await -} - #[tokio::test] async fn test_session_seed_rp_signature_verification_invalid_prefix() -> eyre::Result<()> { // A signed action must be a nullifier action (MSB 0x00); session prefixes are invalid. - let mut setup = RpModuleTestSetup::new_session_bound_seed().await?; + let mut setup = RpModuleTestSetup::new_bound_session_seed().await?; setup.request.auth.rp_signature_verification = Some(RpSignatureVerification::UniquenessAction { action: action_with_msb(0x01).into(), @@ -760,25 +727,9 @@ async fn test_session_seed_rp_signature_verification_invalid_prefix() -> eyre::R .await } -#[tokio::test] -async fn test_session_action_query_rejects_rp_signature_verification() -> eyre::Result<()> { - // Only seed queries (0x01) may carry verification data, not session-action queries (0x02). - let mut setup = RpModuleTestSetup::new_session_with_fe_type(SessionFeType::Action).await?; - setup.request.auth.rp_signature_verification = - Some(RpSignatureVerification::UniquenessAction { - action: setup.setup.rp_fixture.action.into(), - }); - setup - .assert_auth_err( - error_codes::INVALID_RP_SIGNATURE_VERIFICATION, - "Invalid RP signature verification data", - ) - .await -} - #[tokio::test] async fn test_uniqueness_rejects_rp_signature_verification() -> eyre::Result<()> { - // The uniqueness module signs the regular action; extra verification is meaningless there. + // Verification data is only valid on the session module. let mut setup = RpModuleTestSetup::new_uniqueness().await?; setup.request.auth.rp_signature_verification = Some(RpSignatureVerification::UniquenessAction { @@ -792,24 +743,6 @@ async fn test_uniqueness_rejects_rp_signature_verification() -> eyre::Result<()> .await } -#[tokio::test] -async fn test_session_wip101_rejects_rp_signature_verification() -> eyre::Result<()> { - // WIP101 contract-backed RPs do not support session queries with verification data. - let mut setup = RpModuleTestSetup::new_session_bound_seed().await?; - let addr = deploy!(WIP101Correct, setup); - setup.set_contract_signer(addr, None).await; - setup.request.auth.rp_signature_verification = - Some(RpSignatureVerification::UniquenessAction { - action: setup.setup.rp_fixture.action.into(), - }); - setup - .assert_auth_err( - error_codes::INVALID_RP_SIGNATURE_VERIFICATION, - "Invalid RP signature verification data", - ) - .await -} - // ── Uniqueness-specific tests ──────────────────────────────────────────── #[tokio::test] From 0ea91d5507c33aa66e097908adafb8c1371b6fdb Mon Sep 17 00:00:00 2001 From: kilianglas Date: Wed, 15 Jul 2026 17:10:00 +0200 Subject: [PATCH 25/36] feat: improved request validation --- crates/primitives/src/request/mod.rs | 37 +++++++++++++++++++--------- 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/crates/primitives/src/request/mod.rs b/crates/primitives/src/request/mod.rs index bfb37cc64..9181020c7 100644 --- a/crates/primitives/src/request/mod.rs +++ b/crates/primitives/src/request/mod.rs @@ -464,22 +464,23 @@ impl ProofRequest { /// Returns [`PrimitiveError::InvalidInput`] when the request has an invalid /// combination of `proof_type`, `session_id`, and `action`. pub fn validate_proof_type(&self) -> Result<(), PrimitiveError> { - match (self.proof_type, self.session_id) { - (ProofType::Uniqueness, _) => Ok(()), - (ProofType::Session, SessionRef::None) => Err(PrimitiveError::InvalidInput { + match (self.proof_type, self.session_id, self.action) { + (ProofType::Uniqueness, _, None) => Err(PrimitiveError::InvalidInput { + attribute: "action".to_string(), + reason: "must be present for uniqueness proofs".to_string(), + }), + (ProofType::Session, SessionRef::None, _) => Err(PrimitiveError::InvalidInput { attribute: "session_id".to_string(), reason: "must be \"create\" or an existing session id for session proofs" .to_string(), }), - (ProofType::Session, SessionRef::Create | SessionRef::Existing(_)) => { - if self.action.is_some() { - return Err(PrimitiveError::InvalidInput { - attribute: "action".to_string(), - reason: "must be omitted for session proofs".to_string(), - }); - } - Ok(()) + (ProofType::Session, SessionRef::Create | SessionRef::Existing(_), Some(_)) => { + Err(PrimitiveError::InvalidInput { + attribute: "action".to_string(), + reason: "must be omitted for session proofs".to_string(), + }) } + _ => Ok(()), } } @@ -2401,7 +2402,7 @@ mod tests { version: RequestVersion::V1, proof_type: ProofType::Uniqueness, session_id: SessionRef::Existing(test_session_id(1)), - action: None, + action: Some(FieldElement::ZERO), created_at: 1_735_689_600, expires_at: 1_735_689_900, rp_id: RpId::new(1), @@ -2423,6 +2424,15 @@ mod tests { assert!(uniqueness_with_session.binds_session()); assert!(!uniqueness_with_session.is_session_proof()); + let uniqueness_without_action = ProofRequest { + action: None, + ..uniqueness_with_session.clone() + }; + assert!(matches!( + uniqueness_without_action.validate_proof_type(), + Err(PrimitiveError::InvalidInput { attribute, .. }) if attribute == "action" + )); + let plain_uniqueness = ProofRequest { session_id: SessionRef::None, ..uniqueness_with_session.clone() @@ -2441,6 +2451,7 @@ mod tests { let session_without_session = ProofRequest { proof_type: ProofType::Session, session_id: SessionRef::None, + action: None, ..uniqueness_with_session.clone() }; assert!(matches!( @@ -2452,6 +2463,7 @@ mod tests { let session_create = ProofRequest { proof_type: ProofType::Session, session_id: SessionRef::Create, + action: None, ..uniqueness_with_session.clone() }; assert!(session_create.validate_proof_type().is_ok()); @@ -2460,6 +2472,7 @@ mod tests { let session_existing = ProofRequest { proof_type: ProofType::Session, + action: None, ..uniqueness_with_session.clone() }; assert!(session_existing.validate_proof_type().is_ok()); From 06bf943270e65641fbaee4176f76dccde32bfb1c Mon Sep 17 00:00:00 2001 From: kilianglas Date: Wed, 15 Jul 2026 18:25:33 +0200 Subject: [PATCH 26/36] feat: allow bound uniqueness proofs to re-derive session r seed --- crates/authenticator/src/error.rs | 6 --- crates/authenticator/src/prove.rs | 75 +++++++++++------------------ crates/core/tests/generate_proof.rs | 25 +++++----- crates/proof/src/oprf_query.rs | 21 ++------ docs/world-id-4-specs/README.md | 3 +- 5 files changed, 46 insertions(+), 84 deletions(-) diff --git a/crates/authenticator/src/error.rs b/crates/authenticator/src/error.rs index 3cb97f834..20d373bc6 100644 --- a/crates/authenticator/src/error.rs +++ b/crates/authenticator/src/error.rs @@ -135,12 +135,6 @@ pub enum AuthenticatorError { #[error("the expected session id and the generated session id do not match")] SessionIdMismatch, - /// Binding a session to a Uniqueness Proof requires the cached `session_id_r_seed`. - /// Re-deriving it inside a uniqueness request is not possible; run a session-type - /// request first to obtain it. - #[error("session binding requires a cached `session_id_r_seed`")] - SessionSeedRequired, - /// Generic error for other unexpected issues. #[error("{0}")] Generic(String), diff --git a/crates/authenticator/src/prove.rs b/crates/authenticator/src/prove.rs index 37414f732..5720e1d7d 100644 --- a/crates/authenticator/src/prove.rs +++ b/crates/authenticator/src/prove.rs @@ -208,17 +208,11 @@ impl Authenticator { account_inclusion_proof: Option>, ) -> Result<(SessionId, FieldElement), AuthenticatorError> { proof_request.validate_proof_type()?; - if !proof_request.is_session_proof() - && !matches!( - (proof_request.proof_type, proof_request.session_id), - (ProofType::Uniqueness, SessionRef::Create) - ) - { + if proof_request.session_id.is_none() { return Err(AuthenticatorError::PrimitiveError( world_id_primitives::PrimitiveError::InvalidInput { attribute: "session_id".to_string(), - reason: "session ids can only be built for session proofs or uniqueness session creation" - .to_string(), + reason: "session_id must be \"create\" or an existing session id".to_string(), }, )); } @@ -227,7 +221,10 @@ impl Authenticator { let oprf_seed = match proof_request.session_id { SessionRef::Existing(session_id) => session_id.oprf_seed, - SessionRef::Create | SessionRef::None => SessionId::generate_oprf_seed(&mut rng), + SessionRef::Create => SessionId::generate_oprf_seed(&mut rng), + SessionRef::None => { + unreachable!("SessionRef::None should be handled by the guard above") + } }; let resolved_session_id_r_seed = match session_id_r_seed { @@ -280,11 +277,9 @@ impl Authenticator { /// - `credentials` — one [`CredentialInput`] per credential to prove, /// matched to request items by `issuer_schema_id`. /// - `account_inclusion_proof` — a cached inclusion proof if available (a fresh one will be fetched otherwise) - /// - `session_id_r_seed` — a cached session `r` seed. For Session Proofs it is re-computed - /// if unavailable; for session-bound Uniqueness Proofs with an existing session id - /// ([`ProofRequest::binds_session`]) it is required and the call fails with - /// [`AuthenticatorError::SessionSeedRequired`] otherwise. Create flows mint a fresh - /// session and return the new `session_id_r_seed` for caching. + /// - `session_id_r_seed` — a cached session `r` seed. For requests using an existing + /// session it is re-derived if unavailable. Create flows mint a fresh session and return + /// the new `session_id_r_seed` for caching. /// /// # Caller Responsibilities /// 1. The caller must ensure the request can be fulfilled with the credentials which the user has available, @@ -315,44 +310,28 @@ impl Authenticator { .ok_or(AuthenticatorError::UnfullfilableRequest)?; // 2. Resolve session seed - let (resolved_session_id, resolved_session_seed) = - match (proof_request.proof_type, proof_request.session_id) { - (ProofType::Uniqueness, SessionRef::None) => (None, None), - // Bind the proof to the existing session. Requires the cached `r`. - (ProofType::Uniqueness, SessionRef::Existing(session_id)) => { - let seed = session_id_r_seed.ok_or(AuthenticatorError::SessionSeedRequired)?; + let (resolved_session_id, resolved_session_r_seed) = match proof_request.session_id { + SessionRef::None => (None, None), + SessionRef::Create => { + let (session_id, seed) = self + .build_session_id(proof_request, None, account_inclusion_proof) + .await?; + (Some(session_id), Some(seed)) + } + SessionRef::Existing(session_id) => { + if let Some(seed) = session_id_r_seed { self.validate_cached_session_r_seed(seed, session_id)?; (Some(session_id), Some(seed)) - } - (ProofType::Uniqueness | ProofType::Session, SessionRef::Create) => { - let (session_id, seed) = self + } else { + // Re-derive the same `r` from the existing session's `oprf_seed` when the + // caller did not provide a cached seed. + let (_session_id, seed) = self .build_session_id(proof_request, None, account_inclusion_proof) .await?; (Some(session_id), Some(seed)) } - (ProofType::Session, SessionRef::Existing(session_id)) => { - if let Some(seed) = session_id_r_seed { - self.validate_cached_session_r_seed(seed, session_id)?; - (Some(session_id), Some(seed)) - } else { - // Re-derive the same `r` from the existing session's `oprf_seed` when the - // caller did not provide a cached seed. - let (_session_id, seed) = self - .build_session_id(proof_request, None, account_inclusion_proof) - .await?; - (Some(session_id), Some(seed)) - } - } - // Rejected by validate_proof_type() above; kept explicit to stay exhaustive. - (ProofType::Session, SessionRef::None) => { - return Err(AuthenticatorError::PrimitiveError( - world_id_primitives::PrimitiveError::InvalidInput { - attribute: "session_id".to_string(), - reason: "invalid proof_type/session_id combination".to_string(), - }, - )); - } - }; + } + }; let nullifier_material = self .zk_artifact_source @@ -375,7 +354,7 @@ impl Authenticator { request_item, &cred_input.credential, cred_input.blinding_factor, - resolved_session_seed, + resolved_session_r_seed, resolved_session_id, proof_request.proof_type, proof_request.created_at, @@ -395,7 +374,7 @@ impl Authenticator { // 5. Validate and return response proof_request.validate_response(&proof_response)?; Ok(ProofResult { - session_id_r_seed: resolved_session_seed, + session_id_r_seed: resolved_session_r_seed, proof_response, }) } diff --git a/crates/core/tests/generate_proof.rs b/crates/core/tests/generate_proof.rs index ad02bd84e..43a82542f 100644 --- a/crates/core/tests/generate_proof.rs +++ b/crates/core/tests/generate_proof.rs @@ -459,20 +459,15 @@ async fn e2e_authenticator_generate_proof() -> Result<()> { info!("uniqueness create proof verified via verifyWithSession"); // ── SESSION-BOUND UNIQUENESS PROOF (existing session) ── - // Note: We mock a cached r here. This would be initially obtained from an OPRF query. - let session_id_r_seed = FieldElement::random(&mut rng); - let session_id = SessionId::from_r_seed( - leaf_index, - session_id_r_seed, - SessionId::generate_oprf_seed(&mut rng), - )?; + let session_id_r_seed = created_session_seed; + let session_id = created_session_id; let bound_request = ProofRequest { session_id: SessionRef::Existing(session_id), ..proof_request.clone() }; - // binding requires the cached seed - let err = authenticator + // The seed can be re-derived when it is not cached. + let uncached_bound_result = authenticator .generate_proof( &bound_request, nullifier_for_binding.clone(), @@ -480,9 +475,15 @@ async fn e2e_authenticator_generate_proof() -> Result<()> { None, None, ) - .await - .unwrap_err(); - assert!(matches!(err, AuthenticatorError::SessionSeedRequired)); + .await?; + assert_eq!( + uncached_bound_result.session_id_r_seed, + Some(session_id_r_seed) + ); + assert_eq!( + uncached_bound_result.proof_response.session_id, + Some(session_id) + ); // a seed that does not open the session's commitment is rejected let err = authenticator diff --git a/crates/proof/src/oprf_query.rs b/crates/proof/src/oprf_query.rs index dab2cf47e..823740bdf 100644 --- a/crates/proof/src/oprf_query.rs +++ b/crates/proof/src/oprf_query.rs @@ -21,8 +21,7 @@ use taceo_oprf::{ }; use world_id_primitives::{ - FieldElement, ProofRequest, ProofType, SessionFeType, SessionFieldElement, SessionRef, - TREE_DEPTH, + FieldElement, ProofRequest, ProofType, SessionFeType, SessionFieldElement, TREE_DEPTH, oprf::{ CredentialBlindingFactorOprfRequestAuthV1, NullifierOprfRequestAuthV1, OprfModule, RpSignatureVerification, @@ -291,15 +290,9 @@ impl<'a> OprfEntrypoint<'a> { proof_request .validate_proof_type() .map_err(|err| ProofError::GenerationError(err.to_string()))?; - if !proof_request.is_session_proof() - && !matches!( - (proof_request.proof_type, proof_request.session_id), - (ProofType::Uniqueness, SessionRef::Create) - ) - { + if proof_request.session_id.is_none() { return Err(ProofError::GenerationError( - "session randomness can only be derived for session proofs or uniqueness session creation" - .to_string(), + "session randomness can only be derived for requests with a \"create\" or existing session_id".to_string(), )); } @@ -312,12 +305,8 @@ impl<'a> OprfEntrypoint<'a> { rng, )?; - let rp_signature_verification = match ( - proof_request.proof_type, - proof_request.session_id, - proof_request.action, - ) { - (ProofType::Uniqueness, SessionRef::Create, Some(action)) => { + let rp_signature_verification = match (proof_request.proof_type, proof_request.action) { + (ProofType::Uniqueness, Some(action)) => { Some(RpSignatureVerification::UniquenessAction { action }) } _ => None, diff --git a/docs/world-id-4-specs/README.md b/docs/world-id-4-specs/README.md index 395948d64..24896e38e 100644 --- a/docs/world-id-4-specs/README.md +++ b/docs/world-id-4-specs/README.md @@ -283,8 +283,7 @@ rp->>rp: verify proof (checking sessionId == C' in verifier contract) **Binding Uniqueness Proofs to a Session** - A Uniqueness Proof request may omit `session_id` (unbound), use `"create"` to atomically mint a session and bind the proof to it, or include an existing `sessionId` to bind to a previously established session. Bound proofs carry the session's commitment `C` as their `id_commitment` public signal, proving in-circuit that the session and the nullifier belong to the same World ID. -- `proof_type: "uniqueness", session_id: "create"` performs two OPRF rounds: the normal uniqueness-nullifier query and a session-seed query. The latter carries the RP-signed uniqueness action as `rp_signature_verification: { "uniqueness_action": { "action": ... } }`, allowing the same RP signature to authorize the session module. -- Binding to an existing `sessionId` requires the Authenticator's cached `r`; re-deriving `r` is only possible through a session-type request. +- A session-bound Uniqueness Proof performs two OPRF rounds when `r` is not cached: the normal uniqueness-nullifier query and a session-seed query. The latter carries the RP-signed uniqueness action as `rp_signature_verification: { "uniqueness_action": { "action": ... } }`, allowing the same RP signature to authorize the session module. For `session_id: "create"` this mints a fresh session; for an existing `sessionId` it deterministically re-derives the same `r`. - Verifiers MUST check bound proofs against the session's commitment. With the session commitment set to `0` the proof is valid but unbound. On-chain, the dedicated `verifyWithSession()` entry point does this (it rejects `sessionId == 0`). The convenience `verify()` entry point pins the signal to `0` and rejects bound proofs, so binding is explicit in both directions. - Binding one `sessionId` to Uniqueness Proofs under different actions intentionally links those actions to the same World ID; Authenticators MUST clearly surface this to users. - OPRF nodes must support `rp_signature_verification` on session-seed queries before authenticators begin sending it. The field is additive: requests without it behave identically to before. From 25f454c7926cd3faa9ab933e936eed4f9e608bcd Mon Sep 17 00:00:00 2001 From: kilianglas Date: Thu, 16 Jul 2026 13:35:18 +0200 Subject: [PATCH 27/36] docs: polish readme --- docs/world-id-4-specs/README.md | 49 +++++++++++++++++++++++++-------- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/docs/world-id-4-specs/README.md b/docs/world-id-4-specs/README.md index 24896e38e..0764e040b 100644 --- a/docs/world-id-4-specs/README.md +++ b/docs/world-id-4-specs/README.md @@ -230,21 +230,20 @@ Both the Relying Party Registry and the Credential Schema Issuer Registry charge ### Session Proofs -RPs can create sessions for their app to ensure that it's still the same World ID interacting with them across multiple interactions. Session Proofs intentionally allow the RP to link multiple interactions in their app to the same World ID. Potential use cases include: +RPs can create sessions for their app to ensure that it's still the same World ID interacting with them across multiple interactions. Session Proofs intentionally allow the RP to link multiple interactions in their app to the same World ID. Sessions require the RP to store a `sessionId`. A `sessionId` can be created as part of a request for a Uniqueness Proof (see [Binding Uniqueness Proofs to a Session](#binding-uniqueness-proofs-to-a-session)), which binds the `sessionId` to a `nullifier`, or without uniqueness binding. Potential use cases include: - Credential upgrade: A user verified previously with one credential and now wants to prove using another one (e.g. unlocking additional benefits). **Important Note**. While this can be used to prove a new Credential belongs to the same World ID, the implications must be carefully considered when it comes to uniqueness. **Uniqueness sets are independent**, e.g. users may have both a PoH and a government document Credential, but this doesn't mean that by accepting both as an RP you can get guarantees that only a single human is behind each. A user may choose to obtain a PoH Credential and a document Credential in different World IDs. - Credential expiration check: A user previously enrolled with one Credential; periodically,the RP wants to make sure the user's Credential is still valid (for example not expired). - (Future). RP-level Face Auth: Currently, Face Auth only ensures that the whoever produces the proof is the same person that received the Credential. However, for some applications an RP may want to make sure the same person is behind multiple interactions. Session Proofs use the same zero-knowledge circuits as Uniqueness Proofs, but authenticators MUST clearly distinguish them to users since they involve a reusable identifier that can link interactions. Instead of a nullifier, Session Proofs return a `sessionNullifier` which is required for verification but does **not** provide the same uniqueness guarantee (see below on `sessionNullifier`). +Session Proofs without uniqueness binding work in the following manner: -Session Proofs work in the following manner: - -- An RP requests an authenticator to create a session. On the wire this is a proof request with `"proof_type": "session"` and `"session_id": "create"`; the session is created and proven in the same response. -- The authenticator provides a `sessionId`. A unique identifier bound to the user's World ID for that RP. +- An RP requests an authenticator to create a session. +- The authenticator provides a `sessionId`, together with an initial session proof that proves that the `sessionId` is well formed. A unique identifier bound to the user's World ID for that RP. - The RP stores this `sessionId` alongside their account for the user. -- For subsequent interactions, the RP includes the stored `sessionId` (a `session_`-prefixed string) as `session_id` in proof requests with `"proof_type": "session"`. The user can then generate a Session Proof to prove they have the same World ID. Different proofs over time with the same `sessionId` may use different credentials. -- The `sessionId` is generated as outlined below, where `r` is computationally indistinguishable from random. +- For subsequent interactions, the RP includes the `sessionId` in proof requests. The user can then generate a Session Proof to prove they have the same World ID. Different proofs over time with the same `sessionId` may use different credentials. +- A `sessionId` is generated as outlined below, where `r` is computationally indistinguishable from random. ```mermaid sequenceDiagram @@ -258,7 +257,7 @@ a->>a: Generate oprf_seed locally (CSPRNG) a->>o: r=OPRF(rpPublicKey, DS_C || leafIndex || oprf_seed) a->>a: Compute C = H(DS_C || leafIndex || r) a->>a: sessionId = encode(C, oprf_seed) -a ->> rp: sessionId +a ->> rp: sessionId + proof (see below) end rp->>a: session proof request (incl. sessionId) @@ -282,11 +281,39 @@ rp->>rp: verify proof (checking sessionId == C' in verifier contract) **Binding Uniqueness Proofs to a Session** -- A Uniqueness Proof request may omit `session_id` (unbound), use `"create"` to atomically mint a session and bind the proof to it, or include an existing `sessionId` to bind to a previously established session. Bound proofs carry the session's commitment `C` as their `id_commitment` public signal, proving in-circuit that the session and the nullifier belong to the same World ID. -- A session-bound Uniqueness Proof performs two OPRF rounds when `r` is not cached: the normal uniqueness-nullifier query and a session-seed query. The latter carries the RP-signed uniqueness action as `rp_signature_verification: { "uniqueness_action": { "action": ... } }`, allowing the same RP signature to authorize the session module. For `session_id: "create"` this mints a fresh session; for an existing `sessionId` it deterministically re-derives the same `r`. +- A Uniqueness Proof request may include an existing `sessionId` to bind the uniqueness proof to a previously established session, or set the `sessionId` field to `"create"` to atomically mint a session and bind the proof to it. In both cases, the protocol verifies in-circuit that the session and the nullifier belong to the same World ID. The flow for creating a `sessionId` as part of a uniqueness proof is outlined below. +- As for session proofs, the blinding factor `r` of the `sessionId` may be cached or re-derived from the `oprf_seed`. - Verifiers MUST check bound proofs against the session's commitment. With the session commitment set to `0` the proof is valid but unbound. On-chain, the dedicated `verifyWithSession()` entry point does this (it rejects `sessionId == 0`). The convenience `verify()` entry point pins the signal to `0` and rejects bound proofs, so binding is explicit in both directions. - Binding one `sessionId` to Uniqueness Proofs under different actions intentionally links those actions to the same World ID; Authenticators MUST clearly surface this to users. -- OPRF nodes must support `rp_signature_verification` on session-seed queries before authenticators begin sending it. The field is additive: requests without it behave identically to before. + +```mermaid +sequenceDiagram +participant rp as RP +participant a as Authenticator +participant o as OPRF Nodes +participant v as Verifier + +rp->>a: Signed Uniqueness Proof request (action + sessionId) +alt sessionId = "create" +a->>a: Generate oprf_seed +a->>o: Derive session blinding factor r +a->>a: sessionId = encode(H(DS_C || leafIndex || r), oprf_seed) +else existing sessionId +a->>o: Re-derive r from sessionId.oprf_seed if not cached +a->>a: Check H(DS_C || leafIndex || r) == sessionId.commitment +end +par Session binding +a->>a: Constrain sessionId.commitment to the user's leafIndex +and Uniqueness +a->>o: Derive nullifier for (leafIndex, rpId, action) +end +a->>a: Generate final proof with sessionId.commitment as a public signal +a->>rp: proof + nullifier + sessionId +rp->>v: verifyWithSession(..., sessionId.commitment, proof) +v->>v: Verify the non-zero session commitment and proof +v-->>rp: Valid session-bound Uniqueness Proof +rp->>rp: Verify nullifier uniqueness +``` ### Web-based Authenticator Provider From 828b5ec512f301f78fb4808964fe470c9e56d46e Mon Sep 17 00:00:00 2001 From: kilianglas Date: Tue, 21 Jul 2026 12:54:40 +0200 Subject: [PATCH 28/36] refactor: remove unnecessary unreachable! --- crates/authenticator/src/prove.rs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/crates/authenticator/src/prove.rs b/crates/authenticator/src/prove.rs index 5720e1d7d..ab90ff1cb 100644 --- a/crates/authenticator/src/prove.rs +++ b/crates/authenticator/src/prove.rs @@ -208,22 +208,19 @@ impl Authenticator { account_inclusion_proof: Option>, ) -> Result<(SessionId, FieldElement), AuthenticatorError> { proof_request.validate_proof_type()?; - if proof_request.session_id.is_none() { - return Err(AuthenticatorError::PrimitiveError( - world_id_primitives::PrimitiveError::InvalidInput { - attribute: "session_id".to_string(), - reason: "session_id must be \"create\" or an existing session id".to_string(), - }, - )); - } - let mut rng = rand::rngs::OsRng; let oprf_seed = match proof_request.session_id { SessionRef::Existing(session_id) => session_id.oprf_seed, SessionRef::Create => SessionId::generate_oprf_seed(&mut rng), SessionRef::None => { - unreachable!("SessionRef::None should be handled by the guard above") + return Err(AuthenticatorError::PrimitiveError( + world_id_primitives::PrimitiveError::InvalidInput { + attribute: "session_id".to_string(), + reason: "session_id must be \"create\" or an existing session id" + .to_string(), + }, + )); } }; From 2d7d2284eb1ad08f01ba5ed6ade3b92ecac4f7a1 Mon Sep 17 00:00:00 2001 From: kilianglas Date: Tue, 21 Jul 2026 14:17:11 +0200 Subject: [PATCH 29/36] fix: remove unused imports in generate-solidity-fixtures --- tools/generate-solidity-fixtures/src/main.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tools/generate-solidity-fixtures/src/main.rs b/tools/generate-solidity-fixtures/src/main.rs index 5dc327bca..6a9654fc0 100644 --- a/tools/generate-solidity-fixtures/src/main.rs +++ b/tools/generate-solidity-fixtures/src/main.rs @@ -38,8 +38,7 @@ use world_id_gateway::{ spawn_gateway_for_tests, }; use world_id_primitives::{ - Config, FieldElement, ServiceEndpoint, SessionFieldElement, SessionId, SessionRef, TREE_DEPTH, - merkle::AccountInclusionProof, + Config, FieldElement, ServiceEndpoint, SessionRef, TREE_DEPTH, merkle::AccountInclusionProof, }; use world_id_test_utils::{ anvil::WorldIDVerifierV2, From c379ea98fffeca6de834cb2dde56c8fa3c949c12 Mon Sep 17 00:00:00 2001 From: kilianglas Date: Mon, 3 Aug 2026 21:41:58 +0200 Subject: [PATCH 30/36] feat: remove uniqueness bindign to existing session id --- crates/authenticator/src/authenticator.rs | 4 +- crates/core/tests/generate_proof.rs | 109 ---------------------- crates/primitives/src/request/mod.rs | 108 ++++++++++----------- crates/primitives/src/session.rs | 2 +- docs/world-id-4-specs/README.md | 15 +-- 5 files changed, 59 insertions(+), 179 deletions(-) diff --git a/crates/authenticator/src/authenticator.rs b/crates/authenticator/src/authenticator.rs index 198437986..314691237 100644 --- a/crates/authenticator/src/authenticator.rs +++ b/crates/authenticator/src/authenticator.rs @@ -61,8 +61,8 @@ pub struct CredentialInput { pub struct ProofResult { /// The session_id_r_seed (`r`), when a session was created or proven. /// - /// Returned for session proofs and for uniqueness proofs that create or bind - /// a session. The SDK should cache this keyed by [`SessionId::oprf_seed`]. + /// Returned for session proofs and for uniqueness proofs that create a bound + /// session. The SDK should cache this keyed by [`SessionId::oprf_seed`]. pub session_id_r_seed: Option, /// The response to deliver to an RP. diff --git a/crates/core/tests/generate_proof.rs b/crates/core/tests/generate_proof.rs index 43a82542f..bf7caac6b 100644 --- a/crates/core/tests/generate_proof.rs +++ b/crates/core/tests/generate_proof.rs @@ -323,8 +323,6 @@ async fn e2e_authenticator_generate_proof() -> Result<()> { .generate_nullifier(&proof_request, None) .await?; assert_ne!(nullifier.oprf_output(), FieldElement::ZERO); - // reused below for the session-bound proof; `generate_proof` does not contact the nodes - let nullifier_for_binding = nullifier.clone(); let credentials = [CredentialInput { credential: credential.clone(), @@ -458,113 +456,6 @@ async fn e2e_authenticator_generate_proof() -> Result<()> { .await?; info!("uniqueness create proof verified via verifyWithSession"); - // ── SESSION-BOUND UNIQUENESS PROOF (existing session) ── - let session_id_r_seed = created_session_seed; - let session_id = created_session_id; - let bound_request = ProofRequest { - session_id: SessionRef::Existing(session_id), - ..proof_request.clone() - }; - - // The seed can be re-derived when it is not cached. - let uncached_bound_result = authenticator - .generate_proof( - &bound_request, - nullifier_for_binding.clone(), - &credentials, - None, - None, - ) - .await?; - assert_eq!( - uncached_bound_result.session_id_r_seed, - Some(session_id_r_seed) - ); - assert_eq!( - uncached_bound_result.proof_response.session_id, - Some(session_id) - ); - - // a seed that does not open the session's commitment is rejected - let err = authenticator - .generate_proof( - &bound_request, - nullifier_for_binding.clone(), - &credentials, - None, - Some(FieldElement::random(&mut rng)), - ) - .await - .unwrap_err(); - assert!(matches!(err, AuthenticatorError::SessionIdMismatch)); - - let bound_result = authenticator - .generate_proof( - &bound_request, - nullifier_for_binding, - &credentials, - None, - Some(session_id_r_seed), - ) - .await?; - info!("generated session-bound uniqueness proof"); - - assert_eq!(bound_result.proof_response.session_id, Some(session_id)); - let bound_item = &bound_result.proof_response.responses[0]; - assert!(bound_item.session_nullifier.is_none()); - let bound_nullifier = bound_item - .nullifier - .expect("bound proof is a uniqueness proof"); - // same RP/action => same deterministic nullifier as the unbound proof - assert_eq!(bound_nullifier, response_item.nullifier.unwrap()); - - // `verify()` pins the sessionId signal to 0, so it must reject the bound proof - let unbound_verify = world_id_verifier - .verify( - bound_nullifier.into(), - rp_fixture.action.into(), - rp_fixture.world_rp_id.into_inner(), - rp_fixture.nonce.into(), - request_item.signal_hash().into(), - bound_item.expires_at_min, - issuer_schema_id, - request_item - .genesis_issued_at_min - .unwrap_or_default() - .try_into() - .expect("u64 fits into U256"), - bound_item.proof.as_ethereum_representation(), - ) - .call() - .await; - assert!( - unbound_verify.is_err(), - "bound proof must not verify with sessionId = 0" - ); - info!("session-bound proof correctly rejected by the sessionId=0 entry point"); - - // `verifyWithSession` checks the sessionId signal against the session's commitment - world_id_verifier - .verifyWithSession( - bound_nullifier.into(), - rp_fixture.action.into(), - rp_fixture.world_rp_id.into_inner(), - rp_fixture.nonce.into(), - request_item.signal_hash().into(), - bound_item.expires_at_min, - issuer_schema_id, - request_item - .genesis_issued_at_min - .unwrap_or_default() - .try_into() - .expect("u64 fits into U256"), - session_id.commitment.into(), - bound_item.proof.as_ethereum_representation(), - ) - .call() - .await?; - info!("session-bound proof verified via verifyWithSession"); - indexer_handle.abort(); info!("e2e_authenticator_generate_proof finished successfully"); Ok(()) diff --git a/crates/primitives/src/request/mod.rs b/crates/primitives/src/request/mod.rs index 9181020c7..49f0434b6 100644 --- a/crates/primitives/src/request/mod.rs +++ b/crates/primitives/src/request/mod.rs @@ -52,9 +52,9 @@ impl<'de> serde::Deserialize<'de> for RequestVersion { pub enum ProofType { /// A uniqueness proof scoped by the RP-provided action. /// - /// May carry a `session_id` to mint a fresh session, bind the proof to an - /// existing session, or omit session involvement entirely — see - /// [`ProofRequest::binds_session`]. + /// May carry `session_id: "create"` to mint a fresh session bound to the proof, + /// or omit session involvement entirely — see [`ProofRequest::binds_session`]. + /// Binding to an already existing session is not supported. #[default] Uniqueness, /// Prove an RP-scoped session — either minting a fresh one @@ -101,9 +101,9 @@ pub struct ProofRequest { /// Session identifier that links proofs for the same user/RP pair across requests. /// /// Three states: absent/`null` (no session), `"create"` (mint a fresh session), - /// or an existing `"session_"`-prefixed id. For [`ProofType::Uniqueness`], all - /// three are valid; for [`ProofType::Session`], `"create"` or an existing id is - /// required (see [`Self::binds_session`]). + /// or an existing `"session_"`-prefixed id. [`ProofType::Uniqueness`] accepts + /// absent or `"create"` (see [`Self::binds_session`]); [`ProofType::Session`] + /// requires `"create"` or an existing id. /// The proof will only be valid if the session ID is meant for this context and /// this particular World ID holder. #[serde(default)] @@ -235,9 +235,9 @@ pub struct ProofResponse { /// the newly generated `SessionId`. For subsequent Session Proofs, this /// echoes back the `SessionId` from the request for convenience. /// - /// For Uniqueness Proofs this is present when the request asked to create or - /// bind a session ([`ProofRequest::binds_session`]). Create responses carry - /// the newly minted `SessionId`; existing-session responses echo the bound id. + /// For Uniqueness Proofs this is present when the request asked to create a + /// bound session ([`ProofRequest::binds_session`]) and carries the newly + /// minted `SessionId`. #[serde(skip_serializing_if = "Option::is_none")] pub session_id: Option, /// Error message if the entire proof request failed. @@ -469,6 +469,12 @@ impl ProofRequest { attribute: "action".to_string(), reason: "must be present for uniqueness proofs".to_string(), }), + (ProofType::Uniqueness, SessionRef::Existing(_), _) => { + Err(PrimitiveError::InvalidInput { + attribute: "session_id".to_string(), + reason: "must be omitted or \"create\" for uniqueness proofs".to_string(), + }) + } (ProofType::Session, SessionRef::None, _) => Err(PrimitiveError::InvalidInput { attribute: "session_id".to_string(), reason: "must be \"create\" or an existing session id for session proofs" @@ -490,7 +496,8 @@ impl ProofRequest { self.proof_type.is_session() } - /// Returns true if this request asks for a Uniqueness Proof committed to a session. + /// Returns true if this request asks for a Uniqueness Proof committed to a freshly + /// minted session. /// /// A committed proof carries [`SessionId::commitment`] as its `id_commitment` public /// signal, proving in-circuit that session and nullifier belong to the same World ID. @@ -498,7 +505,7 @@ impl ProofRequest { /// proof is valid but unbound. #[must_use] pub const fn binds_session(&self) -> bool { - self.proof_type.is_uniqueness() && !self.session_id.is_none() + self.proof_type.is_uniqueness() && self.session_id.is_create() } /// Validates the structural integrity of the constraint expression. @@ -558,10 +565,10 @@ impl ProofRequest { return Err(ValidationError::MissingSessionId); } } - (ProofType::Uniqueness, SessionRef::Existing(session_id)) => { - if response.session_id != Some(session_id) { - return Err(ValidationError::SessionIdMismatch); - } + (ProofType::Uniqueness, SessionRef::Existing(_)) => { + return Err(ValidationError::InvalidProofRequest( + "uniqueness proof with an existing session_id".to_string(), + )); } (ProofType::Session, SessionRef::Create) => { // No request-side id to compare — the freshly minted id must be present. @@ -2397,11 +2404,11 @@ mod tests { #[test] fn test_validate_proof_type_is_strict() { - let uniqueness_with_session = ProofRequest { + let uniqueness_with_create = ProofRequest { id: "req_bound_uniqueness".into(), version: RequestVersion::V1, proof_type: ProofType::Uniqueness, - session_id: SessionRef::Existing(test_session_id(1)), + session_id: SessionRef::Create, action: Some(FieldElement::ZERO), created_at: 1_735_689_600, expires_at: 1_735_689_900, @@ -2419,14 +2426,14 @@ mod tests { constraints: None, }; - // uniqueness + session_id = session-bound uniqueness proof - assert!(uniqueness_with_session.validate_proof_type().is_ok()); - assert!(uniqueness_with_session.binds_session()); - assert!(!uniqueness_with_session.is_session_proof()); + // uniqueness + "create" mints and binds a session + assert!(uniqueness_with_create.validate_proof_type().is_ok()); + assert!(uniqueness_with_create.binds_session()); + assert!(!uniqueness_with_create.is_session_proof()); let uniqueness_without_action = ProofRequest { action: None, - ..uniqueness_with_session.clone() + ..uniqueness_with_create.clone() }; assert!(matches!( uniqueness_without_action.validate_proof_type(), @@ -2435,24 +2442,27 @@ mod tests { let plain_uniqueness = ProofRequest { session_id: SessionRef::None, - ..uniqueness_with_session.clone() + ..uniqueness_with_create.clone() }; assert!(plain_uniqueness.validate_proof_type().is_ok()); assert!(!plain_uniqueness.binds_session()); - // uniqueness + "create" mints and binds a session - let uniqueness_with_create = ProofRequest { - session_id: SessionRef::Create, - ..uniqueness_with_session.clone() + // uniqueness cannot bind an already existing session + let uniqueness_with_existing = ProofRequest { + session_id: SessionRef::Existing(test_session_id(1)), + ..uniqueness_with_create.clone() }; - assert!(uniqueness_with_create.validate_proof_type().is_ok()); - assert!(uniqueness_with_create.binds_session()); + assert!(matches!( + uniqueness_with_existing.validate_proof_type(), + Err(PrimitiveError::InvalidInput { attribute, .. }) if attribute == "session_id" + )); + assert!(!uniqueness_with_existing.binds_session()); let session_without_session = ProofRequest { proof_type: ProofType::Session, session_id: SessionRef::None, action: None, - ..uniqueness_with_session.clone() + ..uniqueness_with_create.clone() }; assert!(matches!( session_without_session.validate_proof_type(), @@ -2464,7 +2474,7 @@ mod tests { proof_type: ProofType::Session, session_id: SessionRef::Create, action: None, - ..uniqueness_with_session.clone() + ..uniqueness_with_create.clone() }; assert!(session_create.validate_proof_type().is_ok()); assert!(session_create.is_session_proof()); @@ -2472,8 +2482,9 @@ mod tests { let session_existing = ProofRequest { proof_type: ProofType::Session, + session_id: SessionRef::Existing(test_session_id(1)), action: None, - ..uniqueness_with_session.clone() + ..uniqueness_with_create.clone() }; assert!(session_existing.validate_proof_type().is_ok()); @@ -2483,7 +2494,7 @@ mod tests { proof_type: ProofType::Session, session_id, action: Some(FieldElement::ZERO), - ..uniqueness_with_session.clone() + ..uniqueness_with_create.clone() }; assert!(matches!( session_with_action.validate_proof_type(), @@ -2498,7 +2509,7 @@ mod tests { id: "req_bound".into(), version: RequestVersion::V1, proof_type: ProofType::Uniqueness, - session_id: SessionRef::Existing(test_session_id(1)), + session_id: SessionRef::Create, action: Some(FieldElement::ZERO), created_at: 1_735_689_600, expires_at: 1_735_689_900, @@ -2562,7 +2573,7 @@ mod tests { } #[test] - fn test_validate_response_bound_uniqueness_echoes_session_id() { + fn test_validate_response_uniqueness_rejects_existing_session() { let session_id = test_session_id(7); let request = ProofRequest { id: "req_bound".into(), @@ -2586,8 +2597,7 @@ mod tests { constraints: None, }; - // bound uniqueness responses carry a uniqueness nullifier + the echoed session id - let valid = ProofResponse { + let response = ProofResponse { id: request.id.clone(), version: RequestVersion::V1, session_id: Some(session_id), @@ -2600,26 +2610,10 @@ mod tests { 1_735_689_600, )], }; - assert!(request.validate_response(&valid).is_ok()); - - // downgraded response (no echo) is rejected - let missing_echo = ProofResponse { - session_id: None, - ..valid.clone() - }; - assert!(matches!( - request.validate_response(&missing_echo), - Err(ValidationError::SessionIdMismatch) - )); - - // different session id is rejected - let wrong_echo = ProofResponse { - session_id: Some(test_session_id(8)), - ..valid.clone() - }; + // uniqueness proofs can only mint a session, never bind an existing one assert!(matches!( - request.validate_response(&wrong_echo), - Err(ValidationError::SessionIdMismatch) + request.validate_response(&response), + Err(ValidationError::InvalidProofRequest(_)) )); // plain uniqueness requests still reject any session id in the response @@ -2628,7 +2622,7 @@ mod tests { ..request }; assert!(matches!( - plain_request.validate_response(&valid), + plain_request.validate_response(&response), Err(ValidationError::UnexpectedSessionId) )); } diff --git a/crates/primitives/src/session.rs b/crates/primitives/src/session.rs index 627651574..3942f6172 100644 --- a/crates/primitives/src/session.rs +++ b/crates/primitives/src/session.rs @@ -258,7 +258,7 @@ pub enum SessionRef { /// session in the same response; for [`crate::ProofType::Uniqueness`] this /// returns a uniqueness proof committed to the newly minted session. Create, - /// Refer to an existing session. + /// Refer to an existing session. Only valid for [`crate::ProofType::Session`]. Existing(SessionId), } diff --git a/docs/world-id-4-specs/README.md b/docs/world-id-4-specs/README.md index 0764e040b..7f2e93c59 100644 --- a/docs/world-id-4-specs/README.md +++ b/docs/world-id-4-specs/README.md @@ -97,7 +97,7 @@ Diagram of components for the World ID 4.0 Protocol. 6. Details about the nature, number, and diversity requirements of OPRF nodes must be established before the production network is live. 4. Protocol differences at a glance: - | | **World ID ≤3.0** | **World ID 4.0 (2025)** | + | | **World ID ≤3.0** | **World ID 4.0 (2025)** | | --- | --- | --- | | What is a World ID? | A secret. | An entry in public registry. | | Proof Generation | [Semaphore](https://semaphore.pse.dev/) proofs generated on the client. | *Conceptually the same but with new ZK-circuits.* Users generate a query proof for OPRF nodes, which provide computations that enable the nullifier generation. A final Uniqueness Proof is generated and presented to RPs. | @@ -230,7 +230,7 @@ Both the Relying Party Registry and the Credential Schema Issuer Registry charge ### Session Proofs -RPs can create sessions for their app to ensure that it's still the same World ID interacting with them across multiple interactions. Session Proofs intentionally allow the RP to link multiple interactions in their app to the same World ID. Sessions require the RP to store a `sessionId`. A `sessionId` can be created as part of a request for a Uniqueness Proof (see [Binding Uniqueness Proofs to a Session](#binding-uniqueness-proofs-to-a-session)), which binds the `sessionId` to a `nullifier`, or without uniqueness binding. Potential use cases include: +RPs can create sessions for their app to ensure that it's still the same World ID interacting with them across multiple interactions. Session Proofs intentionally allow the RP to link multiple interactions in their app to the same World ID. Sessions require the RP to store a `sessionId`. A `sessionId` can be created as part of a request for a Uniqueness Proof (see [Binding Uniqueness Proofs to a Session](#binding-uniqueness-proofs-to-a-session)), which binds the `sessionId` to a `nullifier`, or standalone without uniqueness binding. Potential use cases include: - Credential upgrade: A user verified previously with one credential and now wants to prove using another one (e.g. unlocking additional benefits). **Important Note**. While this can be used to prove a new Credential belongs to the same World ID, the implications must be carefully considered when it comes to uniqueness. **Uniqueness sets are independent**, e.g. users may have both a PoH and a government document Credential, but this doesn't mean that by accepting both as an RP you can get guarantees that only a single human is behind each. A user may choose to obtain a PoH Credential and a document Credential in different World IDs. - Credential expiration check: A user previously enrolled with one Credential; periodically,the RP wants to make sure the user's Credential is still valid (for example not expired). @@ -281,8 +281,8 @@ rp->>rp: verify proof (checking sessionId == C' in verifier contract) **Binding Uniqueness Proofs to a Session** -- A Uniqueness Proof request may include an existing `sessionId` to bind the uniqueness proof to a previously established session, or set the `sessionId` field to `"create"` to atomically mint a session and bind the proof to it. In both cases, the protocol verifies in-circuit that the session and the nullifier belong to the same World ID. The flow for creating a `sessionId` as part of a uniqueness proof is outlined below. -- As for session proofs, the blinding factor `r` of the `sessionId` may be cached or re-derived from the `oprf_seed`. +- A Uniqueness Proof request may set the `sessionId` field to `"create"` to atomically mint a session and bind the proof to it. The protocol verifies in-circuit that the session and the nullifier belong to the same World ID. The flow is outlined below. Binding a Uniqueness Proof to an already existing `sessionId` is not supported. A session is either created together with the uniqueness proof, or it carries no uniqueness binding at all. +- The blinding factor `r` of the minted `sessionId` is returned to the Authenticator for caching; as for session proofs it can always be re-derived from the `oprf_seed`. - Verifiers MUST check bound proofs against the session's commitment. With the session commitment set to `0` the proof is valid but unbound. On-chain, the dedicated `verifyWithSession()` entry point does this (it rejects `sessionId == 0`). The convenience `verify()` entry point pins the signal to `0` and rejects bound proofs, so binding is explicit in both directions. - Binding one `sessionId` to Uniqueness Proofs under different actions intentionally links those actions to the same World ID; Authenticators MUST clearly surface this to users. @@ -293,15 +293,10 @@ participant a as Authenticator participant o as OPRF Nodes participant v as Verifier -rp->>a: Signed Uniqueness Proof request (action + sessionId) -alt sessionId = "create" +rp->>a: Signed Uniqueness Proof request (action + sessionId = "create") a->>a: Generate oprf_seed a->>o: Derive session blinding factor r a->>a: sessionId = encode(H(DS_C || leafIndex || r), oprf_seed) -else existing sessionId -a->>o: Re-derive r from sessionId.oprf_seed if not cached -a->>a: Check H(DS_C || leafIndex || r) == sessionId.commitment -end par Session binding a->>a: Constrain sessionId.commitment to the user's leafIndex and Uniqueness From c2eb105fb203e2e462917a87d5577974a9067a39 Mon Sep 17 00:00:00 2001 From: kilianglas Date: Mon, 3 Aug 2026 22:08:34 +0200 Subject: [PATCH 31/36] fix(contracts): reject zero sessionId in verifySession MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero is the circuit's "no session" sentinel, satisfiable by any World ID regardless of mt_index, so a session record left at zero could be satisfied by an arbitrary World ID with a patched authenticator. verifyWithSession already rejected it; apply the same check to verifySession. V2 only — older implementations are immutable. Reported via HackerOne #3912490. Co-Authored-By: Claude Opus 5 (1M context) --- contracts/src/core/WorldIDVerifierV2.sol | 3 +++ contracts/test/core/WorldIDVerifierV2Test.t.sol | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/contracts/src/core/WorldIDVerifierV2.sol b/contracts/src/core/WorldIDVerifierV2.sol index bd6e08897..f2f06ab53 100644 --- a/contracts/src/core/WorldIDVerifierV2.sol +++ b/contracts/src/core/WorldIDVerifierV2.sol @@ -96,6 +96,9 @@ contract WorldIDVerifierV2 is IWorldIDVerifierV2, WorldIDVerifier { if (uint8(action >> 248) != uint8(2)) { revert InvalidAction(); } + if (sessionId == 0) { + revert InvalidSessionId(); + } verifyProofAndSignals( sessionNullifier[0], diff --git a/contracts/test/core/WorldIDVerifierV2Test.t.sol b/contracts/test/core/WorldIDVerifierV2Test.t.sol index 3b4957df7..e32e58f77 100644 --- a/contracts/test/core/WorldIDVerifierV2Test.t.sol +++ b/contracts/test/core/WorldIDVerifierV2Test.t.sol @@ -149,6 +149,17 @@ contract WorldIDVerifierV2Test is Test { ); } + function test_SessionRevertsWhenSessionIdZero() public { + // Valid session action prefix, but a zero session id must not pass + uint256 action = 0x0200000000000000000000000000000000000000000000000000000000000001; + + vm.warp(expiresAtMin + 1 hours); + vm.expectRevert(abi.encodeWithSelector(IWorldIDVerifierV2.InvalidSessionId.selector)); + verifier.verifySession( + rpIdCorrect, nonce, signalHash, expiresAtMin, credentialIssuerIdCorrect, 0, 0, [nullifier, action], proof + ); + } + function test_PassesActionCheckWhenFirstByteZero() public { // passes the prefix check but fails proof verification uint256 action = 0x00d4b66e5417cb9875f6a2b5be9814dca80651d7c74b3b21685fdd494566e7; From ae0f2dda819a07b1d38b60f7c0387d1d64c3cafb Mon Sep 17 00:00:00 2001 From: kilianglas Date: Tue, 4 Aug 2026 12:02:48 +0200 Subject: [PATCH 32/36] refactor!: move session binding to WorldIDVerifierV3 V2 is released and immutable, so the new entry point and the session id validation move to a new version instead of extending V2 in place. - IWorldIDVerifierV3 declares verifyWithSession + InvalidSessionId - WorldIDVerifierV3 implements verifyWithSession and overrides verifySession to reject sessionId == 0 - WorldIDVerifierV2.sol is restored byte-identical to main; InvalidAction stays declared inline there and V3 inherits it - V2 tests return to their original set (regenerated fixture values only); the binding and zero-session cases move to WorldIDVerifierV3Test - test-utils deploys V3 behind the proxy; e2e and the fixture tool retarget Co-Authored-By: Claude Opus 5 (1M context) --- contracts/src/core/WorldIDVerifierV2.sol | 53 +---- contracts/src/core/WorldIDVerifierV3.sol | 84 +++++++ ...DVerifierV2.sol => IWorldIDVerifierV3.sol} | 20 +- .../test/core/WorldIDVerifierV2Test.t.sol | 154 +------------ .../test/core/WorldIDVerifierV3Test.t.sol | 213 ++++++++++++++++++ crates/core/tests/generate_proof.rs | 6 +- crates/test-utils/src/anvil.rs | 10 +- tools/generate-solidity-fixtures/src/main.rs | 6 +- 8 files changed, 329 insertions(+), 217 deletions(-) create mode 100644 contracts/src/core/WorldIDVerifierV3.sol rename contracts/src/core/interfaces/{IWorldIDVerifierV2.sol => IWorldIDVerifierV3.sol} (79%) create mode 100644 contracts/test/core/WorldIDVerifierV3Test.t.sol diff --git a/contracts/src/core/WorldIDVerifierV2.sol b/contracts/src/core/WorldIDVerifierV2.sol index f2f06ab53..64e923e55 100644 --- a/contracts/src/core/WorldIDVerifierV2.sol +++ b/contracts/src/core/WorldIDVerifierV2.sol @@ -3,17 +3,23 @@ pragma solidity ^0.8.13; import {WorldIDVerifier} from "./WorldIDVerifier.sol"; import {IWorldIDVerifier} from "./interfaces/IWorldIDVerifier.sol"; -import {IWorldIDVerifierV2} from "./interfaces/IWorldIDVerifierV2.sol"; /** - * @title WorldIDVerifierV2 + * @title WorldIDVerifier * @author World Contributors * @notice Verifies World ID proofs (Uniqueness and Session proofs). * @dev In addition to verifying the Groth16 Proof, it verifies relevant public inputs to the * circuits through checks with the WorldIDRegistry, CredentialSchemaIssuerRegistry, and OprfKeyRegistry. * @custom:repo https://github.com/world-id/world-id-protocol */ -contract WorldIDVerifierV2 is IWorldIDVerifierV2, WorldIDVerifier { +contract WorldIDVerifierV2 is WorldIDVerifier { + /** + * @dev Thrown when the action is not valid for the type of proof. The prefix is enforced + * to ensure any nullifier request for a Uniqueness Proof is signed by the RP (actions + * without this prefix, i.e. for sessions, it doesn't need to be signed). + */ + error InvalidAction(); + /// @inheritdoc IWorldIDVerifier function verify( uint256 nullifier, @@ -25,7 +31,7 @@ contract WorldIDVerifierV2 is IWorldIDVerifierV2, WorldIDVerifier { uint64 issuerSchemaId, uint256 credentialGenesisIssuedAtMin, uint256[5] calldata zeroKnowledgeProof - ) external view virtual override(IWorldIDVerifier, WorldIDVerifier) onlyProxy onlyInitialized { + ) external view virtual override onlyProxy onlyInitialized { if (uint8(action >> 248) != uint8(0)) { revert InvalidAction(); } @@ -46,40 +52,6 @@ contract WorldIDVerifierV2 is IWorldIDVerifierV2, WorldIDVerifier { ); } - /// @inheritdoc IWorldIDVerifierV2 - function verifyWithSession( - uint256 nullifier, - uint256 action, - uint64 rpId, - uint256 nonce, - uint256 signalHash, - uint64 expiresAtMin, - uint64 issuerSchemaId, - uint256 credentialGenesisIssuedAtMin, - uint256 sessionId, - uint256[5] calldata zeroKnowledgeProof - ) external view virtual override onlyProxy onlyInitialized { - if (uint8(action >> 248) != uint8(0)) { - revert InvalidAction(); - } - if (sessionId == 0) { - revert InvalidSessionId(); - } - - verifyProofAndSignals( - nullifier, - action, - rpId, - nonce, - signalHash, - expiresAtMin, - issuerSchemaId, - credentialGenesisIssuedAtMin, - sessionId, - zeroKnowledgeProof - ); - } - /// @inheritdoc IWorldIDVerifier function verifySession( uint64 rpId, @@ -91,14 +63,11 @@ contract WorldIDVerifierV2 is IWorldIDVerifierV2, WorldIDVerifier { uint256 sessionId, uint256[2] calldata sessionNullifier, uint256[5] calldata zeroKnowledgeProof - ) external view virtual override(IWorldIDVerifier, WorldIDVerifier) onlyProxy onlyInitialized { + ) external view virtual override onlyProxy onlyInitialized { uint256 action = sessionNullifier[1]; if (uint8(action >> 248) != uint8(2)) { revert InvalidAction(); } - if (sessionId == 0) { - revert InvalidSessionId(); - } verifyProofAndSignals( sessionNullifier[0], diff --git a/contracts/src/core/WorldIDVerifierV3.sol b/contracts/src/core/WorldIDVerifierV3.sol new file mode 100644 index 000000000..23a51b191 --- /dev/null +++ b/contracts/src/core/WorldIDVerifierV3.sol @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +import {WorldIDVerifierV2} from "./WorldIDVerifierV2.sol"; +import {IWorldIDVerifier} from "./interfaces/IWorldIDVerifier.sol"; +import {IWorldIDVerifierV3} from "./interfaces/IWorldIDVerifierV3.sol"; + +/** + * @title WorldIDVerifierV3 + * @author World Contributors + * @notice Verifies World ID proofs (Uniqueness and Session proofs). + * @dev In addition to verifying the Groth16 Proof, it verifies relevant public inputs to the + * circuits through checks with the WorldIDRegistry, CredentialSchemaIssuerRegistry, and OprfKeyRegistry. + * @custom:repo https://github.com/world-id/world-id-protocol + */ +contract WorldIDVerifierV3 is IWorldIDVerifierV3, WorldIDVerifierV2 { + /// @inheritdoc IWorldIDVerifierV3 + function verifyWithSession( + uint256 nullifier, + uint256 action, + uint64 rpId, + uint256 nonce, + uint256 signalHash, + uint64 expiresAtMin, + uint64 issuerSchemaId, + uint256 credentialGenesisIssuedAtMin, + uint256 sessionId, + uint256[5] calldata zeroKnowledgeProof + ) external view virtual override onlyProxy onlyInitialized { + if (uint8(action >> 248) != uint8(0)) { + revert InvalidAction(); + } + if (sessionId == 0) { + revert InvalidSessionId(); + } + + verifyProofAndSignals( + nullifier, + action, + rpId, + nonce, + signalHash, + expiresAtMin, + issuerSchemaId, + credentialGenesisIssuedAtMin, + sessionId, + zeroKnowledgeProof + ); + } + + /// @inheritdoc IWorldIDVerifier + 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 virtual override(IWorldIDVerifier, WorldIDVerifierV2) onlyProxy onlyInitialized { + uint256 action = sessionNullifier[1]; + if (uint8(action >> 248) != uint8(2)) { + revert InvalidAction(); + } + if (sessionId == 0) { + revert InvalidSessionId(); + } + + verifyProofAndSignals( + sessionNullifier[0], + action, + rpId, + nonce, + signalHash, + expiresAtMin, + issuerSchemaId, + credentialGenesisIssuedAtMin, + sessionId, + zeroKnowledgeProof + ); + } +} diff --git a/contracts/src/core/interfaces/IWorldIDVerifierV2.sol b/contracts/src/core/interfaces/IWorldIDVerifierV3.sol similarity index 79% rename from contracts/src/core/interfaces/IWorldIDVerifierV2.sol rename to contracts/src/core/interfaces/IWorldIDVerifierV3.sol index 7c673c9f0..326e5d7de 100644 --- a/contracts/src/core/interfaces/IWorldIDVerifierV2.sol +++ b/contracts/src/core/interfaces/IWorldIDVerifierV3.sol @@ -4,28 +4,20 @@ pragma solidity ^0.8.13; import {IWorldIDVerifier} from "./IWorldIDVerifier.sol"; /** - * @title IWorldIDVerifierV2 + * @title IWorldIDVerifierV3 * @author World Contributors * @notice Interface for verifying World ID proofs (Uniqueness and Session proofs). - * @dev V2 enforces the action-prefix convention on the convenience entry points (`verify` - * requires the action's most significant byte to be `0x00`, `verifySession` requires `0x02`) - * and adds `verifyWithSession` for Uniqueness Proofs bound to an existing session. + * @dev V3 adds `verifyWithSession` for Uniqueness Proofs bound to an existing session, and rejects + * a zero `sessionId` on every session-carrying entry point. */ -interface IWorldIDVerifierV2 is IWorldIDVerifier { +interface IWorldIDVerifierV3 is IWorldIDVerifier { //////////////////////////////////////////////////////////// // ERRORS // //////////////////////////////////////////////////////////// /** - * @dev Thrown when the action is not valid for the type of proof. The prefix is enforced - * to ensure any nullifier request for a Uniqueness Proof is signed by the RP (actions - * without this prefix, i.e. for sessions, it doesn't need to be signed). - */ - error InvalidAction(); - - /** - * @dev Thrown when a session-bound verification is attempted with `sessionId == 0`, - * which would silently degrade to unbound `verify` semantics. + * @dev Thrown when a session-carrying verification is attempted with `sessionId == 0`. Zero is + * the circuit's "no session" sentinel and is satisfiable by any World ID, so it proves nothing. */ error InvalidSessionId(); diff --git a/contracts/test/core/WorldIDVerifierV2Test.t.sol b/contracts/test/core/WorldIDVerifierV2Test.t.sol index e32e58f77..5899250ef 100644 --- a/contracts/test/core/WorldIDVerifierV2Test.t.sol +++ b/contracts/test/core/WorldIDVerifierV2Test.t.sol @@ -5,7 +5,6 @@ import {Test} from "forge-std/Test.sol"; import {WorldIDVerifierV2} from "../../src/core/WorldIDVerifierV2.sol"; import {WorldIDVerifier} from "../../src/core/WorldIDVerifier.sol"; import {IWorldIDVerifier} from "../../src/core/interfaces/IWorldIDVerifier.sol"; -import {IWorldIDVerifierV2} from "../../src/core/interfaces/IWorldIDVerifierV2.sol"; import {BabyJubJub} from "oprf-key-registry/src/BabyJubJub.sol"; import {Verifier} from "../../src/core/Verifier.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; @@ -27,8 +26,6 @@ contract WorldIDVerifierV2Test is Test { uint64 expiresAtMin = 0x6a54cd68; uint256 signalHash = 0x1578ed0de47522ad0b38e87031739c6a65caecc39ce3410bf3799e756a220f; uint256 nonce = 0x38ed3d4d95deac6e369dde48890d5b14b49a2d26a0b2e8854d429ff7c52cf99; - uint256 actionCorrect = 0x978cc65f06353d8543971b65da8751833ff1253a192f58bed14f2739c0a345; - uint256 sessionIdCorrect = 0x2018a266d26fbc1cd41743cc3126321302b8f0af39c367fe5718eeafb341d494; uint256[5] proof = [ 0x2a184f5930f2b6a0f367f649a80757e081b3fb28b76fffcfe325d82df87395b3, @@ -38,16 +35,6 @@ contract WorldIDVerifierV2Test is Test { rootCorrect ]; - // Uniqueness proof over the same request as `proof`, bound to `sessionIdCorrect` - // (the session commitment is its `session_id` public signal). - uint256[5] boundProof = [ - 0x3de969d8cdd738c55fd10ccbd127b8cb41d21dc9f827b83e0063e3dcb84e8d3c, - 0x16861d8a24289d3b35f3939bc11162379e7ba20afed09cd2ac87a0bd4bff5194, - 0x94ec109be9e4e3a6a3199ecde261bf300f9f04ccfee6401ebf3689272ed907d, - 0x42010c88d24d3cb7ef95c32b49ccee40acdc083f8d8b3c3a26164bff52dfb699, - rootCorrect - ]; - function setUp() public { address oprfKeyRegistry = address(new OprfKeyRegistryMock()); address worldIDRegistryMock = address(new WorldIDRegistryMock()); @@ -73,7 +60,7 @@ contract WorldIDVerifierV2Test is Test { uint256 action = 0x15d4b66e5417cb9875f6a2b5be9814dca80651d7c74b3b21685fdd494566e79f; vm.warp(expiresAtMin + 1 hours); - vm.expectRevert(abi.encodeWithSelector(IWorldIDVerifierV2.InvalidAction.selector)); + vm.expectRevert(abi.encodeWithSelector(WorldIDVerifierV2.InvalidAction.selector)); verifier.verify( nullifier, action, rpIdCorrect, nonce, signalHash, expiresAtMin, credentialIssuerIdCorrect, 0, proof ); @@ -84,7 +71,7 @@ contract WorldIDVerifierV2Test is Test { vm.assume(uint8(action >> 248) != 0); vm.warp(expiresAtMin + 1 hours); - vm.expectRevert(abi.encodeWithSelector(IWorldIDVerifierV2.InvalidAction.selector)); + vm.expectRevert(abi.encodeWithSelector(WorldIDVerifierV2.InvalidAction.selector)); verifier.verify( nullifier, action, rpIdCorrect, nonce, signalHash, expiresAtMin, credentialIssuerIdCorrect, 0, proof ); @@ -96,7 +83,7 @@ contract WorldIDVerifierV2Test is Test { uint256 sessionId = 1; vm.warp(expiresAtMin + 1 hours); - vm.expectRevert(abi.encodeWithSelector(IWorldIDVerifierV2.InvalidAction.selector)); + vm.expectRevert(abi.encodeWithSelector(WorldIDVerifierV2.InvalidAction.selector)); verifier.verifySession( rpIdCorrect, nonce, @@ -115,7 +102,7 @@ contract WorldIDVerifierV2Test is Test { uint256 sessionId = 1; vm.warp(expiresAtMin + 1 hours); - vm.expectRevert(abi.encodeWithSelector(IWorldIDVerifierV2.InvalidAction.selector)); + vm.expectRevert(abi.encodeWithSelector(WorldIDVerifierV2.InvalidAction.selector)); verifier.verifySession( rpIdCorrect, nonce, @@ -149,17 +136,6 @@ contract WorldIDVerifierV2Test is Test { ); } - function test_SessionRevertsWhenSessionIdZero() public { - // Valid session action prefix, but a zero session id must not pass - uint256 action = 0x0200000000000000000000000000000000000000000000000000000000000001; - - vm.warp(expiresAtMin + 1 hours); - vm.expectRevert(abi.encodeWithSelector(IWorldIDVerifierV2.InvalidSessionId.selector)); - verifier.verifySession( - rpIdCorrect, nonce, signalHash, expiresAtMin, credentialIssuerIdCorrect, 0, 0, [nullifier, action], proof - ); - } - function test_PassesActionCheckWhenFirstByteZero() public { // passes the prefix check but fails proof verification uint256 action = 0x00d4b66e5417cb9875f6a2b5be9814dca80651d7c74b3b21685fdd494566e7; @@ -171,126 +147,4 @@ contract WorldIDVerifierV2Test is Test { nullifier, action, rpIdCorrect, nonce, signalHash, expiresAtMin, credentialIssuerIdCorrect, 0, proof ); } - - function test_BoundRevertsWhenActionFirstByteNonZero() public { - uint256 action = 0x15d4b66e5417cb9875f6a2b5be9814dca80651d7c74b3b21685fdd494566e79f; - uint256 sessionId = 1; - - vm.warp(expiresAtMin + 1 hours); - vm.expectRevert(abi.encodeWithSelector(IWorldIDVerifierV2.InvalidAction.selector)); - verifier.verifyWithSession( - nullifier, - action, - rpIdCorrect, - nonce, - signalHash, - expiresAtMin, - credentialIssuerIdCorrect, - 0, - sessionId, - proof - ); - } - - function test_BoundRevertsWhenSessionIdZero() public { - // Valid uniqueness action prefix, but a zero session id must not pass — - // it would silently degrade to unbound verify() semantics. - uint256 action = 0x00d4b66e5417cb9875f6a2b5be9814dca80651d7c74b3b21685fdd494566e7; - - vm.warp(expiresAtMin + 1 hours); - vm.expectRevert(abi.encodeWithSelector(IWorldIDVerifierV2.InvalidSessionId.selector)); - verifier.verifyWithSession( - nullifier, action, rpIdCorrect, nonce, signalHash, expiresAtMin, credentialIssuerIdCorrect, 0, 0, proof - ); - } - - function test_BoundPassesChecksWhenValid() public { - // 0x00 action prefix and non-zero session id — passes both checks, - // reverts later in proof verification - uint256 action = 0x00d4b66e5417cb9875f6a2b5be9814dca80651d7c74b3b21685fdd494566e7; - uint256 sessionId = 1; - - vm.warp(expiresAtMin + 1 hours); - vm.expectRevert(abi.encodeWithSelector(Verifier.ProofInvalid.selector)); - verifier.verifyWithSession( - nullifier, - action, - rpIdCorrect, - nonce, - signalHash, - expiresAtMin, - credentialIssuerIdCorrect, - 0, - sessionId, - proof - ); - } - - function test_BoundSuccess() public { - vm.warp(expiresAtMin + 1 hours); - verifier.verifyWithSession( - nullifier, - actionCorrect, - rpIdCorrect, - nonce, - signalHash, - expiresAtMin, - credentialIssuerIdCorrect, - 0, - sessionIdCorrect, - boundProof - ); - } - - function test_BoundRejectedByVerify() public { - // The bound proof commits to the session id, while verify() pins the signal to 0 - vm.warp(expiresAtMin + 1 hours); - vm.expectRevert(abi.encodeWithSelector(Verifier.ProofInvalid.selector)); - verifier.verify( - nullifier, - actionCorrect, - rpIdCorrect, - nonce, - signalHash, - expiresAtMin, - credentialIssuerIdCorrect, - 0, - boundProof - ); - } - - function test_UnboundRejectedByVerifyWithSession() public { - // The unbound proof commits to a session id of 0 - vm.warp(expiresAtMin + 1 hours); - vm.expectRevert(abi.encodeWithSelector(Verifier.ProofInvalid.selector)); - verifier.verifyWithSession( - nullifier, - actionCorrect, - rpIdCorrect, - nonce, - signalHash, - expiresAtMin, - credentialIssuerIdCorrect, - 0, - sessionIdCorrect, - proof - ); - } - - function test_BoundWrongSessionId() public { - vm.warp(expiresAtMin + 1 hours); - vm.expectRevert(abi.encodeWithSelector(Verifier.ProofInvalid.selector)); - verifier.verifyWithSession( - nullifier, - actionCorrect, - rpIdCorrect, - nonce, - signalHash, - expiresAtMin, - credentialIssuerIdCorrect, - 0, - sessionIdCorrect + 1, // NOTE incorrect session id - boundProof - ); - } } diff --git a/contracts/test/core/WorldIDVerifierV3Test.t.sol b/contracts/test/core/WorldIDVerifierV3Test.t.sol new file mode 100644 index 000000000..f7034dda3 --- /dev/null +++ b/contracts/test/core/WorldIDVerifierV3Test.t.sol @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.20; + +import {Test} from "forge-std/Test.sol"; +import {WorldIDVerifierV3} from "../../src/core/WorldIDVerifierV3.sol"; +import {WorldIDVerifierV2} from "../../src/core/WorldIDVerifierV2.sol"; +import {WorldIDVerifier} from "../../src/core/WorldIDVerifier.sol"; +import {IWorldIDVerifierV3} from "../../src/core/interfaces/IWorldIDVerifierV3.sol"; +import {Verifier} from "../../src/core/Verifier.sol"; +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import { + OprfKeyRegistryMock, + WorldIDRegistryMock, + CredentialSchemaIssuerRegistryMock, + credentialIssuerIdCorrect, + rpIdCorrect, + rootCorrect +} from "./WorldIDVerifierTest.t.sol"; + +contract WorldIDVerifierV3Test is Test { + WorldIDVerifierV3 public verifier; + + uint256 nullifier = 0x5968cd4d3c50bfd2305671d1092bee10ccb679b93db3ca779b6477e4885e476; + uint64 expiresAtMin = 0x6a54cd68; + uint256 signalHash = 0x1578ed0de47522ad0b38e87031739c6a65caecc39ce3410bf3799e756a220f; + uint256 nonce = 0x38ed3d4d95deac6e369dde48890d5b14b49a2d26a0b2e8854d429ff7c52cf99; + uint256 actionCorrect = 0x978cc65f06353d8543971b65da8751833ff1253a192f58bed14f2739c0a345; + uint256 sessionIdCorrect = 0x2018a266d26fbc1cd41743cc3126321302b8f0af39c367fe5718eeafb341d494; + + uint256[5] proof = [ + 0x2a184f5930f2b6a0f367f649a80757e081b3fb28b76fffcfe325d82df87395b3, + 0xf6849ab589365a7537beeb70014958ae261fe2dd7fdbf5c4823c6b527aefa34, + 0xac5ee090f2ee180619c5c9825f22cee8873fc0d4764b7b0c5ffd7802d8f2e0f9, + 0x4a221ef1d3b5522ac95f38db43afda6863d34836f7a8cff0b50aa9c8ca52e727, + rootCorrect + ]; + + // Uniqueness proof over the same request as `proof`, bound to `sessionIdCorrect` + // (the session commitment is its `session_id` public signal). + uint256[5] boundProof = [ + 0x3de969d8cdd738c55fd10ccbd127b8cb41d21dc9f827b83e0063e3dcb84e8d3c, + 0x16861d8a24289d3b35f3939bc11162379e7ba20afed09cd2ac87a0bd4bff5194, + 0x94ec109be9e4e3a6a3199ecde261bf300f9f04ccfee6401ebf3689272ed907d, + 0x42010c88d24d3cb7ef95c32b49ccee40acdc083f8d8b3c3a26164bff52dfb699, + rootCorrect + ]; + + function setUp() public { + address oprfKeyRegistry = address(new OprfKeyRegistryMock()); + address worldIDRegistryMock = address(new WorldIDRegistryMock()); + address credentialSchemaIssuerRegistryMock = address(new CredentialSchemaIssuerRegistryMock()); + address groth16Verifier = address(new Verifier()); + uint256 minExpirationThreshold = 5 hours; + + WorldIDVerifierV3 implementation = new WorldIDVerifierV3(); + bytes memory initData = abi.encodeWithSelector( + WorldIDVerifier.initialize.selector, + credentialSchemaIssuerRegistryMock, + worldIDRegistryMock, + oprfKeyRegistry, + groth16Verifier, + minExpirationThreshold + ); + ERC1967Proxy proxy = new ERC1967Proxy(address(implementation), initData); + verifier = WorldIDVerifierV3(address(proxy)); + verifier.updateOprfKeyRegistry(oprfKeyRegistry); + } + + function test_BoundRevertsWhenActionFirstByteNonZero() public { + uint256 action = 0x15d4b66e5417cb9875f6a2b5be9814dca80651d7c74b3b21685fdd494566e79f; + uint256 sessionId = 1; + + vm.warp(expiresAtMin + 1 hours); + vm.expectRevert(abi.encodeWithSelector(WorldIDVerifierV2.InvalidAction.selector)); + verifier.verifyWithSession( + nullifier, + action, + rpIdCorrect, + nonce, + signalHash, + expiresAtMin, + credentialIssuerIdCorrect, + 0, + sessionId, + proof + ); + } + + function test_BoundRevertsWhenSessionIdZero() public { + // Valid uniqueness action prefix, but a zero session id must not pass — + // it would silently degrade to unbound verify() semantics. + uint256 action = 0x00d4b66e5417cb9875f6a2b5be9814dca80651d7c74b3b21685fdd494566e7; + + vm.warp(expiresAtMin + 1 hours); + vm.expectRevert(abi.encodeWithSelector(IWorldIDVerifierV3.InvalidSessionId.selector)); + verifier.verifyWithSession( + nullifier, action, rpIdCorrect, nonce, signalHash, expiresAtMin, credentialIssuerIdCorrect, 0, 0, proof + ); + } + + function test_SessionRevertsWhenSessionIdZero() public { + // Valid session action prefix, but a zero session id must not pass + uint256 action = 0x0200000000000000000000000000000000000000000000000000000000000001; + + vm.warp(expiresAtMin + 1 hours); + vm.expectRevert(abi.encodeWithSelector(IWorldIDVerifierV3.InvalidSessionId.selector)); + verifier.verifySession( + rpIdCorrect, nonce, signalHash, expiresAtMin, credentialIssuerIdCorrect, 0, 0, [nullifier, action], proof + ); + } + + function test_SessionRevertsWhenActionMissing0x02Prefix() public { + // The inherited V2 prefix check still applies, and runs before the session id check + uint256 action = 0x00d4b66e5417cb9875f6a2b5be9814dca80651d7c74b3b21685fdd494566e7; + + vm.warp(expiresAtMin + 1 hours); + vm.expectRevert(abi.encodeWithSelector(WorldIDVerifierV2.InvalidAction.selector)); + verifier.verifySession( + rpIdCorrect, nonce, signalHash, expiresAtMin, credentialIssuerIdCorrect, 0, 0, [nullifier, action], proof + ); + } + + function test_BoundPassesChecksWhenValid() public { + // 0x00 action prefix and non-zero session id — passes both checks, + // reverts later in proof verification + uint256 action = 0x00d4b66e5417cb9875f6a2b5be9814dca80651d7c74b3b21685fdd494566e7; + uint256 sessionId = 1; + + vm.warp(expiresAtMin + 1 hours); + vm.expectRevert(abi.encodeWithSelector(Verifier.ProofInvalid.selector)); + verifier.verifyWithSession( + nullifier, + action, + rpIdCorrect, + nonce, + signalHash, + expiresAtMin, + credentialIssuerIdCorrect, + 0, + sessionId, + proof + ); + } + + function test_BoundSuccess() public { + vm.warp(expiresAtMin + 1 hours); + verifier.verifyWithSession( + nullifier, + actionCorrect, + rpIdCorrect, + nonce, + signalHash, + expiresAtMin, + credentialIssuerIdCorrect, + 0, + sessionIdCorrect, + boundProof + ); + } + + function test_BoundRejectedByVerify() public { + // The bound proof commits to the session id, while verify() pins the signal to 0 + vm.warp(expiresAtMin + 1 hours); + vm.expectRevert(abi.encodeWithSelector(Verifier.ProofInvalid.selector)); + verifier.verify( + nullifier, + actionCorrect, + rpIdCorrect, + nonce, + signalHash, + expiresAtMin, + credentialIssuerIdCorrect, + 0, + boundProof + ); + } + + function test_UnboundRejectedByVerifyWithSession() public { + // The unbound proof commits to a session id of 0 + vm.warp(expiresAtMin + 1 hours); + vm.expectRevert(abi.encodeWithSelector(Verifier.ProofInvalid.selector)); + verifier.verifyWithSession( + nullifier, + actionCorrect, + rpIdCorrect, + nonce, + signalHash, + expiresAtMin, + credentialIssuerIdCorrect, + 0, + sessionIdCorrect, + proof + ); + } + + function test_BoundWrongSessionId() public { + vm.warp(expiresAtMin + 1 hours); + vm.expectRevert(abi.encodeWithSelector(Verifier.ProofInvalid.selector)); + verifier.verifyWithSession( + nullifier, + actionCorrect, + rpIdCorrect, + nonce, + signalHash, + expiresAtMin, + credentialIssuerIdCorrect, + 0, + sessionIdCorrect + 1, // NOTE incorrect session id + boundProof + ); + } +} diff --git a/crates/core/tests/generate_proof.rs b/crates/core/tests/generate_proof.rs index 0cb726390..187beba3d 100644 --- a/crates/core/tests/generate_proof.rs +++ b/crates/core/tests/generate_proof.rs @@ -34,7 +34,7 @@ use world_id_primitives::{ Config, FieldElement, ServiceEndpoint, SessionId, TREE_DEPTH, merkle::AccountInclusionProof, }; use world_id_test_utils::{ - anvil::WorldIDVerifierV2, + anvil::WorldIDVerifierV3, fixtures::{ MerkleFixture, RegistryTestContext, build_base_credential, generate_rp_fixture, single_leaf_merkle_fixture, @@ -340,9 +340,9 @@ async fn e2e_authenticator_generate_proof() -> Result<()> { // verify proof with verifier contract let request_item = &proof_request.requests[0]; - let world_id_verifier: WorldIDVerifierV2::WorldIDVerifierV2Instance< + let world_id_verifier: WorldIDVerifierV3::WorldIDVerifierV3Instance< alloy::providers::DynProvider, - > = WorldIDVerifierV2::new(world_id_verifier, anvil.provider()?); + > = WorldIDVerifierV3::new(world_id_verifier, anvil.provider()?); world_id_verifier .verify( response_item diff --git a/crates/test-utils/src/anvil.rs b/crates/test-utils/src/anvil.rs index d7af66bb5..b25cf0fc9 100644 --- a/crates/test-utils/src/anvil.rs +++ b/crates/test-utils/src/anvil.rs @@ -152,10 +152,10 @@ sol!( sol!( #[allow(clippy::too_many_arguments)] #[sol(rpc, ignore_unlinked)] - WorldIDVerifierV2, + WorldIDVerifierV3, concat!( env!("CARGO_MANIFEST_DIR"), - "/../../contracts/out/WorldIDVerifierV2.sol/WorldIDVerifierV2.json" + "/../../contracts/out/WorldIDVerifierV3.sol/WorldIDVerifierV3.json" ) ); @@ -714,12 +714,12 @@ impl TestAnvil { .context("failed to deploy Verifier (Groth16) contract")?; // WorldID verifier (upgradeable, delegates to Groth16 verifier) - let world_id_verifier = WorldIDVerifierV2::deploy(provider.clone()) + let world_id_verifier = WorldIDVerifierV3::deploy(provider.clone()) .await - .context("failed to deploy WorldIDVerifierV2 contract")?; + .context("failed to deploy WorldIDVerifierV3 contract")?; let init_data = Bytes::from( - WorldIDVerifierV2::initializeCall { + WorldIDVerifierV3::initializeCall { credentialIssuerRegistry: credential_issuer_registry, worldIDRegistry: world_id_registry, oprfKeyRegistry: oprf_key_registry, diff --git a/tools/generate-solidity-fixtures/src/main.rs b/tools/generate-solidity-fixtures/src/main.rs index 28950ab43..9697c62d3 100644 --- a/tools/generate-solidity-fixtures/src/main.rs +++ b/tools/generate-solidity-fixtures/src/main.rs @@ -42,7 +42,7 @@ use world_id_primitives::{ merkle::AccountInclusionProof, }; use world_id_test_utils::{ - anvil::WorldIDVerifierV2, + anvil::WorldIDVerifierV3, fixtures::{ MerkleFixture, RegistryTestContext, build_base_credential, generate_rp_fixture, single_leaf_merkle_fixture, @@ -315,9 +315,9 @@ async fn main() -> Result<()> { // Verify on-chain. info!("Verifying uniqueness proof on-chain..."); - let verifier_instance: WorldIDVerifierV2::WorldIDVerifierV2Instance< + let verifier_instance: WorldIDVerifierV3::WorldIDVerifierV3Instance< alloy::providers::DynProvider, - > = WorldIDVerifierV2::new(world_id_verifier, anvil.provider()?); + > = WorldIDVerifierV3::new(world_id_verifier, anvil.provider()?); verifier_instance .verify( uniqueness_response From 788a0033073f53089b76a0ce24ea3ebad59894f2 Mon Sep 17 00:00:00 2001 From: kilianglas Date: Tue, 4 Aug 2026 13:11:01 +0200 Subject: [PATCH 33/36] refactor: restore WorldIDVerifierV2Test to its original state The regenerated fixture values were unnecessary: all six V2 tests are negative (four revert on InvalidAction before any proof math, two expect ProofInvalid), so they pass unchanged against the refreshed mocks. Leaves the PR with no V2 footprint at all. Co-Authored-By: Claude Opus 5 (1M context) --- contracts/test/core/WorldIDVerifierV2Test.t.sol | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/contracts/test/core/WorldIDVerifierV2Test.t.sol b/contracts/test/core/WorldIDVerifierV2Test.t.sol index 5899250ef..3118f30e2 100644 --- a/contracts/test/core/WorldIDVerifierV2Test.t.sol +++ b/contracts/test/core/WorldIDVerifierV2Test.t.sol @@ -22,16 +22,16 @@ import { contract WorldIDVerifierV2Test is Test { WorldIDVerifierV2 public verifier; - uint256 nullifier = 0x5968cd4d3c50bfd2305671d1092bee10ccb679b93db3ca779b6477e4885e476; - uint64 expiresAtMin = 0x6a54cd68; + uint256 nullifier = 0x1bae01b23e5f0ee96151331fffb0550351c52e5ee0ced452c762e120723ae702; + uint64 expiresAtMin = 0x699cfa47; uint256 signalHash = 0x1578ed0de47522ad0b38e87031739c6a65caecc39ce3410bf3799e756a220f; - uint256 nonce = 0x38ed3d4d95deac6e369dde48890d5b14b49a2d26a0b2e8854d429ff7c52cf99; + uint256 nonce = 0x18e3ab3d5fedc6eaa5e0d06a3a6f3dd5e0bf2d17b18b797a1cc6ff4706169d1e; uint256[5] proof = [ - 0x2a184f5930f2b6a0f367f649a80757e081b3fb28b76fffcfe325d82df87395b3, - 0xf6849ab589365a7537beeb70014958ae261fe2dd7fdbf5c4823c6b527aefa34, - 0xac5ee090f2ee180619c5c9825f22cee8873fc0d4764b7b0c5ffd7802d8f2e0f9, - 0x4a221ef1d3b5522ac95f38db43afda6863d34836f7a8cff0b50aa9c8ca52e727, + 0x4906f4e17b969ef2cfc44bd96520f01a3f5c32972bca2e10b70e05e03e3d9f13, + 0xd6d9a3456e9af7d8f6f78eb3380deb8c93505c062f62fa18b8ef8a2ccb55db8, + 0xa92a48edeb327b190048648788de9a8eff0abed5dc93bee8881387da40571278, + 0x38f52985c393efb732be8f54b5f00f7f25370ac5945de84e0d8d2f2d298866b8, rootCorrect ]; From fca6f361fe16c3d91c22cb22586a3107d84d1cd2 Mon Sep 17 00:00:00 2001 From: kilianglas Date: Tue, 4 Aug 2026 13:54:14 +0200 Subject: [PATCH 34/36] refactor: scope this PR to verifyWithSession only The verifySession zero-session check is a separate security fix and moves to its own PR against main. V3 here adds only the new entry point. Depends on that PR landing for the session-proof hardening. Co-Authored-By: Claude Opus 5 (1M context) --- contracts/src/core/WorldIDVerifierV3.sol | 35 ------------------- .../core/interfaces/IWorldIDVerifierV3.sol | 3 +- .../test/core/WorldIDVerifierV3Test.t.sol | 22 ------------ 3 files changed, 1 insertion(+), 59 deletions(-) diff --git a/contracts/src/core/WorldIDVerifierV3.sol b/contracts/src/core/WorldIDVerifierV3.sol index 23a51b191..e6a5f7c78 100644 --- a/contracts/src/core/WorldIDVerifierV3.sol +++ b/contracts/src/core/WorldIDVerifierV3.sol @@ -2,7 +2,6 @@ pragma solidity ^0.8.13; import {WorldIDVerifierV2} from "./WorldIDVerifierV2.sol"; -import {IWorldIDVerifier} from "./interfaces/IWorldIDVerifier.sol"; import {IWorldIDVerifierV3} from "./interfaces/IWorldIDVerifierV3.sol"; /** @@ -47,38 +46,4 @@ contract WorldIDVerifierV3 is IWorldIDVerifierV3, WorldIDVerifierV2 { zeroKnowledgeProof ); } - - /// @inheritdoc IWorldIDVerifier - 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 virtual override(IWorldIDVerifier, WorldIDVerifierV2) onlyProxy onlyInitialized { - uint256 action = sessionNullifier[1]; - if (uint8(action >> 248) != uint8(2)) { - revert InvalidAction(); - } - if (sessionId == 0) { - revert InvalidSessionId(); - } - - verifyProofAndSignals( - sessionNullifier[0], - action, - rpId, - nonce, - signalHash, - expiresAtMin, - issuerSchemaId, - credentialGenesisIssuedAtMin, - sessionId, - zeroKnowledgeProof - ); - } } diff --git a/contracts/src/core/interfaces/IWorldIDVerifierV3.sol b/contracts/src/core/interfaces/IWorldIDVerifierV3.sol index 326e5d7de..f1fc0df09 100644 --- a/contracts/src/core/interfaces/IWorldIDVerifierV3.sol +++ b/contracts/src/core/interfaces/IWorldIDVerifierV3.sol @@ -7,8 +7,7 @@ import {IWorldIDVerifier} from "./IWorldIDVerifier.sol"; * @title IWorldIDVerifierV3 * @author World Contributors * @notice Interface for verifying World ID proofs (Uniqueness and Session proofs). - * @dev V3 adds `verifyWithSession` for Uniqueness Proofs bound to an existing session, and rejects - * a zero `sessionId` on every session-carrying entry point. + * @dev V3 adds `verifyWithSession` for Uniqueness Proofs bound to a session commitment. */ interface IWorldIDVerifierV3 is IWorldIDVerifier { //////////////////////////////////////////////////////////// diff --git a/contracts/test/core/WorldIDVerifierV3Test.t.sol b/contracts/test/core/WorldIDVerifierV3Test.t.sol index f7034dda3..9829a6dc7 100644 --- a/contracts/test/core/WorldIDVerifierV3Test.t.sol +++ b/contracts/test/core/WorldIDVerifierV3Test.t.sol @@ -99,28 +99,6 @@ contract WorldIDVerifierV3Test is Test { ); } - function test_SessionRevertsWhenSessionIdZero() public { - // Valid session action prefix, but a zero session id must not pass - uint256 action = 0x0200000000000000000000000000000000000000000000000000000000000001; - - vm.warp(expiresAtMin + 1 hours); - vm.expectRevert(abi.encodeWithSelector(IWorldIDVerifierV3.InvalidSessionId.selector)); - verifier.verifySession( - rpIdCorrect, nonce, signalHash, expiresAtMin, credentialIssuerIdCorrect, 0, 0, [nullifier, action], proof - ); - } - - function test_SessionRevertsWhenActionMissing0x02Prefix() public { - // The inherited V2 prefix check still applies, and runs before the session id check - uint256 action = 0x00d4b66e5417cb9875f6a2b5be9814dca80651d7c74b3b21685fdd494566e7; - - vm.warp(expiresAtMin + 1 hours); - vm.expectRevert(abi.encodeWithSelector(WorldIDVerifierV2.InvalidAction.selector)); - verifier.verifySession( - rpIdCorrect, nonce, signalHash, expiresAtMin, credentialIssuerIdCorrect, 0, 0, [nullifier, action], proof - ); - } - function test_BoundPassesChecksWhenValid() public { // 0x00 action prefix and non-zero session id — passes both checks, // reverts later in proof verification From 477b7589f4fe19cfb13dfa7d16d9c35ccb13e7fa Mon Sep 17 00:00:00 2001 From: kilianglas Date: Thu, 6 Aug 2026 11:32:06 +0200 Subject: [PATCH 35/36] fix: drop the stale bind-to-existing fixture section The merge left main's session-bound section alongside the create-and-bind one, so the tool no longer compiled: session_id is now SessionRef, not Option. Removes the stale block and the nullifier clone it needed. Co-Authored-By: Claude Opus 5 (1M context) --- tools/generate-solidity-fixtures/src/main.rs | 55 -------------------- 1 file changed, 55 deletions(-) diff --git a/tools/generate-solidity-fixtures/src/main.rs b/tools/generate-solidity-fixtures/src/main.rs index 0a2c086a0..caf3b2cb1 100644 --- a/tools/generate-solidity-fixtures/src/main.rs +++ b/tools/generate-solidity-fixtures/src/main.rs @@ -297,10 +297,6 @@ async fn main() -> Result<()> { .generate_nullifier(&uniqueness_request, None) .await?; - // Clone the nullifier data before it's consumed — we reuse it for the - // session-bound uniqueness proof. - let nullifier_data_for_bound = nullifier_data.clone(); - let uniqueness_result = authenticator .generate_proof( &uniqueness_request, @@ -474,57 +470,6 @@ async fn main() -> Result<()> { .await?; info!("Session proof verified ✓"); - // ── SESSION-BOUND UNIQUENESS PROOF (same action, bound to the session above) ── - let bound_request = ProofRequest { - proof_type: ProofType::Uniqueness, - session_id: Some(session_id), - ..uniqueness_request.clone() - }; - - let bound_result = authenticator - .generate_proof( - &bound_request, - nullifier_data_for_bound, - &credentials, - None, - Some(session_id_r_seed), - ) - .await?; - let bound_response = &bound_result.proof_response.responses[0]; - let bound_nullifier = bound_response - .nullifier - .expect("bound uniqueness proof should have nullifier"); - // Same RP/action => same deterministic nullifier as the unbound proof. - assert_eq!( - bound_nullifier, - uniqueness_response - .nullifier - .expect("uniqueness proof has nullifier") - ); - - // Verify bound proof on-chain. - info!("Verifying session-bound uniqueness proof on-chain..."); - verifier_instance - .verifyWithSession( - bound_nullifier.into(), - rp_fixture.action.into(), - rp_fixture.world_rp_id.into_inner(), - rp_fixture.nonce.into(), - request_item.signal_hash().into(), - bound_response.expires_at_min, - issuer_schema_id, - request_item - .genesis_issued_at_min - .unwrap_or_default() - .try_into() - .expect("u64 fits into U256"), - session_id.commitment.into(), - bound_response.proof.as_ethereum_representation(), - ) - .call() - .await?; - info!("Session-bound uniqueness proof verified ✓"); - // ── PRINT SOLIDITY FIXTURE ── let u_proof = uniqueness_response.proof.as_ethereum_representation(); From 1f93b70d6deaa2cdd0f2312a8b423c9dfa49a23f Mon Sep 17 00:00:00 2001 From: kilianglas Date: Thu, 6 Aug 2026 11:50:17 +0200 Subject: [PATCH 36/36] feat: restore the one-byte ProofType encoding Keeps the discriminants aligned with the action prefixes: 0x00 for a uniqueness action, 0x02 for a session action. 0x01 is skipped because it prefixes the session oprf_seed rather than a proof flow. Reinstates the reservation from #711 for future signed request payloads, and pins it with a test so the values cannot renumber silently. Co-Authored-By: Claude Opus 5 (1M context) --- crates/primitives/src/request/mod.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/crates/primitives/src/request/mod.rs b/crates/primitives/src/request/mod.rs index 49f0434b6..1a43338d3 100644 --- a/crates/primitives/src/request/mod.rs +++ b/crates/primitives/src/request/mod.rs @@ -47,6 +47,14 @@ impl<'de> serde::Deserialize<'de> for RequestVersion { } /// The high-level proof flow requested by an RP. +/// +/// Explicit discriminants reserve a stable one-byte protocol encoding for future +/// signed request payloads. JSON serialization remains the snake_case variant name. +/// +/// The values match the action prefixes (see [`crate::SessionFeType`]): `0x00` for a +/// uniqueness action, `0x02` for a session action. `0x01` is skipped because it prefixes +/// the session `oprf_seed`, which is not a proof flow of its own. +#[repr(u8)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ProofType { @@ -56,10 +64,10 @@ pub enum ProofType { /// or omit session involvement entirely — see [`ProofRequest::binds_session`]. /// Binding to an already existing session is not supported. #[default] - Uniqueness, + Uniqueness = 0x00, /// Prove an RP-scoped session — either minting a fresh one /// (`session_id: "create"`) or an existing one (`session_id: "session_"`). - Session, + Session = 0x02, } impl ProofType { @@ -2647,6 +2655,14 @@ mod tests { ); } + #[test] + fn proof_type_byte_encoding_is_stable() { + // Matches the action prefixes; 0x01 is skipped because it prefixes the session + // `oprf_seed` rather than a proof flow. + assert_eq!(ProofType::Uniqueness as u8, 0x00); + assert_eq!(ProofType::Session as u8, 0x02); + } + #[test] fn test_validate_response_session_create_requires_minted_session_id() { let request = ProofRequest {