Skip to main content

walletkit_core/issuers/
recovery_bindings_manager.rs

1//! Bindings for managing recovery agents via the Proof-of-Personhood (`PoP`) backend.
2//!
3//! A recovery agent is an entity authorized to initiate account recovery on behalf of
4//! a user. This module provides [`RecoveryBindingManager`], which handles the authenticated
5//! registration and removal of recovery agents through a challenge-response protocol
6//! secured by the authenticator's signing key.
7//!
8//! ## Protocol
9//!
10//! 1. Fetch a one-time challenge from the `PoP` backend.
11//! 2. Construct a commitment: `keccak256(challenge || leaf_index || sub)`.
12//! 3. Sign the commitment with the authenticator's key to produce a security token.
13//! 4. Submit the request with the signature and challenge as auth headers.
14
15use crate::authenticator::Authenticator;
16use crate::error::WalletKitError;
17use crate::issuers::pop_backend_client::ManageRecoveryBindingRequest;
18use crate::issuers::pop_backend_client::RecoveryBindingResponse;
19use crate::issuers::PopBackendClient;
20use crate::user_agent::UserAgentBuilder;
21use crate::Environment;
22use alloy_core::primitives::keccak256;
23use alloy_core::primitives::Address;
24use std::string::String;
25/// Represents a recovery binding.
26#[derive(Debug, PartialEq, Eq, uniffi::Record)]
27pub struct RecoveryBinding {
28    /// The hex address of the recovery agent (e.g. `"0x1234…"`).
29    pub recovery_agent: Option<String>,
30    /// The hex address of the pending recovery agent (e.g. `"0x1234…"`).
31    pub pending_recovery_agent: Option<String>,
32    /// The timestamp of the recovery agent update in seconds since the Unix epoch.
33    pub execute_after: Option<String>,
34}
35
36impl From<RecoveryBindingResponse> for RecoveryBinding {
37    fn from(response: RecoveryBindingResponse) -> Self {
38        Self {
39            recovery_agent: response.recovery_agent,
40            pending_recovery_agent: response.pending_recovery_agent,
41            execute_after: response.execute_after,
42        }
43    }
44}
45
46/// Client for registering and unregistering recovery agents with the `PoP` backend.
47///
48/// Each instance is bound to a specific [`Environment`] (staging or production),
49/// which determines the backend URL used for all requests.
50#[derive(uniffi::Object)]
51pub struct RecoveryBindingManager {
52    pop_backend_client: PopBackendClient,
53}
54
55#[uniffi::export]
56impl RecoveryBindingManager {
57    /// Creates a new `RecoveryBindingManager` for the specified environment.
58    ///
59    /// # Errors
60    ///
61    /// Returns an error if the HTTP client cannot be built.
62    #[uniffi::constructor]
63    pub fn new(
64        environment: &Environment,
65        user_agent_builder: &UserAgentBuilder,
66    ) -> Result<Self, WalletKitError> {
67        let base_url = match environment {
68            Environment::Staging => "https://app.stage.orb.worldcoin.org",
69            Environment::Production => "https://app.orb.worldcoin.org",
70        }
71        .to_string();
72        Self::new_with_base_url(base_url.as_str(), user_agent_builder)
73    }
74
75    /// Creates a new `RecoveryBindingManager` for the specified base URL and user agent.
76    ///
77    /// # Errors
78    ///
79    /// Returns an error if the HTTP client cannot be built.
80    #[uniffi::constructor]
81    pub fn new_with_base_url(
82        base_url: &str,
83        user_agent_builder: &UserAgentBuilder,
84    ) -> Result<Self, WalletKitError> {
85        let user_agent = user_agent_builder.build().to_string();
86        let pop_backend_client =
87            PopBackendClient::new(base_url.to_string(), user_agent);
88        Ok(Self { pop_backend_client })
89    }
90}
91
92#[uniffi::export(async_runtime = "tokio")]
93impl RecoveryBindingManager {
94    /// Registers a recovery agent for the given authenticator.
95    ///
96    /// # Arguments
97    ///
98    /// * `authenticator` — The authenticator whose signing key authorizes the request.
99    /// * `sub` — Hex-encoded subject identifier of the recovery agent to register.
100    /// * `recovery_agent_address` — The checksummed hex address of the new recovery agent (e.g. `"0x1234…"`).
101    ///
102    /// # Errors
103    ///
104    /// Returns an error if the challenge fetch, signing, or backend request fails,
105    /// or if the user is not eligible for recovery ([`WalletKitError::NotEligibleForRecovery`]).
106    /// or if the user fails the eligibility check ([`WalletKitError::IdentityNotFound`],
107    /// [`WalletKitError::NoSuccessfulCaptureFound`], [`WalletKitError::DebugReportNotFound`]).
108    /// or if any other unexpected error occurs ([`WalletKitError::NetworkError`]).
109    pub async fn bind_recovery_agent(
110        &self,
111        authenticator: &Authenticator,
112        sub: String,
113        recovery_agent_address: String,
114    ) -> Result<(), WalletKitError> {
115        let challenge = self.pop_backend_client.get_challenge().await?;
116        let leaf_index = authenticator.leaf_index();
117        let sig_recovery_update = authenticator
118            .danger_sign_initiate_recovery_agent_update(recovery_agent_address.clone())
119            .await?;
120        let request = ManageRecoveryBindingRequest {
121            sub,
122            leaf_index,
123            signature: format!("0x{}", hex::encode(sig_recovery_update.signature)),
124            nonce: sig_recovery_update.nonce.to_string(),
125            recovery_agent: recovery_agent_address.clone(),
126        };
127        let security_token = Self::generate_recovery_agent_security_token(
128            authenticator,
129            &request,
130            &challenge,
131        )?;
132
133        self.pop_backend_client
134            .bind_recovery_agent(request, security_token, challenge)
135            .await?;
136        Ok(())
137    }
138
139    /// Removes a previously registered recovery agent.
140    ///
141    /// # Arguments
142    ///
143    /// * `authenticator` — The authenticator whose signing key authorizes the request.
144    /// * `sub` — Hex-encoded subject identifier of the recovery agent to remove.
145    ///
146    /// # Errors
147    ///
148    /// Returns an error if the challenge fetch, signing, or backend request fails,
149    /// or if the account does not exist ([`WalletKitError::AccountDoesNotExist`]).
150    pub async fn unbind_recovery_agent(
151        &self,
152        authenticator: &Authenticator,
153        sub: String,
154    ) -> Result<(), WalletKitError> {
155        let leaf_index = authenticator.leaf_index();
156        let recovery_agent = Address::ZERO.to_string();
157        let sig_recovery_update = authenticator
158            .danger_sign_initiate_recovery_agent_update(recovery_agent.clone())
159            .await?;
160        let request = ManageRecoveryBindingRequest {
161            sub,
162            leaf_index,
163            signature: format!("0x{}", hex::encode(sig_recovery_update.signature)),
164            nonce: sig_recovery_update.nonce.to_string(),
165            recovery_agent,
166        };
167        let challenge = self.pop_backend_client.get_challenge().await?;
168        let security_token = Self::generate_recovery_agent_security_token(
169            authenticator,
170            &request,
171            &challenge,
172        )?;
173        self.pop_backend_client
174            .unbind_recovery_agent(request, security_token, challenge)
175            .await?;
176        Ok(())
177    }
178
179    /// Fetches a recovery binding via `GET /api/v1/recovery-binding`.
180    ///
181    /// # Arguments
182    ///
183    /// * `leaf_index` — The authenticator's leaf index in the World ID Merkle tree.
184    /// # Errors
185    ///
186    /// * [`WalletKitError::NetworkError`] — non-success HTTP status.
187    /// * [`WalletKitError::SerializationError`] — response body is not valid JSON.
188    /// * [`WalletKitError::RecoveryBindingDoesNotExist`] — HTTP 404 (no binding found).
189    pub async fn get_recovery_binding(
190        &self,
191        leaf_index: u64,
192    ) -> Result<RecoveryBinding, WalletKitError> {
193        let recovery_binding = self
194            .pop_backend_client
195            .get_recovery_binding(leaf_index)
196            .await?;
197        Ok(recovery_binding.into())
198    }
199}
200
201impl RecoveryBindingManager {
202    /// Builds a hex-encoded security token by signing `keccak256(challenge || leaf_index || sub)`
203    /// with the authenticator's key.
204    fn generate_recovery_agent_security_token(
205        authenticator: &Authenticator,
206        request: &ManageRecoveryBindingRequest,
207        challenge: &str,
208    ) -> Result<String, WalletKitError> {
209        let message_bytes =
210            Self::create_bytes_to_sign(challenge, request.leaf_index, &request.sub)?;
211        let commitment = keccak256(&message_bytes);
212        let signature: Vec<u8> =
213            authenticator.danger_sign_challenge(commitment.to_vec())?;
214        Ok(format!("0x{}", hex::encode(signature)))
215    }
216
217    /// Assembles the byte payload `challenge || leaf_index || sub` used as the
218    /// pre-image for the keccak256 commitment.
219    ///
220    /// Both `challenge` and `sub` are expected as hex strings (with optional `0x` prefix).
221    /// `leaf_index` is encoded as 8 big-endian bytes.
222    fn create_bytes_to_sign(
223        challenge: &str,
224        leaf_index: u64,
225        sub: &str,
226    ) -> Result<Vec<u8>, WalletKitError> {
227        let challenge_bytes =
228            hex::decode(challenge.trim_start_matches("0x")).map_err(|e| {
229                WalletKitError::Generic {
230                    error: e.to_string(),
231                }
232            })?;
233
234        let leaf_index_bytes = leaf_index.to_be_bytes();
235
236        let sub_bytes = hex::decode(sub.trim_start_matches("0x")).map_err(|e| {
237            WalletKitError::Generic {
238                error: e.to_string(),
239            }
240        })?;
241
242        let mut concatenated = Vec::new();
243        concatenated.extend_from_slice(&challenge_bytes);
244        concatenated.extend_from_slice(&leaf_index_bytes);
245        concatenated.extend_from_slice(&sub_bytes);
246
247        Ok(concatenated)
248    }
249}
250
251#[cfg(test)]
252#[cfg(all(not(target_arch = "wasm32"), feature = "embed-zkeys"))]
253mod tests {
254    use super::*;
255    use crate::authenticator::artifacts::caching::CachingZkArtifacts;
256    use crate::storage::tests_utils::{temp_root_path, InMemoryStorageProvider};
257    use crate::storage::CredentialStore;
258    use mockito::ServerGuard;
259    use std::sync::Arc;
260
261    // First four bytes of keccak256("getRecoveryCounter(uint64)").
262    const GET_RECOVERY_COUNTER_SELECTOR: &[u8] = b"3a51ad3d";
263
264    #[tokio::test]
265    async fn test_recovery_agent_token_generator_success() {
266        let mut pop_api_server = mockito::Server::new_async().await;
267        let sub = "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
268            .to_string();
269
270        // Mock the challenge endpoint
271        let challenge_url_path = "/api/v1/challenge".to_string();
272        let challenge =
273            "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
274                .to_string();
275        let challenge_mock = pop_api_server
276            .mock("GET", challenge_url_path.as_str())
277            .with_status(200)
278            .with_body(format!("{{\"challenge\": \"{challenge}\"}}"))
279            .create_async()
280            .await;
281
282        // Mock the recovery binding registration endpoint
283        let url_path = "/api/v1/recovery-binding".to_string();
284        let recovery_agent = "0x1000000000000000000000000000000000000000".to_string();
285        let private_key =
286            "d1995ace62b15d907bfb351ffe3cac57a8a84089a1b034101d2d7c78da415d58";
287        let private_key_bytes = hex::decode(private_key).unwrap();
288        let (mock_eth_server, eth_mock) = create_mock_eth_server().await;
289        let rpc_url = mock_eth_server.url();
290        let authenticator =
291            create_test_authenticator(&private_key_bytes, rpc_url).await;
292        let leaf_index = authenticator.leaf_index();
293        let mock = pop_api_server
294            .mock("POST", url_path.as_str())
295            .match_header(
296                "X-Auth-Signature",
297                mockito::Matcher::Regex(".*".to_string()),
298            )
299            .match_header("X-Auth-Challenge", challenge.as_str())
300            .match_body(mockito::Matcher::PartialJson(serde_json::json!({
301                "sub": sub.as_str(),
302                "leafIndex": leaf_index,
303                "recoveryAgent": recovery_agent.as_str(),
304
305            })))
306            .with_status(201)
307            .with_body("{}")
308            .create_async()
309            .await;
310
311        let recovery_binding_manager = RecoveryBindingManager::new_with_base_url(
312            pop_api_server.url().as_str(),
313            &UserAgentBuilder::new().with_walletkit_segment(),
314        )
315        .unwrap();
316
317        let result = recovery_binding_manager
318            .bind_recovery_agent(&authenticator, sub.clone(), recovery_agent.clone())
319            .await;
320        assert!(
321            result.is_ok(),
322            "Expected success, but got error: {result:?}"
323        );
324        challenge_mock.assert_async().await;
325
326        mock.assert_async().await;
327        eth_mock.assert_async().await;
328        drop(pop_api_server);
329        drop(mock_eth_server);
330    }
331
332    #[tokio::test]
333    async fn test_recovery_bindings_signature() {
334        let sub = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
335            .to_string();
336        let challenge =
337            "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
338                .to_string();
339        let private_key =
340            "d1995ace62b15d907bfb351ffe3cac57a8a84089a1b034101d2d7c78da415d58";
341        let private_key_bytes = hex::decode(private_key).unwrap();
342        let (mock_eth_server, eth_mock) = create_mock_eth_server().await;
343        let rpc_url = mock_eth_server.url();
344        let authenticator =
345            create_test_authenticator(&private_key_bytes, rpc_url).await;
346        let leaf_index = authenticator.leaf_index();
347        let message_bytes =
348            RecoveryBindingManager::create_bytes_to_sign(&challenge, leaf_index, &sub)
349                .unwrap();
350        log::info!("message_bytes: {:?}", hex::encode(message_bytes.clone()));
351        assert_eq!(hex::encode(message_bytes.clone()), "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2000000000000002aabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890");
352        let signature = "0x01".to_string();
353        let nonce = "0x02".to_string();
354        let recovery_agent = "0x1000000000000000000000000000000000000000".to_string();
355        let request = ManageRecoveryBindingRequest {
356            sub: sub.clone(),
357            leaf_index,
358            signature: signature.clone(),
359            nonce: nonce.clone(),
360            recovery_agent: recovery_agent.clone(),
361        };
362        let security_token =
363            RecoveryBindingManager::generate_recovery_agent_security_token(
364                &authenticator,
365                &request,
366                &challenge,
367            )
368            .unwrap();
369
370        assert!(
371            !security_token.is_empty(),
372            "Expected success, but got error: {security_token:?}"
373        );
374        let expect_signature = "0x72ec312737276c94e3ac32ab1c393a63b9474480d3a9eb434b8bf6927b7222ef7eb1fea0812ff62a7fb144db9631751e505969162a9c590cabb27bf0bd5005581c";
375        assert_eq!(security_token, expect_signature);
376        eth_mock.assert_async().await;
377        drop(mock_eth_server);
378    }
379
380    async fn create_test_authenticator(seed: &[u8], rpc_url: String) -> Authenticator {
381        let _ = rustls::crypto::ring::default_provider().install_default();
382        let store = create_test_credential_store();
383
384        let artifacts =
385            Arc::new(CachingZkArtifacts::new(Arc::new(store.paths().unwrap())));
386
387        let authenticator = Authenticator::init_with_defaults(
388            seed.to_vec(),
389            Some(rpc_url.clone()),
390            &Environment::Staging,
391            None,
392            artifacts,
393            store.clone(),
394        )
395        .await
396        .unwrap();
397
398        authenticator
399    }
400
401    fn create_test_credential_store() -> Arc<CredentialStore> {
402        let root = temp_root_path();
403        let provider = InMemoryStorageProvider::new(&root);
404        Arc::new(
405            CredentialStore::from_provider(&provider).expect("create credential store"),
406        )
407    }
408
409    async fn create_mock_eth_server() -> (ServerGuard, mockito::Mock) {
410        let mut mock_eth_server = mockito::Server::new_async().await;
411        let mock = mock_eth_server
412            .mock("POST", "/")
413            .with_status(200)
414            .with_header("content-type", "application/json")
415            .with_body_from_request(|request| {
416                let result = if request
417                    .body()
418                    .expect("request body")
419                    .windows(GET_RECOVERY_COUNTER_SELECTOR.len())
420                    .any(|window| window == GET_RECOVERY_COUNTER_SELECTOR)
421                {
422                    "0x0000000000000000000000000000000000000000000000000000000000000000"
423                } else {
424                    "0x000000000000000000000000000000000000000000000000000000000000002a"
425                };
426                serde_json::json!({
427                    "jsonrpc": "2.0",
428                    "id": 1,
429                    "result": result
430                })
431                .to_string()
432                .into_bytes()
433            })
434            .expect_at_least(1)
435            .expect_at_most(3)
436            .create_async()
437            .await;
438        (mock_eth_server, mock)
439    }
440}