···88pub mod oauth_client;
99pub mod pds_client;
1010pub mod plc_monitor;
1111+pub mod recovery;
11121213use crypto::{build_did_plc_genesis_op_with_external_signer, CryptoError, DidKeyUri};
1314use serde::{Deserialize, Serialize};
+54
apps/identity-wallet/src-tauri/src/recovery.rs
···11+// pattern: Mixed (Functional Core types + Imperative Shell commands)
22+//
33+// Functional Core: Types and error enums for recovery override operations
44+// Imperative Shell: Recovery override building and submission commands (in later phases)
55+66+use crate::claim::OpDiff;
77+use serde::Serialize;
88+99+/// Result of building a recovery override operation.
1010+/// Mirrors `VerifiedClaimOp` from `claim.rs` but without `warnings`.
1111+#[derive(Debug, Serialize, Clone)]
1212+#[serde(rename_all = "camelCase")]
1313+pub struct SignedRecoveryOp {
1414+ /// Human-readable diff of what the recovery operation changes.
1515+ pub diff: OpDiff,
1616+ /// The signed PLC operation JSON, ready to POST to plc.directory.
1717+ pub signed_op: serde_json::Value,
1818+}
1919+2020+/// Errors from recovery override operations.
2121+#[derive(Debug, Serialize, thiserror::Error)]
2222+#[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")]
2323+pub enum RecoveryError {
2424+ #[error("Recovery window has expired (72 hours elapsed)")]
2525+ RecoveryWindowExpired,
2626+ #[error("Signing failed: {message}")]
2727+ SigningFailed { message: String },
2828+ #[error("PLC directory error: {message}")]
2929+ PlcDirectoryError { message: String },
3030+ #[error("Network error: {message}")]
3131+ NetworkError { message: String },
3232+ #[error("Identity not found: {message}")]
3333+ IdentityNotFound { message: String },
3434+ #[error("No unauthorized changes found for the given CID")]
3535+ UnauthorizedChangeNotFound,
3636+}
3737+3838+#[cfg(test)]
3939+mod tests {
4040+ use super::*;
4141+4242+ #[test]
4343+ fn test_recovery_error_serialization() {
4444+ let err = RecoveryError::RecoveryWindowExpired;
4545+ let serialized = serde_json::to_value(&err).unwrap();
4646+ assert_eq!(serialized.get("code").map(|v| v.as_str()), Some(Some("RECOVERY_WINDOW_EXPIRED")));
4747+4848+ let err2 = RecoveryError::SigningFailed {
4949+ message: "test error".to_string(),
5050+ };
5151+ let serialized2 = serde_json::to_value(&err2).unwrap();
5252+ assert_eq!(serialized2.get("code").map(|v| v.as_str()), Some(Some("SIGNING_FAILED")));
5353+ }
5454+}
···11+# Recovery Override Implementation Plan
22+33+**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.
44+55+**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`.
66+77+**Tech Stack:** Rust (Tauri v2 backend), SvelteKit 2 + Svelte 5 (frontend), crypto crate (PLC operations), iOS Keychain (key storage)
88+99+**Scope:** 4 phases from design Phase 7 (recovery override)
1010+1111+**Codebase verified:** 2026-03-31
1212+1313+---
1414+1515+## Acceptance Criteria Coverage
1616+1717+This phase implements and tests:
1818+1919+### plc-key-management.AC7: Recovery override
2020+- **plc-key-management.AC7.1 Success:** `build_recovery_override` produces a signed PLC operation with `prev` pointing to the fork point CID
2121+- **plc-key-management.AC7.2 Success:** Recovery operation restores the pre-unauthorized `rotationKeys`, `services`, and `verificationMethods`
2222+- **plc-key-management.AC7.3 Success:** Recovery operation is signed by the device key (highest authority)
2323+- **plc-key-management.AC7.5 Failure:** `build_recovery_override` returns `RECOVERY_WINDOW_EXPIRED` when the 72-hour deadline has passed
2424+- **plc-key-management.AC7.7 Edge:** Multiple unauthorized operations in sequence — recovery override targets the earliest fork point
2525+2626+---
2727+2828+## Phase 1: Recovery module — types, error types, and fork-point identification
2929+3030+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.
3131+3232+<!-- START_SUBCOMPONENT_A (tasks 1-3) -->
3333+3434+<!-- START_TASK_1 -->
3535+### Task 1: Create recovery.rs with types and error enum
3636+3737+**Verifies:** None (infrastructure — types only)
3838+3939+**Files:**
4040+- Create: `apps/identity-wallet/src-tauri/src/recovery.rs`
4141+- Modify: `apps/identity-wallet/src-tauri/src/lib.rs` (add `pub mod recovery;`)
4242+4343+**Implementation:**
4444+4545+Create `recovery.rs` with these types, following the existing patterns from `claim.rs` and `plc_monitor.rs`:
4646+4747+```rust
4848+use serde::Serialize;
4949+use std::collections::BTreeMap;
5050+5151+use crate::claim::{ClaimResult, OpDiff};
5252+5353+/// Result of building a recovery override operation.
5454+/// Mirrors `VerifiedClaimOp` from `claim.rs` but without `warnings`.
5555+#[derive(Debug, Serialize, Clone)]
5656+#[serde(rename_all = "camelCase")]
5757+pub struct SignedRecoveryOp {
5858+ /// Human-readable diff of what the recovery operation changes.
5959+ pub diff: OpDiff,
6060+ /// The signed PLC operation JSON, ready to POST to plc.directory.
6161+ pub signed_op: serde_json::Value,
6262+}
6363+6464+/// Errors from recovery override operations.
6565+#[derive(Debug, Serialize, thiserror::Error)]
6666+#[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")]
6767+pub enum RecoveryError {
6868+ #[error("Recovery window has expired (72 hours elapsed)")]
6969+ RecoveryWindowExpired,
7070+ #[error("Signing failed: {message}")]
7171+ SigningFailed { message: String },
7272+ #[error("PLC directory error: {message}")]
7373+ PlcDirectoryError { message: String },
7474+ #[error("Network error: {message}")]
7575+ NetworkError { message: String },
7676+ #[error("Identity not found: {message}")]
7777+ IdentityNotFound { message: String },
7878+ #[error("No unauthorized changes found for the given CID")]
7979+ UnauthorizedChangeNotFound,
8080+}
8181+```
8282+8383+Add `pub mod recovery;` to `lib.rs` alongside the other module declarations (after `pub mod plc_monitor;`).
8484+8585+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.
8686+8787+**Verification:**
8888+Run: `cargo build -p identity-wallet 2>&1 | head -5`
8989+Expected: Build succeeds (no errors)
9090+9191+**Commit:** `feat(identity-wallet): add recovery module with types and error enum`
9292+9393+<!-- END_TASK_1 -->
9494+9595+<!-- START_TASK_2 -->
9696+### Task 2: Implement find_fork_point function
9797+9898+**Verifies:** plc-key-management.AC7.1 (fork point identification is prerequisite), plc-key-management.AC7.7 (earliest fork point for multiple unauthorized ops)
9999+100100+**Files:**
101101+- Modify: `apps/identity-wallet/src-tauri/src/recovery.rs`
102102+103103+**Implementation:**
104104+105105+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.
106106+107107+```rust
108108+use crypto::{AuditEntry, DidKeyUri};
109109+110110+/// Identifies the fork point — the last legitimate operation before unauthorized changes began.
111111+///
112112+/// Walks backward through the audit log from the target unauthorized operation CID.
113113+/// For multiple sequential unauthorized ops (AC7.7), returns the earliest fork point
114114+/// (the last device-key-signed op before the first unauthorized op in the sequence).
115115+///
116116+/// Returns `(fork_point_entry, pre_unauthorized_state)` where:
117117+/// - `fork_point_entry` is the last legitimate AuditEntry (its CID becomes the `prev` for the counter-op)
118118+/// - `pre_unauthorized_state` is the VerifiedPlcOp representing the state to restore
119119+pub(crate) fn find_fork_point(
120120+ audit_log: &[AuditEntry],
121121+ unauthorized_op_cid: &str,
122122+ device_key: &DidKeyUri,
123123+) -> Result<(AuditEntry, crypto::VerifiedPlcOp), RecoveryError> {
124124+ // Find the index of the unauthorized operation in the audit log.
125125+ let target_idx = audit_log
126126+ .iter()
127127+ .position(|e| e.cid == unauthorized_op_cid)
128128+ .ok_or(RecoveryError::UnauthorizedChangeNotFound)?;
129129+130130+ if target_idx == 0 {
131131+ return Err(RecoveryError::SigningFailed {
132132+ message: "Cannot recover from the genesis operation".to_string(),
133133+ });
134134+ }
135135+136136+ // Walk backward from the operation BEFORE the unauthorized one to find the
137137+ // last operation signed by the device key. This handles AC7.7: if multiple
138138+ // unauthorized ops are in sequence, we skip past all of them to find the
139139+ // earliest fork point.
140140+ for i in (0..target_idx).rev() {
141141+ let entry = &audit_log[i];
142142+ let op_json = serde_json::to_string(&entry.operation).map_err(|e| {
143143+ RecoveryError::SigningFailed {
144144+ message: format!("Failed to serialize operation: {e}"),
145145+ }
146146+ })?;
147147+148148+ // Try to verify with the device key. If verification succeeds,
149149+ // this is the last legitimate operation (the fork point).
150150+ match crypto::verify_plc_operation(&op_json, &[device_key.clone()]) {
151151+ Ok(verified) => return Ok((entry.clone(), verified)),
152152+ Err(_) => continue, // Not signed by device key, keep looking
153153+ }
154154+ }
155155+156156+ Err(RecoveryError::SigningFailed {
157157+ message: "No device-key-signed operation found before the unauthorized change".to_string(),
158158+ })
159159+}
160160+```
161161+162162+**Testing:**
163163+164164+Tests must verify:
165165+- plc-key-management.AC7.1: Fork point identification returns the correct entry (the one whose CID becomes `prev`)
166166+- 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)
167167+168168+Test scenarios:
169169+1. Single unauthorized op after a device-key-signed genesis → fork point is the genesis
170170+2. Two unauthorized ops in sequence → fork point is the last device-key-signed op before both
171171+3. Target CID not found in audit log → returns `UnauthorizedChangeNotFound`
172172+4. Target CID is the genesis op (index 0) → returns error
173173+174174+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.
175175+176176+**Verification:**
177177+Run: `cargo test -p identity-wallet find_fork_point`
178178+Expected: All tests pass
179179+180180+**Commit:** `feat(identity-wallet): implement fork-point identification for recovery override`
181181+182182+<!-- END_TASK_2 -->
183183+184184+<!-- START_TASK_3 -->
185185+### Task 3: Implement check_recovery_window function
186186+187187+**Verifies:** plc-key-management.AC7.5 (RECOVERY_WINDOW_EXPIRED when 72h deadline passed)
188188+189189+**Files:**
190190+- Modify: `apps/identity-wallet/src-tauri/src/recovery.rs`
191191+192192+**Implementation:**
193193+194194+Add the recovery window check. The 72-hour window is computed from the unauthorized operation's `created_at` timestamp.
195195+196196+```rust
197197+use chrono::{DateTime, Duration, Utc};
198198+199199+const RECOVERY_WINDOW_HOURS: i64 = 72;
200200+201201+/// Checks whether the 72-hour recovery window is still open for an unauthorized operation.
202202+///
203203+/// Returns `Ok(())` if recovery is still possible, or `Err(RecoveryWindowExpired)` if
204204+/// the 72-hour deadline has passed.
205205+pub(crate) fn check_recovery_window(
206206+ unauthorized_op_created_at: &str,
207207+) -> Result<(), RecoveryError> {
208208+ let op_time = DateTime::parse_from_rfc3339(unauthorized_op_created_at)
209209+ .map_err(|e| RecoveryError::SigningFailed {
210210+ message: format!("Failed to parse operation timestamp: {e}"),
211211+ })?
212212+ .with_timezone(&Utc);
213213+214214+ let deadline = op_time + Duration::hours(RECOVERY_WINDOW_HOURS);
215215+216216+ if Utc::now() > deadline {
217217+ return Err(RecoveryError::RecoveryWindowExpired);
218218+ }
219219+220220+ Ok(())
221221+}
222222+```
223223+224224+Note: Add `chrono` following the workspace dependency convention:
225225+1. In the root `Cargo.toml`, add to `[workspace.dependencies]`:
226226+```toml
227227+chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
228228+```
229229+2. In `apps/identity-wallet/src-tauri/Cargo.toml`, add to `[dependencies]`:
230230+```toml
231231+chrono = { workspace = true }
232232+```
233233+234234+**Testing:**
235235+236236+Tests must verify:
237237+- plc-key-management.AC7.5: Timestamps older than 72 hours return `RecoveryWindowExpired`
238238+- Timestamps within 72 hours return `Ok(())`
239239+- Invalid timestamp strings return an appropriate error
240240+241241+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).
242242+243243+**Verification:**
244244+Run: `cargo test -p identity-wallet check_recovery_window`
245245+Expected: All tests pass
246246+247247+**Commit:** `feat(identity-wallet): add recovery window expiry check`
248248+249249+<!-- END_TASK_3 -->
250250+251251+<!-- END_SUBCOMPONENT_A -->
···11+# Recovery Override Implementation Plan
22+33+**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.
44+55+**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`.
66+77+**Tech Stack:** Rust (Tauri v2 backend), SvelteKit 2 + Svelte 5 (frontend), crypto crate (PLC operations), iOS Keychain (key storage)
88+99+**Scope:** 4 phases from design Phase 7 (recovery override)
1010+1111+**Codebase verified:** 2026-03-31
1212+1313+---
1414+1515+## Acceptance Criteria Coverage
1616+1717+This phase implements and tests:
1818+1919+### plc-key-management.AC7: Recovery override
2020+- **plc-key-management.AC7.1 Success:** `build_recovery_override` produces a signed PLC operation with `prev` pointing to the fork point CID
2121+- **plc-key-management.AC7.2 Success:** Recovery operation restores the pre-unauthorized `rotationKeys`, `services`, and `verificationMethods`
2222+- **plc-key-management.AC7.3 Success:** Recovery operation is signed by the device key (highest authority)
2323+- **plc-key-management.AC7.5 Failure:** `build_recovery_override` returns `RECOVERY_WINDOW_EXPIRED` when the 72-hour deadline has passed
2424+- **plc-key-management.AC7.7 Edge:** Multiple unauthorized operations in sequence — recovery override targets the earliest fork point
2525+2626+---
2727+2828+## Phase 2: build_recovery_override implementation
2929+3030+This phase implements the core `build_recovery_override` function that constructs a signed counter-operation.
3131+3232+<!-- START_SUBCOMPONENT_A (tasks 1-3) -->
3333+3434+<!-- START_TASK_1 -->
3535+### Task 1: Implement build_op_diff helper
3636+3737+**Verifies:** plc-key-management.AC7.2 (diff shows pre-unauthorized state restoration)
3838+3939+**Files:**
4040+- Modify: `apps/identity-wallet/src-tauri/src/recovery.rs`
4141+4242+**Implementation:**
4343+4444+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`.
4545+4646+```rust
4747+use crate::claim::{ChangeType, OpDiff, ServiceChange};
4848+4949+/// Computes the diff between the current unauthorized state and the state being
5050+/// restored by the recovery operation.
5151+///
5252+/// `fork_point_state`: the VerifiedPlcOp at the fork point (state to restore)
5353+/// `fork_point_cid`: the CID of the fork point operation (becomes `prev` in counter-op)
5454+pub(crate) fn build_op_diff(
5555+ fork_point_state: &crypto::VerifiedPlcOp,
5656+ fork_point_cid: &str,
5757+) -> OpDiff {
5858+ // The recovery op restores fork_point_state, so the "added" keys are those
5959+ // in the fork point but not in the current (unauthorized) state. Since we
6060+ // don't have the unauthorized state readily available as a VerifiedPlcOp,
6161+ // we report the full fork-point state as what's being restored.
6262+ OpDiff {
6363+ added_keys: fork_point_state.rotation_keys.clone(),
6464+ removed_keys: vec![],
6565+ changed_services: fork_point_state
6666+ .services
6767+ .iter()
6868+ .map(|(id, svc)| ServiceChange {
6969+ id: id.clone(),
7070+ change_type: ChangeType::Modified,
7171+ old_endpoint: None,
7272+ new_endpoint: Some(svc.endpoint.clone()),
7373+ })
7474+ .collect(),
7575+ prev_cid: Some(fork_point_cid.to_string()),
7676+ }
7777+}
7878+```
7979+8080+**Verification:**
8181+Run: `cargo build -p identity-wallet`
8282+Expected: Build succeeds
8383+8484+**Commit:** `feat(identity-wallet): add recovery op diff builder`
8585+8686+<!-- END_TASK_1 -->
8787+8888+<!-- START_TASK_2 -->
8989+### Task 2: Implement build_recovery_override function
9090+9191+**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
9292+9393+**Files:**
9494+- Modify: `apps/identity-wallet/src-tauri/src/recovery.rs`
9595+9696+**Implementation:**
9797+9898+This is the core function. It:
9999+1. Fetches the audit log from plc.directory
100100+2. Finds the unauthorized operation and checks the recovery window
101101+3. Identifies the fork point (handling multiple sequential unauthorized ops per AC7.7)
102102+4. Builds a counter-operation restoring the fork-point state
103103+5. Signs with the per-DID device key
104104+105105+```rust
106106+use crate::identity_store::IdentityStore;
107107+use crate::pds_client::PdsClient;
108108+109109+/// Builds a signed recovery override operation.
110110+///
111111+/// Fetches the full audit log, identifies the fork point (last device-key-signed
112112+/// operation before the unauthorized change), builds a PLC rotation op that
113113+/// restores the pre-unauthorized state, and signs it with the per-DID device key.
114114+///
115115+/// For multiple sequential unauthorized ops (AC7.7), targets the earliest fork point.
116116+pub async fn build_recovery_override(
117117+ pds_client: &PdsClient,
118118+ did: &str,
119119+ unauthorized_op_cid: &str,
120120+) -> Result<SignedRecoveryOp, RecoveryError> {
121121+ let store = IdentityStore;
122122+123123+ // 1. Fetch the current full audit log from plc.directory.
124124+ let audit_log_json = pds_client
125125+ .fetch_audit_log(did)
126126+ .await
127127+ .map_err(|e| RecoveryError::NetworkError {
128128+ message: format!("Failed to fetch audit log: {e}"),
129129+ })?;
130130+131131+ let audit_log = crypto::parse_audit_log(&audit_log_json).map_err(|e| {
132132+ RecoveryError::SigningFailed {
133133+ message: format!("Failed to parse audit log: {e}"),
134134+ }
135135+ })?;
136136+137137+ // 2. Find the unauthorized operation and check the recovery window.
138138+ let unauthorized_entry = audit_log
139139+ .iter()
140140+ .find(|e| e.cid == unauthorized_op_cid)
141141+ .ok_or(RecoveryError::UnauthorizedChangeNotFound)?;
142142+143143+ check_recovery_window(&unauthorized_entry.created_at)?;
144144+145145+ // 3. Get the device key for this DID.
146146+ let device_pub = store.get_or_create_device_key(did).map_err(|e| {
147147+ RecoveryError::IdentityNotFound {
148148+ message: format!("Failed to get device key: {e}"),
149149+ }
150150+ })?;
151151+ let device_key_uri = DidKeyUri(device_pub.key_id.clone());
152152+153153+ // 4. Identify the fork point.
154154+ let (fork_entry, fork_state) =
155155+ find_fork_point(&audit_log, unauthorized_op_cid, &device_key_uri)?;
156156+157157+ // 5. Build the counter-operation restoring the fork-point state.
158158+ // The `prev` field points to the fork point's CID.
159159+ let diff = build_op_diff(&fork_state, &fork_entry.cid);
160160+161161+ // 6. Sign with the per-DID device key.
162162+ // On macOS/simulator: read private key bytes from Keychain, sign with P-256.
163163+ // On real iOS: use Secure Enclave via the app label in Keychain.
164164+ let signed_op = sign_recovery_op(
165165+ did,
166166+ &fork_entry.cid,
167167+ &fork_state,
168168+ )?;
169169+170170+ Ok(SignedRecoveryOp {
171171+ diff,
172172+ signed_op: serde_json::from_str(&signed_op.signed_op_json).map_err(|e| {
173173+ RecoveryError::SigningFailed {
174174+ message: format!("Failed to parse signed op JSON: {e}"),
175175+ }
176176+ })?,
177177+ })
178178+}
179179+180180+/// Signs a recovery operation using the per-DID device key.
181181+///
182182+/// Uses the same `#[cfg]` dispatch pattern as `identity_store.rs`:
183183+/// - macOS/simulator: reads private key bytes from Keychain, creates P-256 signing closure
184184+/// - Real iOS: reads SE app label from Keychain, signs via Secure Enclave
185185+fn sign_recovery_op(
186186+ did: &str,
187187+ prev_cid: &str,
188188+ fork_state: &crypto::VerifiedPlcOp,
189189+) -> Result<crypto::SignedPlcOperation, RecoveryError> {
190190+ let sign_closure = build_sign_closure(did)?;
191191+192192+ crypto::build_did_plc_rotation_op(
193193+ prev_cid,
194194+ fork_state.rotation_keys.clone(),
195195+ fork_state.verification_methods.clone(),
196196+ fork_state.also_known_as.clone(),
197197+ fork_state.services.clone(),
198198+ sign_closure,
199199+ )
200200+ .map_err(|e| RecoveryError::SigningFailed {
201201+ message: format!("Failed to build rotation op: {e}"),
202202+ })
203203+}
204204+205205+/// Builds a signing closure for the per-DID device key.
206206+///
207207+/// macOS/simulator path: reads the raw P-256 private key scalar from Keychain
208208+/// and returns a closure that signs CBOR bytes using RFC 6979 deterministic ECDSA.
209209+#[cfg(any(target_os = "macos", all(target_os = "ios", target_env = "sim")))]
210210+fn build_sign_closure(
211211+ did: &str,
212212+) -> Result<impl FnOnce(&[u8]) -> Result<Vec<u8>, crypto::CryptoError>, RecoveryError> {
213213+ use p256::ecdsa::signature::Signer;
214214+ use p256::ecdsa::{Signature, SigningKey};
215215+216216+ let account = format!("{did}:device-key");
217217+ let private_bytes = crate::keychain::get_item(&account).map_err(|e| {
218218+ if crate::keychain::is_not_found(&e) {
219219+ RecoveryError::IdentityNotFound {
220220+ message: "Device key not found in Keychain".to_string(),
221221+ }
222222+ } else {
223223+ RecoveryError::SigningFailed {
224224+ message: format!("Keychain error: {e}"),
225225+ }
226226+ }
227227+ })?;
228228+229229+ let signing_key = SigningKey::from_slice(&private_bytes).map_err(|_| {
230230+ RecoveryError::SigningFailed {
231231+ message: "Invalid P-256 private key in Keychain".to_string(),
232232+ }
233233+ })?;
234234+235235+ Ok(move |data: &[u8]| -> Result<Vec<u8>, crypto::CryptoError> {
236236+ let signature: Signature = signing_key.sign(data);
237237+ let signature = signature.normalize_s().unwrap_or(signature);
238238+ Ok(signature.to_bytes().to_vec())
239239+ })
240240+}
241241+242242+/// Builds a signing closure for the per-DID device key (Secure Enclave path).
243243+#[cfg(all(target_os = "ios", not(target_env = "sim")))]
244244+fn build_sign_closure(
245245+ did: &str,
246246+) -> Result<impl FnOnce(&[u8]) -> Result<Vec<u8>, crypto::CryptoError>, RecoveryError> {
247247+ use p256::ecdsa::Signature;
248248+249249+ let app_label_account = format!("{did}:device-key-app-label");
250250+ let app_label = crate::keychain::get_item(&app_label_account).map_err(|e| {
251251+ if crate::keychain::is_not_found(&e) {
252252+ RecoveryError::IdentityNotFound {
253253+ message: "Device key app label not found in Keychain".to_string(),
254254+ }
255255+ } else {
256256+ RecoveryError::SigningFailed {
257257+ message: format!("Keychain error: {e}"),
258258+ }
259259+ }
260260+ })?;
261261+262262+ Ok(move |data: &[u8]| -> Result<Vec<u8>, crypto::CryptoError> {
263263+ use security_framework::item::{ItemClass, ItemSearchOptions, SearchResult};
264264+ use security_framework::key::Algorithm;
265265+266266+ let query_results = ItemSearchOptions::new()
267267+ .class(ItemClass::key())
268268+ .application_label(&app_label)
269269+ .load_refs(true)
270270+ .search()
271271+ .map_err(|e| {
272272+ crypto::CryptoError::PlcOperation(format!("SE key lookup failed: {e}"))
273273+ })?;
274274+275275+ let sec_key = match query_results.first() {
276276+ Some(SearchResult::Ref(r)) => r.as_sec_key().ok_or_else(|| {
277277+ crypto::CryptoError::PlcOperation("SE result is not a key".into())
278278+ })?,
279279+ _ => {
280280+ return Err(crypto::CryptoError::PlcOperation(
281281+ "SE key not found".into(),
282282+ ))
283283+ }
284284+ };
285285+286286+ let der_sig = sec_key
287287+ .create_signature(Algorithm::ECDSASignatureMessageX962SHA256, data)
288288+ .map_err(|e| {
289289+ crypto::CryptoError::PlcOperation(format!("SE signing failed: {e}"))
290290+ })?;
291291+292292+ let sig = Signature::from_der(&der_sig).map_err(|e| {
293293+ crypto::CryptoError::PlcOperation(format!("DER decode failed: {e}"))
294294+ })?;
295295+ let sig = sig.normalize_s().unwrap_or(sig);
296296+ Ok(sig.to_bytes().to_vec())
297297+ })
298298+}
299299+```
300300+301301+**Testing:**
302302+303303+Tests must verify:
304304+- plc-key-management.AC7.1: The built counter-op has `prev` pointing to the fork point CID
305305+- plc-key-management.AC7.2: The counter-op restores the fork-point `rotationKeys`, `services`, and `verificationMethods`
306306+- plc-key-management.AC7.3: The counter-op is signed by the device key (verifiable via `crypto::verify_plc_operation`)
307307+- plc-key-management.AC7.5: Calling with a timestamp >72h ago returns `RecoveryWindowExpired`
308308+- plc-key-management.AC7.7: With multiple sequential unauthorized ops, the counter-op's `prev` targets the earliest fork point
309309+310310+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.
311311+312312+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.
313313+314314+**Verification:**
315315+Run: `cargo test -p identity-wallet build_recovery_override`
316316+Expected: All tests pass
317317+318318+**Commit:** `feat(identity-wallet): implement build_recovery_override with per-DID signing`
319319+320320+<!-- END_TASK_2 -->
321321+322322+<!-- START_TASK_3 -->
323323+### Task 3: Store pending recovery op in AppState
324324+325325+**Verifies:** None (infrastructure — state management for submit flow)
326326+327327+**Files:**
328328+- Modify: `apps/identity-wallet/src-tauri/src/recovery.rs`
329329+- Modify: `apps/identity-wallet/src-tauri/src/oauth.rs` (add recovery_state field to AppState)
330330+331331+**Implementation:**
332332+333333+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`.
334334+335335+In `recovery.rs`, add:
336336+```rust
337337+/// State for a pending recovery override, held between build and submit.
338338+pub struct RecoveryState {
339339+ pub did: String,
340340+ pub signed_op: serde_json::Value,
341341+}
342342+```
343343+344344+In `oauth.rs`, add `recovery_state` to `AppState`:
345345+```rust
346346+pub recovery_state: tokio::sync::Mutex<Option<crate::recovery::RecoveryState>>,
347347+```
348348+349349+Initialize as `recovery_state: tokio::sync::Mutex::new(None)` in `AppState::new()`.
350350+351351+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).
352352+353353+**Verification:**
354354+Run: `cargo build -p identity-wallet`
355355+Expected: Build succeeds
356356+357357+**Commit:** `feat(identity-wallet): add RecoveryState to AppState for pending recovery ops`
358358+359359+<!-- END_TASK_3 -->
360360+361361+<!-- END_SUBCOMPONENT_A -->
···11+# Test Requirements: plc-key-management.AC7 (Recovery Override)
22+33+Maps each AC7 acceptance criterion to specific tests. Criteria text is copied verbatim from
44+`docs/design-plans/2026-03-28-plc-key-management.md`.
55+66+## Test Matrix
77+88+| Criterion | Type | Test Location | Description |
99+|-----------|------|---------------|-------------|
1010+| **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. |
1111+| **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. |
1212+| **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. |
1313+| **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. |
1414+| **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`. |
1515+| **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. |
1616+| **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. |
1717+1818+## Notes
1919+2020+- All Rust tests use inline `#[cfg(test)] mod tests` within `recovery.rs`, following the existing project convention (see `plc_monitor.rs`, `claim.rs`).
2121+- Integration tests that require network mocking use `httpmock::MockServer`, matching the established pattern in `plc_monitor.rs`.
2222+- 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.
2323+- AC7.6 is the only criterion requiring human verification. All other criteria are fully automatable with the existing test infrastructure.
2424+- Run all recovery tests with: `cargo test -p identity-wallet recovery`