Skip to main content

world_id_authenticator/
prove.rs

1use secrecy::ExposeSecret;
2use world_id_primitives::{
3    Credential, FieldElement, ProofRequest, ProofResponse, ProofType, RequestItem, ResponseItem,
4    SessionId, SessionNullifier, ZeroKnowledgeProof,
5};
6use world_id_proof::{
7    AuthenticatorProofInput, FullOprfOutput, OprfEntrypoint, ProofCompression,
8    proof::generate_nullifier_proof,
9};
10
11use crate::{
12    api_types::AccountInclusionProof,
13    authenticator::{Authenticator, CredentialInput, ProofResult},
14    error::AuthenticatorError,
15};
16#[cfg(not(target_arch = "wasm32"))]
17use world_id_primitives::OwnershipProof;
18use world_id_primitives::TREE_DEPTH;
19#[cfg(not(target_arch = "wasm32"))]
20use world_id_proof::{
21    circuit_inputs::OwnershipProofCircuitInput, ownership_proof::generate_ownership_proof,
22};
23
24#[expect(unused_imports, reason = "used for docs")]
25use world_id_primitives::Nullifier;
26
27impl Authenticator {
28    /// Gets an object to request OPRF computations to OPRF Nodes.
29    ///
30    /// # Arguments
31    /// - `account_inclusion_proof`: an optionally cached object can be passed to
32    ///   avoid an additional network call. If not passed, it'll be fetched from the indexer.
33    ///
34    /// # Errors
35    /// - Will return an error if there are no OPRF Nodes configured or if the threshold is invalid.
36    /// - Will return an error if proof materials are not loaded.
37    /// - Will return an error if there are issues fetching an inclusion proof.
38    async fn get_oprf_entrypoint(
39        &self,
40        account_inclusion_proof: Option<AccountInclusionProof<TREE_DEPTH>>,
41    ) -> Result<OprfEntrypoint<'_>, AuthenticatorError> {
42        // Check OPRF Config
43        let services = self.config.nullifier_oracle_urls();
44        if services.is_empty() {
45            return Err(AuthenticatorError::Generic(
46                "No nullifier oracle URLs configured".to_string(),
47            ));
48        }
49        let requested_threshold = self.config.nullifier_oracle_threshold();
50        if requested_threshold == 0 {
51            return Err(AuthenticatorError::InvalidConfig {
52                attribute: "nullifier_oracle_threshold".to_string(),
53                reason: "must be at least 1".to_string(),
54            });
55        }
56        let threshold = requested_threshold.min(services.len());
57
58        let query_material = self
59            .query_material
60            .as_ref()
61            .ok_or(AuthenticatorError::ProofMaterialsNotLoaded)?;
62
63        let authenticator_input = self
64            .prepare_authenticator_input(account_inclusion_proof)
65            .await?;
66
67        Ok(OprfEntrypoint::new(
68            services,
69            threshold,
70            query_material,
71            authenticator_input,
72            &self.ws_connector,
73        ))
74    }
75
76    async fn prepare_authenticator_input(
77        &self,
78        account_inclusion_proof: Option<AccountInclusionProof<TREE_DEPTH>>,
79    ) -> Result<AuthenticatorProofInput, AuthenticatorError> {
80        // Fetch inclusion_proof && authenticator key_set if not provided
81        let account_inclusion_proof = if let Some(account_inclusion_proof) = account_inclusion_proof
82        {
83            account_inclusion_proof
84        } else {
85            self.fetch_inclusion_proof().await?
86        };
87
88        let key_index = account_inclusion_proof
89            .authenticator_pubkeys
90            .iter()
91            .position(|pk| {
92                pk.as_ref()
93                    .is_some_and(|pk| pk.pk == self.offchain_pubkey().pk)
94            })
95            .ok_or(AuthenticatorError::PublicKeyNotFound)? as u64;
96
97        let authenticator_input = AuthenticatorProofInput::new(
98            account_inclusion_proof.authenticator_pubkeys,
99            account_inclusion_proof.inclusion_proof,
100            self.signer
101                .offchain_signer_private_key()
102                .expose_secret()
103                .clone(),
104            key_index,
105        );
106
107        Ok(authenticator_input)
108    }
109
110    /// Generates a nullifier for a World ID Proof (through OPRF Nodes).
111    ///
112    /// A [`Nullifier`] is a unique, one-time use, anonymous identifier for a World ID
113    /// on a specific RP context. See [`Nullifier`] for more details.
114    ///
115    /// # Arguments
116    /// - `proof_request`: the request received from the RP.
117    /// - `account_inclusion_proof`: an optionally cached object can be passed to
118    ///   avoid an additional network call. If not passed, it'll be fetched from the indexer.
119    ///
120    /// A Nullifier takes an `action` as input:
121    /// - If `proof_request` is for a Session Proof, a random internal `action` is generated. This
122    ///   is opaque to RPs, and verified internally in the verification contract.
123    /// - If `proof_request` is for a Uniqueness Proof, the `action` is provided by the RP,
124    ///   if not provided a default of [`FieldElement::ZERO`] is used.
125    ///
126    /// # Errors
127    ///
128    /// - Will raise a [`ProofError`](world_id_proof::ProofError) if there is any issue
129    ///   generating the nullifier. For example, network issues, unexpected incorrect responses
130    ///   from OPRF Nodes.
131    /// - Raises an error if the OPRF Nodes configuration is not correctly set.
132    pub async fn generate_nullifier(
133        &self,
134        proof_request: &ProofRequest,
135        account_inclusion_proof: Option<AccountInclusionProof<TREE_DEPTH>>,
136    ) -> Result<FullOprfOutput, AuthenticatorError> {
137        proof_request.validate_proof_type()?;
138        let mut rng = rand::rngs::OsRng;
139
140        let oprf_entrypoint = self.get_oprf_entrypoint(account_inclusion_proof).await?;
141
142        Ok(oprf_entrypoint
143            .gen_nullifier(&mut rng, proof_request)
144            .await?)
145    }
146
147    /// Generates a blinding factor for a Credential sub (through OPRF Nodes). The credential
148    /// blinding factor enables every credential to have a different subject identifier, see
149    /// [`Credential::sub`] for more details.
150    ///
151    /// # Errors
152    ///
153    /// - Will raise a [`ProofError`](world_id_proof::ProofError) if there is any issue
154    ///   generating the blinding factor. For example, network issues, unexpected incorrect
155    ///   responses from OPRF Nodes.
156    /// - Raises an error if the OPRF Nodes configuration is not correctly set.
157    pub async fn generate_credential_blinding_factor(
158        &self,
159        issuer_schema_id: u64,
160    ) -> Result<FieldElement, AuthenticatorError> {
161        let mut rng = rand::rngs::OsRng;
162
163        // This is called sporadic enough that fetching fresh is reasonable
164        let oprf_entrypoint = self.get_oprf_entrypoint(None).await?;
165
166        let (blinding_factor, _share_epoch) = oprf_entrypoint
167            .gen_credential_blinding_factor(&mut rng, issuer_schema_id)
168            .await?;
169
170        Ok(blinding_factor)
171    }
172
173    /// Builds or resolves a [`SessionId`] object which can be used for Session Proofs. This has two uses:
174    /// 1. Creating a new Session, i.e. generating a [`SessionId`] for the first time.
175    /// 2. Reconstructing a session for a Session Proof, particularly if the `session_id_r_seed` is not cached.
176    ///
177    /// Internally, this derives the session randomness (`r`) using OPRF Nodes. For existing
178    /// sessions this re-derives the same `r` from [`SessionId::oprf_seed`]; it does not mint a
179    /// new session. The seed is used to compute the [`SessionId::commitment`] for Session Proofs.
180    ///
181    /// # Arguments
182    /// - `proof_request`: the request received from the RP to create or prove a session id.
183    /// - `session_id_r_seed`: the seed (see below) if it was already generated previously and it's cached.
184    /// - `account_inclusion_proof`: an optionally cached object can be passed to
185    ///   avoid an additional network call. If not passed, it'll be fetched from the indexer.
186    ///
187    /// # Returns
188    /// - `session_id`: The generated or resolved [`SessionId`].
189    /// - `session_id_r_seed`: The `r` value used for this session so the Authenticator can cache it.
190    ///
191    /// # Seed (`session_id_r_seed`)
192    /// - If a `session_id_r_seed` (`r`) is not provided, it'll be derived/re-derived with the OPRF nodes.
193    /// - Even if `r` has been generated before, the same `r` will be computed again for the same
194    ///   context (i.e. `rpId`, [`SessionId::oprf_seed`]). This means caching `r` is optional but RECOMMENDED.
195    /// -  Caching behavior is the responsibility of the Authenticator (and/or its relevant SDKs), not this crate.
196    /// - More information about the seed can be found in [`SessionId::from_r_seed`].
197    pub async fn build_session_id(
198        &self,
199        proof_request: &ProofRequest,
200        session_id_r_seed: Option<FieldElement>,
201        account_inclusion_proof: Option<AccountInclusionProof<TREE_DEPTH>>,
202    ) -> Result<(SessionId, FieldElement), AuthenticatorError> {
203        proof_request.validate_proof_type()?;
204        if !proof_request.is_session_proof() {
205            return Err(AuthenticatorError::PrimitiveError(
206                world_id_primitives::PrimitiveError::InvalidInput {
207                    attribute: "proof_type".to_string(),
208                    reason: "must be create_session or session".to_string(),
209                },
210            ));
211        }
212
213        let mut rng = rand::rngs::OsRng;
214
215        let oprf_seed = match proof_request.session_id {
216            Some(session_id) => session_id.oprf_seed,
217            None => SessionId::generate_oprf_seed(&mut rng),
218        };
219
220        let resolved_session_id_r_seed = match session_id_r_seed {
221            Some(seed) => seed,
222            None => {
223                let entrypoint = self.get_oprf_entrypoint(account_inclusion_proof).await?;
224                let oprf_output = entrypoint
225                    .derive_session_id_r_seed(&mut rng, proof_request, oprf_seed)
226                    .await?;
227                oprf_output.verifiable_oprf_output.output.into()
228            }
229        };
230
231        let session_id =
232            SessionId::from_r_seed(self.leaf_index(), resolved_session_id_r_seed, oprf_seed)?;
233
234        if let Some(request_session_id) = proof_request.session_id
235            && request_session_id != session_id
236        {
237            return Err(AuthenticatorError::SessionIdMismatch);
238        }
239
240        Ok((session_id, resolved_session_id_r_seed))
241    }
242
243    /// Generates a complete [`ProofResponse`] for
244    /// the given [`ProofRequest`] to respond to an RP request.
245    ///
246    /// This orchestrates session resolution, per-credential proof generation,
247    /// response assembly, and self-validation.
248    ///
249    /// # Typical flow
250    /// ```rust,ignore
251    /// // <- check request can be fulfilled with available credentials
252    /// let nullifier = authenticator.generate_nullifier(&request, None).await?;
253    /// // <- check replay guard using nullifier.oprf_output()
254    /// let (response, meta) = authenticator.generate_proof(&request, nullifier, &creds, ...).await?;
255    /// // <- cache `session_id_r_seed` (to speed future proofs) and `nullifier` (to prevent replays)
256    /// ```
257    ///
258    /// # Arguments
259    /// - `proof_request` — the RP's full request.
260    /// - `nullifier` — the OPRF nullifier output, obtained from
261    ///   [`generate_nullifier`](Self::generate_nullifier). The caller MUST check
262    ///   for replays before calling this method to avoid wasted computation.
263    /// - `credentials` — one [`CredentialInput`] per credential to prove,
264    ///   matched to request items by `issuer_schema_id`.
265    /// - `account_inclusion_proof` — a cached inclusion proof if available (a fresh one will be fetched otherwise)
266    /// - `session_id_r_seed` — a cached session `r` seed for Session Proofs. If not available, it will be
267    ///   re-computed.
268    ///
269    /// # Caller Responsibilities
270    /// 1. The caller must ensure the request can be fulfilled with the credentials which the user has available,
271    ///    and provide such credentials.
272    /// 2. The caller must ensure the nullifier has not been used before.
273    ///
274    /// # Errors
275    /// - [`AuthenticatorError::UnfullfilableRequest`] if the provided credentials
276    ///   cannot satisfy the request (including constraints).
277    /// - Other `AuthenticatorError` variants on proof circuit or validation failures.
278    pub async fn generate_proof(
279        &self,
280        proof_request: &ProofRequest,
281        nullifier: FullOprfOutput,
282        credentials: &[CredentialInput],
283        account_inclusion_proof: Option<AccountInclusionProof<TREE_DEPTH>>,
284        session_id_r_seed: Option<FieldElement>,
285    ) -> Result<ProofResult, AuthenticatorError> {
286        proof_request.validate_proof_type()?;
287
288        // 1. Determine request items to prove
289        let available: std::collections::HashSet<u64> = credentials
290            .iter()
291            .map(|c| c.credential.issuer_schema_id)
292            .collect();
293        let items_to_prove = proof_request
294            .credentials_to_prove(&available)
295            .ok_or(AuthenticatorError::UnfullfilableRequest)?;
296
297        // 2. Resolve session seed
298        let (resolved_session_id, resolved_session_seed) = match proof_request.proof_type {
299            ProofType::Uniqueness => (None, None),
300            ProofType::CreateSession => {
301                let (session_id, seed) = self
302                    .build_session_id(proof_request, None, account_inclusion_proof)
303                    .await?;
304                (Some(session_id), Some(seed))
305            }
306            ProofType::Session => {
307                let session_id = proof_request
308                    .session_id
309                    .expect("session proof must have session_id");
310                if let Some(seed) = session_id_r_seed {
311                    // Validate the cached seed produces the expected session ID
312                    let computed =
313                        SessionId::from_r_seed(self.leaf_index(), seed, session_id.oprf_seed)?;
314
315                    if computed != session_id {
316                        return Err(AuthenticatorError::SessionIdMismatch);
317                    }
318                    (Some(session_id), Some(seed))
319                } else {
320                    // Re-derive the same `r` from the existing session's `oprf_seed` when the
321                    // caller did not provide a cached seed.
322                    let (_session_id, seed) = self
323                        .build_session_id(proof_request, None, account_inclusion_proof)
324                        .await?;
325                    (Some(session_id), Some(seed))
326                }
327            }
328        };
329
330        // 3. Generate per-credential proofs for the selected items
331        let creds_by_schema: std::collections::HashMap<u64, &CredentialInput> = credentials
332            .iter()
333            .map(|c| (c.credential.issuer_schema_id, c))
334            .collect();
335
336        let mut responses = Vec::with_capacity(items_to_prove.len());
337        for request_item in &items_to_prove {
338            let cred_input = creds_by_schema[&request_item.issuer_schema_id];
339
340            let response_item = self.generate_credential_proof(
341                nullifier.clone(),
342                request_item,
343                &cred_input.credential,
344                cred_input.blinding_factor,
345                resolved_session_seed,
346                resolved_session_id,
347                proof_request.created_at,
348            )?;
349            responses.push(response_item);
350        }
351
352        // 4. Assemble response
353        let proof_response = ProofResponse {
354            id: proof_request.id.clone(),
355            version: proof_request.version,
356            session_id: resolved_session_id,
357            responses,
358            error: None,
359        };
360
361        // 5. Validate and return response
362        proof_request.validate_response(&proof_response)?;
363        Ok(ProofResult {
364            session_id_r_seed: resolved_session_seed,
365            proof_response,
366        })
367    }
368
369    /// Generates a single World ID Proof from a provided `[ProofRequest]` and `[Credential]`. This
370    /// method generates the raw proof to be translated into a Uniqueness Proof or a Session Proof for the RP.
371    ///
372    /// The correct entrypoint for an RP request is [`Self::generate_proof`].
373    ///
374    /// This assumes the RP's `[ProofRequest]` has already been parsed to determine
375    /// which `[Credential]` is appropriate for the request. This method responds to a
376    /// specific `[RequestItem]` (a `[ProofRequest]` may contain multiple items).
377    ///
378    /// # Arguments
379    /// - `oprf_nullifier`: The output representing the nullifier, generated from the `generate_nullifier` function. All proofs
380    ///   require this attribute.
381    /// - `request_item`: The specific `RequestItem` that is being resolved from the RP's `ProofRequest`.
382    /// - `credential`: The Credential to be used for the proof that fulfills the `RequestItem`.
383    /// - `credential_sub_blinding_factor`: The blinding factor for the Credential's sub.
384    /// - `session_id_r_seed`: The session ID random seed, obtained via [`build_session_id`](Self::build_session_id).
385    ///   For Uniqueness Proofs (when `session_id` is `None`), this value is ignored by the circuit.
386    /// - `session_id`: The expected session ID provided by the RP. Only needed for Session Proofs. Obtained from the RP's [`ProofRequest`].
387    /// - `request_timestamp`: The timestamp of the request. Obtained from the RP's [`ProofRequest`].
388    ///
389    /// # Errors
390    /// - Will error if the any of the provided parameters are not valid.
391    /// - Will error if any of the required network requests fail.
392    /// - Will error if the user does not have a registered World ID.
393    #[expect(clippy::too_many_arguments)]
394    fn generate_credential_proof(
395        &self,
396        oprf_nullifier: FullOprfOutput,
397        request_item: &RequestItem,
398        credential: &Credential,
399        credential_sub_blinding_factor: FieldElement,
400        session_id_r_seed: Option<FieldElement>,
401        session_id: Option<SessionId>,
402        request_timestamp: u64,
403    ) -> Result<ResponseItem, AuthenticatorError> {
404        let mut rng = rand::rngs::OsRng;
405
406        let nullifier_material = self
407            .nullifier_material
408            .as_ref()
409            .ok_or(AuthenticatorError::ProofMaterialsNotLoaded)?;
410
411        let merkle_root: FieldElement = oprf_nullifier.query_proof_input.merkle_root.into();
412        let action_from_query: FieldElement = oprf_nullifier.query_proof_input.action.into();
413
414        let expires_at_min = request_item.effective_expires_at_min(request_timestamp);
415
416        let (proof, _public_inputs, nullifier) = generate_nullifier_proof(
417            nullifier_material,
418            &mut rng,
419            credential,
420            credential_sub_blinding_factor,
421            oprf_nullifier,
422            request_item,
423            session_id.map(|v| v.commitment),
424            session_id_r_seed,
425            expires_at_min,
426        )?;
427
428        let proof = ZeroKnowledgeProof::from_groth16_proof(&proof, merkle_root);
429
430        // Construct the appropriate response item based on proof type
431        let nullifier_fe: FieldElement = nullifier.into();
432        let response_item = if session_id.is_some() {
433            let session_nullifier = SessionNullifier::new(nullifier_fe, action_from_query)?;
434            ResponseItem::new_session(
435                request_item.identifier.clone(),
436                request_item.issuer_schema_id,
437                proof,
438                session_nullifier,
439                expires_at_min,
440            )
441        } else {
442            ResponseItem::new_uniqueness(
443                request_item.identifier.clone(),
444                request_item.issuer_schema_id,
445                proof,
446                nullifier_fe.into(),
447                expires_at_min,
448            )
449        };
450
451        Ok(response_item)
452    }
453
454    /// Generates an Ownership Proof (WIP-103) over a Credential's `sub`.
455    ///
456    /// This proof MUST only be shared with each relevant issuer. This is the responsibility of Authenticators.
457    ///
458    /// # Arguments
459    /// - `nonce`: The nonce of the request provided by the Issuer.
460    /// - `credential_blinding_factor`: The blinding factor generated for the credential.
461    /// - `sub`: The expected `sub` of the Credential in question.
462    /// - `account_inclusion_proof`: An optionally cached account inclusion proof. If not provided, a new inclusion proof will be fetched.
463    ///
464    /// # Returns
465    /// The [`OwnershipProof`] containing the ZKP and Merkle root.
466    #[cfg(not(target_arch = "wasm32"))]
467    pub async fn prove_credential_sub(
468        &self,
469        nonce: FieldElement,
470        credential_blinding_factor: FieldElement,
471        sub: FieldElement,
472        account_inclusion_proof: Option<AccountInclusionProof<TREE_DEPTH>>,
473    ) -> Result<OwnershipProof, AuthenticatorError> {
474        let authenticator_input = self
475            .prepare_authenticator_input(account_inclusion_proof)
476            .await?;
477
478        let commitment = Credential::compute_sub(self.leaf_index(), credential_blinding_factor);
479
480        if commitment != sub {
481            return Err(AuthenticatorError::InvalidSubOrBlindingFactor);
482        }
483
484        let signature = self
485            .signer
486            .offchain_signer_private_key()
487            .expose_secret()
488            .sign(*commitment);
489
490        let input = OwnershipProofCircuitInput {
491            key_index: authenticator_input.key_index,
492            key_set: authenticator_input.key_set.clone(),
493            inclusion_proof: authenticator_input.inclusion_proof.clone(),
494            nonce,
495            signature,
496            commitment_blinder: credential_blinding_factor,
497        };
498
499        Ok(generate_ownership_proof(input)?)
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use crate::{
506        authenticator::Authenticator,
507        error::AuthenticatorError,
508        service_client::{ServiceClient, ServiceKind},
509    };
510    use alloy::primitives::address;
511    use ruint::aliases::U256;
512    use taceo_oprf::client::Connector;
513    use world_id_primitives::{
514        Config, Credential, FieldElement, ServiceEndpoint, Signer, TREE_DEPTH,
515        merkle::AccountInclusionProof,
516    };
517    use world_id_test_utils::fixtures::single_leaf_merkle_fixture;
518
519    fn build_test_authenticator(
520        seed: &[u8; 32],
521        leaf_index: u64,
522    ) -> (Authenticator, AccountInclusionProof<TREE_DEPTH>) {
523        let signer = Signer::from_seed_bytes(seed).expect("valid seed");
524        let pubkey = signer.offchain_signer_pubkey();
525
526        let fixture =
527            single_leaf_merkle_fixture(vec![pubkey], leaf_index).expect("valid merkle fixture");
528        let account_inclusion_proof =
529            AccountInclusionProof::new(fixture.inclusion_proof, fixture.key_set);
530
531        let config = Config::new(
532            None,
533            1,
534            address!("0x0000000000000000000000000000000000000001"),
535            ServiceEndpoint::direct("http://indexer.example.com".to_string()),
536            ServiceEndpoint::direct("http://gateway.example.com".to_string()),
537            Vec::new(),
538            2,
539        )
540        .expect("valid config");
541
542        let http_client = reqwest::Client::new();
543        let authenticator = Authenticator {
544            config: config.clone(),
545            packed_account_data: U256::from(leaf_index),
546            signer,
547            registry: None,
548            indexer_client: ServiceClient::new(
549                http_client.clone(),
550                ServiceKind::Indexer,
551                config.indexer(),
552            )
553            .expect("valid indexer client"),
554            gateway_client: ServiceClient::new(http_client, ServiceKind::Gateway, config.gateway())
555                .expect("valid gateway client"),
556            ws_connector: Connector::Plain,
557            query_material: None,
558            nullifier_material: None,
559        };
560
561        (authenticator, account_inclusion_proof)
562    }
563
564    #[tokio::test]
565    async fn test_prove_credential_sub_rejects_wrong_sub() {
566        let leaf_index = 1u64;
567        let (authenticator, inclusion_proof) = build_test_authenticator(&[42u8; 32], leaf_index);
568
569        let blinding_factor = FieldElement::from(999u64);
570        let wrong_sub = FieldElement::from(123u64);
571
572        let result = authenticator
573            .prove_credential_sub(
574                FieldElement::from(1_234_567_890u64),
575                blinding_factor,
576                wrong_sub,
577                Some(inclusion_proof),
578            )
579            .await;
580
581        assert!(matches!(
582            result,
583            Err(AuthenticatorError::InvalidSubOrBlindingFactor)
584        ));
585    }
586
587    #[tokio::test]
588    async fn test_prove_credential_sub_succeeds_with_correct_sub() {
589        let leaf_index = 1u64;
590        let (authenticator, inclusion_proof) = build_test_authenticator(&[42u8; 32], leaf_index);
591
592        let blinding_factor = FieldElement::from(999u64);
593        let correct_sub = Credential::compute_sub(leaf_index, blinding_factor);
594        let nonce = FieldElement::from(1_234_567_890u64);
595
596        authenticator
597            .prove_credential_sub(nonce, blinding_factor, correct_sub, Some(inclusion_proof))
598            .await
599            .expect("proof generation should succeed");
600    }
601}