1use crate::{
4 authenticator::artifacts::WalletKitZkArtifactSource, defaults,
5 error::WalletKitError, primitives::ParseFromForeignBinding, Environment,
6 FieldElement, Region,
7};
8use alloy_core::primitives::Address;
9use ruint::aliases::U256;
10use ruint_uniffi::Uint256;
11use std::sync::Arc;
12use world_id_core::{
13 api_types::{GatewayErrorCode, GatewayRequestId, GatewayRequestState},
14 primitives::{AuthenticatorPublicKeySet, Config, MAX_AUTHENTICATOR_KEYS},
15 Authenticator as CoreAuthenticator, AuthenticatorError,
16 Credential as CoreCredential, CredentialInput, EdDSAPublicKey,
17 InitializingAuthenticator as CoreInitializingAuthenticator,
18 OnchainKeyRepresentable, Signer,
19};
20
21use crate::requests::{ProofRequest, ProofResponse};
22use crate::storage::CredentialStore;
23use crate::OwnershipProof;
24
25pub mod artifacts;
26mod with_storage;
27
28#[derive(Debug, uniffi::Object)]
30pub struct Authenticator {
31 inner: CoreAuthenticator,
32 store: Arc<CredentialStore>,
33}
34
35impl Authenticator {
36 pub async fn init_with_config(
42 seed: &[u8],
43 config: Config,
44 artifacts: Arc<dyn WalletKitZkArtifactSource>,
45 store: Arc<CredentialStore>,
46 ) -> Result<Self, WalletKitError> {
47 let authenticator = CoreAuthenticator::init(seed, config, artifacts).await?;
48
49 Ok(Self {
50 inner: authenticator,
51 store,
52 })
53 }
54}
55
56fn parse_authenticator_pubkey(
57 attribute: &str,
58 encoded_pubkey: impl AsRef<str>,
59) -> Result<EdDSAPublicKey, WalletKitError> {
60 let encoded_pubkey = encoded_pubkey.as_ref();
61 let invalid_input = |reason: String| WalletKitError::InvalidInput {
62 attribute: attribute.to_string(),
63 reason,
64 };
65 let hex = encoded_pubkey.strip_prefix("0x").ok_or_else(|| {
66 invalid_input("Public key must start with a 0x prefix".to_string())
67 })?;
68
69 if hex.len() != 64 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
70 return Err(invalid_input(
71 "Public key must be exactly 32 bytes (64 hex characters) after the 0x prefix"
72 .to_string(),
73 ));
74 }
75
76 let encoded = U256::from_str_radix(hex, 16)
77 .map_err(|error| invalid_input(error.to_string()))?;
78 let pubkey = EdDSAPublicKey::from_compressed_bytes(encoded.to_le_bytes())
79 .map_err(|error| invalid_input(error.to_string()))?;
80
81 let canonical = pubkey
87 .to_ethereum_representation()
88 .map_err(|error| invalid_input(error.to_string()))?;
89 if canonical != encoded {
90 return Err(invalid_input(
91 "Public key is not the canonical compressed point encoding".to_string(),
92 ));
93 }
94 if canonical == U256::from(1u64) {
95 return Err(invalid_input(
96 "Public key must not be the BabyJubJub identity point".to_string(),
97 ));
98 }
99
100 Ok(pubkey)
101}
102
103#[uniffi::export(async_runtime = "tokio")]
104impl Authenticator {
105 #[must_use]
110 pub fn packed_account_data(&self) -> Uint256 {
111 self.inner.packed_account_data.into()
112 }
113
114 #[must_use]
119 pub fn leaf_index(&self) -> u64 {
120 self.inner.leaf_index()
121 }
122
123 #[must_use]
127 pub fn onchain_address(&self) -> String {
128 self.inner.onchain_address().to_string()
129 }
130
131 #[tracing::instrument(
136 target = "walletkit_latency",
137 name = "rpc_account_data",
138 skip_all
139 )]
140 pub async fn get_packed_account_data_remote(
141 &self,
142 ) -> Result<Uint256, WalletKitError> {
143 let packed_account_data = self.inner.fetch_packed_account_data().await?;
144 Ok(packed_account_data.into())
145 }
146
147 #[tracing::instrument(
156 target = "walletkit_latency",
157 name = "oprf_blinding_factor",
158 skip_all
159 )]
160 pub async fn generate_credential_blinding_factor_remote(
161 &self,
162 issuer_schema_id: u64,
163 ) -> Result<FieldElement, WalletKitError> {
164 Ok(self
165 .inner
166 .generate_credential_blinding_factor(issuer_schema_id)
167 .await
168 .map(Into::into)?)
169 }
170
171 #[must_use]
173 pub fn compute_credential_sub(
174 &self,
175 blinding_factor: &FieldElement,
176 ) -> FieldElement {
177 CoreCredential::compute_sub(self.inner.leaf_index(), blinding_factor.0).into()
178 }
179
180 #[allow(
191 clippy::needless_pass_by_value,
192 reason = "seed is passed by value so uniffi 0.32 maps it to a `RustBuffer` (Kotlin `ByteArray` / Swift `Data`) rather than the non-`Send` `ForeignBytes` view produced for `&[u8]`"
193 )]
194 pub fn danger_sign_challenge(
195 &self,
196 challenge: Vec<u8>,
197 ) -> Result<Vec<u8>, WalletKitError> {
198 let signature = self.inner.danger_sign_challenge(&challenge)?;
199 Ok(signature.as_bytes().to_vec())
200 }
201
202 pub async fn danger_sign_initiate_recovery_agent_update(
226 &self,
227 new_recovery_agent: String,
228 ) -> Result<RecoveryUpdateSignature, WalletKitError> {
229 let new_recovery_agent =
230 Address::parse_from_ffi(&new_recovery_agent, "new_recovery_agent")?;
231 let (sig, nonce) = self
232 .inner
233 .danger_sign_initiate_recovery_agent_update(new_recovery_agent)
234 .await?;
235 Ok(RecoveryUpdateSignature {
236 signature: sig.as_bytes().to_vec(),
237 nonce: nonce.into(),
238 })
239 }
240
241 pub async fn update_recovery_agent(
259 &self,
260 new_recovery_agent: String,
261 ) -> Result<String, WalletKitError> {
262 let new_recovery_agent =
263 Address::parse_from_ffi(&new_recovery_agent, "new_recovery_agent")?;
264
265 let request_id = self.inner.update_recovery_agent(new_recovery_agent).await?;
266
267 Ok(request_id.to_string())
268 }
269
270 pub async fn revert_recovery_agent_update(&self) -> Result<String, WalletKitError> {
284 let request_id = self.inner.revert_recovery_agent_update().await?;
285
286 Ok(request_id.to_string())
287 }
288
289 #[tracing::instrument(
303 target = "walletkit_latency",
304 name = "gateway_insert_authenticator",
305 skip_all
306 )]
307 pub async fn insert_authenticator(
308 &self,
309 new_authenticator_pubkey: String,
310 new_authenticator_address: String,
311 ) -> Result<String, WalletKitError> {
312 let new_authenticator_pubkey = parse_authenticator_pubkey(
313 "new_authenticator_pubkey",
314 new_authenticator_pubkey,
315 )?;
316 let new_authenticator_address = Address::parse_from_ffi(
317 &new_authenticator_address,
318 "new_authenticator_address",
319 )?;
320
321 let request_id = self
322 .inner
323 .insert_authenticator(new_authenticator_pubkey, new_authenticator_address)
324 .await?;
325
326 Ok(request_id.to_string())
327 }
328
329 #[tracing::instrument(
343 target = "walletkit_latency",
344 name = "indexer_authenticator_pubkeys",
345 skip_all
346 )]
347 pub async fn has_authenticator_pubkey(
348 &self,
349 authenticator_pubkey: String,
350 ) -> Result<bool, WalletKitError> {
351 let authenticator_pubkey =
352 parse_authenticator_pubkey("authenticator_pubkey", authenticator_pubkey)?;
353 let pubkeys = self.inner.fetch_authenticator_pubkeys().await?;
354 Ok(pubkeys
355 .iter()
356 .flatten()
357 .any(|existing_pubkey| existing_pubkey == &authenticator_pubkey))
358 }
359
360 #[tracing::instrument(
374 target = "walletkit_latency",
375 name = "indexer_authenticator_pubkeys",
376 skip_all
377 )]
378 pub async fn get_authenticator_pubkeys(
379 &self,
380 ) -> Result<Vec<Option<String>>, WalletKitError> {
381 let key_set = self.inner.fetch_authenticator_pubkeys().await?;
382 key_set
383 .iter()
384 .map(|slot| {
385 slot.as_ref()
386 .map(|pubkey| {
387 let encoded = pubkey.to_ethereum_representation()?;
388 Ok(format!("{encoded:#066x}"))
389 })
390 .transpose()
391 })
392 .collect()
393 }
394
395 #[tracing::instrument(
419 target = "walletkit_latency",
420 name = "gateway_remove_authenticator",
421 skip_all
422 )]
423 pub async fn remove_authenticator(
424 &self,
425 authenticator_address: String,
426 pubkey_id: u32,
427 expected_authenticator_pubkey: String,
428 ) -> Result<String, WalletKitError> {
429 let expected_pubkey = parse_authenticator_pubkey(
430 "expected_authenticator_pubkey",
431 expected_authenticator_pubkey,
432 )?;
433 let authenticator_address =
434 Address::parse_from_ffi(&authenticator_address, "authenticator_address")?;
435
436 if pubkey_id as usize >= MAX_AUTHENTICATOR_KEYS {
437 return Err(WalletKitError::InvalidInput {
438 attribute: "pubkey_id".to_string(),
439 reason: format!(
440 "pubkey_id {pubkey_id} is out of range; the key set has at \
441 most {MAX_AUTHENTICATOR_KEYS} slots"
442 ),
443 });
444 }
445
446 let empty_slot = || WalletKitError::InvalidInput {
447 attribute: "pubkey_id".to_string(),
448 reason: format!("no authenticator at key set slot {pubkey_id}"),
449 };
450 let key_set = self.inner.fetch_authenticator_pubkeys().await?;
451 let actual_pubkey = key_set.get(pubkey_id as usize).ok_or_else(empty_slot)?;
452 if actual_pubkey != &expected_pubkey {
453 return Err(WalletKitError::InvalidInput {
454 attribute: "expected_authenticator_pubkey".to_string(),
455 reason: format!(
456 "key set slot {pubkey_id} holds a different authenticator public key"
457 ),
458 });
459 }
460
461 let request_id = self
462 .inner
463 .remove_authenticator(authenticator_address, pubkey_id)
464 .await
465 .map_err(|error| match error {
466 AuthenticatorError::PublicKeyNotFound => empty_slot(),
470 other => other.into(),
471 })?;
472
473 Ok(request_id.to_string())
474 }
475
476 #[tracing::instrument(
481 target = "walletkit_latency",
482 name = "gateway_poll",
483 skip_all
484 )]
485 pub async fn poll_status(
486 &self,
487 request_id: String,
488 ) -> Result<GatewayRequestStatus, WalletKitError> {
489 let request_id = GatewayRequestId::new(
490 request_id.strip_prefix("gw_").unwrap_or(&request_id),
491 );
492 let status = self.inner.poll_status(&request_id).await?;
493 Ok(status.into())
494 }
495}
496
497#[uniffi::export(async_runtime = "tokio")]
498impl Authenticator {
499 #[uniffi::constructor]
507 #[tracing::instrument(target = "walletkit_latency", name = "rpc_init", skip_all)]
508 pub async fn init_with_defaults(
509 seed: Vec<u8>,
510 rpc_url: Option<String>,
511 environment: &Environment,
512 region: Option<Region>,
513 artifacts: Arc<dyn WalletKitZkArtifactSource>,
514 store: Arc<CredentialStore>,
515 ) -> Result<Self, WalletKitError> {
516 let config = defaults::default_config(environment, rpc_url, region)?;
517 Self::init_with_config(&seed, config, artifacts, store).await
518 }
519
520 #[uniffi::constructor]
530 #[tracing::instrument(target = "walletkit_latency", name = "rpc_init", skip_all)]
531 pub async fn init_with_ohttp_defaults(
532 seed: Vec<u8>,
533 rpc_url: Option<String>,
534 environment: &Environment,
535 region: Option<Region>,
536 artifacts: Arc<dyn WalletKitZkArtifactSource>,
537 store: Arc<CredentialStore>,
538 ) -> Result<Self, WalletKitError> {
539 let config = defaults::default_config_with_ohttp(environment, rpc_url, region)?;
540 Self::init_with_config(&seed, config, artifacts, store).await
541 }
542
543 #[uniffi::constructor]
551 #[tracing::instrument(target = "walletkit_latency", name = "rpc_init", skip_all)]
552 pub async fn init(
553 seed: Vec<u8>,
554 config: &str,
555 artifacts: Arc<dyn WalletKitZkArtifactSource>,
556 store: Arc<CredentialStore>,
557 ) -> Result<Self, WalletKitError> {
558 let config =
559 Config::from_json(config).map_err(|_| WalletKitError::InvalidInput {
560 attribute: "config".to_string(),
561 reason: "Invalid config".to_string(),
562 })?;
563 Self::init_with_config(&seed, config, artifacts, store).await
564 }
565
566 pub async fn generate_proof(
571 &self,
572 proof_request: &ProofRequest,
573 now: Option<u64>,
574 ) -> Result<ProofResponse, WalletKitError> {
575 let now = if let Some(n) = now {
576 n
577 } else {
578 #[cfg(target_arch = "wasm32")]
579 {
580 return Err(WalletKitError::InvalidInput {
581 attribute: "now".to_string(),
582 reason: "`now` must be provided on wasm32 targets".to_string(),
583 });
584 }
585
586 #[cfg(not(target_arch = "wasm32"))]
587 {
588 let start = std::time::SystemTime::now();
589 start
590 .duration_since(std::time::UNIX_EPOCH)
591 .map_err(|e| WalletKitError::Generic {
592 error: format!("Critical. Unable to determine SystemTime: {e}"),
593 })?
594 .as_secs()
595 }
596 };
597
598 let credentials: Vec<_> = self
603 .store
604 .list_credentials(None, now)?
605 .iter()
606 .filter(|c| !c.is_expired)
607 .filter_map(|cred| {
608 if let Ok(Some((credential, blinding_factor))) =
609 self.store.get_credential(cred.issuer_schema_id, now)
610 {
611 Some(CredentialInput {
612 credential: credential.into(),
613 blinding_factor: blinding_factor.into(),
614 })
615 } else {
616 tracing::warn!(
617 issuer_schema_id = %cred.issuer_schema_id,
618 credential_id = %cred.credential_id,
619 "credential listed but not loadable, skipping"
620 );
621 None
622 }
623 })
624 .collect();
625
626 let account_inclusion_proof =
627 self.fetch_inclusion_proof_with_cache(now).await?;
628
629 let nullifier = Box::pin(self.inner.generate_nullifier(
632 &proof_request.0,
633 now,
634 Some(account_inclusion_proof.clone()),
635 ))
636 .await?;
637
638 if self
639 .store
640 .is_nullifier_replay(nullifier.verifiable_oprf_output.output.into(), now)?
641 {
642 return Err(WalletKitError::NullifierReplay);
643 }
644
645 let rp_id = proof_request.0.rp_id.into_inner();
647 let session_id_r_seed =
648 proof_request
649 .0
650 .session_id
651 .existing()
652 .and_then(|session_id| match self.store.get_session_seed(
653 rp_id,
654 session_id.oprf_seed,
655 now,
656 ) {
657 Ok(seed) => seed,
658 Err(err) => {
659 tracing::warn!(error = %err, "failed to load cached session seed, continuing without");
660 None
661 }
662 });
663
664 let result = Box::pin(self.inner.generate_proof(
666 &proof_request.0,
667 nullifier.clone(),
668 &credentials,
669 Some(account_inclusion_proof),
670 session_id_r_seed,
671 ))
672 .await?;
673
674 if let Some(seed) = result.session_id_r_seed {
677 if let Some(session_id) = result.proof_response.session_id {
678 if let Err(err) = self.store.store_session_seed(
679 rp_id,
680 session_id.oprf_seed,
681 seed,
682 now,
683 ) {
684 tracing::error!("error caching session_id_r_seed: {}", err);
685 }
686 }
687 }
688
689 self.store
690 .replay_guard_set(nullifier.verifiable_oprf_output.output.into(), now)?;
691
692 Ok(result.proof_response.into())
693 }
694
695 pub async fn prove_credential_sub(
721 &self,
722 nonce: &FieldElement,
723 context: &FieldElement,
724 blinding_factor: &FieldElement,
725 sub: &FieldElement,
726 ) -> Result<OwnershipProof, WalletKitError> {
727 #[cfg(target_arch = "wasm32")]
728 {
729 let _ = (nonce, context, blinding_factor, sub);
730 return Err(WalletKitError::Generic {
731 error: "credential ownership proofs are not supported on wasm32"
732 .to_string(),
733 });
734 }
735
736 #[cfg(not(target_arch = "wasm32"))]
737 {
738 let now = std::time::SystemTime::now()
739 .duration_since(std::time::UNIX_EPOCH)
740 .map_err(|e| WalletKitError::Generic {
741 error: format!("Critical. Unable to determine SystemTime: {e}"),
742 })?
743 .as_secs();
744
745 let inclusion_proof = self.fetch_inclusion_proof_with_cache(now).await?;
746 let proof = self
747 .inner
748 .prove_credential_sub(
749 nonce.0,
750 context.0,
751 blinding_factor.0,
752 sub.0,
753 Some(inclusion_proof),
754 )
755 .await?;
756
757 Ok(OwnershipProof(proof))
758 }
759 }
760}
761
762#[derive(Debug, Clone, uniffi::Enum)]
764pub enum RegistrationStatus {
765 Queued,
767 Batching,
769 Submitted,
771 Finalized,
773 Failed {
775 error: String,
777 error_code: Option<String>,
779 },
780}
781
782#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
784pub enum GatewayRequestStatus {
785 Queued,
787 Batching,
789 Submitted {
791 tx_hash: String,
793 },
794 Finalized {
796 tx_hash: String,
798 },
799 Failed {
801 error: String,
803 error_code: Option<String>,
805 },
806}
807
808impl From<GatewayRequestState> for GatewayRequestStatus {
809 fn from(state: GatewayRequestState) -> Self {
810 match state {
811 GatewayRequestState::Queued => Self::Queued,
812 GatewayRequestState::Batching => Self::Batching,
813 GatewayRequestState::Submitted { tx_hash } => Self::Submitted { tx_hash },
814 GatewayRequestState::Finalized { tx_hash } => Self::Finalized { tx_hash },
815 GatewayRequestState::Failed { error, error_code } => Self::Failed {
816 error,
817 error_code: error_code.map(|code| code.to_string()),
818 },
819 }
820 }
821}
822
823impl From<GatewayRequestState> for RegistrationStatus {
824 fn from(state: GatewayRequestState) -> Self {
825 match state {
826 GatewayRequestState::Queued => Self::Queued,
827 GatewayRequestState::Batching => Self::Batching,
828 GatewayRequestState::Submitted { .. } => Self::Submitted,
829 GatewayRequestState::Finalized { .. } => Self::Finalized,
830 GatewayRequestState::Failed { error, error_code } => Self::Failed {
831 error,
832 error_code: error_code.map(|c: GatewayErrorCode| c.to_string()),
833 },
834 }
835 }
836}
837
838#[derive(uniffi::Object)]
843pub struct InitializingAuthenticator(CoreInitializingAuthenticator);
844
845#[uniffi::export(async_runtime = "tokio")]
846impl InitializingAuthenticator {
847 #[uniffi::constructor]
855 #[tracing::instrument(
856 target = "walletkit_latency",
857 name = "gateway_register",
858 skip_all
859 )]
860 pub async fn register_with_defaults(
861 seed: Vec<u8>,
862 rpc_url: Option<String>,
863 environment: &Environment,
864 region: Option<Region>,
865 recovery_address: Option<String>,
866 ) -> Result<Self, WalletKitError> {
867 let recovery_address =
868 Address::parse_from_ffi_optional(recovery_address, "recovery_address")?;
869
870 let config = defaults::default_config(environment, rpc_url, region)?;
871
872 let initializing_authenticator =
873 CoreAuthenticator::register(&seed, config, recovery_address).await?;
874
875 Ok(Self(initializing_authenticator))
876 }
877
878 #[uniffi::constructor]
888 #[tracing::instrument(
889 target = "walletkit_latency",
890 name = "gateway_register",
891 skip_all
892 )]
893 pub async fn register_with_ohttp_defaults(
894 seed: Vec<u8>,
895 rpc_url: Option<String>,
896 environment: &Environment,
897 region: Option<Region>,
898 recovery_address: Option<String>,
899 ) -> Result<Self, WalletKitError> {
900 let recovery_address =
901 Address::parse_from_ffi_optional(recovery_address, "recovery_address")?;
902
903 let config = defaults::default_config_with_ohttp(environment, rpc_url, region)?;
904
905 let initializing_authenticator =
906 CoreAuthenticator::register(&seed, config, recovery_address).await?;
907
908 Ok(Self(initializing_authenticator))
909 }
910
911 #[uniffi::constructor]
919 #[tracing::instrument(
920 target = "walletkit_latency",
921 name = "gateway_register",
922 skip_all
923 )]
924 pub async fn register(
925 seed: Vec<u8>,
926 config: &str,
927 recovery_address: Option<String>,
928 ) -> Result<Self, WalletKitError> {
929 let recovery_address =
930 Address::parse_from_ffi_optional(recovery_address, "recovery_address")?;
931
932 let config =
933 Config::from_json(config).map_err(|_| WalletKitError::InvalidInput {
934 attribute: "config".to_string(),
935 reason: "Invalid config".to_string(),
936 })?;
937
938 let initializing_authenticator =
939 CoreAuthenticator::register(&seed, config, recovery_address).await?;
940
941 Ok(Self(initializing_authenticator))
942 }
943
944 #[tracing::instrument(
949 target = "walletkit_latency",
950 name = "gateway_poll",
951 skip_all
952 )]
953 pub async fn poll_status(&self) -> Result<RegistrationStatus, WalletKitError> {
954 let status = self.0.poll_status().await?;
955 Ok(status.into())
956 }
957}
958
959#[derive(Debug, Clone, uniffi::Record)]
965pub struct RecoveryUpdateSignature {
966 pub signature: Vec<u8>,
969 pub nonce: Uint256,
972}
973
974#[derive(Debug, Clone, uniffi::Record)]
982pub struct RecoveryData {
983 pub authenticator_address: String,
985 pub authenticator_pubkey: String,
987 pub offchain_signer_commitment: String,
989}
990
991impl RecoveryData {
992 pub fn from_seed(seed: &[u8]) -> Result<Self, WalletKitError> {
1001 let signer = Signer::from_seed_bytes(seed)?;
1002 let authenticator_address = signer.onchain_signer_address().to_checksum(None);
1003 let authenticator_pubkey: U256 = signer
1004 .offchain_signer_pubkey()
1005 .to_ethereum_representation()?;
1006 let mut key_set = AuthenticatorPublicKeySet::default();
1007 key_set.try_push(signer.offchain_signer_pubkey())?;
1008 let offchain_signer_commitment: U256 = key_set.leaf_hash().into();
1009
1010 Ok(Self {
1011 authenticator_address,
1012 authenticator_pubkey: format!("{authenticator_pubkey:#066x}"),
1013 offchain_signer_commitment: format!("{offchain_signer_commitment:#066x}"),
1014 })
1015 }
1016}
1017
1018#[uniffi::export]
1038pub fn validate_authenticator_pubkey(
1039 authenticator_pubkey: &str,
1040) -> Result<String, WalletKitError> {
1041 let pubkey =
1042 parse_authenticator_pubkey("authenticator_pubkey", authenticator_pubkey)?;
1043 let encoded = pubkey.to_ethereum_representation()?;
1044 Ok(format!("{encoded:#066x}"))
1045}
1046
1047#[uniffi::export]
1054#[allow(
1055 clippy::needless_pass_by_value,
1056 reason = "seed is passed by value so uniffi 0.32 maps it to a `RustBuffer` (Kotlin `ByteArray` / Swift `Data`) rather than the non-`Send` `ForeignBytes` view produced for `&[u8]`"
1057)]
1058pub fn recovery_data_from_seed(seed: Vec<u8>) -> Result<RecoveryData, WalletKitError> {
1059 RecoveryData::from_seed(&seed)
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064 use super::*;
1065
1066 const GET_RECOVERY_COUNTER_SELECTOR: &[u8] = b"3a51ad3d";
1068 const TEST_SEED: [u8; 32] = [1u8; 32];
1069
1070 async fn test_authenticator(
1071 server: &mut mockito::Server,
1072 ) -> (Authenticator, std::path::PathBuf) {
1073 use crate::storage::tests_utils::{temp_root_path, InMemoryStorageProvider};
1074 use alloy::primitives::address;
1075 use world_id_core::primitives::ServiceEndpoint;
1076 use world_id_proof::artifacts::dummy::DummyZkArtifactSource;
1077
1078 let _ = rustls::crypto::ring::default_provider().install_default();
1079
1080 let packed_account_mock = server
1081 .mock("POST", "/packed-account")
1082 .with_status(200)
1083 .with_header("content-type", "application/json")
1084 .with_body(serde_json::json!({ "packed_account_data": "0x2a" }).to_string())
1085 .create_async()
1086 .await;
1087 let config = Config::new(
1088 None,
1089 480,
1090 address!("0x969947cFED008bFb5e3F32a25A1A2CDdf64d46fe"),
1091 ServiceEndpoint::direct(server.url()),
1092 ServiceEndpoint::direct(server.url()),
1093 vec![],
1094 2,
1095 )
1096 .expect("valid config");
1097 let root = temp_root_path();
1098 let provider = InMemoryStorageProvider::new(&root);
1099 let store =
1100 CredentialStore::from_provider(&provider).expect("credential store");
1101 let authenticator = Authenticator::init_with_config(
1102 &TEST_SEED,
1103 config,
1104 Arc::new(DummyZkArtifactSource),
1105 Arc::new(store),
1106 )
1107 .await
1108 .expect("authenticator should initialize");
1109 packed_account_mock.assert_async().await;
1110
1111 (authenticator, root)
1112 }
1113
1114 fn encoded_pubkey(seed: &[u8; 32]) -> String {
1115 let pubkey = Signer::from_seed_bytes(seed)
1116 .expect("valid seed")
1117 .offchain_signer_pubkey()
1118 .to_ethereum_representation()
1119 .expect("public key should encode");
1120 format!("{pubkey:#066x}")
1121 }
1122
1123 async fn mock_authenticator_pubkeys(
1127 server: &mut mockito::Server,
1128 pubkeys: &[Option<&str>],
1129 expected_hits: usize,
1130 ) -> mockito::Mock {
1131 server
1132 .mock("POST", "/authenticator-pubkeys")
1133 .match_body(mockito::Matcher::JsonString(
1134 serde_json::json!({ "leaf_index": "0x2a" }).to_string(),
1135 ))
1136 .with_status(200)
1137 .with_header("content-type", "application/json")
1138 .with_body(
1139 serde_json::json!({
1140 "authenticator_pubkeys": pubkeys,
1141 "offchain_signer_commitment": "0x0"
1142 })
1143 .to_string(),
1144 )
1145 .expect(expected_hits)
1146 .create_async()
1147 .await
1148 }
1149
1150 #[test]
1151 fn test_recovery_data_from_seed() {
1152 let seed = [1u8; 32];
1153 let material = RecoveryData::from_seed(&seed).expect("should derive material");
1154
1155 assert!(material.authenticator_address.starts_with("0x"));
1156 assert_eq!(material.authenticator_address.len(), 42);
1157 assert!(material.authenticator_pubkey.starts_with("0x"));
1158 assert!(material.authenticator_pubkey.len() <= 66);
1159 assert!(material.offchain_signer_commitment.starts_with("0x"));
1160 assert!(material.offchain_signer_commitment.len() <= 66);
1161 assert!(material.authenticator_address.len() > 2);
1162 assert!(material.authenticator_pubkey.len() > 2);
1163 assert!(material.offchain_signer_commitment.len() > 2);
1164 }
1165
1166 #[test]
1167 fn test_recovery_data_rejects_invalid_seed() {
1168 assert!(RecoveryData::from_seed(&[0u8; 16]).is_err());
1169 assert!(RecoveryData::from_seed(&[]).is_err());
1170 }
1171
1172 #[test]
1173 fn test_authenticator_pubkey_validation() {
1174 let canonical = encoded_pubkey(&[2u8; 32]);
1175 assert_eq!(
1176 validate_authenticator_pubkey(&canonical).expect("valid key"),
1177 canonical
1178 );
1179 let uppercase = format!("0x{}", canonical[2..].to_uppercase());
1180 assert_eq!(
1181 validate_authenticator_pubkey(&uppercase)
1182 .expect("uppercase hex should canonicalize"),
1183 canonical
1184 );
1185
1186 for invalid_pubkey in [
1187 "not-a-public-key".to_string(),
1188 format!("0x{}", "ff".repeat(32)),
1189 ] {
1190 assert!(matches!(
1191 validate_authenticator_pubkey(&invalid_pubkey),
1192 Err(WalletKitError::InvalidInput { attribute, .. })
1193 if attribute == "authenticator_pubkey"
1194 ));
1195 }
1196
1197 let identity = format!("0x{}01", "0".repeat(62));
1198 assert!(matches!(
1199 validate_authenticator_pubkey(&identity),
1200 Err(WalletKitError::InvalidInput { attribute, reason })
1201 if attribute == "authenticator_pubkey" && reason.contains("identity")
1202 ));
1203 let sign_bit_alias = format!("0x80{}01", "0".repeat(60));
1204 assert!(matches!(
1205 validate_authenticator_pubkey(&sign_bit_alias),
1206 Err(WalletKitError::InvalidInput { attribute, reason })
1207 if attribute == "authenticator_pubkey" && reason.contains("canonical")
1208 ));
1209 }
1210
1211 #[tokio::test]
1212 async fn test_poll_status_normalizes_request_id() {
1213 use crate::storage::tests_utils::cleanup_test_storage;
1214
1215 let mut server = mockito::Server::new_async().await;
1216 let (authenticator, root) = test_authenticator(&mut server).await;
1217 let status_mock = server
1218 .mock("GET", "/status/gw_poll_test")
1219 .with_status(200)
1220 .with_header("content-type", "application/json")
1221 .with_body(
1222 serde_json::json!({
1223 "request_id": "gw_poll_test",
1224 "kind": "insert_authenticator",
1225 "status": {
1226 "state": "finalized",
1227 "tx_hash": "0x1234"
1228 }
1229 })
1230 .to_string(),
1231 )
1232 .expect(2)
1233 .create_async()
1234 .await;
1235
1236 for request_id in ["poll_test", "gw_poll_test"] {
1237 assert_eq!(
1238 authenticator
1239 .poll_status(request_id.to_string())
1240 .await
1241 .expect("status poll should succeed"),
1242 GatewayRequestStatus::Finalized {
1243 tx_hash: "0x1234".to_string()
1244 }
1245 );
1246 }
1247 status_mock.assert_async().await;
1248
1249 drop(server);
1250 cleanup_test_storage(&root);
1251 }
1252
1253 #[tokio::test]
1254 async fn test_remove_authenticator_refuses_unexpected_slot_contents() {
1255 use crate::storage::tests_utils::cleanup_test_storage;
1256
1257 let mut server = mockito::Server::new_async().await;
1258 let (authenticator, root) = test_authenticator(&mut server).await;
1259 let existing_pubkey = encoded_pubkey(&TEST_SEED);
1260 let slot_pubkey = encoded_pubkey(&[2u8; 32]);
1261
1262 let pubkeys_mock = mock_authenticator_pubkeys(
1263 &mut server,
1264 &[
1265 Some(existing_pubkey.as_str()),
1266 None,
1267 Some(slot_pubkey.as_str()),
1268 ],
1269 2,
1270 )
1271 .await;
1272 let nonce_mock = server
1273 .mock("POST", "/signature-nonce")
1274 .expect(0)
1275 .create_async()
1276 .await;
1277 let remove_mock = server
1278 .mock("POST", "/remove-authenticator")
1279 .expect(0)
1280 .create_async()
1281 .await;
1282
1283 let mismatched = authenticator
1284 .remove_authenticator(
1285 Address::ZERO.to_string(),
1286 2,
1287 encoded_pubkey(&[3u8; 32]),
1288 )
1289 .await;
1290 assert!(matches!(
1291 mismatched,
1292 Err(WalletKitError::InvalidInput { attribute, .. })
1293 if attribute == "expected_authenticator_pubkey"
1294 ));
1295
1296 let empty_slot = authenticator
1297 .remove_authenticator(
1298 Address::ZERO.to_string(),
1299 1,
1300 encoded_pubkey(&[3u8; 32]),
1301 )
1302 .await;
1303 assert!(matches!(
1304 empty_slot,
1305 Err(WalletKitError::InvalidInput { attribute, reason })
1306 if attribute == "pubkey_id"
1307 && reason.contains("no authenticator at key set slot 1")
1308 ));
1309
1310 let out_of_range = authenticator
1311 .remove_authenticator(
1312 Address::ZERO.to_string(),
1313 7,
1314 encoded_pubkey(&[3u8; 32]),
1315 )
1316 .await;
1317 assert!(matches!(
1318 out_of_range,
1319 Err(WalletKitError::InvalidInput { attribute, reason })
1320 if attribute == "pubkey_id" && reason.contains("out of range")
1321 ));
1322
1323 pubkeys_mock.assert_async().await;
1324 nonce_mock.assert_async().await;
1325 remove_mock.assert_async().await;
1326
1327 drop(server);
1328 cleanup_test_storage(&root);
1329 }
1330
1331 #[tokio::test]
1332 async fn test_key_set_reads_return_slots_and_membership() {
1333 use crate::storage::tests_utils::cleanup_test_storage;
1334
1335 let mut server = mockito::Server::new_async().await;
1336 let (authenticator, root) = test_authenticator(&mut server).await;
1337 let existing_pubkey = encoded_pubkey(&TEST_SEED);
1338 let other_pubkey = encoded_pubkey(&[2u8; 32]);
1339
1340 let pubkeys_mock = mock_authenticator_pubkeys(
1341 &mut server,
1342 &[
1343 Some(existing_pubkey.as_str()),
1344 None,
1345 Some(other_pubkey.as_str()),
1346 ],
1347 3,
1348 )
1349 .await;
1350
1351 assert!(authenticator
1352 .has_authenticator_pubkey(existing_pubkey.clone())
1353 .await
1354 .expect("membership read should succeed"));
1355 assert!(!authenticator
1356 .has_authenticator_pubkey(encoded_pubkey(&[3u8; 32]))
1357 .await
1358 .expect("absent key check should succeed"));
1359 assert_eq!(
1360 authenticator
1361 .get_authenticator_pubkeys()
1362 .await
1363 .expect("key set read should succeed"),
1364 vec![Some(existing_pubkey), None, Some(other_pubkey)]
1365 );
1366 pubkeys_mock.assert_async().await;
1367
1368 drop(server);
1369 cleanup_test_storage(&root);
1370 }
1371
1372 #[tokio::test]
1373 async fn test_remove_authenticator_reports_slot_emptied_during_signing() {
1374 use crate::storage::tests_utils::cleanup_test_storage;
1375 use std::sync::atomic::{AtomicUsize, Ordering};
1376
1377 let mut server = mockito::Server::new_async().await;
1378 let (authenticator, root) = test_authenticator(&mut server).await;
1379 let existing_pubkey = encoded_pubkey(&TEST_SEED);
1380 let removed_pubkey = encoded_pubkey(&[2u8; 32]);
1381
1382 let full_body = serde_json::json!({
1388 "authenticator_pubkeys": [existing_pubkey.clone(), removed_pubkey.clone()],
1389 "offchain_signer_commitment": "0x0"
1390 })
1391 .to_string();
1392 let emptied_body = serde_json::json!({
1393 "authenticator_pubkeys": [existing_pubkey],
1394 "offchain_signer_commitment": "0x0"
1395 })
1396 .to_string();
1397 let fetches = Arc::new(AtomicUsize::new(0));
1398 let fetches_in_mock = Arc::clone(&fetches);
1399 let pubkeys_mock = server
1400 .mock("POST", "/authenticator-pubkeys")
1401 .with_status(200)
1402 .with_header("content-type", "application/json")
1403 .with_body_from_request(move |_request| {
1404 if fetches_in_mock.fetch_add(1, Ordering::SeqCst) == 0 {
1405 full_body.clone().into_bytes()
1406 } else {
1407 emptied_body.clone().into_bytes()
1408 }
1409 })
1410 .expect(2)
1411 .create_async()
1412 .await;
1413 let nonce_mock = server
1414 .mock("POST", "/signature-nonce")
1415 .with_status(200)
1416 .with_header("content-type", "application/json")
1417 .with_body(serde_json::json!({ "signature_nonce": "0x1" }).to_string())
1418 .create_async()
1419 .await;
1420 let remove_mock = server
1421 .mock("POST", "/remove-authenticator")
1422 .expect(0)
1423 .create_async()
1424 .await;
1425
1426 let raced = authenticator
1427 .remove_authenticator(Address::ZERO.to_string(), 1, removed_pubkey)
1428 .await;
1429 assert!(matches!(
1430 raced,
1431 Err(WalletKitError::InvalidInput { attribute, .. })
1432 if attribute == "pubkey_id"
1433 ));
1434
1435 pubkeys_mock.assert_async().await;
1436 nonce_mock.assert_async().await;
1437 remove_mock.assert_async().await;
1438
1439 drop(server);
1440 cleanup_test_storage(&root);
1441 }
1442
1443 #[cfg(feature = "embed-zkeys")]
1444 #[tokio::test]
1445 async fn test_init_with_config_and_materials() {
1446 use crate::{
1447 authenticator::artifacts::caching::CachingZkArtifacts,
1448 storage::tests_utils::{
1449 cleanup_test_storage, temp_root_path, InMemoryStorageProvider,
1450 },
1451 };
1452 use alloy::primitives::address;
1453 use world_id_core::primitives::{Config, ServiceEndpoint};
1454
1455 let _ = rustls::crypto::ring::default_provider().install_default();
1456
1457 let mut mock_server = mockito::Server::new_async().await;
1458 mock_server
1459 .mock("POST", "/")
1460 .with_status(200)
1461 .with_header("content-type", "application/json")
1462 .with_body_from_request(|request| {
1463 let result = if request
1464 .body()
1465 .expect("request body")
1466 .windows(GET_RECOVERY_COUNTER_SELECTOR.len())
1467 .any(|window| window == GET_RECOVERY_COUNTER_SELECTOR)
1468 {
1469 "0x0000000000000000000000000000000000000000000000000000000000000000"
1470 } else {
1471 "0x0000000000000000000000000000000000000000000000000000000000000001"
1472 };
1473 serde_json::json!({
1474 "jsonrpc": "2.0",
1475 "id": 1,
1476 "result": result
1477 })
1478 .to_string()
1479 .into_bytes()
1480 })
1481 .create_async()
1482 .await;
1483
1484 let config = Config::new(
1485 Some(mock_server.url()),
1486 480,
1487 address!("0x969947cFED008bFb5e3F32a25A1A2CDdf64d46fe"),
1488 ServiceEndpoint::direct(
1489 "https://indexer.us.id-infra.worldcoin.dev".to_string(),
1490 ),
1491 ServiceEndpoint::direct(
1492 "https://gateway.id-infra.worldcoin.dev".to_string(),
1493 ),
1494 vec![],
1495 2,
1496 )
1497 .unwrap();
1498 let config = serde_json::to_string(&config).unwrap();
1499
1500 let root = temp_root_path();
1501 let provider = InMemoryStorageProvider::new(&root);
1502 let store = CredentialStore::from_provider(&provider).expect("store");
1503 store.init(42, 100).expect("init storage");
1504
1505 let artifacts =
1506 Arc::new(CachingZkArtifacts::new(Arc::new(store.paths().unwrap())));
1507
1508 let _authenticator = Authenticator::init(
1509 [2u8; 32].to_vec(),
1510 &config,
1511 artifacts,
1512 Arc::new(store),
1513 )
1514 .await
1515 .unwrap();
1516 drop(mock_server);
1517
1518 cleanup_test_storage(&root);
1519 }
1520}