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    #[tokio::test]
262    async fn test_recovery_agent_token_generator_success() {
263        let mut pop_api_server = mockito::Server::new_async().await;
264        let sub = "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
265            .to_string();
266
267        // Mock the challenge endpoint
268        let challenge_url_path = "/api/v1/challenge".to_string();
269        let challenge =
270            "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
271                .to_string();
272        let challenge_mock = pop_api_server
273            .mock("GET", challenge_url_path.as_str())
274            .with_status(200)
275            .with_body(format!("{{\"challenge\": \"{challenge}\"}}"))
276            .create_async()
277            .await;
278
279        // Mock the recovery binding registration endpoint
280        let url_path = "/api/v1/recovery-binding".to_string();
281        let recovery_agent = "0x1000000000000000000000000000000000000000".to_string();
282        let private_key =
283            "d1995ace62b15d907bfb351ffe3cac57a8a84089a1b034101d2d7c78da415d58";
284        let private_key_bytes = hex::decode(private_key).unwrap();
285        let (mock_eth_server, eth_mock) = create_mock_eth_server().await;
286        let rpc_url = mock_eth_server.url();
287        let authenticator =
288            create_test_authenticator(&private_key_bytes, rpc_url).await;
289        let leaf_index = authenticator.leaf_index();
290        let mock = pop_api_server
291            .mock("POST", url_path.as_str())
292            .match_header(
293                "X-Auth-Signature",
294                mockito::Matcher::Regex(".*".to_string()),
295            )
296            .match_header("X-Auth-Challenge", challenge.as_str())
297            .match_body(mockito::Matcher::PartialJson(serde_json::json!({
298                "sub": sub.as_str(),
299                "leafIndex": leaf_index,
300                "recoveryAgent": recovery_agent.as_str(),
301
302            })))
303            .with_status(201)
304            .with_body("{}")
305            .create_async()
306            .await;
307
308        let recovery_binding_manager = RecoveryBindingManager::new_with_base_url(
309            pop_api_server.url().as_str(),
310            &UserAgentBuilder::new().with_walletkit_segment(),
311        )
312        .unwrap();
313
314        let result = recovery_binding_manager
315            .bind_recovery_agent(&authenticator, sub.clone(), recovery_agent.clone())
316            .await;
317        assert!(
318            result.is_ok(),
319            "Expected success, but got error: {result:?}"
320        );
321        challenge_mock.assert_async().await;
322
323        mock.assert_async().await;
324        eth_mock.assert_async().await;
325        drop(pop_api_server);
326        drop(mock_eth_server);
327    }
328
329    #[tokio::test]
330    async fn test_recovery_bindings_signature() {
331        let sub = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
332            .to_string();
333        let challenge =
334            "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"
335                .to_string();
336        let private_key =
337            "d1995ace62b15d907bfb351ffe3cac57a8a84089a1b034101d2d7c78da415d58";
338        let private_key_bytes = hex::decode(private_key).unwrap();
339        let (mock_eth_server, eth_mock) = create_mock_eth_server().await;
340        let rpc_url = mock_eth_server.url();
341        let authenticator =
342            create_test_authenticator(&private_key_bytes, rpc_url).await;
343        let leaf_index = authenticator.leaf_index();
344        let message_bytes =
345            RecoveryBindingManager::create_bytes_to_sign(&challenge, leaf_index, &sub)
346                .unwrap();
347        log::info!("message_bytes: {:?}", hex::encode(message_bytes.clone()));
348        assert_eq!(hex::encode(message_bytes.clone()), "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2000000000000002aabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890");
349        let signature = "0x01".to_string();
350        let nonce = "0x02".to_string();
351        let recovery_agent = "0x1000000000000000000000000000000000000000".to_string();
352        let request = ManageRecoveryBindingRequest {
353            sub: sub.clone(),
354            leaf_index,
355            signature: signature.clone(),
356            nonce: nonce.clone(),
357            recovery_agent: recovery_agent.clone(),
358        };
359        let security_token =
360            RecoveryBindingManager::generate_recovery_agent_security_token(
361                &authenticator,
362                &request,
363                &challenge,
364            )
365            .unwrap();
366
367        assert!(
368            !security_token.is_empty(),
369            "Expected success, but got error: {security_token:?}"
370        );
371        let expect_signature = "0x72ec312737276c94e3ac32ab1c393a63b9474480d3a9eb434b8bf6927b7222ef7eb1fea0812ff62a7fb144db9631751e505969162a9c590cabb27bf0bd5005581c";
372        assert_eq!(security_token, expect_signature);
373        eth_mock.assert_async().await;
374        drop(mock_eth_server);
375    }
376
377    async fn create_test_authenticator(seed: &[u8], rpc_url: String) -> Authenticator {
378        let _ = rustls::crypto::ring::default_provider().install_default();
379        let store = create_test_credential_store();
380
381        let artifacts =
382            Arc::new(CachingZkArtifacts::new(Arc::new(store.paths().unwrap())));
383
384        let authenticator = Authenticator::init_with_defaults(
385            seed.to_vec(),
386            Some(rpc_url.clone()),
387            &Environment::Staging,
388            None,
389            artifacts,
390            store.clone(),
391        )
392        .await
393        .unwrap();
394
395        authenticator
396    }
397
398    fn create_test_credential_store() -> Arc<CredentialStore> {
399        let root = temp_root_path();
400        let provider = InMemoryStorageProvider::new(&root);
401        Arc::new(
402            CredentialStore::from_provider(&provider).expect("create credential store"),
403        )
404    }
405
406    async fn create_mock_eth_server() -> (ServerGuard, mockito::Mock) {
407        let mut mock_eth_server = mockito::Server::new_async().await;
408        let mock = mock_eth_server
409            .mock("POST", "/")
410            .with_status(200)
411            .with_header("content-type", "application/json")
412            .with_body(
413                serde_json::json!({
414                    "jsonrpc": "2.0",
415                    "id": 1,
416                    "result": "0x000000000000000000000000000000000000000000000000000000000000002a"
417                })
418                .to_string(),
419            )
420            .expect_at_least(1)
421            .expect_at_most(2)
422            .create_async()
423            .await;
424        (mock_eth_server, mock)
425    }
426}