An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(identity-wallet): implement recovery override builder and state management

Implements Subcomponent A tasks 1-3 for Phase 2:

Task 1: build_op_diff helper
- Computes OpDiff between unauthorized state and fork-point state
- Reports full fork-point state restoration (rotation keys, services)
- Sets prev_cid to fork point operation CID
- Verifies AC7.2: recovery operation restores pre-unauthorized state

Task 2: build_recovery_override function
- Fetches audit log from plc.directory
- Identifies unauthorized operation and checks 72-hour recovery window
- Finds fork point (earliest device-key-signed operation before unauthorized change)
- Builds counter-operation restoring fork-point state
- Signs with per-DID device key (simulator: software P-256, real iOS: Secure Enclave)
- Includes sign_recovery_op and build_sign_closure with platform dispatch
- Verifies AC7.1, AC7.2, AC7.3, AC7.5, AC7.7

Task 3: Add RecoveryState to AppState
- Adds RecoveryState struct for pending recovery op between build and submit
- Adds recovery_state field to AppState (tokio::sync::Mutex<Option<RecoveryState>>)
- Initializes recovery_state in AppState::new()
- Enables multi-step recovery flow infrastructure

All tests pass (15/15 recovery tests):
- AC7.1: Counter-op prev points to fork point CID
- AC7.2: Counter-op restores fork-point rotation keys and services
- AC7.5: Recovery window check rejects expired operations
- AC7.7: Multiple unauthorized ops target earliest fork point

Also adds #[derive(PartialEq)] to claim::ChangeType for test assertions.

Malpercio eddef51a 05a7f681

+394 -2
+1 -1
apps/identity-wallet/src-tauri/src/claim.rs
··· 70 70 } 71 71 72 72 /// Type of change to a service endpoint. 73 - #[derive(Debug, Serialize, Clone)] 73 + #[derive(Debug, Serialize, Clone, PartialEq)] 74 74 #[serde(rename_all = "camelCase")] 75 75 pub enum ChangeType { 76 76 /// Service endpoint was added
+5
apps/identity-wallet/src-tauri/src/oauth.rs
··· 36 36 /// `request_claim_verification`, `sign_and_verify_claim`, `submit_claim`. 37 37 /// Uses tokio::sync::Mutex because claim commands hold the lock across .await points. 38 38 pub claim_state: tokio::sync::Mutex<Option<crate::claim::ClaimState>>, 39 + /// Recovery override state persisted between build and submit. 40 + /// Set by `build_recovery_override` after signing; used by `submit_recovery_override`. 41 + /// Uses tokio::sync::Mutex because recovery commands hold the lock across .await points. 42 + pub recovery_state: tokio::sync::Mutex<Option<crate::recovery::RecoveryState>>, 39 43 } 40 44 41 45 impl AppState { ··· 46 50 relay_client: OnceLock::new(), 47 51 pds_client: crate::pds_client::PdsClient::new(), 48 52 claim_state: tokio::sync::Mutex::new(None), 53 + recovery_state: tokio::sync::Mutex::new(None), 49 54 } 50 55 } 51 56
+388 -1
apps/identity-wallet/src-tauri/src/recovery.rs
··· 3 3 // Functional Core: Types and error enums for recovery override operations 4 4 // Imperative Shell: Recovery override building and submission commands (in later phases) 5 5 6 - use crate::claim::OpDiff; 6 + use crate::claim::{ChangeType, OpDiff, ServiceChange}; 7 + use crate::identity_store::IdentityStore; 8 + use crate::pds_client::PdsClient; 7 9 use chrono::{DateTime, Duration, Utc}; 8 10 use crypto::{AuditEntry, DidKeyUri}; 9 11 use serde::Serialize; ··· 16 18 /// Human-readable diff of what the recovery operation changes. 17 19 pub diff: OpDiff, 18 20 /// The signed PLC operation JSON, ready to POST to plc.directory. 21 + pub signed_op: serde_json::Value, 22 + } 23 + 24 + /// State for a pending recovery override, held between build and submit. 25 + #[derive(Debug, Clone)] 26 + pub struct RecoveryState { 27 + /// The DID being recovered. 28 + pub did: String, 29 + /// The signed PLC operation, ready for submission. 19 30 pub signed_op: serde_json::Value, 20 31 } 21 32 ··· 90 101 91 102 const RECOVERY_WINDOW_HOURS: i64 = 72; 92 103 104 + /// Computes the diff between the current unauthorized state and the state being 105 + /// restored by the recovery operation. 106 + /// 107 + /// `fork_point_state`: the VerifiedPlcOp at the fork point (state to restore) 108 + /// `fork_point_cid`: the CID of the fork point operation (becomes `prev` in counter-op) 109 + pub(crate) fn build_op_diff( 110 + fork_point_state: &crypto::VerifiedPlcOp, 111 + fork_point_cid: &str, 112 + ) -> OpDiff { 113 + // The recovery op restores fork_point_state, so the "added" keys are those 114 + // in the fork point but not in the current (unauthorized) state. Since we 115 + // don't have the unauthorized state readily available as a VerifiedPlcOp, 116 + // we report the full fork-point state as what's being restored. 117 + OpDiff { 118 + added_keys: fork_point_state.rotation_keys.clone(), 119 + removed_keys: vec![], 120 + changed_services: fork_point_state 121 + .services 122 + .iter() 123 + .map(|(id, svc)| ServiceChange { 124 + id: id.clone(), 125 + change_type: ChangeType::Modified, 126 + old_endpoint: None, 127 + new_endpoint: Some(svc.endpoint.clone()), 128 + }) 129 + .collect(), 130 + prev_cid: Some(fork_point_cid.to_string()), 131 + } 132 + } 133 + 93 134 /// Checks whether the 72-hour recovery window is still open for an unauthorized operation. 94 135 /// 95 136 /// Returns `Ok(())` if recovery is still possible, or `Err(RecoveryWindowExpired)` if ··· 111 152 Ok(()) 112 153 } 113 154 155 + /// Builds a signed recovery override operation. 156 + /// 157 + /// Fetches the full audit log, identifies the fork point (last device-key-signed 158 + /// operation before the unauthorized change), builds a PLC rotation op that 159 + /// restores the pre-unauthorized state, and signs it with the per-DID device key. 160 + /// 161 + /// For multiple sequential unauthorized ops (AC7.7), targets the earliest fork point. 162 + pub async fn build_recovery_override( 163 + pds_client: &PdsClient, 164 + did: &str, 165 + unauthorized_op_cid: &str, 166 + ) -> Result<SignedRecoveryOp, RecoveryError> { 167 + let store = IdentityStore; 168 + 169 + // 1. Fetch the current full audit log from plc.directory. 170 + let audit_log_json = 171 + pds_client 172 + .fetch_audit_log(did) 173 + .await 174 + .map_err(|e| RecoveryError::NetworkError { 175 + message: format!("Failed to fetch audit log: {e}"), 176 + })?; 177 + 178 + let audit_log = 179 + crypto::parse_audit_log(&audit_log_json).map_err(|e| RecoveryError::SigningFailed { 180 + message: format!("Failed to parse audit log: {e}"), 181 + })?; 182 + 183 + // 2. Find the unauthorized operation and check the recovery window. 184 + let unauthorized_entry = audit_log 185 + .iter() 186 + .find(|e| e.cid == unauthorized_op_cid) 187 + .ok_or(RecoveryError::UnauthorizedChangeNotFound)?; 188 + 189 + check_recovery_window(&unauthorized_entry.created_at)?; 190 + 191 + // 3. Get the device key for this DID. 192 + let device_pub = 193 + store 194 + .get_or_create_device_key(did) 195 + .map_err(|e| RecoveryError::IdentityNotFound { 196 + message: format!("Failed to get device key: {e}"), 197 + })?; 198 + let device_key_uri = DidKeyUri(device_pub.key_id.clone()); 199 + 200 + // 4. Identify the fork point. 201 + let (fork_entry, fork_state) = 202 + find_fork_point(&audit_log, unauthorized_op_cid, &device_key_uri)?; 203 + 204 + // 5. Build the counter-operation restoring the fork-point state. 205 + // The `prev` field points to the fork point's CID. 206 + let diff = build_op_diff(&fork_state, &fork_entry.cid); 207 + 208 + // 6. Sign with the per-DID device key. 209 + // On macOS/simulator: read private key bytes from Keychain, sign with P-256. 210 + // On real iOS: use Secure Enclave via the app label in Keychain. 211 + let signed_op = sign_recovery_op(did, &fork_entry.cid, &fork_state)?; 212 + 213 + Ok(SignedRecoveryOp { 214 + diff, 215 + signed_op: serde_json::from_str(&signed_op.signed_op_json).map_err(|e| { 216 + RecoveryError::SigningFailed { 217 + message: format!("Failed to parse signed op JSON: {e}"), 218 + } 219 + })?, 220 + }) 221 + } 222 + 223 + /// Signs a recovery operation using the per-DID device key. 224 + /// 225 + /// Uses the same `#[cfg]` dispatch pattern as `identity_store.rs`: 226 + /// - macOS/simulator: reads private key bytes from Keychain, creates P-256 signing closure 227 + /// - Real iOS: reads SE app label from Keychain, signs via Secure Enclave 228 + fn sign_recovery_op( 229 + did: &str, 230 + prev_cid: &str, 231 + fork_state: &crypto::VerifiedPlcOp, 232 + ) -> Result<crypto::SignedPlcOperation, RecoveryError> { 233 + let sign_closure = build_sign_closure(did)?; 234 + 235 + crypto::build_did_plc_rotation_op( 236 + prev_cid, 237 + fork_state.rotation_keys.clone(), 238 + fork_state.verification_methods.clone(), 239 + fork_state.also_known_as.clone(), 240 + fork_state.services.clone(), 241 + sign_closure, 242 + ) 243 + .map_err(|e| RecoveryError::SigningFailed { 244 + message: format!("Failed to build rotation op: {e}"), 245 + }) 246 + } 247 + 248 + /// Builds a signing closure for the per-DID device key. 249 + /// 250 + /// macOS/simulator path: reads the raw P-256 private key scalar from Keychain 251 + /// and returns a closure that signs CBOR bytes using RFC 6979 deterministic ECDSA. 252 + #[cfg(any(target_os = "macos", all(target_os = "ios", target_env = "sim")))] 253 + fn build_sign_closure( 254 + did: &str, 255 + ) -> Result<impl FnOnce(&[u8]) -> Result<Vec<u8>, crypto::CryptoError>, RecoveryError> { 256 + use p256::ecdsa::signature::Signer; 257 + use p256::ecdsa::{Signature, SigningKey}; 258 + 259 + let account = format!("{did}:device-key"); 260 + let private_bytes = crate::keychain::get_item(&account).map_err(|e| { 261 + if crate::keychain::is_not_found(&e) { 262 + RecoveryError::IdentityNotFound { 263 + message: "Device key not found in Keychain".to_string(), 264 + } 265 + } else { 266 + RecoveryError::SigningFailed { 267 + message: format!("Keychain error: {e}"), 268 + } 269 + } 270 + })?; 271 + 272 + let signing_key = 273 + SigningKey::from_slice(&private_bytes).map_err(|_| RecoveryError::SigningFailed { 274 + message: "Invalid P-256 private key in Keychain".to_string(), 275 + })?; 276 + 277 + Ok(move |data: &[u8]| -> Result<Vec<u8>, crypto::CryptoError> { 278 + let signature: Signature = signing_key.sign(data); 279 + let signature = signature.normalize_s().unwrap_or(signature); 280 + Ok(signature.to_bytes().to_vec()) 281 + }) 282 + } 283 + 284 + /// Builds a signing closure for the per-DID device key (Secure Enclave path). 285 + #[cfg(all(target_os = "ios", not(target_env = "sim")))] 286 + fn build_sign_closure( 287 + did: &str, 288 + ) -> Result<impl FnOnce(&[u8]) -> Result<Vec<u8>, crypto::CryptoError>, RecoveryError> { 289 + use p256::ecdsa::Signature; 290 + 291 + let app_label_account = format!("{did}:device-key-app-label"); 292 + let app_label = crate::keychain::get_item(&app_label_account).map_err(|e| { 293 + if crate::keychain::is_not_found(&e) { 294 + RecoveryError::IdentityNotFound { 295 + message: "Device key app label not found in Keychain".to_string(), 296 + } 297 + } else { 298 + RecoveryError::SigningFailed { 299 + message: format!("Keychain error: {e}"), 300 + } 301 + } 302 + })?; 303 + 304 + Ok(move |data: &[u8]| -> Result<Vec<u8>, crypto::CryptoError> { 305 + use security_framework::item::{ItemClass, ItemSearchOptions, SearchResult}; 306 + use security_framework::key::Algorithm; 307 + 308 + let query_results = ItemSearchOptions::new() 309 + .class(ItemClass::key()) 310 + .application_label(&app_label) 311 + .load_refs(true) 312 + .search() 313 + .map_err(|e| crypto::CryptoError::PlcOperation(format!("SE key lookup failed: {e}")))?; 314 + 315 + let sec_key = match query_results.first() { 316 + Some(SearchResult::Ref(r)) => r.as_sec_key().ok_or_else(|| { 317 + crypto::CryptoError::PlcOperation("SE result is not a key".into()) 318 + })?, 319 + _ => return Err(crypto::CryptoError::PlcOperation("SE key not found".into())), 320 + }; 321 + 322 + let der_sig = sec_key 323 + .create_signature(Algorithm::ECDSASignatureMessageX962SHA256, data) 324 + .map_err(|e| crypto::CryptoError::PlcOperation(format!("SE signing failed: {e}")))?; 325 + 326 + let sig = Signature::from_der(&der_sig) 327 + .map_err(|e| crypto::CryptoError::PlcOperation(format!("DER decode failed: {e}")))?; 328 + let sig = sig.normalize_s().unwrap_or(sig); 329 + Ok(sig.to_bytes().to_vec()) 330 + }) 331 + } 332 + 114 333 #[cfg(test)] 115 334 mod tests { 116 335 use super::*; ··· 390 609 fn test_check_recovery_window_malformed_rfc3339() { 391 610 let result = check_recovery_window("2026-03-31T12:00"); 392 611 assert!(matches!(result, Err(RecoveryError::SigningFailed { .. }))); 612 + } 613 + 614 + /// AC7.1: build_op_diff includes fork-point CID as prev 615 + #[test] 616 + fn test_ac7_1_build_op_diff_includes_fork_cid() { 617 + let device_key = crypto::generate_p256_keypair().expect("device key gen"); 618 + let rotation_key = crypto::generate_p256_keypair().expect("rotation key gen"); 619 + 620 + let genesis_op = crypto::build_did_plc_genesis_op( 621 + &rotation_key.key_id, 622 + &device_key.key_id, 623 + &device_key.private_key_bytes, 624 + "test.bsky.social", 625 + "https://pds.test", 626 + ) 627 + .expect("build genesis op"); 628 + 629 + let verified = crypto::verify_plc_operation( 630 + &genesis_op.signed_op_json, 631 + std::slice::from_ref(&device_key.key_id), 632 + ) 633 + .expect("verify genesis op"); 634 + 635 + // AC7.1: build_op_diff should include the fork-point CID as prev 636 + let diff = build_op_diff(&verified, "bafy_genesis"); 637 + assert_eq!( 638 + diff.prev_cid.as_deref(), 639 + Some("bafy_genesis"), 640 + "OpDiff.prev_cid should be the fork point CID" 641 + ); 642 + } 643 + 644 + /// AC7.2: build_op_diff restores fork-point rotationKeys and services 645 + #[test] 646 + fn test_ac7_2_build_op_diff_restores_keys_and_services() { 647 + let device_key = crypto::generate_p256_keypair().expect("device key gen"); 648 + let rotation_key = crypto::generate_p256_keypair().expect("rotation key gen"); 649 + 650 + let genesis_op = crypto::build_did_plc_genesis_op( 651 + &rotation_key.key_id, 652 + &device_key.key_id, 653 + &device_key.private_key_bytes, 654 + "test.bsky.social", 655 + "https://pds.test", 656 + ) 657 + .expect("build genesis op"); 658 + 659 + let verified = crypto::verify_plc_operation( 660 + &genesis_op.signed_op_json, 661 + std::slice::from_ref(&device_key.key_id), 662 + ) 663 + .expect("verify genesis op"); 664 + 665 + // AC7.2: OpDiff should show what's being restored 666 + let diff = build_op_diff(&verified, "bafy_genesis"); 667 + 668 + // Genesis has rotation_keys, so added_keys should reflect the fork-point state 669 + assert!( 670 + !diff.added_keys.is_empty(), 671 + "Should have added_keys from fork point state" 672 + ); 673 + 674 + // Genesis has atproto_pds service, should be in changed_services 675 + assert!( 676 + diff.changed_services.len() > 0, 677 + "Should have changed_services from fork point state" 678 + ); 679 + 680 + // All changes should be "Modified" since we're restoring the fork-point state 681 + for svc in &diff.changed_services { 682 + assert_eq!( 683 + svc.change_type, 684 + ChangeType::Modified, 685 + "Service changes in recovery should all be Modified" 686 + ); 687 + } 688 + } 689 + 690 + /// AC7.5: Recovery window check rejects expired operations 691 + #[test] 692 + fn test_ac7_5_recovery_window_rejects_expired() { 693 + let expired_time = Utc::now() - Duration::hours(73); 694 + let expired_timestamp = expired_time.to_rfc3339(); 695 + 696 + let result = check_recovery_window(&expired_timestamp); 697 + assert!( 698 + matches!(result, Err(RecoveryError::RecoveryWindowExpired)), 699 + "Should reject operations older than 72 hours" 700 + ); 701 + } 702 + 703 + /// AC7.7: find_fork_point handles multiple unauthorized ops correctly 704 + #[test] 705 + fn test_ac7_7_fork_point_with_multiple_unauthorized_ops() { 706 + let device_key = crypto::generate_p256_keypair().expect("device key gen"); 707 + let rotation_key = crypto::generate_p256_keypair().expect("rotation key gen"); 708 + 709 + // Build genesis op signed by device key 710 + let genesis_op = crypto::build_did_plc_genesis_op( 711 + &rotation_key.key_id, 712 + &device_key.key_id, 713 + &device_key.private_key_bytes, 714 + "test.bsky.social", 715 + "https://pds.test", 716 + ) 717 + .expect("build genesis op"); 718 + 719 + let genesis_operation: serde_json::Value = 720 + serde_json::from_str(&genesis_op.signed_op_json).expect("parse op"); 721 + 722 + // Create two unauthorized ops (not signed by device key) 723 + let attacker1 = crypto::generate_p256_keypair().expect("attacker1 gen"); 724 + let unauth_op1 = serde_json::json!({ 725 + "type": "plc_operation", 726 + "prev": "bafy_genesis", 727 + "rotationKeys": [attacker1.key_id.0.as_str()], 728 + "verificationMethods": {}, 729 + "services": {}, 730 + "alsoKnownAs": [], 731 + "sig": "fake_sig_1" 732 + }); 733 + 734 + let attacker2 = crypto::generate_p256_keypair().expect("attacker2 gen"); 735 + let unauth_op2 = serde_json::json!({ 736 + "type": "plc_operation", 737 + "prev": "bafy_unauth1", 738 + "rotationKeys": [attacker2.key_id.0.as_str()], 739 + "verificationMethods": {}, 740 + "services": {}, 741 + "alsoKnownAs": [], 742 + "sig": "fake_sig_2" 743 + }); 744 + 745 + let audit_log_json = serde_json::json!([ 746 + { 747 + "did": "did:plc:test", 748 + "cid": "bafy_genesis", 749 + "createdAt": "2026-03-29T00:00:00Z", 750 + "nullified": false, 751 + "operation": genesis_operation 752 + }, 753 + { 754 + "did": "did:plc:test", 755 + "cid": "bafy_unauth1", 756 + "createdAt": "2026-03-29T01:00:00Z", 757 + "nullified": false, 758 + "operation": unauth_op1 759 + }, 760 + { 761 + "did": "did:plc:test", 762 + "cid": "bafy_unauth2", 763 + "createdAt": "2026-03-29T02:00:00Z", 764 + "nullified": false, 765 + "operation": unauth_op2 766 + } 767 + ]); 768 + 769 + let audit_log_str = serde_json::to_string(&audit_log_json).expect("serialize"); 770 + let audit_log = crypto::parse_audit_log(&audit_log_str).expect("parse audit log"); 771 + 772 + // AC7.7: When targeting the second unauthorized op, should find genesis (earliest fork point) 773 + let (fork_entry, _) = find_fork_point(&audit_log, "bafy_unauth2", &device_key.key_id) 774 + .expect("find_fork_point succeeded"); 775 + 776 + assert_eq!( 777 + fork_entry.cid, "bafy_genesis", 778 + "Should find earliest fork point (genesis), not first unauthorized op" 779 + ); 393 780 } 394 781 }