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

Configure Feed

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

feat(identity-wallet): implement fork-point identification for recovery override

- Add find_fork_point function to identify last device-key-signed operation
- Supports AC7.1 (fork point identification) and AC7.7 (multiple sequential unauthorized ops)
- Comprehensive test coverage: single unauthorized op, not found, genesis case, multiple ops
- All tests passing

+252
+252
apps/identity-wallet/src-tauri/src/recovery.rs
··· 5 5 6 6 use crate::claim::OpDiff; 7 7 use serde::Serialize; 8 + use crypto::{AuditEntry, DidKeyUri}; 8 9 9 10 /// Result of building a recovery override operation. 10 11 /// Mirrors `VerifiedClaimOp` from `claim.rs` but without `warnings`. ··· 35 36 UnauthorizedChangeNotFound, 36 37 } 37 38 39 + /// Identifies the fork point — the last legitimate operation before unauthorized changes began. 40 + /// 41 + /// Walks backward through the audit log from the target unauthorized operation CID. 42 + /// For multiple sequential unauthorized ops (AC7.7), returns the earliest fork point 43 + /// (the last device-key-signed op before the first unauthorized op in the sequence). 44 + /// 45 + /// Returns `(fork_point_entry, pre_unauthorized_state)` where: 46 + /// - `fork_point_entry` is the last legitimate AuditEntry (its CID becomes the `prev` for the counter-op) 47 + /// - `pre_unauthorized_state` is the VerifiedPlcOp representing the state to restore 48 + #[allow(dead_code)] 49 + pub(crate) fn find_fork_point( 50 + audit_log: &[AuditEntry], 51 + unauthorized_op_cid: &str, 52 + device_key: &DidKeyUri, 53 + ) -> Result<(AuditEntry, crypto::VerifiedPlcOp), RecoveryError> { 54 + // Find the index of the unauthorized operation in the audit log. 55 + let target_idx = audit_log 56 + .iter() 57 + .position(|e| e.cid == unauthorized_op_cid) 58 + .ok_or(RecoveryError::UnauthorizedChangeNotFound)?; 59 + 60 + if target_idx == 0 { 61 + return Err(RecoveryError::SigningFailed { 62 + message: "Cannot recover from the genesis operation".to_string(), 63 + }); 64 + } 65 + 66 + // Walk backward from the operation BEFORE the unauthorized one to find the 67 + // last operation signed by the device key. This handles AC7.7: if multiple 68 + // unauthorized ops are in sequence, we skip past all of them to find the 69 + // earliest fork point. 70 + for i in (0..target_idx).rev() { 71 + let entry = &audit_log[i]; 72 + let op_json = serde_json::to_string(&entry.operation).map_err(|e| { 73 + RecoveryError::SigningFailed { 74 + message: format!("Failed to serialize operation: {e}"), 75 + } 76 + })?; 77 + 78 + // Try to verify with the device key. If verification succeeds, 79 + // this is the last legitimate operation (the fork point). 80 + match crypto::verify_plc_operation(&op_json, &[device_key.clone()]) { 81 + Ok(verified) => return Ok((entry.clone(), verified)), 82 + Err(_) => continue, // Not signed by device key, keep looking 83 + } 84 + } 85 + 86 + Err(RecoveryError::SigningFailed { 87 + message: "No device-key-signed operation found before the unauthorized change".to_string(), 88 + }) 89 + } 90 + 38 91 #[cfg(test)] 39 92 mod tests { 40 93 use super::*; ··· 50 103 }; 51 104 let serialized2 = serde_json::to_value(&err2).unwrap(); 52 105 assert_eq!(serialized2.get("code").map(|v| v.as_str()), Some(Some("SIGNING_FAILED"))); 106 + } 107 + 108 + #[test] 109 + fn test_find_fork_point_single_unauthorized_after_genesis() { 110 + // Setup: Generate two keys - the device key will sign operations, another rotation key for initial setup 111 + let device_key = crypto::generate_p256_keypair().expect("device key generation"); 112 + let device_key_uri = device_key.key_id; 113 + 114 + let rotation_key = crypto::generate_p256_keypair().expect("rotation key generation"); 115 + 116 + // Build genesis operation where device key is the signing key (simulating a device-signed operation) 117 + let genesis_op = crypto::build_did_plc_genesis_op( 118 + &rotation_key.key_id, 119 + &device_key_uri, 120 + &device_key.private_key_bytes, 121 + "alice.test", 122 + "https://pds.test", 123 + ) 124 + .expect("build genesis op"); 125 + 126 + let genesis_operation: serde_json::Value = 127 + serde_json::from_str(&genesis_op.signed_op_json).expect("parse genesis op json"); 128 + 129 + let genesis_cid = "bafy_genesis"; 130 + let did = &genesis_op.did; 131 + 132 + // Create an unauthorized operation entry (signed by attacker, not device key) 133 + let attacker_key = crypto::generate_p256_keypair().expect("attacker key generation"); 134 + let unauth_operation = serde_json::json!({ 135 + "type": "plc_operation", 136 + "prev": genesis_cid, 137 + "rotationKeys": [attacker_key.key_id.0.as_str()], 138 + "verificationMethods": {}, 139 + "services": {}, 140 + "alsoKnownAs": [], 141 + "sig": "fake_signature_from_attacker" 142 + }); 143 + 144 + // Create audit log JSON and parse it to get proper AuditEntry structs 145 + let unauthorized_cid = "bafy_unauthorized"; 146 + let audit_log_json = serde_json::json!([ 147 + { 148 + "did": did, 149 + "cid": genesis_cid, 150 + "createdAt": "2026-03-29T00:00:00Z", 151 + "nullified": false, 152 + "operation": genesis_operation 153 + }, 154 + { 155 + "did": did, 156 + "cid": unauthorized_cid, 157 + "createdAt": "2026-03-29T01:00:00Z", 158 + "nullified": false, 159 + "operation": unauth_operation 160 + } 161 + ]); 162 + 163 + let audit_log_str = serde_json::to_string(&audit_log_json).expect("serialize audit log"); 164 + let audit_log = crypto::parse_audit_log(&audit_log_str).expect("parse audit log"); 165 + 166 + // Test: find_fork_point should return the genesis entry (which was signed by device_key) 167 + let (fork_entry, _verified) = find_fork_point(&audit_log, unauthorized_cid, &device_key_uri) 168 + .expect("find_fork_point succeeds"); 169 + 170 + assert_eq!(fork_entry.cid, genesis_cid); 171 + assert_eq!(fork_entry.created_at, "2026-03-29T00:00:00Z"); 172 + } 173 + 174 + #[test] 175 + fn test_find_fork_point_target_cid_not_found() { 176 + let device_key = crypto::generate_p256_keypair().expect("device key generation"); 177 + let device_key_uri = device_key.key_id; 178 + 179 + let audit_log = vec![]; 180 + 181 + let result = find_fork_point(&audit_log, "bafy_nonexistent", &device_key_uri); 182 + assert!(matches!(result, Err(RecoveryError::UnauthorizedChangeNotFound))); 183 + } 184 + 185 + #[test] 186 + fn test_find_fork_point_target_is_genesis() { 187 + let device_key = crypto::generate_p256_keypair().expect("device key generation"); 188 + let device_key_uri = device_key.key_id; 189 + 190 + let relay_key = crypto::generate_p256_keypair().expect("relay key generation"); 191 + let genesis_op = crypto::build_did_plc_genesis_op( 192 + &device_key_uri, 193 + &relay_key.key_id, 194 + &relay_key.private_key_bytes, 195 + "alice.test", 196 + "https://pds.test", 197 + ) 198 + .expect("build genesis op"); 199 + 200 + let genesis_operation: serde_json::Value = 201 + serde_json::from_str(&genesis_op.signed_op_json).expect("parse genesis op json"); 202 + 203 + let genesis_cid = "bafy_genesis"; 204 + 205 + // Create audit log with just the genesis entry 206 + let audit_log_json = serde_json::json!([{ 207 + "did": genesis_op.did.as_str(), 208 + "cid": genesis_cid, 209 + "createdAt": "2026-03-29T00:00:00Z", 210 + "nullified": false, 211 + "operation": genesis_operation 212 + }]); 213 + 214 + let audit_log_str = serde_json::to_string(&audit_log_json).expect("serialize audit log"); 215 + let audit_log = crypto::parse_audit_log(&audit_log_str).expect("parse audit log"); 216 + 217 + // Trying to recover from genesis should fail 218 + let result = find_fork_point(&audit_log, genesis_cid, &device_key_uri); 219 + assert!(matches!(result, Err(RecoveryError::SigningFailed { .. }))); 220 + } 221 + 222 + #[test] 223 + fn test_find_fork_point_multiple_unauthorized_ops_in_sequence() { 224 + // Setup: Generate device key that will sign the genesis 225 + let device_key = crypto::generate_p256_keypair().expect("device key generation"); 226 + let device_key_uri = device_key.key_id; 227 + 228 + let rotation_key = crypto::generate_p256_keypair().expect("rotation key generation"); 229 + 230 + // Genesis (device-key signed with rotation_key in rotationKeys[0]) 231 + let genesis_op = crypto::build_did_plc_genesis_op( 232 + &rotation_key.key_id, 233 + &device_key_uri, 234 + &device_key.private_key_bytes, 235 + "alice.test", 236 + "https://pds.test", 237 + ) 238 + .expect("build genesis op"); 239 + 240 + let genesis_operation: serde_json::Value = 241 + serde_json::from_str(&genesis_op.signed_op_json).expect("parse genesis op json"); 242 + 243 + let genesis_cid = "bafy_genesis"; 244 + let did = &genesis_op.did; 245 + 246 + // First unauthorized operation (created by attacker1) 247 + let attacker1 = crypto::generate_p256_keypair().expect("attacker1 key generation"); 248 + let unauth_op1 = serde_json::json!({ 249 + "type": "plc_operation", 250 + "prev": genesis_cid, 251 + "rotationKeys": [attacker1.key_id.0.as_str()], 252 + "verificationMethods": {}, 253 + "services": {}, 254 + "alsoKnownAs": [], 255 + "sig": "fake_sig_1" 256 + }); 257 + 258 + // Second unauthorized operation (built on top of first) 259 + let attacker2 = crypto::generate_p256_keypair().expect("attacker2 key generation"); 260 + let unauth_cid1 = "bafy_unauth1"; 261 + let unauth_op2 = serde_json::json!({ 262 + "type": "plc_operation", 263 + "prev": unauth_cid1, 264 + "rotationKeys": [attacker2.key_id.0.as_str()], 265 + "verificationMethods": {}, 266 + "services": {}, 267 + "alsoKnownAs": [], 268 + "sig": "fake_sig_2" 269 + }); 270 + 271 + // Create audit log with all three entries 272 + let unauth_cid2 = "bafy_unauth2"; 273 + let audit_log_json = serde_json::json!([ 274 + { 275 + "did": did, 276 + "cid": genesis_cid, 277 + "createdAt": "2026-03-29T00:00:00Z", 278 + "nullified": false, 279 + "operation": genesis_operation 280 + }, 281 + { 282 + "did": did, 283 + "cid": unauth_cid1, 284 + "createdAt": "2026-03-29T01:00:00Z", 285 + "nullified": false, 286 + "operation": unauth_op1 287 + }, 288 + { 289 + "did": did, 290 + "cid": unauth_cid2, 291 + "createdAt": "2026-03-29T02:00:00Z", 292 + "nullified": false, 293 + "operation": unauth_op2 294 + } 295 + ]); 296 + 297 + let audit_log_str = serde_json::to_string(&audit_log_json).expect("serialize audit log"); 298 + let audit_log = crypto::parse_audit_log(&audit_log_str).expect("parse audit log"); 299 + 300 + // When targeting the second unauthorized op, should find the earliest fork point (genesis) 301 + let (fork_entry, _) = find_fork_point(&audit_log, unauth_cid2, &device_key_uri) 302 + .expect("find_fork_point succeeds"); 303 + 304 + assert_eq!(fork_entry.cid, genesis_cid); 53 305 } 54 306 }