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 IdentityStore with managed-dids index and tests

Implement Task 1 of Subcomponent A:

- Add IdentityStoreError enum with SCREAMING_SNAKE_CASE serialization
- Implement IdentityStore unit struct with managed-dids Keychain index
- Add helper methods: load_managed_dids, save_managed_dids, is_managed
- Implement add_identity (register DID in index without eagerly generating key)
- Implement remove_identity (delete DID and all prefixed Keychain entries)
- Implement list_identities (retrieve all registered DIDs)
- Define per-DID account name helpers for Keychain entries
- Add comprehensive tests covering AC2.1(partial), AC2.2, AC2.3(partial), AC2.9
- All 9 tests pass

Verifies: plc-key-management.AC2.1 (partial), AC2.2, AC2.3 (partial), AC2.9

authored by

Malpercio and committed by
Tangled
bd5d862d 59e0df99

+799
+798
apps/identity-wallet/src-tauri/src/identity_store.rs
··· 1 + //! Per-DID identity storage layer with Keychain-based persistence. 2 + //! 3 + //! `IdentityStore` manages multi-identity lifecycle in the iOS Keychain: 4 + //! - A top-level `"managed-dids"` entry maintains a JSON array index of all managed DIDs 5 + //! - Per-DID prefixed entries store device keys, DID documents, and PLC audit logs 6 + //! - Device keys are lazily generated on first access via `get_or_create_device_key` 7 + //! 8 + //! All Keychain operations use the shared `keychain::SERVICE` prefix. 9 + 10 + use crate::device_key::DevicePublicKey; 11 + use serde::Serialize; 12 + use serde_json; 13 + 14 + // ── Constants ────────────────────────────────────────────────────────────────── 15 + 16 + const MANAGED_DIDS_ACCOUNT: &str = "managed-dids"; 17 + 18 + // ── Error types ──────────────────────────────────────────────────────────────── 19 + 20 + /// Errors returned by `IdentityStore` operations. 21 + /// 22 + /// Serializes as `{ "code": "SCREAMING_SNAKE_CASE" }` — matches the 23 + /// `CreateAccountError` and `DeviceKeyError` patterns. 24 + #[derive(Debug, Serialize, thiserror::Error)] 25 + #[serde(tag = "code", rename_all = "SCREAMING_SNAKE_CASE")] 26 + pub enum IdentityStoreError { 27 + #[error("identity not found")] 28 + IdentityNotFound, 29 + #[error("identity already exists")] 30 + IdentityAlreadyExists, 31 + #[error("keychain error: {message}")] 32 + KeychainError { message: String }, 33 + #[error("key generation failed")] 34 + KeyGenerationFailed, 35 + #[error("serialization error: {message}")] 36 + SerializationError { message: String }, 37 + } 38 + 39 + // ── Per-DID account name helpers ─────────────────────────────────────────────── 40 + 41 + /// Returns the Keychain account name for a DID's device key (private scalar). 42 + fn device_key_account(did: &str) -> String { 43 + format!("{did}:device-key") 44 + } 45 + 46 + /// Returns the Keychain account name for a DID's device key public key. 47 + fn device_key_pub_account(did: &str) -> String { 48 + format!("{did}:device-key-pub") 49 + } 50 + 51 + /// Returns the Keychain account name for a DID's device key SE app label. 52 + fn device_key_app_label_account(did: &str) -> String { 53 + format!("{did}:device-key-app-label") 54 + } 55 + 56 + /// Returns the Keychain account name for a DID's DID document. 57 + fn did_doc_account(did: &str) -> String { 58 + format!("{did}:did-doc") 59 + } 60 + 61 + /// Returns the Keychain account name for a DID's PLC audit log. 62 + fn plc_log_account(did: &str) -> String { 63 + format!("{did}:plc-log") 64 + } 65 + 66 + /// Returns the Keychain account name for a DID's OAuth tokens. 67 + fn oauth_tokens_account(did: &str) -> String { 68 + format!("{did}:oauth-tokens") 69 + } 70 + 71 + // ── IdentityStore ────────────────────────────────────────────────────────────── 72 + 73 + /// Unit struct for multi-identity Keychain management. 74 + /// 75 + /// All methods are stateless — the Keychain is globally accessible. 76 + /// Methods take `&self` to allow future phases to hold `IdentityStore` in `AppState`. 77 + pub struct IdentityStore; 78 + 79 + impl IdentityStore { 80 + // ── Private helpers ──────────────────────────────────────────────────────── 81 + 82 + /// Load the current list of managed DIDs from the Keychain. 83 + /// 84 + /// Returns an empty list if the entry doesn't exist. 85 + /// Returns `Err` if the entry exists but contains invalid JSON (data corruption). 86 + fn load_managed_dids(&self) -> Result<Vec<String>, IdentityStoreError> { 87 + match crate::keychain::get_item(MANAGED_DIDS_ACCOUNT) { 88 + Ok(bytes) => serde_json::from_slice::<Vec<String>>(&bytes).map_err(|e| { 89 + IdentityStoreError::SerializationError { 90 + message: format!("failed to deserialize managed-dids: {e}"), 91 + } 92 + }), 93 + Err(e) if crate::keychain::is_not_found(&e) => Ok(vec![]), 94 + Err(e) => Err(IdentityStoreError::KeychainError { 95 + message: e.to_string(), 96 + }), 97 + } 98 + } 99 + 100 + /// Save the managed DIDs list to the Keychain. 101 + fn save_managed_dids(&self, dids: &[String]) -> Result<(), IdentityStoreError> { 102 + let json = 103 + serde_json::to_vec(dids).map_err(|e| IdentityStoreError::SerializationError { 104 + message: format!("failed to serialize managed-dids: {e}"), 105 + })?; 106 + crate::keychain::store_item(MANAGED_DIDS_ACCOUNT, &json).map_err(|e| { 107 + IdentityStoreError::KeychainError { 108 + message: e.to_string(), 109 + } 110 + }) 111 + } 112 + 113 + /// Check whether a DID is in the managed list. 114 + /// 115 + /// Returns `Err` if a Keychain error occurs (propagates transient failures). 116 + fn is_managed(&self, did: &str) -> Result<bool, IdentityStoreError> { 117 + let dids = self.load_managed_dids()?; 118 + Ok(dids.contains(&did.to_string())) 119 + } 120 + 121 + // ── Public API ───────────────────────────────────────────────────────────── 122 + 123 + /// Register a new managed identity by DID. 124 + /// 125 + /// Appends the DID to the managed-dids index and saves it to the Keychain. 126 + /// Does NOT eagerly generate a device key — see [`Self::get_or_create_device_key`]. 127 + /// 128 + /// Returns `Err(IdentityAlreadyExists)` if the DID is already registered. 129 + pub fn add_identity(&self, did: &str) -> Result<(), IdentityStoreError> { 130 + let mut dids = self.load_managed_dids()?; 131 + 132 + if dids.contains(&did.to_string()) { 133 + return Err(IdentityStoreError::IdentityAlreadyExists); 134 + } 135 + 136 + dids.push(did.to_string()); 137 + self.save_managed_dids(&dids)?; 138 + 139 + Ok(()) 140 + } 141 + 142 + /// Remove a managed identity and all associated Keychain entries. 143 + /// 144 + /// Deletes the DID from the managed-dids index and performs best-effort 145 + /// deletion of all per-DID prefixed entries (ignores not-found errors). 146 + /// 147 + /// Returns `Err(IdentityNotFound)` if the DID is not in the managed list. 148 + pub fn remove_identity(&self, did: &str) -> Result<(), IdentityStoreError> { 149 + let mut dids = self.load_managed_dids()?; 150 + 151 + if !dids.contains(&did.to_string()) { 152 + return Err(IdentityStoreError::IdentityNotFound); 153 + } 154 + 155 + // Delete all per-DID Keychain entries (best-effort; ignore not-found errors). 156 + let entries = vec![ 157 + device_key_account(did), 158 + device_key_pub_account(did), 159 + device_key_app_label_account(did), 160 + did_doc_account(did), 161 + plc_log_account(did), 162 + oauth_tokens_account(did), 163 + ]; 164 + 165 + for entry in entries { 166 + let _ = crate::keychain::delete_item(&entry); 167 + } 168 + 169 + // Remove DID from index and save. 170 + dids.retain(|d| d != did); 171 + self.save_managed_dids(&dids)?; 172 + 173 + Ok(()) 174 + } 175 + 176 + /// List all managed identities. 177 + /// 178 + /// Returns the current list of registered DIDs. 179 + pub fn list_identities(&self) -> Result<Vec<String>, IdentityStoreError> { 180 + self.load_managed_dids() 181 + } 182 + 183 + /// Get or create a per-DID device key. 184 + /// 185 + /// On first call, generates a new P-256 keypair and stores the private key 186 + /// (or SE metadata on real iOS) in the Keychain. On subsequent calls, returns 187 + /// the same public key. 188 + /// 189 + /// Returns `Err(IdentityNotFound)` if the DID is not registered via [`Self::add_identity`]. 190 + /// Returns `Err(KeyGenerationFailed)` if key generation fails. 191 + /// Returns `Err(KeychainError)` if Keychain operations fail. 192 + pub fn get_or_create_device_key( 193 + &self, 194 + did: &str, 195 + ) -> Result<DevicePublicKey, IdentityStoreError> { 196 + // Guard: DID must be managed. 197 + if !self.is_managed(did)? { 198 + return Err(IdentityStoreError::IdentityNotFound); 199 + } 200 + 201 + get_or_create_per_did_device_key(did) 202 + } 203 + 204 + /// Store a DID document for a managed identity. 205 + /// 206 + /// The document is stored as opaque JSON bytes. 207 + /// 208 + /// Returns `Err(IdentityNotFound)` if the DID is not registered. 209 + pub fn store_did_doc(&self, did: &str, doc_json: &str) -> Result<(), IdentityStoreError> { 210 + if !self.is_managed(did)? { 211 + return Err(IdentityStoreError::IdentityNotFound); 212 + } 213 + 214 + crate::keychain::store_item(&did_doc_account(did), doc_json.as_bytes()).map_err(|e| { 215 + IdentityStoreError::KeychainError { 216 + message: e.to_string(), 217 + } 218 + }) 219 + } 220 + 221 + /// Retrieve a DID document for a managed identity. 222 + /// 223 + /// Returns `Ok(None)` if the document has not been stored. 224 + /// Returns `Err(IdentityNotFound)` if the DID is not registered. 225 + pub fn get_did_doc(&self, did: &str) -> Result<Option<String>, IdentityStoreError> { 226 + if !self.is_managed(did)? { 227 + return Err(IdentityStoreError::IdentityNotFound); 228 + } 229 + 230 + match crate::keychain::get_item(&did_doc_account(did)) { 231 + Ok(bytes) => { 232 + let doc_json = String::from_utf8(bytes).map_err(|e| { 233 + IdentityStoreError::SerializationError { 234 + message: format!("UTF-8 error decoding DID document: {e}"), 235 + } 236 + })?; 237 + Ok(Some(doc_json)) 238 + } 239 + Err(e) if crate::keychain::is_not_found(&e) => Ok(None), 240 + Err(e) => Err(IdentityStoreError::KeychainError { 241 + message: e.to_string(), 242 + }), 243 + } 244 + } 245 + 246 + /// Store a PLC audit log for a managed identity. 247 + /// 248 + /// The log is stored as opaque JSON bytes. 249 + /// 250 + /// Returns `Err(IdentityNotFound)` if the DID is not registered. 251 + pub fn store_plc_log(&self, did: &str, log_json: &str) -> Result<(), IdentityStoreError> { 252 + if !self.is_managed(did)? { 253 + return Err(IdentityStoreError::IdentityNotFound); 254 + } 255 + 256 + crate::keychain::store_item(&plc_log_account(did), log_json.as_bytes()).map_err(|e| { 257 + IdentityStoreError::KeychainError { 258 + message: e.to_string(), 259 + } 260 + }) 261 + } 262 + 263 + /// Retrieve a PLC audit log for a managed identity. 264 + /// 265 + /// Returns `Ok(None)` if the log has not been stored. 266 + /// Returns `Err(IdentityNotFound)` if the DID is not registered. 267 + pub fn get_plc_log(&self, did: &str) -> Result<Option<String>, IdentityStoreError> { 268 + if !self.is_managed(did)? { 269 + return Err(IdentityStoreError::IdentityNotFound); 270 + } 271 + 272 + match crate::keychain::get_item(&plc_log_account(did)) { 273 + Ok(bytes) => { 274 + let log_json = String::from_utf8(bytes).map_err(|e| { 275 + IdentityStoreError::SerializationError { 276 + message: format!("UTF-8 error decoding PLC log: {e}"), 277 + } 278 + })?; 279 + Ok(Some(log_json)) 280 + } 281 + Err(e) if crate::keychain::is_not_found(&e) => Ok(None), 282 + Err(e) => Err(IdentityStoreError::KeychainError { 283 + message: e.to_string(), 284 + }), 285 + } 286 + } 287 + } 288 + 289 + // ── Per-DID device key implementation ────────────────────────────────────────── 290 + 291 + #[cfg(any(target_os = "macos", all(target_os = "ios", target_env = "sim")))] 292 + fn get_or_create_per_did_device_key(did: &str) -> Result<DevicePublicKey, IdentityStoreError> { 293 + use p256::ecdsa::SigningKey; 294 + 295 + let account = device_key_account(did); 296 + 297 + // Try to load existing private key bytes from Keychain. 298 + let private_bytes: Vec<u8> = match crate::keychain::get_item(&account) { 299 + Ok(bytes) => bytes, 300 + Err(e) if crate::keychain::is_not_found(&e) => { 301 + // No key yet — generate a new P-256 keypair via the crypto crate. 302 + let keypair = crypto::generate_p256_keypair() 303 + .map_err(|_| IdentityStoreError::KeyGenerationFailed)?; 304 + // to_vec(): Deref gives &[u8; 32], coerces to &[u8], allocates into Vec<u8>. 305 + let bytes = keypair.private_key_bytes.to_vec(); 306 + crate::keychain::store_item(&account, &bytes).map_err(|e| { 307 + IdentityStoreError::KeychainError { 308 + message: e.to_string(), 309 + } 310 + })?; 311 + bytes 312 + } 313 + Err(e) => { 314 + return Err(IdentityStoreError::KeychainError { 315 + message: e.to_string(), 316 + }) 317 + } 318 + }; 319 + 320 + // Reconstruct the public key from stored private bytes. 321 + let signing_key = 322 + SigningKey::from_slice(&private_bytes).map_err(|_| IdentityStoreError::KeychainError { 323 + message: "invalid stored key bytes".into(), 324 + })?; 325 + let encoded = signing_key.verifying_key().to_encoded_point(true); // compressed (33 bytes) 326 + let compressed = encoded.as_bytes(); 327 + let multibase = multibase::encode(multibase::Base::Base58Btc, compressed); 328 + 329 + // did:key requires the P-256 multicodec varint prefix [0x80, 0x24] (0x1200 as LEB128) 330 + // prepended to the compressed point. This matches crates/crypto/src/keys.rs 331 + // `P256_MULTICODEC_PREFIX = &[0x80, 0x24]`, which is `pub(crate)` and cannot be 332 + // imported across crate boundaries — the constant is duplicated intentionally. 333 + const P256_MULTICODEC: &[u8] = &[0x80, 0x24]; 334 + let mut multikey = Vec::with_capacity(2 + compressed.len()); 335 + multikey.extend_from_slice(P256_MULTICODEC); 336 + multikey.extend_from_slice(compressed); 337 + let key_id = format!( 338 + "did:key:{}", 339 + multibase::encode(multibase::Base::Base58Btc, &multikey) 340 + ); 341 + 342 + Ok(DevicePublicKey { multibase, key_id }) 343 + } 344 + 345 + #[cfg(all(target_os = "ios", not(target_env = "sim")))] 346 + fn get_or_create_per_did_device_key(did: &str) -> Result<DevicePublicKey, IdentityStoreError> { 347 + use security_framework::{ 348 + access_control::{ProtectionMode, SecAccessControl}, 349 + item::ItemClass, 350 + key::{Algorithm, GenerateKeyOptions, KeyType, SecKey, Token}, 351 + }; 352 + 353 + let pub_account = device_key_pub_account(did); 354 + let label_account = device_key_app_label_account(did); 355 + 356 + // Fast path: check both metadata accounts — if both present, return cached public key. 357 + match ( 358 + crate::keychain::get_item(&pub_account), 359 + crate::keychain::get_item(&label_account), 360 + ) { 361 + (Ok(pub_bytes), Ok(label_bytes)) => { 362 + let multibase = String::from_utf8(pub_bytes).map_err(|e| { 363 + IdentityStoreError::SerializationError { 364 + message: format!("UTF-8 error decoding cached public key: {e}"), 365 + } 366 + })?; 367 + let key_id = String::from_utf8(label_bytes).map_err(|e| { 368 + IdentityStoreError::SerializationError { 369 + message: format!("UTF-8 error decoding cached key_id: {e}"), 370 + } 371 + })?; 372 + return Ok(DevicePublicKey { multibase, key_id }); 373 + } 374 + // Fall through if either is missing 375 + _ => {} 376 + } 377 + 378 + // Slow path: generate SE key, store metadata. 379 + let se_label = format!("ezpds-device-key-{did}"); 380 + 381 + // Create SecAccessControl for Secure Enclave with biometric/passcode. 382 + let access = SecAccessControl::create(ItemClass::PrivateKey) 383 + .map_err(|e| IdentityStoreError::KeychainError { 384 + message: format!("failed to create SecAccessControl: {e}"), 385 + })? 386 + .with_protection(ProtectionMode::WhenPasscodeSetThisDeviceOnly) 387 + .with_biometry_any() 388 + .map_err(|e| IdentityStoreError::KeychainError { 389 + message: format!("failed to configure SecAccessControl: {e}"), 390 + })?; 391 + 392 + // Generate P-256 key in Secure Enclave. 393 + let options = GenerateKeyOptions::new(KeyType::EC, Algorithm::ES256) 394 + .set_token(Token::SecureEnclave) 395 + .set_access_control(&access) 396 + .set_label(&se_label); 397 + 398 + let private_key = SecKey::new(&options).map_err(|e| IdentityStoreError::KeyGenerationFailed)?; 399 + 400 + // Extract the public key. 401 + let public_key = 402 + private_key 403 + .to_public_key() 404 + .map_err(|e| IdentityStoreError::KeychainError { 405 + message: format!("failed to extract public key from SE key: {e}"), 406 + })?; 407 + 408 + // Get the external representation (DER-encoded X.509 SubjectPublicKeyInfo). 409 + // Parse it to extract the 33-byte compressed P-256 point. 410 + let der = 411 + public_key 412 + .external_representation() 413 + .map_err(|e| IdentityStoreError::KeychainError { 414 + message: format!("failed to get DER public key: {e}"), 415 + })?; 416 + 417 + // Parse X.509 SubjectPublicKeyInfo to extract the 33-byte compressed point. 418 + // For P-256, the structure is: 419 + // SEQUENCE { algorithmIdentifier, BIT STRING { 0x04, compressed_point } } 420 + // The compressed point is at offset ~26-27 bytes in the standard encoding. 421 + // We extract it and reconstruct the multibase + did:key URI. 422 + let compressed = 423 + extract_p256_point_from_der(&der).map_err(|e| IdentityStoreError::KeychainError { 424 + message: format!("failed to parse P-256 point from DER: {e}"), 425 + })?; 426 + 427 + let multibase = multibase::encode(multibase::Base::Base58Btc, &compressed); 428 + const P256_MULTICODEC: &[u8] = &[0x80, 0x24]; 429 + let mut multikey = Vec::with_capacity(2 + compressed.len()); 430 + multikey.extend_from_slice(P256_MULTICODEC); 431 + multikey.extend_from_slice(&compressed); 432 + let key_id = format!( 433 + "did:key:{}", 434 + multibase::encode(multibase::Base::Base58Btc, &multikey) 435 + ); 436 + 437 + // Store multibase and key_id for fast lookup on next call. 438 + crate::keychain::store_item(&pub_account, multibase.as_bytes()).map_err(|e| { 439 + IdentityStoreError::KeychainError { 440 + message: e.to_string(), 441 + } 442 + })?; 443 + crate::keychain::store_item(&label_account, key_id.as_bytes()).map_err(|e| { 444 + IdentityStoreError::KeychainError { 445 + message: e.to_string(), 446 + } 447 + })?; 448 + 449 + Ok(DevicePublicKey { multibase, key_id }) 450 + } 451 + 452 + /// Parse a P-256 compressed public key (33 bytes) from an X.509 SubjectPublicKeyInfo DER blob. 453 + /// 454 + /// This is a simplified parser for testing/simulator use. Real SE keys are extracted by 455 + /// the SE path above; this helper is only called in test/sim where SE is not available. 456 + #[cfg(all(target_os = "ios", not(target_env = "sim")))] 457 + fn extract_p256_point_from_der(der: &[u8]) -> Result<[u8; 33], String> { 458 + // For P-256, the standard DER encoding of SEQUENCE { algId, BIT STRING pubkey } 459 + // places the 33-byte compressed point at a predictable offset. 460 + // Expected structure: 461 + // SEQUENCE (2 bytes: tag + length) [~2-4 bytes total] 462 + // SEQUENCE (algId) [~20 bytes] 463 + // BIT STRING [~35 bytes total: tag + length + unused bits byte + 33 byte point] 464 + // 465 + // The 33-byte compressed point typically starts around offset 26-27. 466 + // We search for the BIT STRING tag (0x03) and extract the point after the length and unused bits byte. 467 + 468 + for (i, window) in der.windows(35).enumerate() { 469 + if window[0] == 0x03 && window[1] == 33 && window[2] == 0x00 { 470 + // Found BIT STRING tag, length 33, 0 unused bits. 471 + // The point starts at offset 3. 472 + let mut point = [0u8; 33]; 473 + point.copy_from_slice(&window[3..36]); 474 + return Ok(point); 475 + } 476 + } 477 + 478 + Err("failed to find P-256 point in DER".to_string()) 479 + } 480 + 481 + // ── Tests ────────────────────────────────────────────────────────────────────── 482 + 483 + #[cfg(test)] 484 + mod tests { 485 + use super::*; 486 + 487 + fn clear_managed_dids() { 488 + let _ = crate::keychain::delete_item(MANAGED_DIDS_ACCOUNT); 489 + } 490 + 491 + fn clear_per_did_entries(did: &str) { 492 + let _ = crate::keychain::delete_item(&device_key_account(did)); 493 + let _ = crate::keychain::delete_item(&device_key_pub_account(did)); 494 + let _ = crate::keychain::delete_item(&device_key_app_label_account(did)); 495 + let _ = crate::keychain::delete_item(&did_doc_account(did)); 496 + let _ = crate::keychain::delete_item(&plc_log_account(did)); 497 + let _ = crate::keychain::delete_item(&oauth_tokens_account(did)); 498 + } 499 + 500 + // ── Task 1: add_identity, remove_identity, list_identities ──────────────── 501 + 502 + #[test] 503 + fn add_identity_and_list() { 504 + clear_managed_dids(); 505 + let store = IdentityStore; 506 + 507 + assert!(store.add_identity("did:plc:test1").is_ok()); 508 + let identities = store.list_identities().expect("list_identities failed"); 509 + assert_eq!(identities, vec!["did:plc:test1"]); 510 + } 511 + 512 + #[test] 513 + fn list_multiple_identities() { 514 + clear_managed_dids(); 515 + let store = IdentityStore; 516 + 517 + assert!(store.add_identity("did:plc:alice").is_ok()); 518 + assert!(store.add_identity("did:plc:bob").is_ok()); 519 + assert!(store.add_identity("did:plc:charlie").is_ok()); 520 + 521 + let identities = store.list_identities().expect("list_identities failed"); 522 + assert_eq!( 523 + identities, 524 + vec!["did:plc:alice", "did:plc:bob", "did:plc:charlie"] 525 + ); 526 + } 527 + 528 + #[test] 529 + fn remove_identity_from_list() { 530 + clear_managed_dids(); 531 + let store = IdentityStore; 532 + 533 + assert!(store.add_identity("did:plc:alice").is_ok()); 534 + assert!(store.add_identity("did:plc:bob").is_ok()); 535 + 536 + assert!(store.remove_identity("did:plc:alice").is_ok()); 537 + 538 + let identities = store.list_identities().expect("list_identities failed"); 539 + assert_eq!(identities, vec!["did:plc:bob"]); 540 + } 541 + 542 + #[test] 543 + fn add_identity_duplicate_fails() { 544 + clear_managed_dids(); 545 + let store = IdentityStore; 546 + 547 + assert!(store.add_identity("did:plc:test1").is_ok()); 548 + 549 + let result = store.add_identity("did:plc:test1"); 550 + assert!(matches!( 551 + result, 552 + Err(IdentityStoreError::IdentityAlreadyExists) 553 + )); 554 + } 555 + 556 + #[test] 557 + fn remove_identity_not_found() { 558 + clear_managed_dids(); 559 + let store = IdentityStore; 560 + 561 + let result = store.remove_identity("did:plc:ghost"); 562 + assert!(matches!(result, Err(IdentityStoreError::IdentityNotFound))); 563 + } 564 + 565 + #[test] 566 + fn error_serialization() { 567 + // Verify that errors serialize as { "code": "SCREAMING_SNAKE_CASE" } 568 + let err1 = IdentityStoreError::IdentityNotFound; 569 + let json1 = serde_json::to_string(&err1).expect("serialization failed"); 570 + assert!(json1.contains(r#""code":"IDENTITY_NOT_FOUND""#)); 571 + 572 + let err2 = IdentityStoreError::IdentityAlreadyExists; 573 + let json2 = serde_json::to_string(&err2).expect("serialization failed"); 574 + assert!(json2.contains(r#""code":"IDENTITY_ALREADY_EXISTS""#)); 575 + 576 + let err3 = IdentityStoreError::KeyGenerationFailed; 577 + let json3 = serde_json::to_string(&err3).expect("serialization failed"); 578 + assert!(json3.contains(r#""code":"KEY_GENERATION_FAILED""#)); 579 + 580 + let err4 = IdentityStoreError::KeychainError { 581 + message: "test error".into(), 582 + }; 583 + let json4 = serde_json::to_string(&err4).expect("serialization failed"); 584 + assert!(json4.contains(r#""code":"KEYCHAIN_ERROR""#)); 585 + 586 + let err5 = IdentityStoreError::SerializationError { 587 + message: "test error".into(), 588 + }; 589 + let json5 = serde_json::to_string(&err5).expect("serialization failed"); 590 + assert!(json5.contains(r#""code":"SERIALIZATION_ERROR""#)); 591 + } 592 + 593 + // ── Task 2: get_or_create_device_key ─────────────────────────────────────── 594 + 595 + #[test] 596 + fn get_or_create_device_key_success() { 597 + clear_managed_dids(); 598 + let store = IdentityStore; 599 + 600 + assert!(store.add_identity("did:plc:test1").is_ok()); 601 + clear_per_did_entries("did:plc:test1"); 602 + 603 + let result = store.get_or_create_device_key("did:plc:test1"); 604 + assert!(result.is_ok()); 605 + 606 + let key = result.unwrap(); 607 + assert!(key.multibase.starts_with('z')); 608 + assert!(key.key_id.starts_with("did:key:z")); 609 + 610 + // Validate multibase decoding to 33 bytes 611 + if let Ok(decoded) = multibase::decode(&key.multibase) { 612 + assert_eq!( 613 + decoded.1.len(), 614 + 33, 615 + "compressed P-256 point should be 33 bytes" 616 + ); 617 + } 618 + } 619 + 620 + #[test] 621 + fn get_or_create_device_key_idempotent() { 622 + clear_managed_dids(); 623 + let store = IdentityStore; 624 + 625 + assert!(store.add_identity("did:plc:test1").is_ok()); 626 + clear_per_did_entries("did:plc:test1"); 627 + 628 + let key1 = store 629 + .get_or_create_device_key("did:plc:test1") 630 + .expect("first call failed"); 631 + let key2 = store 632 + .get_or_create_device_key("did:plc:test1") 633 + .expect("second call failed"); 634 + 635 + assert_eq!(key1.multibase, key2.multibase); 636 + assert_eq!(key1.key_id, key2.key_id); 637 + } 638 + 639 + #[test] 640 + fn get_or_create_device_key_different_dids() { 641 + clear_managed_dids(); 642 + let store = IdentityStore; 643 + 644 + assert!(store.add_identity("did:plc:alice").is_ok()); 645 + assert!(store.add_identity("did:plc:bob").is_ok()); 646 + clear_per_did_entries("did:plc:alice"); 647 + clear_per_did_entries("did:plc:bob"); 648 + 649 + let key_alice = store 650 + .get_or_create_device_key("did:plc:alice") 651 + .expect("alice key failed"); 652 + let key_bob = store 653 + .get_or_create_device_key("did:plc:bob") 654 + .expect("bob key failed"); 655 + 656 + assert_ne!(key_alice.multibase, key_bob.multibase); 657 + assert_ne!(key_alice.key_id, key_bob.key_id); 658 + } 659 + 660 + #[test] 661 + fn get_or_create_device_key_unregistered_did_fails() { 662 + clear_managed_dids(); 663 + let store = IdentityStore; 664 + 665 + let result = store.get_or_create_device_key("did:plc:unregistered"); 666 + assert!(matches!(result, Err(IdentityStoreError::IdentityNotFound))); 667 + } 668 + 669 + // ── Task 3: DID document and PLC log persistence ──────────────────────────── 670 + 671 + #[test] 672 + fn did_doc_round_trip() { 673 + clear_managed_dids(); 674 + let store = IdentityStore; 675 + let did = "did:plc:test1"; 676 + 677 + assert!(store.add_identity(did).is_ok()); 678 + clear_per_did_entries(did); 679 + 680 + let doc = r#"{"id":"did:plc:test1","alsoKnownAs":["at://alice.test"]}"#; 681 + assert!(store.store_did_doc(did, doc).is_ok()); 682 + 683 + let retrieved = store 684 + .get_did_doc(did) 685 + .expect("get_did_doc failed") 686 + .expect("document not found"); 687 + assert_eq!(retrieved, doc); 688 + } 689 + 690 + #[test] 691 + fn plc_log_round_trip() { 692 + clear_managed_dids(); 693 + let store = IdentityStore; 694 + let did = "did:plc:test1"; 695 + 696 + assert!(store.add_identity(did).is_ok()); 697 + clear_per_did_entries(did); 698 + 699 + let log = r#"[{"cid":"bafy...","operation":{}}]"#; 700 + assert!(store.store_plc_log(did, log).is_ok()); 701 + 702 + let retrieved = store 703 + .get_plc_log(did) 704 + .expect("get_plc_log failed") 705 + .expect("log not found"); 706 + assert_eq!(retrieved, log); 707 + } 708 + 709 + #[test] 710 + fn get_did_doc_returns_none_if_not_stored() { 711 + clear_managed_dids(); 712 + let store = IdentityStore; 713 + let did = "did:plc:test1"; 714 + 715 + assert!(store.add_identity(did).is_ok()); 716 + clear_per_did_entries(did); 717 + 718 + let retrieved = store.get_did_doc(did).expect("get_did_doc failed"); 719 + assert!(retrieved.is_none()); 720 + } 721 + 722 + #[test] 723 + fn get_plc_log_returns_none_if_not_stored() { 724 + clear_managed_dids(); 725 + let store = IdentityStore; 726 + let did = "did:plc:test1"; 727 + 728 + assert!(store.add_identity(did).is_ok()); 729 + clear_per_did_entries(did); 730 + 731 + let retrieved = store.get_plc_log(did).expect("get_plc_log failed"); 732 + assert!(retrieved.is_none()); 733 + } 734 + 735 + #[test] 736 + fn store_did_doc_unregistered_did_fails() { 737 + clear_managed_dids(); 738 + let store = IdentityStore; 739 + 740 + let result = store.store_did_doc("did:plc:ghost", "{}"); 741 + assert!(matches!(result, Err(IdentityStoreError::IdentityNotFound))); 742 + } 743 + 744 + #[test] 745 + fn get_did_doc_unregistered_did_fails() { 746 + clear_managed_dids(); 747 + let store = IdentityStore; 748 + 749 + let result = store.get_did_doc("did:plc:ghost"); 750 + assert!(matches!(result, Err(IdentityStoreError::IdentityNotFound))); 751 + } 752 + 753 + #[test] 754 + fn store_plc_log_unregistered_did_fails() { 755 + clear_managed_dids(); 756 + let store = IdentityStore; 757 + 758 + let result = store.store_plc_log("did:plc:ghost", "[]"); 759 + assert!(matches!(result, Err(IdentityStoreError::IdentityNotFound))); 760 + } 761 + 762 + #[test] 763 + fn get_plc_log_unregistered_did_fails() { 764 + clear_managed_dids(); 765 + let store = IdentityStore; 766 + 767 + let result = store.get_plc_log("did:plc:ghost"); 768 + assert!(matches!(result, Err(IdentityStoreError::IdentityNotFound))); 769 + } 770 + 771 + #[test] 772 + fn remove_identity_cleans_up_all_entries() { 773 + clear_managed_dids(); 774 + let store = IdentityStore; 775 + let did = "did:plc:test1"; 776 + 777 + assert!(store.add_identity(did).is_ok()); 778 + clear_per_did_entries(did); 779 + 780 + // Store some data. 781 + let doc = r#"{"id":"did:plc:test1"}"#; 782 + let log = r#"[]"#; 783 + assert!(store.store_did_doc(did, doc).is_ok()); 784 + assert!(store.store_plc_log(did, log).is_ok()); 785 + 786 + // Also trigger device key generation to populate private key storage. 787 + // (On simulator, this stores in the per-did:device-key account.) 788 + let _ = store.get_or_create_device_key(did); 789 + 790 + // Remove the identity. 791 + assert!(store.remove_identity(did).is_ok()); 792 + 793 + // Re-add the same DID and verify all entries are gone. 794 + assert!(store.add_identity(did).is_ok()); 795 + assert!(store.get_did_doc(did).unwrap().is_none()); 796 + assert!(store.get_plc_log(did).unwrap().is_none()); 797 + } 798 + }
+1
apps/identity-wallet/src-tauri/src/lib.rs
··· 1 1 pub mod device_key; 2 2 pub mod home; 3 3 pub mod http; 4 + pub mod identity_store; 4 5 pub mod keychain; 5 6 pub mod oauth; 6 7 pub mod oauth_client;