diff --git a/crates/authenticator/src/authenticator.rs b/crates/authenticator/src/authenticator.rs index efee8ddde..314691237 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 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/authenticator/src/prove.rs b/crates/authenticator/src/prove.rs index b180836c0..ab90ff1cb 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, @@ -208,20 +208,20 @@ impl Authenticator { account_inclusion_proof: Option>, ) -> Result<(SessionId, FieldElement), AuthenticatorError> { proof_request.validate_proof_type()?; - if !proof_request.is_session_proof() { - return Err(AuthenticatorError::PrimitiveError( - world_id_primitives::PrimitiveError::InvalidInput { - attribute: "proof_type".to_string(), - reason: "must be create_session or session".to_string(), - }, - )); - } - 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 => SessionId::generate_oprf_seed(&mut rng), + SessionRef::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 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)?; } @@ -274,9 +274,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 ([`ProofRequest::binds_session`]) - /// it is required and the call fails with [`AuthenticatorError::SessionSeedRequired`] otherwise. + /// - `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, @@ -307,26 +307,15 @@ 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 { - // Bind the proof to the existing session. Requires the cached `r`. - Some(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 (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)) } - ProofType::Session => { - let session_id = proof_request - .session_id - .expect("session proof must have session_id"); + 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)) @@ -362,7 +351,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, @@ -382,7 +371,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 8c069cb52..45c44fe24 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::{ @@ -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::WorldIDVerifierV3, @@ -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(), @@ -322,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(), @@ -366,111 +365,96 @@ async fn e2e_authenticator_generate_proof() -> Result<()> { .await?; 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, - session_id_r_seed, - SessionId::generate_oprf_seed(&mut rng), - )?; - let bound_request = ProofRequest { - session_id: Some(session_id), + // ── 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() }; - - // 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 match 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), - ) + let create_nullifier = authenticator + .generate_nullifier(&create_request, None) .await?; - info!("generated session-bound uniqueness proof"); + 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 + ); - 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 + let create_nullifier = create_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 + .expect("create uniqueness proof should have nullifier"); let unbound_verify = world_id_verifier .verify( - bound_nullifier.into(), + create_nullifier.into(), rp_fixture.action.into(), rp_fixture.world_rp_id.into_inner(), - rp_fixture.nonce.into(), + create_nonce.into(), request_item.signal_hash().into(), - bound_item.expires_at_min, + create_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(), + create_item.proof.as_ethereum_representation(), ) .call() .await; assert!( unbound_verify.is_err(), - "bound proof must not verify with sessionId = 0" + "create-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(), + create_nullifier.into(), rp_fixture.action.into(), rp_fixture.world_rp_id.into_inner(), - rp_fixture.nonce.into(), + create_nonce.into(), request_item.signal_hash().into(), - bound_item.expires_at_min, + create_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(), + created_session_id.commitment.into(), + create_item.proof.as_ethereum_representation(), ) .call() .await?; - info!("session-bound proof verified via verifyWithSession"); + info!("uniqueness create proof verified via verifyWithSession"); indexer_handle.abort(); info!("e2e_authenticator_generate_proof finished successfully"); 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/oprf.rs b/crates/primitives/src/oprf.rs index b883f27a1..9caaf6573 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; @@ -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,6 +83,12 @@ pub struct NullifierOprfRequestAuthV1 { with = "serde_utils::hex_bytes_opt" )] pub wip101_data: Option>, + /// Additional data needed to reconstruct the RP-signed message. + /// + /// 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 rp_signature_verification: Option, } /// A request sent by a client for OPRF credential blinding factor authentication. @@ -171,6 +191,12 @@ pub enum WorldIdRequestAuthError { /// prefixes. #[error("invalid_action_for_session")] InvalidActionSession, + /// 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, @@ -247,6 +273,7 @@ impl WorldIdRequestAuthError { | Self::InvalidQueryProof | Self::InvalidActionSchemaIssuer | Self::InvalidActionSession + | Self::InvalidRpSignatureVerification | Self::RpSignatureMissing => ErrorActor::Authenticator, Self::Internal | Self::Unknown(_) => ErrorActor::OprfNode, } @@ -268,6 +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_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, @@ -309,6 +337,9 @@ impl From for u16 { error_codes::INVALID_ACTION_NULLIFIER } WorldIdRequestAuthError::InvalidActionSession => error_codes::INVALID_ACTION_SESSION, + WorldIdRequestAuthError::InvalidRpSignatureVerification => { + error_codes::INVALID_RP_SIGNATURE_VERIFICATION + } WorldIdRequestAuthError::RpSignatureExpired => error_codes::RP_SIGNATURE_EXPIRED, WorldIdRequestAuthError::CreatedAtTooFarInFuture => { error_codes::CREATED_AT_TOO_FAR_IN_FUTURE @@ -386,6 +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::InvalidRpSignatureVerification`]. + pub const INVALID_RP_SIGNATURE_VERIFICATION: u16 = 4524; /// Error code for [`super::WorldIdRequestAuthError::Internal`]. pub const INTERNAL: u16 = 1011; } @@ -468,6 +501,9 @@ impl From for OprfRequestAuthenticatorError { // this should never truncate as code is a U256 encoded as hex CloseFrameMessage::new_truncate(format!("{:#x}", code)) } + 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" ), @@ -496,6 +532,68 @@ 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( + rp_signature_verification: 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, + rp_signature_verification, + } + } + + #[test] + 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.rp_signature_verification, Some(verification)); + } + + #[test] + 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.rp_signature_verification, Some(verification)); + } + #[test] fn error_code_roundtrip() { let codes: &[u16] = &[ @@ -511,6 +609,7 @@ mod tests { error_codes::UNKNOWN_SCHEMA_ISSUER, error_codes::INVALID_ACTION_NULLIFIER, error_codes::INVALID_ACTION_SESSION, + error_codes::INVALID_RP_SIGNATURE_VERIFICATION, error_codes::INACTIVE_RP, error_codes::RP_SIGNATURE_EXPIRED, error_codes::INVALID_TIMESTAMP, diff --git a/crates/primitives/src/request/mod.rs b/crates/primitives/src/request/mod.rs index 9638354eb..1a43338d3 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; @@ -50,19 +50,23 @@ impl<'de> serde::Deserialize<'de> for RequestVersion { /// /// 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 { /// 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 `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 = 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`. + /// Prove an RP-scoped session — either minting a fresh one + /// (`session_id: "create"`) or an existing one (`session_id: "session_"`). Session = 0x02, } @@ -76,7 +80,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 +108,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), + /// 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. - 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, @@ -237,8 +243,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 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. @@ -465,39 +472,30 @@ 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(), - }); - } + 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::Uniqueness, SessionRef::Existing(_), _) => { + Err(PrimitiveError::InvalidInput { + attribute: "session_id".to_string(), + reason: "must be omitted or \"create\" for uniqueness 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(), - }); - } - if self.action.is_some() { - return Err(PrimitiveError::InvalidInput { - attribute: "action".to_string(), - reason: "must be omitted for session 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(_), Some(_)) => { + 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. @@ -506,21 +504,16 @@ 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 freshly + /// minted 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.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.is_create() } /// Validates the structural integrity of the constraint expression. @@ -569,26 +562,39 @@ 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::None) => { + if response.session_id.is_some() { return Err(ValidationError::UnexpectedSessionId); } } - ProofType::CreateSession => { + (ProofType::Uniqueness, SessionRef::Create) => { + if response.session_id.is_none() { + return Err(ValidationError::MissingSessionId); + } + } + (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. 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 +765,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 +983,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 +1024,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 +1066,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 +1212,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 +1280,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 +1423,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 +1531,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 +1575,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 +1632,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 +1709,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 +1883,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 +1926,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 +1992,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 +2040,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 +2113,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 +2180,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 +2289,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, @@ -2406,12 +2412,12 @@ 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: Some(test_session_id(1)), - action: None, + 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), @@ -2428,36 +2434,81 @@ 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_create.clone() + }; + assert!(matches!( + uniqueness_without_action.validate_proof_type(), + Err(PrimitiveError::InvalidInput { attribute, .. }) if attribute == "action" + )); let plain_uniqueness = ProofRequest { - session_id: None, - ..uniqueness_with_session.clone() + session_id: SessionRef::None, + ..uniqueness_with_create.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() + // 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!(matches!( - create_session_with_session.validate_proof_type(), + 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: None, - ..uniqueness_with_session + session_id: SessionRef::None, + action: None, + ..uniqueness_with_create.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, + action: None, + ..uniqueness_with_create.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, + session_id: SessionRef::Existing(test_session_id(1)), + action: None, + ..uniqueness_with_create.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_create.clone() + }; + assert!(matches!( + session_with_action.validate_proof_type(), + Err(PrimitiveError::InvalidInput { attribute, .. }) if attribute == "action" + )); + } } #[test] @@ -2466,7 +2517,7 @@ mod tests { id: "req_bound".into(), version: RequestVersion::V1, proof_type: ProofType::Uniqueness, - session_id: Some(test_session_id(1)), + session_id: SessionRef::Create, action: Some(FieldElement::ZERO), created_at: 1_735_689_600, expires_at: 1_735_689_900, @@ -2494,13 +2545,49 @@ mod tests { } #[test] - fn test_validate_response_bound_uniqueness_echoes_session_id() { + 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_uniqueness_rejects_existing_session() { 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), + session_id: SessionRef::Existing(session_id), action: Some(FieldElement::ZERO), created_at: 1_735_689_600, expires_at: 1_735_689_900, @@ -2518,8 +2605,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), @@ -2532,53 +2618,58 @@ 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 let plain_request = ProofRequest { - session_id: None, + session_id: SessionRef::None, ..request }; assert!(matches!( - plain_request.validate_response(&valid), + plain_request.validate_response(&response), Err(ValidationError::UnexpectedSessionId) )); } #[test] - fn proof_type_protocol_encoding_is_stable() { + 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 + ); + } + + #[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::CreateSession as u8, 0x01); assert_eq!(ProofType::Session as u8, 0x02); } #[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, @@ -2621,6 +2712,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 @@ -2628,7 +2768,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 +2814,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 +2863,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..3942f6172 100644 --- a/crates/primitives/src/session.rs +++ b/crates/primitives/src/session.rs @@ -245,6 +245,137 @@ 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. 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. Only valid for [`crate::ProofType::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 +702,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..823740bdf 100644 --- a/crates/proof/src/oprf_query.rs +++ b/crates/proof/src/oprf_query.rs @@ -21,8 +21,11 @@ use taceo_oprf::{ }; use world_id_primitives::{ - FieldElement, ProofRequest, SessionFeType, SessionFieldElement, TREE_DEPTH, - oprf::{CredentialBlindingFactorOprfRequestAuthV1, NullifierOprfRequestAuthV1, OprfModule}, + FieldElement, ProofRequest, ProofType, SessionFeType, SessionFieldElement, TREE_DEPTH, + oprf::{ + CredentialBlindingFactorOprfRequestAuthV1, NullifierOprfRequestAuthV1, OprfModule, + RpSignatureVerification, + }, }; use crate::circuit_inputs::QueryProofCircuitInput; @@ -258,6 +261,7 @@ impl<'a> OprfEntrypoint<'a> { signature: Some(proof_request.signature), rp_id: proof_request.rp_id, wip101_data: None, + rp_signature_verification: None, }; let verifiable_oprf_output = Self::execute_distributed_oprf( @@ -286,9 +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() { + if proof_request.session_id.is_none() { return Err(ProofError::GenerationError( - "proof_type must be create_session or prove_session".to_string(), + "session randomness can only be derived for requests with a \"create\" or existing session_id".to_string(), )); } @@ -301,6 +305,13 @@ impl<'a> OprfEntrypoint<'a> { rng, )?; + let rp_signature_verification = match (proof_request.proof_type, proof_request.action) { + (ProofType::Uniqueness, Some(action)) => { + Some(RpSignatureVerification::UniquenessAction { action }) + } + _ => None, + }; + let auth = NullifierOprfRequestAuthV1 { proof: result.proof.into(), action: *oprf_seed, @@ -311,6 +322,7 @@ impl<'a> OprfEntrypoint<'a> { signature: Some(proof_request.signature), rp_id: proof_request.rp_id, wip101_data: None, + 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 ea5857234..7f2e93c59 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,15 +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)** | + + | | **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. | @@ -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. @@ -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 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). - (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 work in the following manner: +Session Proofs without uniqueness binding work in the following manner: - An RP requests an authenticator to create a session. -- The authenticator provides a `sessionId`. A unique identifier bound to the user's World ID for that RP. +- 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 `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. -- The `sessionId` is generated as outlined below, where `r` is computationally indistinguishable from random. +- 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) @@ -275,24 +274,49 @@ 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 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. +```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 = "create") +a->>a: Generate oprf_seed +a->>o: Derive session blinding factor r +a->>a: sessionId = encode(H(DS_C || leafIndex || r), oprf_seed) +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 -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 +327,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 +349,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. 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..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 @@ -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"), }; @@ -360,6 +360,7 @@ fn generate_oprf_auth_request( signature: Some(proof_request.signature), rp_id: proof_request.rp_id, wip101_data: 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 4635341cc..91baa8ec4 100644 --- a/services/oprf-node/src/auth/rp_module.rs +++ b/services/oprf-node/src/auth/rp_module.rs @@ -3,7 +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) +//! - 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 @@ -31,7 +32,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, }; @@ -40,7 +41,10 @@ pub(crate) mod wip101; /// Distinguishes the two RP-authenticated OPRF modules. #[derive(Debug, Clone, Copy)] 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 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, @@ -62,6 +66,8 @@ pub(crate) enum RpModuleError { #[error("Invalid action for uniqueness (action MSB must be 0x00): {action}")] InvalidActionUniqueness { action: FieldElement }, + #[error("Invalid RP signature verification data: {context}")] + InvalidRpSignatureVerification { context: &'static str }, #[error("Could not verify query proof")] InvalidQueryProof, #[error(transparent)] @@ -120,6 +126,9 @@ impl From<&RpModuleError> for WorldIdRequestAuthError { match value { RpModuleError::InvalidActionSession { .. } => Self::InvalidActionSession, RpModuleError::InvalidActionUniqueness { .. } => Self::InvalidActionNullifier, + RpModuleError::InvalidRpSignatureVerification { .. } => { + Self::InvalidRpSignatureVerification + } RpModuleError::InvalidQueryProof => Self::InvalidQueryProof, RpModuleError::MerkleWatcher(e) => Self::from(e.as_ref()), RpModuleError::RpRegistry(e) => Self::from(e.as_ref()), @@ -307,12 +316,23 @@ 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 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.rp_signature_verification.is_some() { + return Err(RpModuleError::InvalidRpSignatureVerification { + context: "not supported for WIP101 contract-backed RPs", + }); + } Ok(rp .verify_wip101( action, @@ -364,12 +384,29 @@ 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 let nonce_scope = match self.kind { RpModuleKind::Session => { metrics::auth_module::inc_session(); if action.is_valid_for_session(SessionFeType::OprfSeed) { + if let Some(RpSignatureVerification::UniquenessAction { + 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", + }); + } + } NonceScope::SessionOprfSeed } else if action.is_valid_for_session(SessionFeType::Action) { + if request.auth.rp_signature_verification.is_some() { + return Err(RpModuleError::InvalidRpSignatureVerification { + context: "only allowed on session-seed queries", + }); + } NonceScope::SessionAction } else { return Err(RpModuleError::InvalidActionSession { action }); @@ -377,6 +414,11 @@ impl RpModuleAuth { } RpModuleKind::Uniqueness => { metrics::auth_module::inc_nullifier(); + if request.auth.rp_signature_verification.is_some() { + return Err(RpModuleError::InvalidRpSignatureVerification { + 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 8cb517123..2128a1a69 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, }; @@ -36,11 +36,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(); @@ -73,6 +73,48 @@ impl RpModuleTestSetup { signature: Some(signature), rp_id: infra.setup.rp_fixture.world_rp_id, wip101_data: None, + rp_signature_verification: 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 as RP signature verification data + /// (create-and-bind). + pub(crate) async fn new_bound_session_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 verification data 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, + rp_signature_verification: Some(RpSignatureVerification::UniquenessAction { + action: infra.setup.rp_fixture.action.into(), + }), }; Ok(Self { @@ -107,6 +149,7 @@ impl RpModuleTestSetup { signature: Some(infra.setup.rp_fixture.signature), rp_id: infra.setup.rp_fixture.world_rp_id, wip101_data: None, + rp_signature_verification: None, }; Ok(Self { @@ -609,7 +652,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 } @@ -639,6 +682,63 @@ async fn test_session_invalid_action_random_prefix() -> eyre::Result<()> { .await } +// ── RP signature verification (create-and-bind) tests ─────────────────── +// +// 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_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_bound_session_seed().await?; + setup.request.auth.rp_signature_verification = None; + 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_bound_session_seed().await?; + setup.request.auth.rp_signature_verification = + Some(RpSignatureVerification::UniquenessAction { + action: action_with_msb(0x01).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<()> { + // 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 { + 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] diff --git a/tools/generate-solidity-fixtures/src/main.rs b/tools/generate-solidity-fixtures/src/main.rs index 9697c62d3..caf3b2cb1 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, TREE_DEPTH, - merkle::AccountInclusionProof, + Config, FieldElement, ServiceEndpoint, SessionRef, TREE_DEPTH, merkle::AccountInclusionProof, }; use world_id_test_utils::{ anvil::WorldIDVerifierV3, @@ -272,7 +271,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(), @@ -298,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, @@ -341,14 +336,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) ── @@ -363,7 +422,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, @@ -411,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();