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 pub fn danger_sign_challenge(
191 &self,
192 challenge: &[u8],
193 ) -> Result<Vec<u8>, WalletKitError> {
194 let signature = self.inner.danger_sign_challenge(challenge)?;
195 Ok(signature.as_bytes().to_vec())
196 }
197
198 pub async fn danger_sign_initiate_recovery_agent_update(
222 &self,
223 new_recovery_agent: String,
224 ) -> Result<RecoveryUpdateSignature, WalletKitError> {
225 let new_recovery_agent =
226 Address::parse_from_ffi(&new_recovery_agent, "new_recovery_agent")?;
227 let (sig, nonce) = self
228 .inner
229 .danger_sign_initiate_recovery_agent_update(new_recovery_agent)
230 .await?;
231 Ok(RecoveryUpdateSignature {
232 signature: sig.as_bytes().to_vec(),
233 nonce: nonce.into(),
234 })
235 }
236
237 pub async fn update_recovery_agent(
255 &self,
256 new_recovery_agent: String,
257 ) -> Result<String, WalletKitError> {
258 let new_recovery_agent =
259 Address::parse_from_ffi(&new_recovery_agent, "new_recovery_agent")?;
260
261 let request_id = self.inner.update_recovery_agent(new_recovery_agent).await?;
262
263 Ok(request_id.to_string())
264 }
265
266 pub async fn revert_recovery_agent_update(&self) -> Result<String, WalletKitError> {
280 let request_id = self.inner.revert_recovery_agent_update().await?;
281
282 Ok(request_id.to_string())
283 }
284
285 #[tracing::instrument(
299 target = "walletkit_latency",
300 name = "gateway_insert_authenticator",
301 skip_all
302 )]
303 pub async fn insert_authenticator(
304 &self,
305 new_authenticator_pubkey: String,
306 new_authenticator_address: String,
307 ) -> Result<String, WalletKitError> {
308 let new_authenticator_pubkey = parse_authenticator_pubkey(
309 "new_authenticator_pubkey",
310 new_authenticator_pubkey,
311 )?;
312 let new_authenticator_address = Address::parse_from_ffi(
313 &new_authenticator_address,
314 "new_authenticator_address",
315 )?;
316
317 let request_id = self
318 .inner
319 .insert_authenticator(new_authenticator_pubkey, new_authenticator_address)
320 .await?;
321
322 Ok(request_id.to_string())
323 }
324
325 #[tracing::instrument(
339 target = "walletkit_latency",
340 name = "indexer_authenticator_pubkeys",
341 skip_all
342 )]
343 pub async fn has_authenticator_pubkey(
344 &self,
345 authenticator_pubkey: String,
346 ) -> Result<bool, WalletKitError> {
347 let authenticator_pubkey =
348 parse_authenticator_pubkey("authenticator_pubkey", authenticator_pubkey)?;
349 let pubkeys = self.inner.fetch_authenticator_pubkeys().await?;
350 Ok(pubkeys
351 .iter()
352 .flatten()
353 .any(|existing_pubkey| existing_pubkey == &authenticator_pubkey))
354 }
355
356 #[tracing::instrument(
370 target = "walletkit_latency",
371 name = "indexer_authenticator_pubkeys",
372 skip_all
373 )]
374 pub async fn get_authenticator_pubkeys(
375 &self,
376 ) -> Result<Vec<Option<String>>, WalletKitError> {
377 let key_set = self.inner.fetch_authenticator_pubkeys().await?;
378 key_set
379 .iter()
380 .map(|slot| {
381 slot.as_ref()
382 .map(|pubkey| {
383 let encoded = pubkey.to_ethereum_representation()?;
384 Ok(format!("{encoded:#066x}"))
385 })
386 .transpose()
387 })
388 .collect()
389 }
390
391 #[tracing::instrument(
415 target = "walletkit_latency",
416 name = "gateway_remove_authenticator",
417 skip_all
418 )]
419 pub async fn remove_authenticator(
420 &self,
421 authenticator_address: String,
422 pubkey_id: u32,
423 expected_authenticator_pubkey: String,
424 ) -> Result<String, WalletKitError> {
425 let expected_pubkey = parse_authenticator_pubkey(
426 "expected_authenticator_pubkey",
427 expected_authenticator_pubkey,
428 )?;
429 let authenticator_address =
430 Address::parse_from_ffi(&authenticator_address, "authenticator_address")?;
431
432 if pubkey_id as usize >= MAX_AUTHENTICATOR_KEYS {
433 return Err(WalletKitError::InvalidInput {
434 attribute: "pubkey_id".to_string(),
435 reason: format!(
436 "pubkey_id {pubkey_id} is out of range; the key set has at \
437 most {MAX_AUTHENTICATOR_KEYS} slots"
438 ),
439 });
440 }
441
442 let empty_slot = || WalletKitError::InvalidInput {
443 attribute: "pubkey_id".to_string(),
444 reason: format!("no authenticator at key set slot {pubkey_id}"),
445 };
446 let key_set = self.inner.fetch_authenticator_pubkeys().await?;
447 let actual_pubkey = key_set.get(pubkey_id as usize).ok_or_else(empty_slot)?;
448 if actual_pubkey != &expected_pubkey {
449 return Err(WalletKitError::InvalidInput {
450 attribute: "expected_authenticator_pubkey".to_string(),
451 reason: format!(
452 "key set slot {pubkey_id} holds a different authenticator public key"
453 ),
454 });
455 }
456
457 let request_id = self
458 .inner
459 .remove_authenticator(authenticator_address, pubkey_id)
460 .await
461 .map_err(|error| match error {
462 AuthenticatorError::PublicKeyNotFound => empty_slot(),
466 other => other.into(),
467 })?;
468
469 Ok(request_id.to_string())
470 }
471
472 #[tracing::instrument(
477 target = "walletkit_latency",
478 name = "gateway_poll",
479 skip_all
480 )]
481 pub async fn poll_status(
482 &self,
483 request_id: String,
484 ) -> Result<GatewayRequestStatus, WalletKitError> {
485 let request_id = GatewayRequestId::new(
486 request_id.strip_prefix("gw_").unwrap_or(&request_id),
487 );
488 let status = self.inner.poll_status(&request_id).await?;
489 Ok(status.into())
490 }
491}
492
493#[uniffi::export(async_runtime = "tokio")]
494impl Authenticator {
495 #[uniffi::constructor]
503 #[tracing::instrument(target = "walletkit_latency", name = "rpc_init", skip_all)]
504 pub async fn init_with_defaults(
505 seed: &[u8],
506 rpc_url: Option<String>,
507 environment: &Environment,
508 region: Option<Region>,
509 artifacts: Arc<dyn WalletKitZkArtifactSource>,
510 store: Arc<CredentialStore>,
511 ) -> Result<Self, WalletKitError> {
512 let config = defaults::default_config(environment, rpc_url, region)?;
513 Self::init_with_config(seed, config, artifacts, store).await
514 }
515
516 #[uniffi::constructor]
526 #[tracing::instrument(target = "walletkit_latency", name = "rpc_init", skip_all)]
527 pub async fn init_with_ohttp_defaults(
528 seed: &[u8],
529 rpc_url: Option<String>,
530 environment: &Environment,
531 region: Option<Region>,
532 artifacts: Arc<dyn WalletKitZkArtifactSource>,
533 store: Arc<CredentialStore>,
534 ) -> Result<Self, WalletKitError> {
535 let config = defaults::default_config_with_ohttp(environment, rpc_url, region)?;
536 Self::init_with_config(seed, config, artifacts, store).await
537 }
538
539 #[uniffi::constructor]
547 #[tracing::instrument(target = "walletkit_latency", name = "rpc_init", skip_all)]
548 pub async fn init(
549 seed: &[u8],
550 config: &str,
551 artifacts: Arc<dyn WalletKitZkArtifactSource>,
552 store: Arc<CredentialStore>,
553 ) -> Result<Self, WalletKitError> {
554 let config =
555 Config::from_json(config).map_err(|_| WalletKitError::InvalidInput {
556 attribute: "config".to_string(),
557 reason: "Invalid config".to_string(),
558 })?;
559 Self::init_with_config(seed, config, artifacts, store).await
560 }
561
562 pub async fn generate_proof(
567 &self,
568 proof_request: &ProofRequest,
569 now: Option<u64>,
570 ) -> Result<ProofResponse, WalletKitError> {
571 let now = if let Some(n) = now {
572 n
573 } else {
574 #[cfg(target_arch = "wasm32")]
575 {
576 return Err(WalletKitError::InvalidInput {
577 attribute: "now".to_string(),
578 reason: "`now` must be provided on wasm32 targets".to_string(),
579 });
580 }
581
582 #[cfg(not(target_arch = "wasm32"))]
583 {
584 let start = std::time::SystemTime::now();
585 start
586 .duration_since(std::time::UNIX_EPOCH)
587 .map_err(|e| WalletKitError::Generic {
588 error: format!("Critical. Unable to determine SystemTime: {e}"),
589 })?
590 .as_secs()
591 }
592 };
593
594 let credentials: Vec<_> = self
599 .store
600 .list_credentials(None, now)?
601 .iter()
602 .filter(|c| !c.is_expired)
603 .filter_map(|cred| {
604 if let Ok(Some((credential, blinding_factor))) =
605 self.store.get_credential(cred.issuer_schema_id, now)
606 {
607 Some(CredentialInput {
608 credential: credential.into(),
609 blinding_factor: blinding_factor.into(),
610 })
611 } else {
612 tracing::warn!(
613 issuer_schema_id = %cred.issuer_schema_id,
614 credential_id = %cred.credential_id,
615 "credential listed but not loadable, skipping"
616 );
617 None
618 }
619 })
620 .collect();
621
622 let account_inclusion_proof =
623 self.fetch_inclusion_proof_with_cache(now).await?;
624
625 let nullifier = Box::pin(self.inner.generate_nullifier(
628 &proof_request.0,
629 Some(account_inclusion_proof.clone()),
630 ))
631 .await?;
632
633 if self
634 .store
635 .is_nullifier_replay(nullifier.verifiable_oprf_output.output.into(), now)?
636 {
637 return Err(WalletKitError::NullifierReplay);
638 }
639
640 let session_id_r_seed =
642 proof_request
643 .0
644 .session_id
645 .and_then(|session_id| {
646 match self.store.get_session_seed(session_id.oprf_seed, now) {
647 Ok(seed) => seed,
648 Err(err) => {
649 tracing::warn!(error = %err, "failed to load cached session seed, continuing without");
650 None
651 }
652 }
653 });
654
655 let result = Box::pin(self.inner.generate_proof(
657 &proof_request.0,
658 nullifier.clone(),
659 &credentials,
660 Some(account_inclusion_proof),
661 session_id_r_seed,
662 ))
663 .await?;
664
665 if let Some(seed) = result.session_id_r_seed {
668 if let Some(session_id) = result.proof_response.session_id {
669 if let Err(err) =
670 self.store
671 .store_session_seed(session_id.oprf_seed, seed, now)
672 {
673 tracing::error!("error caching session_id_r_seed: {}", err);
674 }
675 }
676 }
677
678 self.store
679 .replay_guard_set(nullifier.verifiable_oprf_output.output.into(), now)?;
680
681 Ok(result.proof_response.into())
682 }
683
684 pub async fn prove_credential_sub(
709 &self,
710 nonce: &FieldElement,
711 blinding_factor: &FieldElement,
712 sub: &FieldElement,
713 ) -> Result<OwnershipProof, WalletKitError> {
714 #[cfg(target_arch = "wasm32")]
715 {
716 let _ = (nonce, blinding_factor, sub);
717 return Err(WalletKitError::Generic {
718 error: "credential ownership proofs are not supported on wasm32"
719 .to_string(),
720 });
721 }
722
723 #[cfg(not(target_arch = "wasm32"))]
724 {
725 let now = std::time::SystemTime::now()
726 .duration_since(std::time::UNIX_EPOCH)
727 .map_err(|e| WalletKitError::Generic {
728 error: format!("Critical. Unable to determine SystemTime: {e}"),
729 })?
730 .as_secs();
731
732 let inclusion_proof = self.fetch_inclusion_proof_with_cache(now).await?;
733 let proof = self
734 .inner
735 .prove_credential_sub(
736 nonce.0,
737 blinding_factor.0,
738 sub.0,
739 Some(inclusion_proof),
740 )
741 .await?;
742
743 Ok(OwnershipProof(proof))
744 }
745 }
746}
747
748#[derive(Debug, Clone, uniffi::Enum)]
750pub enum RegistrationStatus {
751 Queued,
753 Batching,
755 Submitted,
757 Finalized,
759 Failed {
761 error: String,
763 error_code: Option<String>,
765 },
766}
767
768#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
770pub enum GatewayRequestStatus {
771 Queued,
773 Batching,
775 Submitted {
777 tx_hash: String,
779 },
780 Finalized {
782 tx_hash: String,
784 },
785 Failed {
787 error: String,
789 error_code: Option<String>,
791 },
792}
793
794impl From<GatewayRequestState> for GatewayRequestStatus {
795 fn from(state: GatewayRequestState) -> Self {
796 match state {
797 GatewayRequestState::Queued => Self::Queued,
798 GatewayRequestState::Batching => Self::Batching,
799 GatewayRequestState::Submitted { tx_hash } => Self::Submitted { tx_hash },
800 GatewayRequestState::Finalized { tx_hash } => Self::Finalized { tx_hash },
801 GatewayRequestState::Failed { error, error_code } => Self::Failed {
802 error,
803 error_code: error_code.map(|code| code.to_string()),
804 },
805 }
806 }
807}
808
809impl From<GatewayRequestState> for RegistrationStatus {
810 fn from(state: GatewayRequestState) -> Self {
811 match state {
812 GatewayRequestState::Queued => Self::Queued,
813 GatewayRequestState::Batching => Self::Batching,
814 GatewayRequestState::Submitted { .. } => Self::Submitted,
815 GatewayRequestState::Finalized { .. } => Self::Finalized,
816 GatewayRequestState::Failed { error, error_code } => Self::Failed {
817 error,
818 error_code: error_code.map(|c: GatewayErrorCode| c.to_string()),
819 },
820 }
821 }
822}
823
824#[derive(uniffi::Object)]
829pub struct InitializingAuthenticator(CoreInitializingAuthenticator);
830
831#[uniffi::export(async_runtime = "tokio")]
832impl InitializingAuthenticator {
833 #[uniffi::constructor]
841 #[tracing::instrument(
842 target = "walletkit_latency",
843 name = "gateway_register",
844 skip_all
845 )]
846 pub async fn register_with_defaults(
847 seed: &[u8],
848 rpc_url: Option<String>,
849 environment: &Environment,
850 region: Option<Region>,
851 recovery_address: Option<String>,
852 ) -> Result<Self, WalletKitError> {
853 let recovery_address =
854 Address::parse_from_ffi_optional(recovery_address, "recovery_address")?;
855
856 let config = defaults::default_config(environment, rpc_url, region)?;
857
858 let initializing_authenticator =
859 CoreAuthenticator::register(seed, config, recovery_address).await?;
860
861 Ok(Self(initializing_authenticator))
862 }
863
864 #[uniffi::constructor]
874 #[tracing::instrument(
875 target = "walletkit_latency",
876 name = "gateway_register",
877 skip_all
878 )]
879 pub async fn register_with_ohttp_defaults(
880 seed: &[u8],
881 rpc_url: Option<String>,
882 environment: &Environment,
883 region: Option<Region>,
884 recovery_address: Option<String>,
885 ) -> Result<Self, WalletKitError> {
886 let recovery_address =
887 Address::parse_from_ffi_optional(recovery_address, "recovery_address")?;
888
889 let config = defaults::default_config_with_ohttp(environment, rpc_url, region)?;
890
891 let initializing_authenticator =
892 CoreAuthenticator::register(seed, config, recovery_address).await?;
893
894 Ok(Self(initializing_authenticator))
895 }
896
897 #[uniffi::constructor]
905 #[tracing::instrument(
906 target = "walletkit_latency",
907 name = "gateway_register",
908 skip_all
909 )]
910 pub async fn register(
911 seed: &[u8],
912 config: &str,
913 recovery_address: Option<String>,
914 ) -> Result<Self, WalletKitError> {
915 let recovery_address =
916 Address::parse_from_ffi_optional(recovery_address, "recovery_address")?;
917
918 let config =
919 Config::from_json(config).map_err(|_| WalletKitError::InvalidInput {
920 attribute: "config".to_string(),
921 reason: "Invalid config".to_string(),
922 })?;
923
924 let initializing_authenticator =
925 CoreAuthenticator::register(seed, config, recovery_address).await?;
926
927 Ok(Self(initializing_authenticator))
928 }
929
930 #[tracing::instrument(
935 target = "walletkit_latency",
936 name = "gateway_poll",
937 skip_all
938 )]
939 pub async fn poll_status(&self) -> Result<RegistrationStatus, WalletKitError> {
940 let status = self.0.poll_status().await?;
941 Ok(status.into())
942 }
943}
944
945#[derive(Debug, Clone, uniffi::Record)]
951pub struct RecoveryUpdateSignature {
952 pub signature: Vec<u8>,
955 pub nonce: Uint256,
958}
959
960#[derive(Debug, Clone, uniffi::Record)]
968pub struct RecoveryData {
969 pub authenticator_address: String,
971 pub authenticator_pubkey: String,
973 pub offchain_signer_commitment: String,
975}
976
977impl RecoveryData {
978 pub fn from_seed(seed: &[u8]) -> Result<Self, WalletKitError> {
987 let signer = Signer::from_seed_bytes(seed)?;
988 let authenticator_address = signer.onchain_signer_address().to_checksum(None);
989 let authenticator_pubkey: U256 = signer
990 .offchain_signer_pubkey()
991 .to_ethereum_representation()?;
992 let mut key_set = AuthenticatorPublicKeySet::default();
993 key_set.try_push(signer.offchain_signer_pubkey())?;
994 let offchain_signer_commitment: U256 = key_set.leaf_hash().into();
995
996 Ok(Self {
997 authenticator_address,
998 authenticator_pubkey: format!("{authenticator_pubkey:#066x}"),
999 offchain_signer_commitment: format!("{offchain_signer_commitment:#066x}"),
1000 })
1001 }
1002}
1003
1004#[uniffi::export]
1024pub fn validate_authenticator_pubkey(
1025 authenticator_pubkey: &str,
1026) -> Result<String, WalletKitError> {
1027 let pubkey =
1028 parse_authenticator_pubkey("authenticator_pubkey", authenticator_pubkey)?;
1029 let encoded = pubkey.to_ethereum_representation()?;
1030 Ok(format!("{encoded:#066x}"))
1031}
1032
1033#[uniffi::export]
1040pub fn recovery_data_from_seed(seed: &[u8]) -> Result<RecoveryData, WalletKitError> {
1041 RecoveryData::from_seed(seed)
1042}
1043
1044#[cfg(test)]
1045mod tests {
1046 use super::*;
1047
1048 const TEST_SEED: [u8; 32] = [1u8; 32];
1049
1050 async fn test_authenticator(
1051 server: &mut mockito::Server,
1052 ) -> (Authenticator, std::path::PathBuf) {
1053 use crate::storage::tests_utils::{temp_root_path, InMemoryStorageProvider};
1054 use alloy::primitives::address;
1055 use world_id_core::primitives::ServiceEndpoint;
1056 use world_id_proof::artifacts::dummy::DummyZkArtifactSource;
1057
1058 let _ = rustls::crypto::ring::default_provider().install_default();
1059
1060 let packed_account_mock = server
1061 .mock("POST", "/packed-account")
1062 .with_status(200)
1063 .with_header("content-type", "application/json")
1064 .with_body(serde_json::json!({ "packed_account_data": "0x2a" }).to_string())
1065 .create_async()
1066 .await;
1067 let config = Config::new(
1068 None,
1069 480,
1070 address!("0x969947cFED008bFb5e3F32a25A1A2CDdf64d46fe"),
1071 ServiceEndpoint::direct(server.url()),
1072 ServiceEndpoint::direct(server.url()),
1073 vec![],
1074 2,
1075 )
1076 .expect("valid config");
1077 let root = temp_root_path();
1078 let provider = InMemoryStorageProvider::new(&root);
1079 let store =
1080 CredentialStore::from_provider(&provider).expect("credential store");
1081 let authenticator = Authenticator::init_with_config(
1082 &TEST_SEED,
1083 config,
1084 Arc::new(DummyZkArtifactSource),
1085 Arc::new(store),
1086 )
1087 .await
1088 .expect("authenticator should initialize");
1089 packed_account_mock.assert_async().await;
1090
1091 (authenticator, root)
1092 }
1093
1094 fn encoded_pubkey(seed: &[u8; 32]) -> String {
1095 let pubkey = Signer::from_seed_bytes(seed)
1096 .expect("valid seed")
1097 .offchain_signer_pubkey()
1098 .to_ethereum_representation()
1099 .expect("public key should encode");
1100 format!("{pubkey:#066x}")
1101 }
1102
1103 async fn mock_authenticator_pubkeys(
1107 server: &mut mockito::Server,
1108 pubkeys: &[Option<&str>],
1109 expected_hits: usize,
1110 ) -> mockito::Mock {
1111 server
1112 .mock("POST", "/authenticator-pubkeys")
1113 .match_body(mockito::Matcher::JsonString(
1114 serde_json::json!({ "leaf_index": "0x2a" }).to_string(),
1115 ))
1116 .with_status(200)
1117 .with_header("content-type", "application/json")
1118 .with_body(
1119 serde_json::json!({
1120 "authenticator_pubkeys": pubkeys,
1121 "offchain_signer_commitment": "0x0"
1122 })
1123 .to_string(),
1124 )
1125 .expect(expected_hits)
1126 .create_async()
1127 .await
1128 }
1129
1130 #[test]
1131 fn test_recovery_data_from_seed() {
1132 let seed = [1u8; 32];
1133 let material = RecoveryData::from_seed(&seed).expect("should derive material");
1134
1135 assert!(material.authenticator_address.starts_with("0x"));
1136 assert_eq!(material.authenticator_address.len(), 42);
1137 assert!(material.authenticator_pubkey.starts_with("0x"));
1138 assert!(material.authenticator_pubkey.len() <= 66);
1139 assert!(material.offchain_signer_commitment.starts_with("0x"));
1140 assert!(material.offchain_signer_commitment.len() <= 66);
1141 assert!(material.authenticator_address.len() > 2);
1142 assert!(material.authenticator_pubkey.len() > 2);
1143 assert!(material.offchain_signer_commitment.len() > 2);
1144 }
1145
1146 #[test]
1147 fn test_recovery_data_rejects_invalid_seed() {
1148 assert!(RecoveryData::from_seed(&[0u8; 16]).is_err());
1149 assert!(RecoveryData::from_seed(&[]).is_err());
1150 }
1151
1152 #[test]
1153 fn test_authenticator_pubkey_validation() {
1154 let canonical = encoded_pubkey(&[2u8; 32]);
1155 assert_eq!(
1156 validate_authenticator_pubkey(&canonical).expect("valid key"),
1157 canonical
1158 );
1159 let uppercase = format!("0x{}", canonical[2..].to_uppercase());
1160 assert_eq!(
1161 validate_authenticator_pubkey(&uppercase)
1162 .expect("uppercase hex should canonicalize"),
1163 canonical
1164 );
1165
1166 for invalid_pubkey in [
1167 "not-a-public-key".to_string(),
1168 format!("0x{}", "ff".repeat(32)),
1169 ] {
1170 assert!(matches!(
1171 validate_authenticator_pubkey(&invalid_pubkey),
1172 Err(WalletKitError::InvalidInput { attribute, .. })
1173 if attribute == "authenticator_pubkey"
1174 ));
1175 }
1176
1177 let identity = format!("0x{}01", "0".repeat(62));
1178 assert!(matches!(
1179 validate_authenticator_pubkey(&identity),
1180 Err(WalletKitError::InvalidInput { attribute, reason })
1181 if attribute == "authenticator_pubkey" && reason.contains("identity")
1182 ));
1183 let sign_bit_alias = format!("0x80{}01", "0".repeat(60));
1184 assert!(matches!(
1185 validate_authenticator_pubkey(&sign_bit_alias),
1186 Err(WalletKitError::InvalidInput { attribute, reason })
1187 if attribute == "authenticator_pubkey" && reason.contains("canonical")
1188 ));
1189 }
1190
1191 #[tokio::test]
1192 async fn test_poll_status_normalizes_request_id() {
1193 use crate::storage::tests_utils::cleanup_test_storage;
1194
1195 let mut server = mockito::Server::new_async().await;
1196 let (authenticator, root) = test_authenticator(&mut server).await;
1197 let status_mock = server
1198 .mock("GET", "/status/gw_poll_test")
1199 .with_status(200)
1200 .with_header("content-type", "application/json")
1201 .with_body(
1202 serde_json::json!({
1203 "request_id": "gw_poll_test",
1204 "kind": "insert_authenticator",
1205 "status": {
1206 "state": "finalized",
1207 "tx_hash": "0x1234"
1208 }
1209 })
1210 .to_string(),
1211 )
1212 .expect(2)
1213 .create_async()
1214 .await;
1215
1216 for request_id in ["poll_test", "gw_poll_test"] {
1217 assert_eq!(
1218 authenticator
1219 .poll_status(request_id.to_string())
1220 .await
1221 .expect("status poll should succeed"),
1222 GatewayRequestStatus::Finalized {
1223 tx_hash: "0x1234".to_string()
1224 }
1225 );
1226 }
1227 status_mock.assert_async().await;
1228
1229 drop(server);
1230 cleanup_test_storage(&root);
1231 }
1232
1233 #[tokio::test]
1234 async fn test_remove_authenticator_refuses_unexpected_slot_contents() {
1235 use crate::storage::tests_utils::cleanup_test_storage;
1236
1237 let mut server = mockito::Server::new_async().await;
1238 let (authenticator, root) = test_authenticator(&mut server).await;
1239 let existing_pubkey = encoded_pubkey(&TEST_SEED);
1240 let slot_pubkey = encoded_pubkey(&[2u8; 32]);
1241
1242 let pubkeys_mock = mock_authenticator_pubkeys(
1243 &mut server,
1244 &[
1245 Some(existing_pubkey.as_str()),
1246 None,
1247 Some(slot_pubkey.as_str()),
1248 ],
1249 2,
1250 )
1251 .await;
1252 let nonce_mock = server
1253 .mock("POST", "/signature-nonce")
1254 .expect(0)
1255 .create_async()
1256 .await;
1257 let remove_mock = server
1258 .mock("POST", "/remove-authenticator")
1259 .expect(0)
1260 .create_async()
1261 .await;
1262
1263 let mismatched = authenticator
1264 .remove_authenticator(
1265 Address::ZERO.to_string(),
1266 2,
1267 encoded_pubkey(&[3u8; 32]),
1268 )
1269 .await;
1270 assert!(matches!(
1271 mismatched,
1272 Err(WalletKitError::InvalidInput { attribute, .. })
1273 if attribute == "expected_authenticator_pubkey"
1274 ));
1275
1276 let empty_slot = authenticator
1277 .remove_authenticator(
1278 Address::ZERO.to_string(),
1279 1,
1280 encoded_pubkey(&[3u8; 32]),
1281 )
1282 .await;
1283 assert!(matches!(
1284 empty_slot,
1285 Err(WalletKitError::InvalidInput { attribute, reason })
1286 if attribute == "pubkey_id"
1287 && reason.contains("no authenticator at key set slot 1")
1288 ));
1289
1290 let out_of_range = authenticator
1291 .remove_authenticator(
1292 Address::ZERO.to_string(),
1293 7,
1294 encoded_pubkey(&[3u8; 32]),
1295 )
1296 .await;
1297 assert!(matches!(
1298 out_of_range,
1299 Err(WalletKitError::InvalidInput { attribute, reason })
1300 if attribute == "pubkey_id" && reason.contains("out of range")
1301 ));
1302
1303 pubkeys_mock.assert_async().await;
1304 nonce_mock.assert_async().await;
1305 remove_mock.assert_async().await;
1306
1307 drop(server);
1308 cleanup_test_storage(&root);
1309 }
1310
1311 #[tokio::test]
1312 async fn test_key_set_reads_return_slots_and_membership() {
1313 use crate::storage::tests_utils::cleanup_test_storage;
1314
1315 let mut server = mockito::Server::new_async().await;
1316 let (authenticator, root) = test_authenticator(&mut server).await;
1317 let existing_pubkey = encoded_pubkey(&TEST_SEED);
1318 let other_pubkey = encoded_pubkey(&[2u8; 32]);
1319
1320 let pubkeys_mock = mock_authenticator_pubkeys(
1321 &mut server,
1322 &[
1323 Some(existing_pubkey.as_str()),
1324 None,
1325 Some(other_pubkey.as_str()),
1326 ],
1327 3,
1328 )
1329 .await;
1330
1331 assert!(authenticator
1332 .has_authenticator_pubkey(existing_pubkey.clone())
1333 .await
1334 .expect("membership read should succeed"));
1335 assert!(!authenticator
1336 .has_authenticator_pubkey(encoded_pubkey(&[3u8; 32]))
1337 .await
1338 .expect("absent key check should succeed"));
1339 assert_eq!(
1340 authenticator
1341 .get_authenticator_pubkeys()
1342 .await
1343 .expect("key set read should succeed"),
1344 vec![Some(existing_pubkey), None, Some(other_pubkey)]
1345 );
1346 pubkeys_mock.assert_async().await;
1347
1348 drop(server);
1349 cleanup_test_storage(&root);
1350 }
1351
1352 #[tokio::test]
1353 async fn test_remove_authenticator_reports_slot_emptied_during_signing() {
1354 use crate::storage::tests_utils::cleanup_test_storage;
1355 use std::sync::atomic::{AtomicUsize, Ordering};
1356
1357 let mut server = mockito::Server::new_async().await;
1358 let (authenticator, root) = test_authenticator(&mut server).await;
1359 let existing_pubkey = encoded_pubkey(&TEST_SEED);
1360 let removed_pubkey = encoded_pubkey(&[2u8; 32]);
1361
1362 let full_body = serde_json::json!({
1368 "authenticator_pubkeys": [existing_pubkey.clone(), removed_pubkey.clone()],
1369 "offchain_signer_commitment": "0x0"
1370 })
1371 .to_string();
1372 let emptied_body = serde_json::json!({
1373 "authenticator_pubkeys": [existing_pubkey],
1374 "offchain_signer_commitment": "0x0"
1375 })
1376 .to_string();
1377 let fetches = Arc::new(AtomicUsize::new(0));
1378 let fetches_in_mock = Arc::clone(&fetches);
1379 let pubkeys_mock = server
1380 .mock("POST", "/authenticator-pubkeys")
1381 .with_status(200)
1382 .with_header("content-type", "application/json")
1383 .with_body_from_request(move |_request| {
1384 if fetches_in_mock.fetch_add(1, Ordering::SeqCst) == 0 {
1385 full_body.clone().into_bytes()
1386 } else {
1387 emptied_body.clone().into_bytes()
1388 }
1389 })
1390 .expect(2)
1391 .create_async()
1392 .await;
1393 let nonce_mock = server
1394 .mock("POST", "/signature-nonce")
1395 .with_status(200)
1396 .with_header("content-type", "application/json")
1397 .with_body(serde_json::json!({ "signature_nonce": "0x1" }).to_string())
1398 .create_async()
1399 .await;
1400 let remove_mock = server
1401 .mock("POST", "/remove-authenticator")
1402 .expect(0)
1403 .create_async()
1404 .await;
1405
1406 let raced = authenticator
1407 .remove_authenticator(Address::ZERO.to_string(), 1, removed_pubkey)
1408 .await;
1409 assert!(matches!(
1410 raced,
1411 Err(WalletKitError::InvalidInput { attribute, .. })
1412 if attribute == "pubkey_id"
1413 ));
1414
1415 pubkeys_mock.assert_async().await;
1416 nonce_mock.assert_async().await;
1417 remove_mock.assert_async().await;
1418
1419 drop(server);
1420 cleanup_test_storage(&root);
1421 }
1422
1423 #[cfg(feature = "embed-zkeys")]
1424 #[tokio::test]
1425 async fn test_init_with_config_and_materials() {
1426 use crate::{
1427 authenticator::artifacts::caching::CachingZkArtifacts,
1428 storage::tests_utils::{
1429 cleanup_test_storage, temp_root_path, InMemoryStorageProvider,
1430 },
1431 };
1432 use alloy::primitives::address;
1433 use world_id_core::primitives::{Config, ServiceEndpoint};
1434
1435 let _ = rustls::crypto::ring::default_provider().install_default();
1436
1437 let mut mock_server = mockito::Server::new_async().await;
1438 mock_server
1439 .mock("POST", "/")
1440 .with_status(200)
1441 .with_header("content-type", "application/json")
1442 .with_body(
1443 serde_json::json!({
1444 "jsonrpc": "2.0",
1445 "id": 1,
1446 "result": "0x0000000000000000000000000000000000000000000000000000000000000001"
1447 })
1448 .to_string(),
1449 )
1450 .create_async()
1451 .await;
1452
1453 let config = Config::new(
1454 Some(mock_server.url()),
1455 480,
1456 address!("0x969947cFED008bFb5e3F32a25A1A2CDdf64d46fe"),
1457 ServiceEndpoint::direct(
1458 "https://indexer.us.id-infra.worldcoin.dev".to_string(),
1459 ),
1460 ServiceEndpoint::direct(
1461 "https://gateway.id-infra.worldcoin.dev".to_string(),
1462 ),
1463 vec![],
1464 2,
1465 )
1466 .unwrap();
1467 let config = serde_json::to_string(&config).unwrap();
1468
1469 let root = temp_root_path();
1470 let provider = InMemoryStorageProvider::new(&root);
1471 let store = CredentialStore::from_provider(&provider).expect("store");
1472 store.init(42, 100).expect("init storage");
1473
1474 let artifacts =
1475 Arc::new(CachingZkArtifacts::new(Arc::new(store.paths().unwrap())));
1476
1477 let _authenticator =
1478 Authenticator::init(&[2u8; 32], &config, artifacts, Arc::new(store))
1479 .await
1480 .unwrap();
1481 drop(mock_server);
1482
1483 cleanup_test_storage(&root);
1484 }
1485}