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