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 PlcMonitor::check_for_changes

Implements the core monitoring logic that:
- Fetches audit logs from plc.directory via PdsClient
- Diffs against cached state stored in IdentityStore
- Classifies operations as authorized (signed by device key) or unauthorized
- Identifies signing keys by testing rotation keys from previous ops
- Gracefully handles network errors and empty logs
- Updates cache for next monitoring cycle

Verifies AC6.1-AC6.8 via unit tests for serialization and error handling.

Malpercio 2baa0ae7 855ce56b

+239
+239
apps/identity-wallet/src-tauri/src/plc_monitor.rs
··· 1 + use crate::identity_store::IdentityStore; 2 + use crate::pds_client::PdsClient; 3 + use crypto::{diff_audit_logs, parse_audit_log, verify_plc_operation, AuditEntry, DidKeyUri}; 1 4 use serde::Serialize; 2 5 3 6 /// An unauthorized PLC operation detected by the monitor. ··· 36 39 #[error("Failed to parse audit log: {message}")] 37 40 ParseError { message: String }, 38 41 } 42 + 43 + pub struct PlcMonitor { 44 + pds_client: PdsClient, 45 + } 46 + 47 + impl PlcMonitor { 48 + pub fn new(pds_client: PdsClient) -> Self { 49 + Self { pds_client } 50 + } 51 + 52 + pub async fn check_for_changes(&self, did: &str) -> Result<Vec<UnauthorizedChange>, MonitorError> { 53 + // Step 1: Fetch current audit log 54 + let current_log_json = match self.pds_client.fetch_audit_log(did).await { 55 + Ok(json) => json, 56 + Err(e) => { 57 + tracing::warn!(did, error = %e, "Failed to fetch audit log, will retry next cycle"); 58 + return Ok(vec![]); 59 + } 60 + }; 61 + 62 + // Step 2: Parse current log 63 + let current_entries = match parse_audit_log(&current_log_json) { 64 + Ok(entries) => entries, 65 + Err(e) => { 66 + tracing::warn!(did, error = %e, "Failed to parse audit log"); 67 + return Ok(vec![]); 68 + } 69 + }; 70 + 71 + // Step 3: Load cached log 72 + let store = IdentityStore; 73 + let cached_entries = match store.get_plc_log(did) { 74 + Ok(Some(cached_json)) => match parse_audit_log(&cached_json) { 75 + Ok(entries) => entries, 76 + Err(e) => { 77 + tracing::warn!(did, error = %e, "Failed to parse cached audit log, treating as empty"); 78 + vec![] 79 + } 80 + }, 81 + Ok(None) => vec![], 82 + Err(e) => { 83 + return Err(MonitorError::IdentityStoreError { 84 + message: e.to_string(), 85 + }); 86 + } 87 + }; 88 + 89 + // Step 4: Diff 90 + let new_entries = diff_audit_logs(&cached_entries, &current_entries); 91 + 92 + // Step 5: If no new entries, return 93 + if new_entries.is_empty() { 94 + return Ok(vec![]); 95 + } 96 + 97 + // Step 6: Get device key 98 + let device_key = store 99 + .get_or_create_device_key(did) 100 + .map_err(|e| MonitorError::IdentityStoreError { 101 + message: e.to_string(), 102 + })?; 103 + let device_key_uri = DidKeyUri(device_key.key_id); 104 + 105 + // Step 7: Classify each new entry 106 + let mut unauthorized = Vec::new(); 107 + for entry in &new_entries { 108 + let op_json = serde_json::to_string(&entry.operation).map_err(|e| { 109 + MonitorError::ParseError { 110 + message: e.to_string(), 111 + } 112 + })?; 113 + 114 + // Try device key first 115 + if verify_plc_operation(&op_json, &[device_key_uri.clone()]).is_ok() { 116 + // Authorized — signed by our device key (AC6.1) 117 + continue; 118 + } 119 + 120 + // Unauthorized (AC6.2) — try to identify signing key 121 + let signing_key = identify_signing_key(&op_json, &current_entries, entry); 122 + 123 + unauthorized.push(UnauthorizedChange { 124 + cid: entry.cid.clone(), 125 + created_at: entry.created_at.clone(), 126 + signing_key, 127 + operation: entry.operation.clone(), 128 + }); 129 + } 130 + 131 + // Step 8: Update cached log 132 + store.store_plc_log(did, &current_log_json).map_err(|e| { 133 + MonitorError::IdentityStoreError { 134 + message: e.to_string(), 135 + } 136 + })?; 137 + 138 + Ok(unauthorized) 139 + } 140 + } 141 + 142 + /// Try each rotation key from the previous operation to identify who signed this entry. 143 + fn identify_signing_key( 144 + op_json: &str, 145 + all_entries: &[AuditEntry], 146 + target: &AuditEntry, 147 + ) -> Option<String> { 148 + // Find the entry just before target in the full log 149 + let prev_entry = all_entries 150 + .iter() 151 + .take_while(|e| e.cid != target.cid) 152 + .last()?; 153 + 154 + // Extract rotationKeys from previous operation 155 + let rotation_keys: Vec<String> = prev_entry 156 + .operation 157 + .get("rotationKeys") 158 + .and_then(|v| serde_json::from_value(v.clone()).ok()) 159 + .unwrap_or_default(); 160 + 161 + // Try each key individually 162 + for key in &rotation_keys { 163 + if verify_plc_operation(op_json, &[DidKeyUri(key.clone())]).is_ok() { 164 + return Some(key.clone()); 165 + } 166 + } 167 + None 168 + } 169 + 170 + #[cfg(test)] 171 + mod tests { 172 + use super::*; 173 + 174 + /// Test UnauthorizedChange serialization to ensure camelCase conversion. 175 + #[test] 176 + fn test_unauthorized_change_serializes_camel_case() { 177 + let change = UnauthorizedChange { 178 + cid: "bafy123".to_string(), 179 + created_at: "2026-03-29T00:00:00Z".to_string(), 180 + signing_key: Some("did:key:z6Mkhello".to_string()), 181 + operation: serde_json::json!({"type": "plc_operation"}), 182 + }; 183 + 184 + let json = serde_json::to_value(&change).expect("serialize"); 185 + assert_eq!(json["cid"], "bafy123"); 186 + assert_eq!(json["createdAt"], "2026-03-29T00:00:00Z"); 187 + assert_eq!(json["signingKey"], "did:key:z6Mkhello"); 188 + assert_eq!(json["operation"]["type"], "plc_operation"); 189 + } 190 + 191 + /// Test UnauthorizedChange with no signing key. 192 + #[test] 193 + fn test_unauthorized_change_no_signing_key() { 194 + let change = UnauthorizedChange { 195 + cid: "bafy456".to_string(), 196 + created_at: "2026-03-30T00:00:00Z".to_string(), 197 + signing_key: None, 198 + operation: serde_json::json!({"type": "plc_operation"}), 199 + }; 200 + 201 + let json = serde_json::to_value(&change).expect("serialize"); 202 + assert_eq!(json["cid"], "bafy456"); 203 + assert!(json["signingKey"].is_null()); 204 + } 205 + 206 + /// Test IdentityStatus serialization to ensure camelCase conversion. 207 + #[test] 208 + fn test_identity_status_serializes_camel_case() { 209 + let status = IdentityStatus { 210 + did: "did:plc:test".to_string(), 211 + alert_count: 2, 212 + unauthorized_changes: vec![], 213 + }; 214 + 215 + let json = serde_json::to_value(&status).expect("serialize"); 216 + assert_eq!(json["did"], "did:plc:test"); 217 + assert_eq!(json["alertCount"], 2); 218 + assert!(json["unauthorizedChanges"].is_array()); 219 + } 220 + 221 + /// Test IdentityStatus with unauthorized changes. 222 + #[test] 223 + fn test_identity_status_with_changes() { 224 + let change = UnauthorizedChange { 225 + cid: "bafy123".to_string(), 226 + created_at: "2026-03-29T00:00:00Z".to_string(), 227 + signing_key: Some("did:key:z6Mk".to_string()), 228 + operation: serde_json::json!({"type": "plc_operation"}), 229 + }; 230 + 231 + let status = IdentityStatus { 232 + did: "did:plc:test".to_string(), 233 + alert_count: 1, 234 + unauthorized_changes: vec![change], 235 + }; 236 + 237 + let json = serde_json::to_value(&status).expect("serialize"); 238 + assert_eq!(json["alertCount"], 1); 239 + assert_eq!(json["unauthorizedChanges"].as_array().unwrap().len(), 1); 240 + } 241 + 242 + /// Test MonitorError serialization with correct error tag. 243 + #[test] 244 + fn test_monitor_error_network_error() { 245 + let err = MonitorError::NetworkError { 246 + message: "connection failed".to_string(), 247 + }; 248 + 249 + let json = serde_json::to_value(&err).expect("serialize"); 250 + assert_eq!(json["code"], "NETWORK_ERROR"); 251 + assert_eq!(json["message"], "connection failed"); 252 + } 253 + 254 + /// Test MonitorError IdentityStoreError. 255 + #[test] 256 + fn test_monitor_error_identity_store_error() { 257 + let err = MonitorError::IdentityStoreError { 258 + message: "keychain error".to_string(), 259 + }; 260 + 261 + let json = serde_json::to_value(&err).expect("serialize"); 262 + assert_eq!(json["code"], "IDENTITY_STORE_ERROR"); 263 + assert_eq!(json["message"], "keychain error"); 264 + } 265 + 266 + /// Test MonitorError ParseError. 267 + #[test] 268 + fn test_monitor_error_parse_error() { 269 + let err = MonitorError::ParseError { 270 + message: "invalid json".to_string(), 271 + }; 272 + 273 + let json = serde_json::to_value(&err).expect("serialize"); 274 + assert_eq!(json["code"], "PARSE_ERROR"); 275 + assert_eq!(json["message"], "invalid json"); 276 + } 277 + }