identity.rs (37764B)
1 #[path = "../src/test_fixtures.rs"] 2 mod test_fixtures; 3 4 use radroots_events::profile::RadrootsProfile; 5 use radroots_identity::{ 6 DEFAULT_IDENTITY_PATH, IdentityError, RadrootsIdentity, RadrootsIdentityId, 7 RadrootsIdentityProfile, RadrootsIdentityPublic, RadrootsIdentitySecretKeyFormat, 8 }; 9 use radroots_identity::{ 10 RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT, RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX, 11 RadrootsEncryptedIdentityFile, encrypted_identity_wrapping_key_path, load_encrypted_identity, 12 load_encrypted_identity_with_key_slot, load_identity_profile, rotate_encrypted_identity, 13 rotate_encrypted_identity_with_key_slot, store_encrypted_identity, 14 store_encrypted_identity_with_key_slot, store_identity_profile, 15 }; 16 #[cfg(feature = "nip49")] 17 use radroots_identity::{ 18 RadrootsIdentityEncryptedSecretKeyOptions, RadrootsIdentityEncryptedSecretKeySecurity, 19 }; 20 use radroots_protected_store::{RadrootsProtectedFileKeySource, RadrootsProtectedStoreEnvelope}; 21 use radroots_runtime_paths::{ 22 RadrootsHostEnvironment, RadrootsPathOverrides, RadrootsPathProfile, RadrootsPathResolver, 23 RadrootsPlatform, 24 }; 25 use std::{ 26 ffi::OsString, 27 path::PathBuf, 28 sync::{Mutex, OnceLock}, 29 }; 30 use test_fixtures::{ApprovedFixtureIdentity, FIXTURE_ALICE, FIXTURE_BOB}; 31 32 fn home_env_lock() -> &'static Mutex<()> { 33 static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); 34 LOCK.get_or_init(|| Mutex::new(())) 35 } 36 37 struct EnvVarGuard { 38 key: &'static str, 39 previous: Option<OsString>, 40 } 41 42 impl EnvVarGuard { 43 fn remove(key: &'static str) -> Self { 44 let previous = std::env::var_os(key); 45 unsafe { std::env::remove_var(key) }; 46 Self { key, previous } 47 } 48 } 49 50 impl Drop for EnvVarGuard { 51 fn drop(&mut self) { 52 if let Some(value) = self.previous.as_ref() { 53 unsafe { std::env::set_var(self.key, value) }; 54 } else { 55 unsafe { std::env::remove_var(self.key) }; 56 } 57 } 58 } 59 60 fn fixture_keys(fixture: ApprovedFixtureIdentity) -> nostr::Keys { 61 let secret = nostr::SecretKey::from_hex(fixture.secret_key_hex).unwrap(); 62 nostr::Keys::new(secret) 63 } 64 65 fn fixture_identity(fixture: ApprovedFixtureIdentity) -> RadrootsIdentity { 66 RadrootsIdentity::from_secret_key_str(fixture.secret_key_hex).unwrap() 67 } 68 69 fn profile_with_identifier(value: &str) -> RadrootsIdentityProfile { 70 RadrootsIdentityProfile { 71 identifier: Some(value.to_string()), 72 ..Default::default() 73 } 74 } 75 76 fn sample_event(content: &str) -> nostr::Event { 77 nostr::EventBuilder::text_note(content) 78 .sign_with_keys(&fixture_keys(FIXTURE_ALICE)) 79 .unwrap() 80 } 81 82 #[test] 83 fn load_from_json_file_hex() { 84 let identity = fixture_identity(FIXTURE_ALICE); 85 let json = serde_json::to_string(&identity.to_file()).unwrap(); 86 87 let dir = tempfile::tempdir().unwrap(); 88 let path = dir.path().join("identity.json"); 89 std::fs::write(&path, json).unwrap(); 90 91 let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap(); 92 assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex); 93 } 94 95 #[test] 96 fn load_from_json_file_profile() { 97 let mut identity = fixture_identity(FIXTURE_ALICE); 98 let profile = RadrootsProfile { 99 name: "relay-agent".to_string(), 100 display_name: Some("Relay Agent".to_string()), 101 nip05: None, 102 about: Some("hello".to_string()), 103 website: None, 104 picture: None, 105 banner: None, 106 lud06: None, 107 lud16: None, 108 bot: None, 109 }; 110 identity.set_profile(RadrootsIdentityProfile { 111 profile: Some(profile), 112 ..Default::default() 113 }); 114 let json = serde_json::to_string(&identity.to_file()).unwrap(); 115 116 let dir = tempfile::tempdir().unwrap(); 117 let path = dir.path().join("identity.json"); 118 std::fs::write(&path, json).unwrap(); 119 120 let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap(); 121 let loaded_profile = loaded.profile().and_then(|p| p.profile.as_ref()).unwrap(); 122 assert_eq!(loaded_profile.name, "relay-agent"); 123 assert_eq!(loaded_profile.display_name.as_deref(), Some("Relay Agent")); 124 assert_eq!(loaded_profile.about.as_deref(), Some("hello")); 125 } 126 127 #[test] 128 fn load_from_text_file_hex() { 129 let identity = fixture_identity(FIXTURE_ALICE); 130 let secret = identity.secret_key_hex(); 131 132 let dir = tempfile::tempdir().unwrap(); 133 let path = dir.path().join("identity.txt"); 134 std::fs::write(&path, secret).unwrap(); 135 136 let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap(); 137 assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex); 138 } 139 140 #[test] 141 fn load_from_text_file_nsec() { 142 let identity = fixture_identity(FIXTURE_ALICE); 143 let secret = identity.secret_key_nsec(); 144 145 let dir = tempfile::tempdir().unwrap(); 146 let path = dir.path().join("identity.txt"); 147 std::fs::write(&path, secret).unwrap(); 148 149 let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap(); 150 assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex); 151 } 152 153 #[test] 154 fn load_from_binary_file() { 155 let identity = fixture_identity(FIXTURE_ALICE); 156 let secret = identity.secret_key_bytes(); 157 158 let dir = tempfile::tempdir().unwrap(); 159 let path = dir.path().join("identity.key"); 160 std::fs::write(&path, secret).unwrap(); 161 162 let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap(); 163 assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex); 164 } 165 166 #[test] 167 fn load_or_generate_missing_disallowed() { 168 let dir = tempfile::tempdir().unwrap(); 169 let path = dir.path().join("identity.json"); 170 171 let err = RadrootsIdentity::load_or_generate(Some(&path), false).unwrap_err(); 172 assert!(matches!(err, IdentityError::GenerationNotAllowed(p) if p == path)); 173 } 174 175 #[test] 176 fn load_or_generate_missing_allowed_creates_json() { 177 let dir = tempfile::tempdir().unwrap(); 178 let path = dir.path().join("identity.json"); 179 180 let identity = RadrootsIdentity::load_or_generate(Some(&path), true).unwrap(); 181 assert!(path.exists()); 182 183 let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap(); 184 assert_eq!(loaded.public_key(), identity.public_key()); 185 } 186 187 #[test] 188 fn load_from_json_file_public_key_npub() { 189 let identity = fixture_identity(FIXTURE_ALICE); 190 let mut file = identity.to_file(); 191 file.public_key = Some(identity.public_key_npub()); 192 let json = serde_json::to_string(&file).unwrap(); 193 194 let dir = tempfile::tempdir().unwrap(); 195 let path = dir.path().join("identity.json"); 196 std::fs::write(&path, json).unwrap(); 197 198 let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap(); 199 assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex); 200 } 201 202 #[test] 203 fn load_from_json_file_public_key_mismatch() { 204 let identity = fixture_identity(FIXTURE_ALICE); 205 let mut file = identity.to_file(); 206 file.public_key = Some(FIXTURE_BOB.public_key_hex.to_string()); 207 let json = serde_json::to_string(&file).unwrap(); 208 209 let dir = tempfile::tempdir().unwrap(); 210 let path = dir.path().join("identity.json"); 211 std::fs::write(&path, json).unwrap(); 212 213 let err = RadrootsIdentity::load_from_path_auto(&path).unwrap_err(); 214 assert!(matches!(err, IdentityError::PublicKeyMismatch)); 215 } 216 217 #[test] 218 fn identity_id_matches_public_key_hex() { 219 let identity = fixture_identity(FIXTURE_ALICE); 220 221 let id = identity.id(); 222 assert_eq!(id.as_str(), FIXTURE_ALICE.public_key_hex); 223 } 224 225 #[test] 226 fn identity_id_parses_hex_and_npub() { 227 let from_hex = RadrootsIdentityId::parse(FIXTURE_ALICE.public_key_hex).unwrap(); 228 let from_npub = RadrootsIdentityId::parse(FIXTURE_ALICE.npub).unwrap(); 229 assert_eq!(from_hex.as_str(), FIXTURE_ALICE.public_key_hex); 230 assert_eq!(from_npub.as_str(), FIXTURE_ALICE.public_key_hex); 231 } 232 233 #[test] 234 fn to_public_projection_excludes_secret_key_fields() { 235 let identity = fixture_identity(FIXTURE_ALICE); 236 let public = identity.to_public(); 237 238 assert_eq!(public.id.as_str(), FIXTURE_ALICE.public_key_hex); 239 assert_eq!(public.public_key_hex, FIXTURE_ALICE.public_key_hex); 240 assert_eq!(public.public_key_npub, FIXTURE_ALICE.npub); 241 assert!(public.profile.is_none()); 242 243 let json = serde_json::to_string(&public).unwrap(); 244 assert!(!json.contains("secret_key")); 245 assert!(!json.contains(&identity.secret_key_hex())); 246 } 247 248 #[test] 249 fn identity_id_trait_paths_and_string_conversions() { 250 let public_key = fixture_identity(FIXTURE_ALICE).public_key(); 251 let public_key_hex = FIXTURE_ALICE.public_key_hex.to_string(); 252 253 let from_impl = RadrootsIdentityId::from(public_key); 254 assert_eq!(from_impl.as_ref(), public_key_hex); 255 256 let from_try = RadrootsIdentityId::try_from(public_key_hex.as_str()).unwrap(); 257 assert_eq!(from_try.to_string(), public_key_hex); 258 assert_eq!(from_try.clone().into_string(), public_key_hex); 259 } 260 261 #[test] 262 fn identity_profile_state_mutation_paths() { 263 let mut identity = RadrootsIdentity::with_profile( 264 fixture_keys(FIXTURE_ALICE), 265 RadrootsIdentityProfile::default(), 266 ); 267 assert!(identity.profile().is_none()); 268 269 identity.set_profile(RadrootsIdentityProfile::default()); 270 assert!(identity.profile().is_none()); 271 272 let profile = profile_with_identifier("radroots-user"); 273 identity.set_profile(profile.clone()); 274 assert!(identity.profile().is_some()); 275 276 let profile_mut = identity.profile_mut().unwrap(); 277 profile_mut.identifier = Some("radroots-user-updated".to_string()); 278 assert_eq!( 279 identity.profile().and_then(|p| p.identifier.as_deref()), 280 Some("radroots-user-updated") 281 ); 282 283 let public = identity.to_public(); 284 assert!(public.profile.is_some()); 285 286 identity.clear_profile(); 287 assert!(identity.profile().is_none()); 288 289 let public_without_profile = RadrootsIdentityPublic::new(identity.public_key()) 290 .with_profile(RadrootsIdentityProfile::default()); 291 assert!(public_without_profile.profile.is_none()); 292 293 let public_with_profile = 294 RadrootsIdentityPublic::new(identity.public_key()).with_profile(profile); 295 assert!(public_with_profile.profile.is_some()); 296 } 297 298 #[test] 299 fn identity_accessor_paths_and_secret_formats() { 300 let identity = fixture_identity(FIXTURE_ALICE); 301 302 assert_eq!( 303 identity.keys().public_key().to_hex(), 304 FIXTURE_ALICE.public_key_hex 305 ); 306 assert_eq!(identity.public_key().to_hex(), FIXTURE_ALICE.public_key_hex); 307 assert_eq!(identity.npub(), FIXTURE_ALICE.npub); 308 assert_eq!(identity.nsec(), FIXTURE_ALICE.nsec); 309 310 let file_nsec = identity.to_file_with_secret_format(RadrootsIdentitySecretKeyFormat::Nsec); 311 assert_eq!(file_nsec.secret_key, FIXTURE_ALICE.nsec); 312 313 let from_keys: RadrootsIdentity = fixture_keys(FIXTURE_ALICE).into(); 314 let roundtrip_keys = from_keys.clone().into_keys(); 315 assert_eq!( 316 roundtrip_keys.public_key().to_hex(), 317 FIXTURE_ALICE.public_key_hex 318 ); 319 } 320 321 #[cfg(feature = "nip49")] 322 #[test] 323 fn encrypted_secret_key_round_trips_to_identity() { 324 let identity = fixture_identity(FIXTURE_ALICE); 325 let encrypted = identity 326 .encrypt_secret_key_ncryptsec("fixture-password") 327 .unwrap(); 328 assert!(encrypted.starts_with("ncryptsec1")); 329 330 let decrypted = 331 RadrootsIdentity::from_encrypted_secret_key_str(&encrypted, "fixture-password").unwrap(); 332 assert_eq!(decrypted.public_key(), identity.public_key()); 333 } 334 335 #[cfg(feature = "nip49")] 336 #[test] 337 fn encrypted_secret_key_options_propagate_to_output() { 338 use nostr::nips::nip19::FromBech32; 339 use nostr::nips::nip49::{EncryptedSecretKey, KeySecurity}; 340 341 let identity = fixture_identity(FIXTURE_ALICE); 342 let encrypted = identity 343 .encrypt_secret_key_ncryptsec_with_options( 344 "fixture-password", 345 RadrootsIdentityEncryptedSecretKeyOptions { 346 log_n: 15, 347 key_security: RadrootsIdentityEncryptedSecretKeySecurity::Medium, 348 }, 349 ) 350 .unwrap(); 351 let parsed = EncryptedSecretKey::from_bech32(&encrypted).unwrap(); 352 assert_eq!(parsed.log_n(), 15); 353 assert_eq!(parsed.key_security(), KeySecurity::Medium); 354 } 355 356 #[cfg(feature = "nip49")] 357 #[test] 358 fn encrypted_secret_key_weak_security_and_invalid_log_n_paths() { 359 use nostr::nips::nip49::KeySecurity; 360 361 assert_eq!( 362 KeySecurity::from(RadrootsIdentityEncryptedSecretKeySecurity::Weak), 363 KeySecurity::Weak 364 ); 365 366 let identity = fixture_identity(FIXTURE_ALICE); 367 let err = identity 368 .encrypt_secret_key_ncryptsec_with_options( 369 "fixture-password", 370 RadrootsIdentityEncryptedSecretKeyOptions { 371 log_n: 255, 372 key_security: RadrootsIdentityEncryptedSecretKeySecurity::Weak, 373 }, 374 ) 375 .unwrap_err(); 376 assert!(matches!(err, IdentityError::EncryptSecretKey(_))); 377 } 378 379 #[cfg(feature = "nip49")] 380 #[test] 381 fn encrypted_secret_key_rejects_invalid_and_wrong_password_inputs() { 382 let identity = fixture_identity(FIXTURE_ALICE); 383 let encrypted = identity 384 .encrypt_secret_key_ncryptsec("fixture-password") 385 .unwrap(); 386 387 let invalid = 388 RadrootsIdentity::from_encrypted_secret_key_str("not-an-encrypted-secret", "password") 389 .unwrap_err(); 390 assert!(matches!( 391 invalid, 392 IdentityError::InvalidEncryptedSecretKey(_) 393 )); 394 395 let wrong_password = 396 RadrootsIdentity::from_encrypted_secret_key_str(&encrypted, "wrong-password").unwrap_err(); 397 assert!(matches!( 398 wrong_password, 399 IdentityError::DecryptEncryptedSecretKey(_) 400 )); 401 } 402 403 #[cfg(feature = "nip49")] 404 #[test] 405 fn load_from_path_auto_rejects_nip49_export_format() { 406 let identity = fixture_identity(FIXTURE_ALICE); 407 let encrypted = identity 408 .encrypt_secret_key_ncryptsec("fixture-password") 409 .unwrap(); 410 411 let dir = tempfile::tempdir().unwrap(); 412 let path = dir.path().join("identity.ncryptsec"); 413 std::fs::write(&path, encrypted).unwrap(); 414 415 let err = RadrootsIdentity::load_from_path_auto(&path).unwrap_err(); 416 assert!(matches!(err, IdentityError::InvalidSecretKey(_))); 417 } 418 419 #[test] 420 fn parse_failures_cover_public_key_errors() { 421 let err_empty = RadrootsIdentityId::parse(" ").unwrap_err(); 422 assert!(matches!(err_empty, IdentityError::InvalidPublicKey(_))); 423 424 let err_invalid = RadrootsIdentityId::parse("invalid-public-key-value").unwrap_err(); 425 assert!(matches!(err_invalid, IdentityError::InvalidPublicKey(_))); 426 } 427 428 #[test] 429 fn from_secret_key_bytes_rejects_wrong_length() { 430 let err = RadrootsIdentity::from_secret_key_bytes(&[1, 2, 3]).unwrap_err(); 431 assert!(matches!(err, IdentityError::InvalidIdentityFormat)); 432 } 433 434 #[test] 435 fn from_secret_key_str_rejects_invalid_secret() { 436 let err = RadrootsIdentity::from_secret_key_str("not-a-secret-key").unwrap_err(); 437 assert!(matches!(err, IdentityError::InvalidSecretKey(_))); 438 } 439 440 #[test] 441 fn from_secret_key_bytes_rejects_invalid_scalar() { 442 let err = RadrootsIdentity::from_secret_key_bytes(&[0u8; 32]).unwrap_err(); 443 assert!(matches!(err, IdentityError::InvalidSecretKey(_))); 444 } 445 446 #[test] 447 fn load_from_path_reports_not_found_and_read_errors() { 448 let dir = tempfile::tempdir().unwrap(); 449 let missing = dir.path().join("missing-identity.json"); 450 let not_found = RadrootsIdentity::load_from_path_auto(&missing).unwrap_err(); 451 assert!(matches!(not_found, IdentityError::NotFound(path) if path == missing)); 452 453 let read_error = RadrootsIdentity::load_from_path_auto(dir.path()).unwrap_err(); 454 assert!(matches!(read_error, IdentityError::Read(path, _) if path == dir.path())); 455 } 456 457 #[test] 458 fn load_from_path_rejects_invalid_payloads() { 459 let dir = tempfile::tempdir().unwrap(); 460 461 let blank_path = dir.path().join("identity-blank.txt"); 462 std::fs::write(&blank_path, " \n\t ").unwrap(); 463 let blank_err = RadrootsIdentity::load_from_path_auto(&blank_path).unwrap_err(); 464 assert!(matches!(blank_err, IdentityError::InvalidIdentityFormat)); 465 466 let invalid_utf8_path = dir.path().join("identity-invalid-utf8.bin"); 467 std::fs::write(&invalid_utf8_path, [0xff, 0xfe, 0xfd]).unwrap(); 468 let utf8_err = RadrootsIdentity::load_from_path_auto(&invalid_utf8_path).unwrap_err(); 469 assert!(matches!(utf8_err, IdentityError::InvalidIdentityFormat)); 470 471 let invalid_json_path = dir.path().join("identity-invalid-json.json"); 472 std::fs::write(&invalid_json_path, "{invalid").unwrap(); 473 let json_err = RadrootsIdentity::load_from_path_auto(&invalid_json_path).unwrap_err(); 474 assert!(matches!(json_err, IdentityError::InvalidJson(_))); 475 } 476 477 #[test] 478 fn load_from_json_file_without_public_key_succeeds() { 479 let identity = fixture_identity(FIXTURE_ALICE); 480 let mut file = identity.to_file(); 481 file.public_key = None; 482 let json = serde_json::to_string(&file).unwrap(); 483 484 let dir = tempfile::tempdir().unwrap(); 485 let path = dir.path().join("identity.json"); 486 std::fs::write(&path, json).unwrap(); 487 488 let loaded = RadrootsIdentity::load_from_path_auto(&path).unwrap(); 489 assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex); 490 } 491 492 #[test] 493 fn load_from_json_file_rejects_invalid_secret_key_string() { 494 let payload = serde_json::json!({ 495 "secret_key": "invalid-secret-key", 496 "public_key": null, 497 }); 498 let dir = tempfile::tempdir().unwrap(); 499 let path = dir.path().join("identity.json"); 500 std::fs::write(&path, payload.to_string()).unwrap(); 501 502 let err = RadrootsIdentity::load_from_path_auto(&path).unwrap_err(); 503 assert!(matches!(err, IdentityError::InvalidSecretKey(_))); 504 } 505 506 #[test] 507 fn load_from_json_file_rejects_invalid_public_key_value() { 508 let identity = fixture_identity(FIXTURE_ALICE); 509 let mut file = identity.to_file(); 510 file.public_key = Some("invalid-public-key".to_string()); 511 let json = serde_json::to_string(&file).unwrap(); 512 513 let dir = tempfile::tempdir().unwrap(); 514 let path = dir.path().join("identity.json"); 515 std::fs::write(&path, json).unwrap(); 516 517 let err = RadrootsIdentity::load_from_path_auto(&path).unwrap_err(); 518 assert!(matches!(err, IdentityError::InvalidPublicKey(_))); 519 } 520 521 #[test] 522 fn save_json_rejects_directory_target() { 523 let identity = fixture_identity(FIXTURE_ALICE); 524 let dir = tempfile::tempdir().unwrap(); 525 let err = identity.save_json(dir.path()).unwrap_err(); 526 assert!(matches!(err, IdentityError::Store(_))); 527 } 528 529 #[cfg(unix)] 530 #[test] 531 fn save_json_reports_write_failure_on_read_only_directory() { 532 use std::os::unix::fs::PermissionsExt; 533 534 let identity = fixture_identity(FIXTURE_ALICE); 535 let dir = tempfile::tempdir().unwrap(); 536 let path = dir.path().join("identity.json"); 537 identity.save_json(path.as_path()).unwrap(); 538 539 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o500)).unwrap(); 540 let err_path = identity.save_json(path.as_path()).unwrap_err(); 541 assert!(matches!(err_path, IdentityError::Store(_))); 542 let err_path_buf = identity.save_json(&path).unwrap_err(); 543 assert!(matches!(err_path_buf, IdentityError::Store(_))); 544 std::fs::set_permissions(dir.path(), std::fs::Permissions::from_mode(0o700)).unwrap(); 545 } 546 547 #[cfg(unix)] 548 #[test] 549 fn load_or_generate_reports_save_failure_when_parent_not_writable() { 550 use std::os::unix::fs::PermissionsExt; 551 552 let dir = tempfile::tempdir().unwrap(); 553 let parent = dir.path().join("readonly"); 554 std::fs::create_dir(&parent).unwrap(); 555 std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o500)).unwrap(); 556 557 let path = parent.join("identity.json"); 558 let err = RadrootsIdentity::load_or_generate::<&std::path::Path>(Some(path.as_path()), true) 559 .unwrap_err(); 560 assert!(matches!(err, IdentityError::Store(_))); 561 let err_path_buf = RadrootsIdentity::load_or_generate(Some(&path), true).unwrap_err(); 562 assert!(matches!(err_path_buf, IdentityError::Store(_))); 563 std::fs::set_permissions(&parent, std::fs::Permissions::from_mode(0o700)).unwrap(); 564 } 565 566 #[test] 567 fn load_or_generate_uses_default_path_when_missing() { 568 let resolver = RadrootsPathResolver::new( 569 RadrootsPlatform::Linux, 570 RadrootsHostEnvironment { 571 home_dir: Some(PathBuf::from("/home/treesap")), 572 ..RadrootsHostEnvironment::default() 573 }, 574 ); 575 let default_path = RadrootsIdentity::default_path_for( 576 &resolver, 577 RadrootsPathProfile::InteractiveUser, 578 &RadrootsPathOverrides::default(), 579 ) 580 .unwrap(); 581 582 let denied = RadrootsIdentity::load_or_generate::<&std::path::Path>(Some(&default_path), false) 583 .unwrap_err(); 584 assert!(matches!(denied, IdentityError::GenerationNotAllowed(path) if path == default_path)); 585 assert_eq!( 586 default_path.file_name().and_then(std::ffi::OsStr::to_str), 587 Some(DEFAULT_IDENTITY_PATH) 588 ); 589 assert_eq!( 590 default_path, 591 PathBuf::from("/home/treesap/.radroots/secrets/shared/identities/default.json") 592 ); 593 } 594 595 #[test] 596 fn default_path_matches_current_resolver_default_path() { 597 let expected = RadrootsIdentity::default_path_for( 598 &RadrootsPathResolver::current(), 599 RadrootsPathProfile::InteractiveUser, 600 &RadrootsPathOverrides::default(), 601 ) 602 .unwrap(); 603 604 assert_eq!(RadrootsIdentity::default_path().unwrap(), expected); 605 } 606 607 #[test] 608 fn default_path_for_reports_missing_home_dir() { 609 let resolver = 610 RadrootsPathResolver::new(RadrootsPlatform::Linux, RadrootsHostEnvironment::default()); 611 let err = RadrootsIdentity::default_path_for( 612 &resolver, 613 RadrootsPathProfile::InteractiveUser, 614 &RadrootsPathOverrides::default(), 615 ) 616 .unwrap_err(); 617 assert!(matches!(err, IdentityError::Paths(_))); 618 } 619 620 #[test] 621 fn load_or_generate_without_explicit_path_propagates_default_path_errors() { 622 let _lock = home_env_lock().lock().unwrap(); 623 let _guard = EnvVarGuard::remove("HOME"); 624 625 let err = RadrootsIdentity::load_or_generate::<&std::path::Path>(None, false).unwrap_err(); 626 assert!(matches!(err, IdentityError::Paths(_))); 627 } 628 629 #[test] 630 fn load_or_generate_creates_at_explicit_default_path() { 631 let dir = tempfile::tempdir().unwrap(); 632 let default_path = dir.path().join(DEFAULT_IDENTITY_PATH); 633 let generated = 634 RadrootsIdentity::load_or_generate::<&std::path::Path>(Some(&default_path), true).unwrap(); 635 assert!(default_path.exists()); 636 637 let loaded = RadrootsIdentity::load_from_path_auto(&default_path).unwrap(); 638 assert_eq!(generated.public_key(), loaded.public_key()); 639 } 640 641 #[test] 642 fn load_or_generate_prefers_existing_path() { 643 let identity = fixture_identity(FIXTURE_ALICE); 644 let payload = serde_json::to_string(&identity.to_file()).unwrap(); 645 646 let dir = tempfile::tempdir().unwrap(); 647 let path = dir.path().join("identity.json"); 648 std::fs::write(&path, payload).unwrap(); 649 650 let loaded = RadrootsIdentity::load_or_generate(Some(&path), false).unwrap(); 651 assert_eq!(loaded.public_key().to_hex(), FIXTURE_ALICE.public_key_hex); 652 } 653 654 #[test] 655 fn path_ref_variants_cover_success_paths() { 656 let identity = fixture_identity(FIXTURE_ALICE); 657 let dir = tempfile::tempdir().unwrap(); 658 659 let saved_path = dir.path().join("saved.json"); 660 identity.save_json(saved_path.as_path()).unwrap(); 661 let loaded = RadrootsIdentity::load_from_path_auto(saved_path.as_path()).unwrap(); 662 assert_eq!(loaded.public_key(), identity.public_key()); 663 664 let generated_path = dir.path().join("generated.json"); 665 let generated = 666 RadrootsIdentity::load_or_generate(Some(generated_path.as_path()), true).unwrap(); 667 assert!(generated_path.exists()); 668 let roundtrip = RadrootsIdentity::load_from_path_auto(generated_path.as_path()).unwrap(); 669 assert_eq!(generated.public_key(), roundtrip.public_key()); 670 } 671 672 #[test] 673 fn generate_with_profile_retains_profile() { 674 let profile = profile_with_identifier("runtime-user"); 675 let identity = RadrootsIdentity::generate_with_profile(profile); 676 assert_eq!( 677 identity.profile().and_then(|p| p.identifier.as_deref()), 678 Some("runtime-user") 679 ); 680 } 681 682 #[test] 683 fn identity_profile_is_empty_checks_metadata_and_application_handler() { 684 let profile_with_metadata = RadrootsIdentityProfile { 685 metadata: Some(sample_event("metadata")), 686 ..Default::default() 687 }; 688 assert!(!profile_with_metadata.is_empty()); 689 690 let profile_with_handler = RadrootsIdentityProfile { 691 application_handler: Some(sample_event("handler")), 692 ..Default::default() 693 }; 694 assert!(!profile_with_handler.is_empty()); 695 } 696 697 #[test] 698 fn identity_error_display_variants_are_exercised() { 699 let missing_path = PathBuf::from("/tmp/missing-identity.json"); 700 assert_eq!( 701 IdentityError::NotFound(missing_path.clone()).to_string(), 702 format!("identity file missing at {}", missing_path.display()) 703 ); 704 assert_eq!( 705 IdentityError::GenerationNotAllowed(missing_path.clone()).to_string(), 706 format!( 707 "identity file missing at {} and generation is not permitted (pass --allow-generate-identity)", 708 missing_path.display() 709 ) 710 ); 711 assert!( 712 IdentityError::Read(missing_path.clone(), std::io::Error::other("boom")) 713 .to_string() 714 .contains("failed to read identity file") 715 ); 716 717 let json_err = serde_json::from_str::<serde_json::Value>("{").unwrap_err(); 718 assert!( 719 IdentityError::InvalidJson(json_err) 720 .to_string() 721 .contains("invalid identity JSON") 722 ); 723 724 let secret_err = nostr::Keys::parse("not-a-secret-key").unwrap_err(); 725 assert!( 726 IdentityError::InvalidSecretKey(secret_err) 727 .to_string() 728 .contains("invalid secret key") 729 ); 730 731 #[cfg(feature = "nip49")] 732 { 733 assert_eq!( 734 IdentityError::EncryptSecretKey("encrypt failed".into()).to_string(), 735 "failed to encrypt secret key: encrypt failed" 736 ); 737 assert_eq!( 738 IdentityError::InvalidEncryptedSecretKey("bad payload".into()).to_string(), 739 "invalid encrypted secret key: bad payload" 740 ); 741 assert_eq!( 742 IdentityError::DecryptEncryptedSecretKey("bad password".into()).to_string(), 743 "failed to decrypt encrypted secret key: bad password" 744 ); 745 } 746 747 assert_eq!( 748 IdentityError::InvalidPublicKey("bad-pubkey".into()).to_string(), 749 "invalid public key: bad-pubkey" 750 ); 751 assert_eq!( 752 IdentityError::PublicKeyMismatch.to_string(), 753 "public key does not match secret key" 754 ); 755 assert_eq!( 756 IdentityError::InvalidIdentityFormat.to_string(), 757 "unsupported identity file format" 758 ); 759 760 #[cfg(all(feature = "std", feature = "json-file"))] 761 { 762 let store_err = fixture_identity(FIXTURE_ALICE) 763 .save_json(tempfile::tempdir().unwrap().path()) 764 .unwrap_err(); 765 assert!(!store_err.to_string().is_empty()); 766 } 767 768 let paths_err = IdentityError::from( 769 radroots_runtime_paths::RadrootsRuntimePathsError::MissingHomeDir { 770 platform: RadrootsPlatform::Linux, 771 }, 772 ); 773 assert_eq!( 774 paths_err.to_string(), 775 "interactive_user on linux requires a home directory" 776 ); 777 } 778 779 #[cfg(feature = "secrecy")] 780 #[test] 781 fn secret_key_hex_secret_returns_secret_string() { 782 use secrecy::ExposeSecret; 783 784 let identity = fixture_identity(FIXTURE_ALICE); 785 let secret = identity.secret_key_hex_secret(); 786 assert_eq!(secret.expose_secret(), &identity.secret_key_hex()); 787 } 788 789 #[cfg(feature = "zeroize")] 790 #[test] 791 fn secret_key_zeroizing_bytes_matches_raw_secret() { 792 let identity = fixture_identity(FIXTURE_ALICE); 793 let raw = identity.secret_key_bytes(); 794 let protected = identity.secret_key_bytes_zeroizing(); 795 assert_eq!(&*protected, &raw); 796 } 797 798 #[test] 799 fn encrypted_identity_storage_public_api_round_trips_and_reports_errors() { 800 let temp = tempfile::tempdir().unwrap(); 801 let path = temp.path().join("identity.enc.json"); 802 let identity = fixture_identity(FIXTURE_ALICE); 803 804 let default_file = RadrootsEncryptedIdentityFile::new(path.as_path()); 805 assert_eq!(default_file.path(), path.as_path()); 806 assert_eq!( 807 default_file.key_slot(), 808 RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT 809 ); 810 assert_eq!( 811 default_file.wrapping_key_path(), 812 encrypted_identity_wrapping_key_path(path.as_path()) 813 ); 814 815 let custom_file = 816 RadrootsEncryptedIdentityFile::with_key_slot(path.as_path(), "field_identity"); 817 assert_eq!(custom_file.key_slot(), "field_identity"); 818 819 store_encrypted_identity(path.as_path(), &identity).unwrap(); 820 rotate_encrypted_identity(path.as_path()).unwrap(); 821 let loaded = load_encrypted_identity(path.as_path()).unwrap(); 822 assert_eq!(loaded.public_key(), identity.public_key()); 823 824 store_encrypted_identity_with_key_slot(path.as_path(), "field_identity", &identity).unwrap(); 825 rotate_encrypted_identity_with_key_slot(path.as_path(), "field_identity").unwrap(); 826 let loaded = load_encrypted_identity_with_key_slot(path.as_path(), "field_identity").unwrap(); 827 assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex()); 828 829 let path_buf_api = temp.path().join("identity-pathbuf.enc.json"); 830 let path_buf_ref = &path_buf_api; 831 let path_buf_file = RadrootsEncryptedIdentityFile::new(path_buf_ref); 832 assert_eq!(path_buf_file.path(), path_buf_api.as_path()); 833 let path_buf_file = 834 RadrootsEncryptedIdentityFile::with_key_slot(path_buf_ref, "path_buf_identity"); 835 assert_eq!(path_buf_file.key_slot(), "path_buf_identity"); 836 store_encrypted_identity(path_buf_ref, &identity).unwrap(); 837 rotate_encrypted_identity(path_buf_ref).unwrap(); 838 let loaded = load_encrypted_identity(path_buf_ref).unwrap(); 839 assert_eq!(loaded.public_key(), identity.public_key()); 840 store_encrypted_identity_with_key_slot(path_buf_ref, "path_buf_identity", &identity).unwrap(); 841 rotate_encrypted_identity_with_key_slot(path_buf_ref, "path_buf_identity").unwrap(); 842 let loaded = load_encrypted_identity_with_key_slot(path_buf_ref, "path_buf_identity").unwrap(); 843 assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex()); 844 845 let missing = temp.path().join("missing.enc.json"); 846 let missing_error = load_encrypted_identity(missing.as_path()).unwrap_err(); 847 assert!(matches!(missing_error, IdentityError::NotFound(error_path) if error_path == missing)); 848 849 let read_error = load_encrypted_identity(temp.path()).unwrap_err(); 850 assert!(matches!(read_error, IdentityError::Read(error_path, _) if error_path == temp.path())); 851 852 let invalid = temp.path().join("invalid.enc.json"); 853 std::fs::write(&invalid, b"not-json").unwrap(); 854 let decode_error = load_encrypted_identity(invalid.as_path()).unwrap_err(); 855 assert!(matches!( 856 decode_error, 857 IdentityError::ProtectedStorage { path: error_path, message } 858 if error_path == invalid && message.contains("decode encrypted identity") 859 )); 860 861 let invalid_plaintext = temp.path().join("invalid-plaintext.enc.json"); 862 let key_source = RadrootsProtectedFileKeySource::from_sidecar_suffix( 863 invalid_plaintext.as_path(), 864 RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX, 865 ); 866 let envelope = RadrootsProtectedStoreEnvelope::seal_with_wrapped_key( 867 &key_source, 868 RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT, 869 b"not identity json", 870 ) 871 .unwrap(); 872 std::fs::write(&invalid_plaintext, envelope.encode_json().unwrap()).unwrap(); 873 let invalid_plaintext_error = load_encrypted_identity(invalid_plaintext.as_path()).unwrap_err(); 874 assert!(matches!( 875 invalid_plaintext_error, 876 IdentityError::InvalidJson(_) 877 )); 878 879 std::fs::write( 880 encrypted_identity_wrapping_key_path(path.as_path()), 881 b"short", 882 ) 883 .unwrap(); 884 let open_error = load_encrypted_identity(path.as_path()).unwrap_err(); 885 assert!(matches!( 886 open_error, 887 IdentityError::ProtectedStorage { path: error_path, message } 888 if error_path == path && message.contains("open encrypted identity") 889 )); 890 } 891 892 #[test] 893 fn encrypted_identity_storage_public_api_reports_store_errors() { 894 let temp = tempfile::tempdir().unwrap(); 895 let identity = fixture_identity(FIXTURE_ALICE); 896 897 let blocked_parent = temp.path().join("blocked-parent"); 898 std::fs::write(&blocked_parent, b"not-a-directory").unwrap(); 899 let create_path = blocked_parent.join("identity.enc.json"); 900 let create_error = store_encrypted_identity(create_path.as_path(), &identity).unwrap_err(); 901 assert!(matches!(create_error, IdentityError::CreateDir(path, _) if path == blocked_parent)); 902 903 let directory_path = temp.path().join("identity-as-directory.enc.json"); 904 std::fs::create_dir(&directory_path).unwrap(); 905 let write_error = store_encrypted_identity(directory_path.as_path(), &identity).unwrap_err(); 906 assert!(matches!(write_error, IdentityError::Write(path, _) if path == directory_path)); 907 908 let sealed_path = temp.path().join("seal-error.enc.json"); 909 std::fs::create_dir(encrypted_identity_wrapping_key_path(sealed_path.as_path())).unwrap(); 910 let seal_error = store_encrypted_identity(sealed_path.as_path(), &identity).unwrap_err(); 911 assert!(matches!( 912 seal_error, 913 IdentityError::ProtectedStorage { path, message } 914 if path == sealed_path && message.contains("seal encrypted identity") 915 )); 916 } 917 918 #[cfg(unix)] 919 #[test] 920 fn encrypted_identity_storage_public_api_restores_key_after_rotation_failure() { 921 use std::os::unix::fs::PermissionsExt; 922 923 let temp = tempfile::tempdir().unwrap(); 924 let path = temp.path().join("identity.enc.json"); 925 let identity = fixture_identity(FIXTURE_ALICE); 926 927 store_encrypted_identity(path.as_path(), &identity).unwrap(); 928 let key_path = encrypted_identity_wrapping_key_path(path.as_path()); 929 let key_before = std::fs::read(&key_path).unwrap(); 930 931 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o400)).unwrap(); 932 let error = rotate_encrypted_identity(path.as_path()).unwrap_err(); 933 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); 934 935 assert!(matches!(error, IdentityError::Write(error_path, _) if error_path == path)); 936 assert_eq!(std::fs::read(&key_path).unwrap(), key_before); 937 let loaded = load_encrypted_identity(path.as_path()).unwrap(); 938 assert_eq!(loaded.public_key(), identity.public_key()); 939 } 940 941 #[test] 942 fn identity_profile_storage_public_api_reports_errors_and_private_fallback() { 943 let temp = tempfile::tempdir().unwrap(); 944 let identity = fixture_identity(FIXTURE_ALICE); 945 946 let path = temp.path().join("profile.json"); 947 store_identity_profile(path.as_path(), &identity).unwrap(); 948 let loaded = load_identity_profile(path.as_path()).unwrap(); 949 assert_eq!(loaded.id, identity.id()); 950 951 let path_buf_profile = temp.path().join("profile-pathbuf.json"); 952 store_identity_profile(&path_buf_profile, &identity).unwrap(); 953 let loaded = load_identity_profile(&path_buf_profile).unwrap(); 954 assert_eq!(loaded.id, identity.id()); 955 956 let blocked_parent = temp.path().join("blocked-profile-parent"); 957 std::fs::write(&blocked_parent, b"not-a-directory").unwrap(); 958 let create_path = blocked_parent.join("profile.json"); 959 let create_error = store_identity_profile(create_path.as_path(), &identity).unwrap_err(); 960 assert!(matches!(create_error, IdentityError::CreateDir(path, _) if path == blocked_parent)); 961 962 let directory_path = temp.path().join("profile-as-directory.json"); 963 std::fs::create_dir(&directory_path).unwrap(); 964 let write_error = store_identity_profile(directory_path.as_path(), &identity).unwrap_err(); 965 assert!(matches!(write_error, IdentityError::Write(path, _) if path == directory_path)); 966 967 let missing = temp.path().join("missing-profile.json"); 968 let missing_error = load_identity_profile(missing.as_path()).unwrap_err(); 969 assert!(matches!(missing_error, IdentityError::NotFound(path) if path == missing)); 970 971 let read_error = load_identity_profile(temp.path()).unwrap_err(); 972 assert!(matches!(read_error, IdentityError::Read(path, _) if path == temp.path())); 973 974 let private_profile = temp.path().join("private-profile.json"); 975 std::fs::write( 976 &private_profile, 977 serde_json::to_vec(&identity.to_file()).unwrap(), 978 ) 979 .unwrap(); 980 let loaded = load_identity_profile(private_profile.as_path()).unwrap(); 981 assert_eq!(loaded.public_key_hex, FIXTURE_ALICE.public_key_hex); 982 } 983 984 #[test] 985 fn storage_public_api_supports_parentless_relative_files() { 986 let temp = tempfile::tempdir().unwrap(); 987 let previous = std::env::current_dir().unwrap(); 988 std::env::set_current_dir(temp.path()).unwrap(); 989 990 let identity = fixture_identity(FIXTURE_ALICE); 991 let encrypted_path = std::path::Path::new("identity.enc.json"); 992 store_encrypted_identity(encrypted_path, &identity).unwrap(); 993 let loaded = load_encrypted_identity(encrypted_path).unwrap(); 994 assert_eq!(loaded.secret_key_hex(), identity.secret_key_hex()); 995 996 let profile_path = std::path::Path::new("profile.json"); 997 store_identity_profile(profile_path, &identity).unwrap(); 998 let loaded = load_identity_profile(profile_path).unwrap(); 999 assert_eq!(loaded.id, identity.id()); 1000 1001 let empty_path = std::path::Path::new(""); 1002 let encrypted_error = store_encrypted_identity(empty_path, &identity).unwrap_err(); 1003 assert!(matches!(encrypted_error, IdentityError::Write(_, _))); 1004 let profile_error = store_identity_profile(empty_path, &identity).unwrap_err(); 1005 assert!(matches!(profile_error, IdentityError::Write(_, _))); 1006 1007 std::env::set_current_dir(previous).unwrap(); 1008 }