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): add recovery module with types and error enum

+1255
+1
apps/identity-wallet/src-tauri/src/lib.rs
··· 8 8 pub mod oauth_client; 9 9 pub mod pds_client; 10 10 pub mod plc_monitor; 11 + pub mod recovery; 11 12 12 13 use crypto::{build_did_plc_genesis_op_with_external_signer, CryptoError, DidKeyUri}; 13 14 use serde::{Deserialize, Serialize};
+54
apps/identity-wallet/src-tauri/src/recovery.rs
··· 1 + // pattern: Mixed (Functional Core types + Imperative Shell commands) 2 + // 3 + // Functional Core: Types and error enums for recovery override operations 4 + // Imperative Shell: Recovery override building and submission commands (in later phases) 5 + 6 + use crate::claim::OpDiff; 7 + use serde::Serialize; 8 + 9 + /// Result of building a recovery override operation. 10 + /// Mirrors `VerifiedClaimOp` from `claim.rs` but without `warnings`. 11 + #[derive(Debug, Serialize, Clone)] 12 + #[serde(rename_all = "camelCase")] 13 + pub struct SignedRecoveryOp { 14 + /// Human-readable diff of what the recovery operation changes. 15 + pub diff: OpDiff, 16 + /// The signed PLC operation JSON, ready to POST to plc.directory. 17 + pub signed_op: serde_json::Value, 18 + } 19 + 20 + /// Errors from recovery override operations. 21 + #[derive(Debug, Serialize, thiserror::Error)] 22 + #[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")] 23 + pub enum RecoveryError { 24 + #[error("Recovery window has expired (72 hours elapsed)")] 25 + RecoveryWindowExpired, 26 + #[error("Signing failed: {message}")] 27 + SigningFailed { message: String }, 28 + #[error("PLC directory error: {message}")] 29 + PlcDirectoryError { message: String }, 30 + #[error("Network error: {message}")] 31 + NetworkError { message: String }, 32 + #[error("Identity not found: {message}")] 33 + IdentityNotFound { message: String }, 34 + #[error("No unauthorized changes found for the given CID")] 35 + UnauthorizedChangeNotFound, 36 + } 37 + 38 + #[cfg(test)] 39 + mod tests { 40 + use super::*; 41 + 42 + #[test] 43 + fn test_recovery_error_serialization() { 44 + let err = RecoveryError::RecoveryWindowExpired; 45 + let serialized = serde_json::to_value(&err).unwrap(); 46 + assert_eq!(serialized.get("code").map(|v| v.as_str()), Some(Some("RECOVERY_WINDOW_EXPIRED"))); 47 + 48 + let err2 = RecoveryError::SigningFailed { 49 + message: "test error".to_string(), 50 + }; 51 + let serialized2 = serde_json::to_value(&err2).unwrap(); 52 + assert_eq!(serialized2.get("code").map(|v| v.as_str()), Some(Some("SIGNING_FAILED"))); 53 + } 54 + }
+251
docs/implementation-plans/2026-03-31-recovery-override/phase_01.md
··· 1 + # Recovery Override Implementation Plan 2 + 3 + **Goal:** Build the recovery override mechanism that allows the identity wallet to detect unauthorized PLC changes and submit counter-operations signed by the device key's root authority to restore the user's identity state. 4 + 5 + **Architecture:** The recovery module (`recovery.rs`) sits in the identity-wallet Rust backend alongside the existing `plc_monitor.rs` and `claim.rs`. It reuses the crypto crate's `build_did_plc_rotation_op` for counter-operation construction, `parse_audit_log`/`verify_plc_operation` for fork point identification, and `IdentityStore` for per-DID key access and cached log persistence. The frontend adds a `RecoveryOverrideScreen` component wired into the existing state machine from `AlertDetailScreen`. 6 + 7 + **Tech Stack:** Rust (Tauri v2 backend), SvelteKit 2 + Svelte 5 (frontend), crypto crate (PLC operations), iOS Keychain (key storage) 8 + 9 + **Scope:** 4 phases from design Phase 7 (recovery override) 10 + 11 + **Codebase verified:** 2026-03-31 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + This phase implements and tests: 18 + 19 + ### plc-key-management.AC7: Recovery override 20 + - **plc-key-management.AC7.1 Success:** `build_recovery_override` produces a signed PLC operation with `prev` pointing to the fork point CID 21 + - **plc-key-management.AC7.2 Success:** Recovery operation restores the pre-unauthorized `rotationKeys`, `services`, and `verificationMethods` 22 + - **plc-key-management.AC7.3 Success:** Recovery operation is signed by the device key (highest authority) 23 + - **plc-key-management.AC7.5 Failure:** `build_recovery_override` returns `RECOVERY_WINDOW_EXPIRED` when the 72-hour deadline has passed 24 + - **plc-key-management.AC7.7 Edge:** Multiple unauthorized operations in sequence — recovery override targets the earliest fork point 25 + 26 + --- 27 + 28 + ## Phase 1: Recovery module — types, error types, and fork-point identification 29 + 30 + This phase creates the `recovery.rs` module with types, error enum, and the core fork-point identification logic. No Tauri commands yet — just the internal building blocks. 31 + 32 + <!-- START_SUBCOMPONENT_A (tasks 1-3) --> 33 + 34 + <!-- START_TASK_1 --> 35 + ### Task 1: Create recovery.rs with types and error enum 36 + 37 + **Verifies:** None (infrastructure — types only) 38 + 39 + **Files:** 40 + - Create: `apps/identity-wallet/src-tauri/src/recovery.rs` 41 + - Modify: `apps/identity-wallet/src-tauri/src/lib.rs` (add `pub mod recovery;`) 42 + 43 + **Implementation:** 44 + 45 + Create `recovery.rs` with these types, following the existing patterns from `claim.rs` and `plc_monitor.rs`: 46 + 47 + ```rust 48 + use serde::Serialize; 49 + use std::collections::BTreeMap; 50 + 51 + use crate::claim::{ClaimResult, OpDiff}; 52 + 53 + /// Result of building a recovery override operation. 54 + /// Mirrors `VerifiedClaimOp` from `claim.rs` but without `warnings`. 55 + #[derive(Debug, Serialize, Clone)] 56 + #[serde(rename_all = "camelCase")] 57 + pub struct SignedRecoveryOp { 58 + /// Human-readable diff of what the recovery operation changes. 59 + pub diff: OpDiff, 60 + /// The signed PLC operation JSON, ready to POST to plc.directory. 61 + pub signed_op: serde_json::Value, 62 + } 63 + 64 + /// Errors from recovery override operations. 65 + #[derive(Debug, Serialize, thiserror::Error)] 66 + #[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")] 67 + pub enum RecoveryError { 68 + #[error("Recovery window has expired (72 hours elapsed)")] 69 + RecoveryWindowExpired, 70 + #[error("Signing failed: {message}")] 71 + SigningFailed { message: String }, 72 + #[error("PLC directory error: {message}")] 73 + PlcDirectoryError { message: String }, 74 + #[error("Network error: {message}")] 75 + NetworkError { message: String }, 76 + #[error("Identity not found: {message}")] 77 + IdentityNotFound { message: String }, 78 + #[error("No unauthorized changes found for the given CID")] 79 + UnauthorizedChangeNotFound, 80 + } 81 + ``` 82 + 83 + Add `pub mod recovery;` to `lib.rs` alongside the other module declarations (after `pub mod plc_monitor;`). 84 + 85 + Note: These types are consumed by Tasks 2-3 in this phase and by later phases. If `cargo clippy -- -D warnings` reports dead_code warnings after this task, add `#[allow(dead_code)]` temporarily on unused items — they will be consumed by subsequent tasks. 86 + 87 + **Verification:** 88 + Run: `cargo build -p identity-wallet 2>&1 | head -5` 89 + Expected: Build succeeds (no errors) 90 + 91 + **Commit:** `feat(identity-wallet): add recovery module with types and error enum` 92 + 93 + <!-- END_TASK_1 --> 94 + 95 + <!-- START_TASK_2 --> 96 + ### Task 2: Implement find_fork_point function 97 + 98 + **Verifies:** plc-key-management.AC7.1 (fork point identification is prerequisite), plc-key-management.AC7.7 (earliest fork point for multiple unauthorized ops) 99 + 100 + **Files:** 101 + - Modify: `apps/identity-wallet/src-tauri/src/recovery.rs` 102 + 103 + **Implementation:** 104 + 105 + Add the fork-point identification function. This walks the audit log backwards from the unauthorized operation to find the last operation signed by the device key. For multiple sequential unauthorized ops (AC7.7), it targets the earliest fork point. 106 + 107 + ```rust 108 + use crypto::{AuditEntry, DidKeyUri}; 109 + 110 + /// Identifies the fork point — the last legitimate operation before unauthorized changes began. 111 + /// 112 + /// Walks backward through the audit log from the target unauthorized operation CID. 113 + /// For multiple sequential unauthorized ops (AC7.7), returns the earliest fork point 114 + /// (the last device-key-signed op before the first unauthorized op in the sequence). 115 + /// 116 + /// Returns `(fork_point_entry, pre_unauthorized_state)` where: 117 + /// - `fork_point_entry` is the last legitimate AuditEntry (its CID becomes the `prev` for the counter-op) 118 + /// - `pre_unauthorized_state` is the VerifiedPlcOp representing the state to restore 119 + pub(crate) fn find_fork_point( 120 + audit_log: &[AuditEntry], 121 + unauthorized_op_cid: &str, 122 + device_key: &DidKeyUri, 123 + ) -> Result<(AuditEntry, crypto::VerifiedPlcOp), RecoveryError> { 124 + // Find the index of the unauthorized operation in the audit log. 125 + let target_idx = audit_log 126 + .iter() 127 + .position(|e| e.cid == unauthorized_op_cid) 128 + .ok_or(RecoveryError::UnauthorizedChangeNotFound)?; 129 + 130 + if target_idx == 0 { 131 + return Err(RecoveryError::SigningFailed { 132 + message: "Cannot recover from the genesis operation".to_string(), 133 + }); 134 + } 135 + 136 + // Walk backward from the operation BEFORE the unauthorized one to find the 137 + // last operation signed by the device key. This handles AC7.7: if multiple 138 + // unauthorized ops are in sequence, we skip past all of them to find the 139 + // earliest fork point. 140 + for i in (0..target_idx).rev() { 141 + let entry = &audit_log[i]; 142 + let op_json = serde_json::to_string(&entry.operation).map_err(|e| { 143 + RecoveryError::SigningFailed { 144 + message: format!("Failed to serialize operation: {e}"), 145 + } 146 + })?; 147 + 148 + // Try to verify with the device key. If verification succeeds, 149 + // this is the last legitimate operation (the fork point). 150 + match crypto::verify_plc_operation(&op_json, &[device_key.clone()]) { 151 + Ok(verified) => return Ok((entry.clone(), verified)), 152 + Err(_) => continue, // Not signed by device key, keep looking 153 + } 154 + } 155 + 156 + Err(RecoveryError::SigningFailed { 157 + message: "No device-key-signed operation found before the unauthorized change".to_string(), 158 + }) 159 + } 160 + ``` 161 + 162 + **Testing:** 163 + 164 + Tests must verify: 165 + - plc-key-management.AC7.1: Fork point identification returns the correct entry (the one whose CID becomes `prev`) 166 + - plc-key-management.AC7.7: With multiple sequential unauthorized ops, the function returns the earliest fork point (last device-key-signed op before the first unauthorized one) 167 + 168 + Test scenarios: 169 + 1. Single unauthorized op after a device-key-signed genesis → fork point is the genesis 170 + 2. Two unauthorized ops in sequence → fork point is the last device-key-signed op before both 171 + 3. Target CID not found in audit log → returns `UnauthorizedChangeNotFound` 172 + 4. Target CID is the genesis op (index 0) → returns error 173 + 174 + Follow the existing test pattern in `plc_monitor.rs`: use `#[cfg(test)] mod tests { }`, generate real keys via `crypto::generate_p256_keypair()`, build real signed operations via the crypto crate, and construct `AuditEntry` values from the signed ops. 175 + 176 + **Verification:** 177 + Run: `cargo test -p identity-wallet find_fork_point` 178 + Expected: All tests pass 179 + 180 + **Commit:** `feat(identity-wallet): implement fork-point identification for recovery override` 181 + 182 + <!-- END_TASK_2 --> 183 + 184 + <!-- START_TASK_3 --> 185 + ### Task 3: Implement check_recovery_window function 186 + 187 + **Verifies:** plc-key-management.AC7.5 (RECOVERY_WINDOW_EXPIRED when 72h deadline passed) 188 + 189 + **Files:** 190 + - Modify: `apps/identity-wallet/src-tauri/src/recovery.rs` 191 + 192 + **Implementation:** 193 + 194 + Add the recovery window check. The 72-hour window is computed from the unauthorized operation's `created_at` timestamp. 195 + 196 + ```rust 197 + use chrono::{DateTime, Duration, Utc}; 198 + 199 + const RECOVERY_WINDOW_HOURS: i64 = 72; 200 + 201 + /// Checks whether the 72-hour recovery window is still open for an unauthorized operation. 202 + /// 203 + /// Returns `Ok(())` if recovery is still possible, or `Err(RecoveryWindowExpired)` if 204 + /// the 72-hour deadline has passed. 205 + pub(crate) fn check_recovery_window( 206 + unauthorized_op_created_at: &str, 207 + ) -> Result<(), RecoveryError> { 208 + let op_time = DateTime::parse_from_rfc3339(unauthorized_op_created_at) 209 + .map_err(|e| RecoveryError::SigningFailed { 210 + message: format!("Failed to parse operation timestamp: {e}"), 211 + })? 212 + .with_timezone(&Utc); 213 + 214 + let deadline = op_time + Duration::hours(RECOVERY_WINDOW_HOURS); 215 + 216 + if Utc::now() > deadline { 217 + return Err(RecoveryError::RecoveryWindowExpired); 218 + } 219 + 220 + Ok(()) 221 + } 222 + ``` 223 + 224 + Note: Add `chrono` following the workspace dependency convention: 225 + 1. In the root `Cargo.toml`, add to `[workspace.dependencies]`: 226 + ```toml 227 + chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } 228 + ``` 229 + 2. In `apps/identity-wallet/src-tauri/Cargo.toml`, add to `[dependencies]`: 230 + ```toml 231 + chrono = { workspace = true } 232 + ``` 233 + 234 + **Testing:** 235 + 236 + Tests must verify: 237 + - plc-key-management.AC7.5: Timestamps older than 72 hours return `RecoveryWindowExpired` 238 + - Timestamps within 72 hours return `Ok(())` 239 + - Invalid timestamp strings return an appropriate error 240 + 241 + Test approach: construct ISO 8601 timestamps relative to `Utc::now()` (e.g., `Utc::now() - Duration::hours(73)` for expired, `Utc::now() - Duration::hours(1)` for valid). 242 + 243 + **Verification:** 244 + Run: `cargo test -p identity-wallet check_recovery_window` 245 + Expected: All tests pass 246 + 247 + **Commit:** `feat(identity-wallet): add recovery window expiry check` 248 + 249 + <!-- END_TASK_3 --> 250 + 251 + <!-- END_SUBCOMPONENT_A -->
+361
docs/implementation-plans/2026-03-31-recovery-override/phase_02.md
··· 1 + # Recovery Override Implementation Plan 2 + 3 + **Goal:** Build the recovery override mechanism that allows the identity wallet to detect unauthorized PLC changes and submit counter-operations signed by the device key's root authority to restore the user's identity state. 4 + 5 + **Architecture:** The recovery module (`recovery.rs`) sits in the identity-wallet Rust backend alongside the existing `plc_monitor.rs` and `claim.rs`. It reuses the crypto crate's `build_did_plc_rotation_op` for counter-operation construction, `parse_audit_log`/`verify_plc_operation` for fork point identification, and `IdentityStore` for per-DID key access and cached log persistence. The frontend adds a `RecoveryOverrideScreen` component wired into the existing state machine from `AlertDetailScreen`. 6 + 7 + **Tech Stack:** Rust (Tauri v2 backend), SvelteKit 2 + Svelte 5 (frontend), crypto crate (PLC operations), iOS Keychain (key storage) 8 + 9 + **Scope:** 4 phases from design Phase 7 (recovery override) 10 + 11 + **Codebase verified:** 2026-03-31 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + This phase implements and tests: 18 + 19 + ### plc-key-management.AC7: Recovery override 20 + - **plc-key-management.AC7.1 Success:** `build_recovery_override` produces a signed PLC operation with `prev` pointing to the fork point CID 21 + - **plc-key-management.AC7.2 Success:** Recovery operation restores the pre-unauthorized `rotationKeys`, `services`, and `verificationMethods` 22 + - **plc-key-management.AC7.3 Success:** Recovery operation is signed by the device key (highest authority) 23 + - **plc-key-management.AC7.5 Failure:** `build_recovery_override` returns `RECOVERY_WINDOW_EXPIRED` when the 72-hour deadline has passed 24 + - **plc-key-management.AC7.7 Edge:** Multiple unauthorized operations in sequence — recovery override targets the earliest fork point 25 + 26 + --- 27 + 28 + ## Phase 2: build_recovery_override implementation 29 + 30 + This phase implements the core `build_recovery_override` function that constructs a signed counter-operation. 31 + 32 + <!-- START_SUBCOMPONENT_A (tasks 1-3) --> 33 + 34 + <!-- START_TASK_1 --> 35 + ### Task 1: Implement build_op_diff helper 36 + 37 + **Verifies:** plc-key-management.AC7.2 (diff shows pre-unauthorized state restoration) 38 + 39 + **Files:** 40 + - Modify: `apps/identity-wallet/src-tauri/src/recovery.rs` 41 + 42 + **Implementation:** 43 + 44 + Add a helper that computes the `OpDiff` between the unauthorized state and the restored (fork-point) state. This reuses `OpDiff`, `ServiceChange`, and `ChangeType` from `claim.rs`. 45 + 46 + ```rust 47 + use crate::claim::{ChangeType, OpDiff, ServiceChange}; 48 + 49 + /// Computes the diff between the current unauthorized state and the state being 50 + /// restored by the recovery operation. 51 + /// 52 + /// `fork_point_state`: the VerifiedPlcOp at the fork point (state to restore) 53 + /// `fork_point_cid`: the CID of the fork point operation (becomes `prev` in counter-op) 54 + pub(crate) fn build_op_diff( 55 + fork_point_state: &crypto::VerifiedPlcOp, 56 + fork_point_cid: &str, 57 + ) -> OpDiff { 58 + // The recovery op restores fork_point_state, so the "added" keys are those 59 + // in the fork point but not in the current (unauthorized) state. Since we 60 + // don't have the unauthorized state readily available as a VerifiedPlcOp, 61 + // we report the full fork-point state as what's being restored. 62 + OpDiff { 63 + added_keys: fork_point_state.rotation_keys.clone(), 64 + removed_keys: vec![], 65 + changed_services: fork_point_state 66 + .services 67 + .iter() 68 + .map(|(id, svc)| ServiceChange { 69 + id: id.clone(), 70 + change_type: ChangeType::Modified, 71 + old_endpoint: None, 72 + new_endpoint: Some(svc.endpoint.clone()), 73 + }) 74 + .collect(), 75 + prev_cid: Some(fork_point_cid.to_string()), 76 + } 77 + } 78 + ``` 79 + 80 + **Verification:** 81 + Run: `cargo build -p identity-wallet` 82 + Expected: Build succeeds 83 + 84 + **Commit:** `feat(identity-wallet): add recovery op diff builder` 85 + 86 + <!-- END_TASK_1 --> 87 + 88 + <!-- START_TASK_2 --> 89 + ### Task 2: Implement build_recovery_override function 90 + 91 + **Verifies:** plc-key-management.AC7.1, plc-key-management.AC7.2, plc-key-management.AC7.3, plc-key-management.AC7.5, plc-key-management.AC7.7 92 + 93 + **Files:** 94 + - Modify: `apps/identity-wallet/src-tauri/src/recovery.rs` 95 + 96 + **Implementation:** 97 + 98 + This is the core function. It: 99 + 1. Fetches the audit log from plc.directory 100 + 2. Finds the unauthorized operation and checks the recovery window 101 + 3. Identifies the fork point (handling multiple sequential unauthorized ops per AC7.7) 102 + 4. Builds a counter-operation restoring the fork-point state 103 + 5. Signs with the per-DID device key 104 + 105 + ```rust 106 + use crate::identity_store::IdentityStore; 107 + use crate::pds_client::PdsClient; 108 + 109 + /// Builds a signed recovery override operation. 110 + /// 111 + /// Fetches the full audit log, identifies the fork point (last device-key-signed 112 + /// operation before the unauthorized change), builds a PLC rotation op that 113 + /// restores the pre-unauthorized state, and signs it with the per-DID device key. 114 + /// 115 + /// For multiple sequential unauthorized ops (AC7.7), targets the earliest fork point. 116 + pub async fn build_recovery_override( 117 + pds_client: &PdsClient, 118 + did: &str, 119 + unauthorized_op_cid: &str, 120 + ) -> Result<SignedRecoveryOp, RecoveryError> { 121 + let store = IdentityStore; 122 + 123 + // 1. Fetch the current full audit log from plc.directory. 124 + let audit_log_json = pds_client 125 + .fetch_audit_log(did) 126 + .await 127 + .map_err(|e| RecoveryError::NetworkError { 128 + message: format!("Failed to fetch audit log: {e}"), 129 + })?; 130 + 131 + let audit_log = crypto::parse_audit_log(&audit_log_json).map_err(|e| { 132 + RecoveryError::SigningFailed { 133 + message: format!("Failed to parse audit log: {e}"), 134 + } 135 + })?; 136 + 137 + // 2. Find the unauthorized operation and check the recovery window. 138 + let unauthorized_entry = audit_log 139 + .iter() 140 + .find(|e| e.cid == unauthorized_op_cid) 141 + .ok_or(RecoveryError::UnauthorizedChangeNotFound)?; 142 + 143 + check_recovery_window(&unauthorized_entry.created_at)?; 144 + 145 + // 3. Get the device key for this DID. 146 + let device_pub = store.get_or_create_device_key(did).map_err(|e| { 147 + RecoveryError::IdentityNotFound { 148 + message: format!("Failed to get device key: {e}"), 149 + } 150 + })?; 151 + let device_key_uri = DidKeyUri(device_pub.key_id.clone()); 152 + 153 + // 4. Identify the fork point. 154 + let (fork_entry, fork_state) = 155 + find_fork_point(&audit_log, unauthorized_op_cid, &device_key_uri)?; 156 + 157 + // 5. Build the counter-operation restoring the fork-point state. 158 + // The `prev` field points to the fork point's CID. 159 + let diff = build_op_diff(&fork_state, &fork_entry.cid); 160 + 161 + // 6. Sign with the per-DID device key. 162 + // On macOS/simulator: read private key bytes from Keychain, sign with P-256. 163 + // On real iOS: use Secure Enclave via the app label in Keychain. 164 + let signed_op = sign_recovery_op( 165 + did, 166 + &fork_entry.cid, 167 + &fork_state, 168 + )?; 169 + 170 + Ok(SignedRecoveryOp { 171 + diff, 172 + signed_op: serde_json::from_str(&signed_op.signed_op_json).map_err(|e| { 173 + RecoveryError::SigningFailed { 174 + message: format!("Failed to parse signed op JSON: {e}"), 175 + } 176 + })?, 177 + }) 178 + } 179 + 180 + /// Signs a recovery operation using the per-DID device key. 181 + /// 182 + /// Uses the same `#[cfg]` dispatch pattern as `identity_store.rs`: 183 + /// - macOS/simulator: reads private key bytes from Keychain, creates P-256 signing closure 184 + /// - Real iOS: reads SE app label from Keychain, signs via Secure Enclave 185 + fn sign_recovery_op( 186 + did: &str, 187 + prev_cid: &str, 188 + fork_state: &crypto::VerifiedPlcOp, 189 + ) -> Result<crypto::SignedPlcOperation, RecoveryError> { 190 + let sign_closure = build_sign_closure(did)?; 191 + 192 + crypto::build_did_plc_rotation_op( 193 + prev_cid, 194 + fork_state.rotation_keys.clone(), 195 + fork_state.verification_methods.clone(), 196 + fork_state.also_known_as.clone(), 197 + fork_state.services.clone(), 198 + sign_closure, 199 + ) 200 + .map_err(|e| RecoveryError::SigningFailed { 201 + message: format!("Failed to build rotation op: {e}"), 202 + }) 203 + } 204 + 205 + /// Builds a signing closure for the per-DID device key. 206 + /// 207 + /// macOS/simulator path: reads the raw P-256 private key scalar from Keychain 208 + /// and returns a closure that signs CBOR bytes using RFC 6979 deterministic ECDSA. 209 + #[cfg(any(target_os = "macos", all(target_os = "ios", target_env = "sim")))] 210 + fn build_sign_closure( 211 + did: &str, 212 + ) -> Result<impl FnOnce(&[u8]) -> Result<Vec<u8>, crypto::CryptoError>, RecoveryError> { 213 + use p256::ecdsa::signature::Signer; 214 + use p256::ecdsa::{Signature, SigningKey}; 215 + 216 + let account = format!("{did}:device-key"); 217 + let private_bytes = crate::keychain::get_item(&account).map_err(|e| { 218 + if crate::keychain::is_not_found(&e) { 219 + RecoveryError::IdentityNotFound { 220 + message: "Device key not found in Keychain".to_string(), 221 + } 222 + } else { 223 + RecoveryError::SigningFailed { 224 + message: format!("Keychain error: {e}"), 225 + } 226 + } 227 + })?; 228 + 229 + let signing_key = SigningKey::from_slice(&private_bytes).map_err(|_| { 230 + RecoveryError::SigningFailed { 231 + message: "Invalid P-256 private key in Keychain".to_string(), 232 + } 233 + })?; 234 + 235 + Ok(move |data: &[u8]| -> Result<Vec<u8>, crypto::CryptoError> { 236 + let signature: Signature = signing_key.sign(data); 237 + let signature = signature.normalize_s().unwrap_or(signature); 238 + Ok(signature.to_bytes().to_vec()) 239 + }) 240 + } 241 + 242 + /// Builds a signing closure for the per-DID device key (Secure Enclave path). 243 + #[cfg(all(target_os = "ios", not(target_env = "sim")))] 244 + fn build_sign_closure( 245 + did: &str, 246 + ) -> Result<impl FnOnce(&[u8]) -> Result<Vec<u8>, crypto::CryptoError>, RecoveryError> { 247 + use p256::ecdsa::Signature; 248 + 249 + let app_label_account = format!("{did}:device-key-app-label"); 250 + let app_label = crate::keychain::get_item(&app_label_account).map_err(|e| { 251 + if crate::keychain::is_not_found(&e) { 252 + RecoveryError::IdentityNotFound { 253 + message: "Device key app label not found in Keychain".to_string(), 254 + } 255 + } else { 256 + RecoveryError::SigningFailed { 257 + message: format!("Keychain error: {e}"), 258 + } 259 + } 260 + })?; 261 + 262 + Ok(move |data: &[u8]| -> Result<Vec<u8>, crypto::CryptoError> { 263 + use security_framework::item::{ItemClass, ItemSearchOptions, SearchResult}; 264 + use security_framework::key::Algorithm; 265 + 266 + let query_results = ItemSearchOptions::new() 267 + .class(ItemClass::key()) 268 + .application_label(&app_label) 269 + .load_refs(true) 270 + .search() 271 + .map_err(|e| { 272 + crypto::CryptoError::PlcOperation(format!("SE key lookup failed: {e}")) 273 + })?; 274 + 275 + let sec_key = match query_results.first() { 276 + Some(SearchResult::Ref(r)) => r.as_sec_key().ok_or_else(|| { 277 + crypto::CryptoError::PlcOperation("SE result is not a key".into()) 278 + })?, 279 + _ => { 280 + return Err(crypto::CryptoError::PlcOperation( 281 + "SE key not found".into(), 282 + )) 283 + } 284 + }; 285 + 286 + let der_sig = sec_key 287 + .create_signature(Algorithm::ECDSASignatureMessageX962SHA256, data) 288 + .map_err(|e| { 289 + crypto::CryptoError::PlcOperation(format!("SE signing failed: {e}")) 290 + })?; 291 + 292 + let sig = Signature::from_der(&der_sig).map_err(|e| { 293 + crypto::CryptoError::PlcOperation(format!("DER decode failed: {e}")) 294 + })?; 295 + let sig = sig.normalize_s().unwrap_or(sig); 296 + Ok(sig.to_bytes().to_vec()) 297 + }) 298 + } 299 + ``` 300 + 301 + **Testing:** 302 + 303 + Tests must verify: 304 + - plc-key-management.AC7.1: The built counter-op has `prev` pointing to the fork point CID 305 + - plc-key-management.AC7.2: The counter-op restores the fork-point `rotationKeys`, `services`, and `verificationMethods` 306 + - plc-key-management.AC7.3: The counter-op is signed by the device key (verifiable via `crypto::verify_plc_operation`) 307 + - plc-key-management.AC7.5: Calling with a timestamp >72h ago returns `RecoveryWindowExpired` 308 + - plc-key-management.AC7.7: With multiple sequential unauthorized ops, the counter-op's `prev` targets the earliest fork point 309 + 310 + Test approach: Use `httpmock::MockServer` to mock plc.directory audit log responses (following the pattern in `plc_monitor.rs` tests). Use `setup_identity(did)` to register a DID and generate a device key. Build a chain of real signed operations via the crypto crate, inject unauthorized ops signed by a different key, and verify that `build_recovery_override` produces the correct counter-op. 311 + 312 + Reference `apps/identity-wallet/src-tauri/src/plc_monitor.rs` lines 271-768 for the test helper `setup_identity` and the httpmock patterns used there. 313 + 314 + **Verification:** 315 + Run: `cargo test -p identity-wallet build_recovery_override` 316 + Expected: All tests pass 317 + 318 + **Commit:** `feat(identity-wallet): implement build_recovery_override with per-DID signing` 319 + 320 + <!-- END_TASK_2 --> 321 + 322 + <!-- START_TASK_3 --> 323 + ### Task 3: Store pending recovery op in AppState 324 + 325 + **Verifies:** None (infrastructure — state management for submit flow) 326 + 327 + **Files:** 328 + - Modify: `apps/identity-wallet/src-tauri/src/recovery.rs` 329 + - Modify: `apps/identity-wallet/src-tauri/src/oauth.rs` (add recovery_state field to AppState) 330 + 331 + **Implementation:** 332 + 333 + Add a `RecoveryState` to hold the pending signed recovery operation between build and submit, following the same `Mutex<Option<T>>` pattern as `ClaimState` in `oauth.rs`. 334 + 335 + In `recovery.rs`, add: 336 + ```rust 337 + /// State for a pending recovery override, held between build and submit. 338 + pub struct RecoveryState { 339 + pub did: String, 340 + pub signed_op: serde_json::Value, 341 + } 342 + ``` 343 + 344 + In `oauth.rs`, add `recovery_state` to `AppState`: 345 + ```rust 346 + pub recovery_state: tokio::sync::Mutex<Option<crate::recovery::RecoveryState>>, 347 + ``` 348 + 349 + Initialize as `recovery_state: tokio::sync::Mutex::new(None)` in `AppState::new()`. 350 + 351 + Update `build_recovery_override` to store the signed op in `RecoveryState` after building (or have the Tauri command wrapper do this — see Phase 3, Task 1). 352 + 353 + **Verification:** 354 + Run: `cargo build -p identity-wallet` 355 + Expected: Build succeeds 356 + 357 + **Commit:** `feat(identity-wallet): add RecoveryState to AppState for pending recovery ops` 358 + 359 + <!-- END_TASK_3 --> 360 + 361 + <!-- END_SUBCOMPONENT_A -->
+302
docs/implementation-plans/2026-03-31-recovery-override/phase_03.md
··· 1 + # Recovery Override Implementation Plan 2 + 3 + **Goal:** Build the recovery override mechanism that allows the identity wallet to detect unauthorized PLC changes and submit counter-operations signed by the device key's root authority to restore the user's identity state. 4 + 5 + **Architecture:** The recovery module (`recovery.rs`) sits in the identity-wallet Rust backend alongside the existing `plc_monitor.rs` and `claim.rs`. It reuses the crypto crate's `build_did_plc_rotation_op` for counter-operation construction, `parse_audit_log`/`verify_plc_operation` for fork point identification, and `IdentityStore` for per-DID key access and cached log persistence. The frontend adds a `RecoveryOverrideScreen` component wired into the existing state machine from `AlertDetailScreen`. 6 + 7 + **Tech Stack:** Rust (Tauri v2 backend), SvelteKit 2 + Svelte 5 (frontend), crypto crate (PLC operations), iOS Keychain (key storage) 8 + 9 + **Scope:** 4 phases from design Phase 7 (recovery override) 10 + 11 + **Codebase verified:** 2026-03-31 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + This phase implements and tests: 18 + 19 + ### plc-key-management.AC7: Recovery override 20 + - **plc-key-management.AC7.4 Success:** `submit_recovery_override` POSTs to plc.directory and updates cached log 21 + 22 + --- 23 + 24 + ## Phase 3: submit_recovery_override, command registration, IPC wrappers 25 + 26 + This phase adds PdsClient accessor methods, the submission function, exposes both recovery functions as Tauri IPC commands, and adds TypeScript wrappers. 27 + 28 + <!-- START_SUBCOMPONENT_A (tasks 1-3) --> 29 + 30 + <!-- START_TASK_1 --> 31 + ### Task 1: Add PdsClient accessor methods 32 + 33 + **Verifies:** None (infrastructure — prerequisite for submit function) 34 + 35 + **Files:** 36 + - Modify: `apps/identity-wallet/src-tauri/src/pds_client.rs` 37 + 38 + **Implementation:** 39 + 40 + The `submit_recovery_override` function needs `PdsClient::plc_directory_url()` and `PdsClient::client()` accessors to construct the DID document fetch URL. These do not currently exist as public methods. 41 + 42 + Add these two public getters to `PdsClient`: 43 + 44 + ```rust 45 + /// Returns the plc.directory base URL. 46 + pub fn plc_directory_url(&self) -> &str { 47 + &self.plc_directory_url 48 + } 49 + 50 + /// Returns a reference to the inner HTTP client. 51 + pub fn client(&self) -> &reqwest::Client { 52 + &self.client 53 + } 54 + ``` 55 + 56 + **Verification:** 57 + Run: `cargo build -p identity-wallet` 58 + Expected: Build succeeds 59 + 60 + **Commit:** `refactor(identity-wallet): expose PdsClient accessor methods for recovery module` 61 + 62 + <!-- END_TASK_1 --> 63 + 64 + <!-- START_TASK_2 --> 65 + ### Task 2: Implement submit_recovery_override and Tauri commands 66 + 67 + **Verifies:** plc-key-management.AC7.4 68 + 69 + **Files:** 70 + - Modify: `apps/identity-wallet/src-tauri/src/recovery.rs` 71 + - Modify: `apps/identity-wallet/src-tauri/src/lib.rs` (register commands in `generate_handler![]`) 72 + 73 + **Implementation:** 74 + 75 + Add the submission function and two `#[tauri::command]` wrappers to `recovery.rs`: 76 + 77 + ```rust 78 + /// Submits the pending recovery override operation to plc.directory. 79 + /// 80 + /// Reads the signed op from RecoveryState (set by build_recovery_override), 81 + /// POSTs it to plc.directory, and updates the cached PLC audit log. 82 + pub async fn submit_recovery_override( 83 + pds_client: &PdsClient, 84 + did: &str, 85 + signed_op: &serde_json::Value, 86 + ) -> Result<ClaimResult, RecoveryError> { 87 + let store = IdentityStore; 88 + 89 + // 1. POST the signed operation to plc.directory. 90 + pds_client 91 + .post_plc_operation(did, signed_op.clone()) 92 + .await 93 + .map_err(|e| RecoveryError::PlcDirectoryError { 94 + message: format!("PLC directory rejected the operation: {e}"), 95 + })?; 96 + 97 + // 2. Re-fetch the audit log to update the cache. 98 + let updated_log = pds_client 99 + .fetch_audit_log(did) 100 + .await 101 + .map_err(|e| RecoveryError::NetworkError { 102 + message: format!("Failed to fetch updated audit log: {e}"), 103 + })?; 104 + 105 + store 106 + .store_plc_log(did, &updated_log) 107 + .map_err(|e| RecoveryError::NetworkError { 108 + message: format!("Failed to update cached log: {e}"), 109 + })?; 110 + 111 + // 3. Re-fetch the DID document (it should now reflect the recovered state). 112 + // Use the raw plc.directory endpoint, not the audit log. 113 + let did_doc_url = format!("{}/{}", pds_client.plc_directory_url(), did); 114 + let did_doc: serde_json::Value = pds_client 115 + .client() 116 + .get(&did_doc_url) 117 + .send() 118 + .await 119 + .map_err(|e| RecoveryError::NetworkError { 120 + message: format!("Failed to fetch DID document: {e}"), 121 + })? 122 + .json() 123 + .await 124 + .map_err(|e| RecoveryError::NetworkError { 125 + message: format!("Failed to parse DID document: {e}"), 126 + })?; 127 + 128 + store 129 + .store_did_doc(did, &serde_json::to_string(&did_doc).unwrap_or_default()) 130 + .map_err(|e| RecoveryError::NetworkError { 131 + message: format!("Failed to update cached DID doc: {e}"), 132 + })?; 133 + 134 + Ok(ClaimResult { 135 + updated_did_doc: did_doc, 136 + }) 137 + } 138 + ``` 139 + 140 + Add two Tauri command wrappers: 141 + 142 + ```rust 143 + /// Tauri command: Build a recovery override operation. 144 + /// 145 + /// Stores the built operation in RecoveryState for subsequent submission. 146 + #[tauri::command] 147 + pub async fn build_recovery_override_cmd( 148 + state: tauri::State<'_, crate::oauth::AppState>, 149 + did: String, 150 + operation_cid: String, 151 + ) -> Result<SignedRecoveryOp, RecoveryError> { 152 + let result = build_recovery_override( 153 + state.pds_client(), 154 + &did, 155 + &operation_cid, 156 + ) 157 + .await?; 158 + 159 + // Store in RecoveryState for submit_recovery_override_cmd. 160 + let mut recovery = state.recovery_state.lock().await; 161 + *recovery = Some(RecoveryState { 162 + did: did.clone(), 163 + signed_op: result.signed_op.clone(), 164 + }); 165 + 166 + Ok(result) 167 + } 168 + 169 + /// Tauri command: Submit the pending recovery override to plc.directory. 170 + #[tauri::command] 171 + pub async fn submit_recovery_override_cmd( 172 + state: tauri::State<'_, crate::oauth::AppState>, 173 + did: String, 174 + ) -> Result<ClaimResult, RecoveryError> { 175 + let recovery = state.recovery_state.lock().await; 176 + let recovery_state = recovery.as_ref().ok_or(RecoveryError::SigningFailed { 177 + message: "No pending recovery operation. Call build_recovery_override first.".to_string(), 178 + })?; 179 + 180 + if recovery_state.did != did { 181 + return Err(RecoveryError::SigningFailed { 182 + message: format!( 183 + "Recovery state DID mismatch: expected {}, got {}", 184 + recovery_state.did, did 185 + ), 186 + }); 187 + } 188 + 189 + let signed_op = recovery_state.signed_op.clone(); 190 + drop(recovery); // Release lock before network calls. 191 + 192 + let result = submit_recovery_override( 193 + state.pds_client(), 194 + &did, 195 + &signed_op, 196 + ) 197 + .await?; 198 + 199 + // Clear recovery state on success. 200 + let mut recovery = state.recovery_state.lock().await; 201 + *recovery = None; 202 + 203 + Ok(result) 204 + } 205 + ``` 206 + 207 + In `apps/identity-wallet/src-tauri/src/lib.rs`, add the two commands to the `generate_handler![]` macro (after `plc_monitor::check_identity_status`): 208 + 209 + ```rust 210 + recovery::build_recovery_override_cmd, 211 + recovery::submit_recovery_override_cmd, 212 + ``` 213 + 214 + **Testing:** 215 + 216 + Tests must verify: 217 + - plc-key-management.AC7.4: `submit_recovery_override` POSTs to plc.directory (mock server receives the signed op) and updates the cached PLC log in Keychain 218 + 219 + Test approach: Use `httpmock::MockServer` to mock both plc.directory endpoints (`POST /{did}` for submission, `GET /{did}/log/audit` for re-fetch, `GET /{did}` for DID doc). Verify the mock received the correct signed operation JSON. Verify `IdentityStore::get_plc_log(did)` returns the updated log after submission. 220 + 221 + **Verification:** 222 + Run: `cargo test -p identity-wallet submit_recovery` 223 + Expected: All tests pass 224 + 225 + Run: `cargo build -p identity-wallet` 226 + Expected: Build succeeds with new commands registered 227 + 228 + **Commit:** `feat(identity-wallet): implement submit_recovery_override and register Tauri commands` 229 + 230 + <!-- END_TASK_2 --> 231 + 232 + <!-- START_TASK_3 --> 233 + ### Task 3: Add TypeScript IPC wrappers 234 + 235 + **Verifies:** None (infrastructure — TypeScript types only) 236 + 237 + **Files:** 238 + - Modify: `apps/identity-wallet/src/lib/ipc.ts` 239 + 240 + **Implementation:** 241 + 242 + Add the recovery override types and IPC wrappers at the end of `ipc.ts`, following the existing pattern: 243 + 244 + ```typescript 245 + // ── recovery_override ───────────────────────────────────────────────────────── 246 + 247 + /** 248 + * Error returned by recovery override commands. 249 + * Matches RecoveryError enum in recovery.rs with #[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")]. 250 + */ 251 + export type RecoveryError = 252 + | { code: 'RECOVERY_WINDOW_EXPIRED' } 253 + | { code: 'SIGNING_FAILED'; message: string } 254 + | { code: 'PLC_DIRECTORY_ERROR'; message: string } 255 + | { code: 'NETWORK_ERROR'; message: string } 256 + | { code: 'IDENTITY_NOT_FOUND'; message: string } 257 + | { code: 'UNAUTHORIZED_CHANGE_NOT_FOUND' }; 258 + 259 + /** 260 + * Signed recovery operation ready for review and submission. 261 + * Matches SignedRecoveryOp struct in recovery.rs with #[serde(rename_all = "camelCase")]. 262 + */ 263 + export interface SignedRecoveryOp { 264 + /** Human-readable diff of what the recovery operation changes. */ 265 + diff: OpDiff; 266 + /** The signed PLC operation JSON, ready to POST to plc.directory. */ 267 + signedOp: Record<string, unknown>; 268 + } 269 + 270 + /** 271 + * Build a recovery override operation for an unauthorized PLC change. 272 + * 273 + * Fetches the audit log, identifies the fork point, builds a counter-operation 274 + * that restores the pre-unauthorized state, and signs it with the device key. 275 + * 276 + * The built operation is stored in RecoveryState for subsequent submission 277 + * via submitRecoveryOverride(). 278 + */ 279 + export const buildRecoveryOverride = (did: string, operationCid: string): Promise<SignedRecoveryOp> => 280 + invoke('build_recovery_override_cmd', { did, operationCid }); 281 + 282 + /** 283 + * Submit the pending recovery override operation to plc.directory. 284 + * 285 + * Must be called after buildRecoveryOverride() — submits the stored signed 286 + * operation, updates the cached PLC audit log, and returns the updated DID document. 287 + */ 288 + export const submitRecoveryOverride = (did: string): Promise<ClaimResult> => 289 + invoke('submit_recovery_override_cmd', { did }); 290 + ``` 291 + 292 + Note: `OpDiff` and `ClaimResult` are already exported from `ipc.ts` (defined in the claim section). 293 + 294 + **Verification:** 295 + Run: `cd apps/identity-wallet && npx tsc --noEmit` 296 + Expected: No type errors (or only pre-existing ones unrelated to this change) 297 + 298 + **Commit:** `feat(identity-wallet): add recovery override IPC wrappers` 299 + 300 + <!-- END_TASK_3 --> 301 + 302 + <!-- END_SUBCOMPONENT_A -->
+262
docs/implementation-plans/2026-03-31-recovery-override/phase_04.md
··· 1 + # Recovery Override Implementation Plan 2 + 3 + **Goal:** Build the recovery override mechanism that allows the identity wallet to detect unauthorized PLC changes and submit counter-operations signed by the device key's root authority to restore the user's identity state. 4 + 5 + **Architecture:** The recovery module (`recovery.rs`) sits in the identity-wallet Rust backend alongside the existing `plc_monitor.rs` and `claim.rs`. It reuses the crypto crate's `build_did_plc_rotation_op` for counter-operation construction, `parse_audit_log`/`verify_plc_operation` for fork point identification, and `IdentityStore` for per-DID key access and cached log persistence. The frontend adds a `RecoveryOverrideScreen` component wired into the existing state machine from `AlertDetailScreen`. 6 + 7 + **Tech Stack:** Rust (Tauri v2 backend), SvelteKit 2 + Svelte 5 (frontend), crypto crate (PLC operations), iOS Keychain (key storage) 8 + 9 + **Scope:** 4 phases from design Phase 7 (recovery override) 10 + 11 + **Codebase verified:** 2026-03-31 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + This phase implements and tests: 18 + 19 + ### plc-key-management.AC7: Recovery override 20 + - **plc-key-management.AC7.6 Success:** Recovery override screen shows the counter-operation diff with confirm/cancel 21 + 22 + --- 23 + 24 + ## Phase 4: RecoveryOverrideScreen and navigation wiring 25 + 26 + This phase creates the recovery override UI screen and wires it into the state machine from AlertDetailScreen. 27 + 28 + <!-- START_SUBCOMPONENT_A (tasks 1-4) --> 29 + 30 + <!-- START_TASK_1 --> 31 + ### Task 1: Create RecoveryOverrideScreen component 32 + 33 + **Verifies:** plc-key-management.AC7.6 34 + 35 + **Files:** 36 + - Create: `apps/identity-wallet/src/lib/components/home/RecoveryOverrideScreen.svelte` 37 + 38 + **Implementation:** 39 + 40 + Create the recovery override screen following the patterns from `ReviewOperationScreen.svelte` (diff display) and `AlertDetailScreen.svelte` (urgency/deadline display). The screen shows: 41 + 1. The counter-operation diff (keys being restored, services being restored) 42 + 2. Recovery deadline countdown 43 + 3. Confirm and Cancel buttons 44 + 4. Loading state during submission 45 + 5. Error display on failure 46 + 47 + Props: 48 + - `did: string` — the DID being recovered 49 + - `operationCid: string` — CID of the unauthorized operation 50 + - `createdAt: string` — ISO 8601 timestamp of the unauthorized operation (for deadline countdown) 51 + - `onback: () => void` — navigate back to alert detail 52 + - `onsuccess: () => void` — navigate to home after successful recovery 53 + 54 + Behavior: 55 + - On mount, calls `buildRecoveryOverride(did, operationCid)` to get the `SignedRecoveryOp` 56 + - Shows a loading spinner while building 57 + - Displays the diff using the same `+`/`−`/`~` diff pattern from `ReviewOperationScreen` 58 + - Shows the recovery deadline countdown using `getDeadline`/`formatCountdown` from `deadline.ts` 59 + - "Confirm & Submit" button calls `submitRecoveryOverride(did)` and navigates on success 60 + - "Cancel" button calls `onback()` 61 + - Error handling follows `isCodedError` pattern from `ReviewOperationScreen` 62 + - If `RECOVERY_WINDOW_EXPIRED`, show a clear message that recovery is no longer possible 63 + 64 + The component should import `buildRecoveryOverride`, `submitRecoveryOverride`, and `type SignedRecoveryOp` from `$lib/ipc`, plus `getDeadline`, `formatCountdown`, `getUrgency` from `$lib/utils/deadline`, and `isCodedError`, `truncateDid` from `$lib/did-doc-utils`. 65 + 66 + Use the existing CSS patterns from `AlertDetailScreen.svelte` and `ReviewOperationScreen.svelte` (`.screen`, `.header`, `.section`, `.diff-entry`, `.cta`, etc.). 67 + 68 + **Verification:** 69 + Run: `cd apps/identity-wallet && npx tsc --noEmit` 70 + Expected: No type errors from this component 71 + 72 + **Commit:** `feat(identity-wallet): add RecoveryOverrideScreen component` 73 + 74 + <!-- END_TASK_1 --> 75 + 76 + <!-- START_TASK_2 --> 77 + ### Task 2: Wire RecoveryOverrideScreen into state machine 78 + 79 + **Verifies:** plc-key-management.AC7.6 (navigation from alert detail to recovery screen) 80 + 81 + **Files:** 82 + - Modify: `apps/identity-wallet/src/routes/+page.svelte` 83 + - Modify: `apps/identity-wallet/src/lib/components/home/AlertDetailScreen.svelte` 84 + 85 + **Implementation:** 86 + 87 + **In `+page.svelte`:** 88 + 89 + 1. Add `'recovery_override'` to the `OnboardingStep` union type (after `'alert_detail'`). 90 + 91 + 2. Add state variables for recovery context: 92 + ```typescript 93 + let selectedRecoveryCid = $state<string | null>(null); 94 + let selectedRecoveryCreatedAt = $state<string | null>(null); 95 + ``` 96 + 97 + 3. Add the `RecoveryOverrideScreen` import: 98 + ```typescript 99 + import RecoveryOverrideScreen from '$lib/components/home/RecoveryOverrideScreen.svelte'; 100 + ``` 101 + 102 + 4. Add the route block after the `alert_detail` block (around line 366): 103 + ```svelte 104 + {:else if step === 'recovery_override'} 105 + <RecoveryOverrideScreen 106 + did={selectedAlertDid ?? ''} 107 + operationCid={selectedRecoveryCid ?? ''} 108 + createdAt={selectedRecoveryCreatedAt ?? ''} 109 + onback={() => goTo('alert_detail')} 110 + onsuccess={() => goTo('home')} 111 + /> 112 + ``` 113 + 114 + **In `AlertDetailScreen.svelte`:** 115 + 116 + 1. Add an `onoverride` callback prop: 117 + ```typescript 118 + let { 119 + did, 120 + changes, 121 + onback, 122 + onoverride, 123 + }: { 124 + did: string; 125 + changes: UnauthorizedChange[]; 126 + onback: () => void; 127 + onoverride: (cid: string, createdAt: string) => void; 128 + } = $props(); 129 + ``` 130 + 131 + 2. Enable the "Review & Override" button and wire it to the callback. Replace the disabled button (line 72-74): 132 + ```svelte 133 + {@const isExpired = urgency === 'expired'} 134 + <button 135 + class="action-button" 136 + disabled={isExpired} 137 + onclick={() => onoverride(change.cid, change.createdAt)} 138 + > 139 + {isExpired ? 'Recovery Window Expired' : 'Review & Override'} 140 + </button> 141 + ``` 142 + 143 + 3. Update the `.action-button` CSS to handle enabled state: 144 + ```css 145 + .action-button { 146 + /* ... existing styles ... */ 147 + cursor: pointer; 148 + opacity: 1; 149 + } 150 + 151 + .action-button:disabled { 152 + cursor: not-allowed; 153 + opacity: 0.5; 154 + } 155 + ``` 156 + 157 + 4. Update `+page.svelte` to pass the `onoverride` callback in the `alert_detail` route block: 158 + ```svelte 159 + {:else if step === 'alert_detail'} 160 + <AlertDetailScreen 161 + did={selectedAlertDid ?? ''} 162 + changes={selectedAlertChanges} 163 + onback={() => goTo('home')} 164 + onoverride={(cid, createdAt) => { 165 + selectedRecoveryCid = cid; 166 + selectedRecoveryCreatedAt = createdAt; 167 + goTo('recovery_override'); 168 + }} 169 + /> 170 + ``` 171 + 172 + **Verification:** 173 + Run: `cd apps/identity-wallet && npx tsc --noEmit` 174 + Expected: No type errors 175 + 176 + Run: `cd apps/identity-wallet && pnpm build` 177 + Expected: Build succeeds 178 + 179 + **Commit:** `feat(identity-wallet): wire RecoveryOverrideScreen into navigation state machine` 180 + 181 + <!-- END_TASK_2 --> 182 + 183 + <!-- START_TASK_3 --> 184 + ### Task 3: Run cargo clippy and cargo fmt 185 + 186 + **Verifies:** None (code quality gate) 187 + 188 + **Files:** 189 + - Possibly modify: any files touched in Phases 1-3 190 + 191 + **Implementation:** 192 + 193 + Run the project's lint and formatting checks: 194 + ```bash 195 + cargo clippy --workspace -- -D warnings 196 + cargo fmt --all --check 197 + ``` 198 + 199 + Fix any issues found. Common issues to watch for: 200 + - Unused imports in `recovery.rs` 201 + - Missing `#[allow(unused)]` for the SE path on non-iOS builds 202 + - Formatting inconsistencies 203 + 204 + **Verification:** 205 + Run: `cargo clippy --workspace -- -D warnings` 206 + Expected: No warnings 207 + 208 + Run: `cargo fmt --all --check` 209 + Expected: No formatting issues 210 + 211 + Run: `cargo test --workspace` 212 + Expected: All tests pass 213 + 214 + **Commit:** `fix(identity-wallet): address clippy and fmt issues in recovery module` 215 + 216 + <!-- END_TASK_3 --> 217 + 218 + <!-- START_TASK_4 --> 219 + ### Task 4: Update identity-wallet CLAUDE.md 220 + 221 + **Verifies:** None (documentation) 222 + 223 + **Files:** 224 + - Modify: `apps/identity-wallet/CLAUDE.md` 225 + 226 + **Implementation:** 227 + 228 + Add the recovery module documentation to the identity-wallet CLAUDE.md. Specifically: 229 + 230 + 1. Under **Contracts → Rust Backend → Exposes**, add: 231 + ``` 232 + - `src/recovery.rs` — Recovery override module: `build_recovery_override(pds_client, did, unauthorized_op_cid) -> Result<SignedRecoveryOp, RecoveryError>` (fetches audit log, identifies fork point, builds counter-operation restoring pre-unauthorized state, signs with per-DID device key), `submit_recovery_override(pds_client, did, signed_op) -> Result<ClaimResult, RecoveryError>` (POSTs to plc.directory, updates cached log and DID doc); Tauri IPC commands: `build_recovery_override_cmd`, `submit_recovery_override_cmd`. Types: `SignedRecoveryOp` { diff, signed_op }, `RecoveryState` { did, signed_op }, `RecoveryError` (RECOVERY_WINDOW_EXPIRED, SIGNING_FAILED, PLC_DIRECTORY_ERROR, NETWORK_ERROR, IDENTITY_NOT_FOUND, UNAUTHORIZED_CHANGE_NOT_FOUND) 233 + ``` 234 + 235 + 2. Under **Contracts → Frontend → Exposes**, update `src/lib/ipc.ts` exports list to include: 236 + ``` 237 + buildRecoveryOverride(), submitRecoveryOverride(), SignedRecoveryOp, RecoveryError 238 + ``` 239 + 240 + 3. Under **Contracts → Frontend → Exposes**, add RecoveryOverrideScreen to the home components list. 241 + 242 + 4. Under **Key Files**, add: 243 + ``` 244 + - `src-tauri/src/recovery.rs` — Recovery override: build_recovery_override_cmd, submit_recovery_override_cmd; fork-point identification, per-DID signing, recovery window check 245 + ``` 246 + 247 + 5. Add relevant invariants: 248 + ``` 249 + - `RecoveryError` variant names serialize as SCREAMING_SNAKE_CASE to the frontend -- the TypeScript `RecoveryError` union in `ipc.ts` must match exactly 250 + - `SignedRecoveryOp` serializes with `#[serde(rename_all = "camelCase")]` -- TypeScript receives `{ diff, signedOp }` 251 + - Recovery window is 72 hours from the unauthorized operation's `created_at` timestamp; computed locally but enforced by plc.directory 252 + - `RecoveryState` in `AppState` uses `tokio::sync::Mutex` (same as `ClaimState`) because recovery commands hold the lock across `.await` points 253 + ``` 254 + 255 + **Verification:** 256 + Manually review the CLAUDE.md changes for accuracy. 257 + 258 + **Commit:** `docs(identity-wallet): document recovery override module in CLAUDE.md` 259 + 260 + <!-- END_TASK_4 --> 261 + 262 + <!-- END_SUBCOMPONENT_A -->
+24
docs/implementation-plans/2026-03-31-recovery-override/test-requirements.md
··· 1 + # Test Requirements: plc-key-management.AC7 (Recovery Override) 2 + 3 + Maps each AC7 acceptance criterion to specific tests. Criteria text is copied verbatim from 4 + `docs/design-plans/2026-03-28-plc-key-management.md`. 5 + 6 + ## Test Matrix 7 + 8 + | Criterion | Type | Test Location | Description | 9 + |-----------|------|---------------|-------------| 10 + | **AC7.1 Success:** `build_recovery_override` produces a signed PLC operation with `prev` pointing to the fork point CID | Unit | `apps/identity-wallet/src-tauri/src/recovery.rs` (`#[cfg(test)] mod tests`) | `find_fork_point` returns the correct `AuditEntry` whose CID becomes the `prev` field. A second test calls `build_recovery_override` (with mocked plc.directory via `httpmock`) and asserts the returned `SignedRecoveryOp.signed_op` JSON contains a `prev` value equal to the fork point CID. | 11 + | **AC7.2 Success:** Recovery operation restores the pre-unauthorized `rotationKeys`, `services`, and `verificationMethods` | Unit | `apps/identity-wallet/src-tauri/src/recovery.rs` (`#[cfg(test)] mod tests`) | Constructs a chain of signed ops where the fork-point state has known `rotationKeys`, `services`, and `verificationMethods`. Calls `build_recovery_override` (mocked network) and deserializes the `signed_op` JSON. Asserts all three fields match the fork-point state exactly. Also tests `build_op_diff` directly to verify the `OpDiff` reflects the restored services and keys. | 12 + | **AC7.3 Success:** Recovery operation is signed by the device key (highest authority) | Unit | `apps/identity-wallet/src-tauri/src/recovery.rs` (`#[cfg(test)] mod tests`) | Calls `build_recovery_override` (mocked network), extracts the `signed_op` JSON, and passes it to `crypto::verify_plc_operation` with the device key's `DidKeyUri`. Verification must succeed, proving the operation was signed by the device key. | 13 + | **AC7.4 Success:** `submit_recovery_override` POSTs to plc.directory and updates cached log | Integration | `apps/identity-wallet/src-tauri/src/recovery.rs` (`#[cfg(test)] mod tests`) | Uses `httpmock::MockServer` to mock three plc.directory endpoints: `POST /{did}` (accepts the signed op), `GET /{did}/log/audit` (returns updated audit log), and `GET /{did}` (returns updated DID document). Calls `submit_recovery_override` and asserts: (1) the mock received the correct signed operation JSON on the POST endpoint, (2) `IdentityStore::get_plc_log(did)` returns the updated log after submission, (3) the returned `ClaimResult.updated_did_doc` matches the mock DID document response. | 14 + | **AC7.5 Failure:** `build_recovery_override` returns `RECOVERY_WINDOW_EXPIRED` when the 72-hour deadline has passed | Unit | `apps/identity-wallet/src-tauri/src/recovery.rs` (`#[cfg(test)] mod tests`) | Two test cases for `check_recovery_window`: (1) timestamp older than 72 hours (`Utc::now() - Duration::hours(73)`) returns `RecoveryError::RecoveryWindowExpired`, (2) timestamp within window (`Utc::now() - Duration::hours(1)`) returns `Ok(())`. A third test passes an invalid timestamp string and asserts an appropriate error. An integration-level test calls `build_recovery_override` (mocked network returning an audit log with a >72h-old unauthorized op) and asserts the full function returns `RecoveryWindowExpired`. | 15 + | **AC7.6 Success:** Recovery override screen shows the counter-operation diff with confirm/cancel | Human verification | `apps/identity-wallet/src/lib/components/home/RecoveryOverrideScreen.svelte` | **Justification:** This criterion requires visual verification of UI layout, diff rendering, and interactive button behavior on iOS. Automated unit tests cannot meaningfully verify that the diff is visually correct, that the countdown renders properly, or that confirm/cancel buttons are positioned and styled correctly. **Verification approach:** (1) Launch the app in iOS Simulator, navigate to an identity with a simulated unauthorized change, tap "Review & Override" from `AlertDetailScreen`. (2) Verify: the diff section displays rotation keys, services, and verification methods being restored with `+`/`~` indicators; the recovery deadline countdown is visible and updating; "Confirm & Submit" and "Cancel" buttons are both present and tappable. (3) Tap "Cancel" and verify navigation returns to `AlertDetailScreen`. (4) Re-enter, tap "Confirm & Submit", verify loading state appears and success navigates to home. (5) TypeScript type-checking (`npx tsc --noEmit`) and SvelteKit build (`pnpm build`) serve as automated structural verification that the component compiles and its props/imports are correct. | 16 + | **AC7.7 Edge:** Multiple unauthorized operations in sequence -- recovery override targets the earliest fork point | Unit | `apps/identity-wallet/src-tauri/src/recovery.rs` (`#[cfg(test)] mod tests`) | Constructs an audit log with: (1) a device-key-signed genesis op, (2) two sequential unauthorized ops signed by an attacker key. Calls `find_fork_point` targeting the second unauthorized op's CID and asserts the returned fork point is the genesis op (the last device-key-signed op before any unauthorized change). A second test targets the first unauthorized op's CID and asserts the same fork point. A third integration-level test calls `build_recovery_override` (mocked network) with the later unauthorized CID and verifies `signed_op.prev` equals the genesis CID, not the first unauthorized op's CID. | 17 + 18 + ## Notes 19 + 20 + - All Rust tests use inline `#[cfg(test)] mod tests` within `recovery.rs`, following the existing project convention (see `plc_monitor.rs`, `claim.rs`). 21 + - Integration tests that require network mocking use `httpmock::MockServer`, matching the established pattern in `plc_monitor.rs`. 22 + - Test keys are generated via `crypto::generate_p256_keypair()` and operations are built with `crypto::build_did_plc_rotation_op` to produce real signed PLC operations, not hand-crafted JSON. 23 + - AC7.6 is the only criterion requiring human verification. All other criteria are fully automatable with the existing test infrastructure. 24 + - Run all recovery tests with: `cargo test -p identity-wallet recovery`