os_keyring.rs (20707B)
1 use std::sync::{Mutex, MutexGuard}; 2 3 use harvestcircle_application::{BoxFuture, DurableRequestId, SecretStore}; 4 use harvestcircle_domain::{PublicKey, SafeError, SafeErrorCode, SafeMessage, SecretKeyInput}; 5 use harvestcircle_product::KEYRING_SERVICE; 6 use zeroize::Zeroizing; 7 8 pub const CREDENTIAL_SERVICE: &str = KEYRING_SERVICE; 9 10 const CREDENTIAL_ENVELOPE_DOMAIN: &[u8] = b"harvestcircle.credential.v1\0"; 11 #[cfg(target_os = "linux")] 12 const CREDENTIAL_OPERATION_ATTRIBUTE: &str = "harvestcircle-operation"; 13 #[cfg(target_os = "linux")] 14 const CREDENTIAL_ACCOUNT_ATTRIBUTE: &str = "account"; 15 #[cfg(target_os = "linux")] 16 const CREDENTIAL_SERVICE_ATTRIBUTE: &str = "service"; 17 18 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 19 enum CreateError { 20 Existing, 21 Unavailable, 22 } 23 24 #[derive(Clone, Copy, Debug, Eq, PartialEq)] 25 enum ReadError { 26 Missing, 27 Unavailable, 28 } 29 30 #[derive(Default)] 31 pub struct OsKeyringSecretStore { 32 operation_lock: Mutex<()>, 33 } 34 35 impl OsKeyringSecretStore { 36 fn operation(&self) -> Result<MutexGuard<'_, ()>, SafeError> { 37 self.operation_lock 38 .lock() 39 .map_err(|_| keyring_unavailable()) 40 } 41 } 42 43 impl SecretStore for OsKeyringSecretStore { 44 fn put<'a>( 45 &'a self, 46 request_id: &'a DurableRequestId, 47 public_key: PublicKey, 48 secret: SecretKeyInput, 49 ) -> BoxFuture<'a, Result<(), SafeError>> { 50 Box::pin(async move { 51 let _operation = self.operation()?; 52 let account = public_key.to_hex(); 53 let encoded = encode_credential(request_id, &secret); 54 match platform_create(&account, request_id, encoded.as_slice()) { 55 Ok(()) => Ok(()), 56 Err(CreateError::Existing) => { 57 let existing = Zeroizing::new(platform_read(&account).map_err(map_read_error)?); 58 verify_existing_replay(request_id, &secret, existing.as_slice()) 59 } 60 Err(CreateError::Unavailable) => Err(keyring_unavailable()), 61 } 62 }) 63 } 64 65 fn verify<'a>( 66 &'a self, 67 request_id: &'a DurableRequestId, 68 public_key: PublicKey, 69 secret: SecretKeyInput, 70 ) -> BoxFuture<'a, Result<(), SafeError>> { 71 Box::pin(async move { 72 let _operation = self.operation()?; 73 let account = public_key.to_hex(); 74 let encoded = Zeroizing::new(platform_read(&account).map_err(map_read_error)?); 75 verify_replay_binding(request_id, &secret, encoded.as_slice()) 76 }) 77 } 78 79 fn load(&self, public_key: PublicKey) -> BoxFuture<'_, Result<SecretKeyInput, SafeError>> { 80 Box::pin(async move { 81 let _operation = self.operation()?; 82 let account = public_key.to_hex(); 83 let encoded = Zeroizing::new(platform_read(&account).map_err(map_read_error)?); 84 decode_credential(encoded.as_slice()).map(|(_, secret)| secret) 85 }) 86 } 87 88 fn contains(&self, public_key: PublicKey) -> BoxFuture<'_, Result<bool, SafeError>> { 89 Box::pin(async move { 90 let _operation = self.operation()?; 91 let account = public_key.to_hex(); 92 match platform_read(&account) { 93 Ok(encoded) => { 94 let encoded = Zeroizing::new(encoded); 95 decode_credential(encoded.as_slice())?; 96 Ok(true) 97 } 98 Err(ReadError::Missing) => Ok(false), 99 Err(ReadError::Unavailable) => Err(keyring_unavailable()), 100 } 101 }) 102 } 103 104 fn delete<'a>( 105 &'a self, 106 _request_id: &'a DurableRequestId, 107 public_key: PublicKey, 108 ) -> BoxFuture<'a, Result<(), SafeError>> { 109 Box::pin(async move { 110 let _operation = self.operation()?; 111 let account = public_key.to_hex(); 112 platform_delete(&account).map_err(map_read_error) 113 }) 114 } 115 } 116 117 fn encode_credential(request_id: &DurableRequestId, secret: &SecretKeyInput) -> Zeroizing<Vec<u8>> { 118 secret.with_exposed_secret(|value| { 119 let mut encoded = Zeroizing::new(Vec::with_capacity( 120 CREDENTIAL_ENVELOPE_DOMAIN.len() + 36 + 1 + value.len(), 121 )); 122 encoded.extend_from_slice(CREDENTIAL_ENVELOPE_DOMAIN); 123 encoded.extend_from_slice(request_id.as_str().as_bytes()); 124 encoded.push(0); 125 encoded.extend_from_slice(value.as_bytes()); 126 encoded 127 }) 128 } 129 130 fn decode_credential(encoded: &[u8]) -> Result<(DurableRequestId, SecretKeyInput), SafeError> { 131 let request_start = CREDENTIAL_ENVELOPE_DOMAIN.len(); 132 let request_end = request_start + 36; 133 let secret_start = request_end + 1; 134 if encoded.len() != secret_start + 64 135 || !encoded.starts_with(CREDENTIAL_ENVELOPE_DOMAIN) 136 || encoded.get(request_end) != Some(&0) 137 { 138 return Err(keyring_unavailable()); 139 } 140 let request = std::str::from_utf8(&encoded[request_start..request_end]) 141 .map_err(|_| keyring_unavailable())?; 142 let request = DurableRequestId::parse(request).map_err(|_| keyring_unavailable())?; 143 let secret = SecretKeyInput::parse_bytes(encoded[secret_start..].to_vec()) 144 .map_err(|_| keyring_unavailable())?; 145 Ok((request, secret)) 146 } 147 148 fn verify_existing_replay( 149 request_id: &DurableRequestId, 150 secret: &SecretKeyInput, 151 encoded: &[u8], 152 ) -> Result<(), SafeError> { 153 let (existing_request, existing_secret) = decode_credential(encoded)?; 154 if existing_request == *request_id 155 && existing_secret 156 .with_exposed_secret(|value| secret.with_exposed_secret(|expected| value == expected)) 157 { 158 Ok(()) 159 } else { 160 Err(credential_exists()) 161 } 162 } 163 164 fn verify_replay_binding( 165 request_id: &DurableRequestId, 166 secret: &SecretKeyInput, 167 encoded: &[u8], 168 ) -> Result<(), SafeError> { 169 verify_existing_replay(request_id, secret, encoded).map_err(|error| { 170 if error.code() == SafeErrorCode::IdentityAlreadyExists { 171 SafeError::new( 172 SafeErrorCode::InvalidApplicationState, 173 SafeMessage::new("The identity operation conflicts with the stored credential."), 174 ) 175 } else { 176 error 177 } 178 }) 179 } 180 181 const fn map_read_error(error: ReadError) -> SafeError { 182 match error { 183 ReadError::Missing => credential_missing(), 184 ReadError::Unavailable => keyring_unavailable(), 185 } 186 } 187 188 #[cfg(target_os = "macos")] 189 fn platform_create( 190 account: &str, 191 _request_id: &DurableRequestId, 192 secret: &[u8], 193 ) -> Result<(), CreateError> { 194 use security_framework::os::macos::keychain::SecKeychain; 195 use security_framework_sys::base::errSecDuplicateItem; 196 197 let keychain = SecKeychain::default().map_err(|_| CreateError::Unavailable)?; 198 keychain 199 .add_generic_password(CREDENTIAL_SERVICE, account, secret) 200 .map_err(|error| { 201 if error.code() == errSecDuplicateItem { 202 CreateError::Existing 203 } else { 204 CreateError::Unavailable 205 } 206 }) 207 } 208 209 #[cfg(target_os = "macos")] 210 fn platform_read(account: &str) -> Result<Vec<u8>, ReadError> { 211 use security_framework::os::macos::keychain::SecKeychain; 212 use security_framework_sys::base::errSecItemNotFound; 213 214 let keychain = SecKeychain::default().map_err(|_| ReadError::Unavailable)?; 215 keychain 216 .find_generic_password(CREDENTIAL_SERVICE, account) 217 .map(|(password, _item)| password.as_ref().to_vec()) 218 .map_err(|error| { 219 if error.code() == errSecItemNotFound { 220 ReadError::Missing 221 } else { 222 ReadError::Unavailable 223 } 224 }) 225 } 226 227 #[cfg(target_os = "macos")] 228 fn platform_delete(account: &str) -> Result<(), ReadError> { 229 use security_framework::item::{ItemClass, ItemSearchOptions}; 230 use security_framework_sys::base::errSecItemNotFound; 231 232 let mut query = ItemSearchOptions::new(); 233 query 234 .class(ItemClass::generic_password()) 235 .service(CREDENTIAL_SERVICE) 236 .account(account); 237 match query.delete() { 238 Ok(()) => Ok(()), 239 Err(error) if error.code() == errSecItemNotFound => Err(ReadError::Missing), 240 Err(_) => Err(ReadError::Unavailable), 241 } 242 } 243 244 #[cfg(target_os = "linux")] 245 fn linux_service() -> Result<secret_service::blocking::SecretService<'static>, ReadError> { 246 use secret_service::EncryptionType; 247 use secret_service::blocking::SecretService; 248 249 SecretService::connect(EncryptionType::Dh).map_err(|_| ReadError::Unavailable) 250 } 251 252 #[cfg(target_os = "linux")] 253 fn linux_items<'a>( 254 service: &'a secret_service::blocking::SecretService<'a>, 255 account: &'a str, 256 ) -> Result<Vec<secret_service::blocking::Item<'a>>, ReadError> { 257 let attributes = std::collections::HashMap::from([ 258 (CREDENTIAL_SERVICE_ATTRIBUTE, CREDENTIAL_SERVICE), 259 (CREDENTIAL_ACCOUNT_ATTRIBUTE, account), 260 ]); 261 let mut result = service 262 .search_items(attributes) 263 .map_err(|_| ReadError::Unavailable)?; 264 if !result.locked.is_empty() { 265 let locked = result.locked.iter().collect::<Vec<_>>(); 266 service 267 .unlock_all(&locked) 268 .map_err(|_| ReadError::Unavailable)?; 269 result.unlocked.append(&mut result.locked); 270 } 271 Ok(result.unlocked) 272 } 273 274 #[cfg(target_os = "linux")] 275 fn platform_create( 276 account: &str, 277 request_id: &DurableRequestId, 278 secret: &[u8], 279 ) -> Result<(), CreateError> { 280 let service = linux_service().map_err(|_| CreateError::Unavailable)?; 281 let existing = linux_items(&service, account).map_err(|_| CreateError::Unavailable)?; 282 if !existing.is_empty() { 283 return Err(CreateError::Existing); 284 } 285 286 let collection = service 287 .get_default_collection() 288 .map_err(|_| CreateError::Unavailable)?; 289 collection 290 .ensure_unlocked() 291 .map_err(|_| CreateError::Unavailable)?; 292 let attributes = std::collections::HashMap::from([ 293 (CREDENTIAL_SERVICE_ATTRIBUTE, CREDENTIAL_SERVICE), 294 (CREDENTIAL_ACCOUNT_ATTRIBUTE, account), 295 (CREDENTIAL_OPERATION_ATTRIBUTE, request_id.as_str()), 296 ]); 297 let created = collection 298 .create_item( 299 "HarvestCircle Nostr identity", 300 attributes, 301 secret, 302 false, 303 "application/octet-stream", 304 ) 305 .map_err(|_| CreateError::Unavailable)?; 306 307 let all = linux_items(&service, account).map_err(|_| CreateError::Unavailable)?; 308 if all.len() == 1 && all.first() == Some(&created) { 309 Ok(()) 310 } else { 311 let _ = created.delete(); 312 Err(CreateError::Existing) 313 } 314 } 315 316 #[cfg(target_os = "linux")] 317 fn platform_read(account: &str) -> Result<Vec<u8>, ReadError> { 318 let service = linux_service()?; 319 let mut items = linux_items(&service, account)?; 320 if items.is_empty() { 321 return Err(ReadError::Missing); 322 } 323 if items.len() != 1 { 324 return Err(ReadError::Unavailable); 325 } 326 items 327 .pop() 328 .expect("single item checked") 329 .get_secret() 330 .map_err(|_| ReadError::Unavailable) 331 } 332 333 #[cfg(target_os = "linux")] 334 fn platform_delete(account: &str) -> Result<(), ReadError> { 335 let service = linux_service()?; 336 let mut items = linux_items(&service, account)?; 337 if items.is_empty() { 338 return Err(ReadError::Missing); 339 } 340 if items.len() != 1 { 341 return Err(ReadError::Unavailable); 342 } 343 items 344 .pop() 345 .expect("single item checked") 346 .delete() 347 .map_err(|_| ReadError::Unavailable) 348 } 349 350 #[cfg(not(any(target_os = "linux", target_os = "macos")))] 351 fn platform_create( 352 _account: &str, 353 _request_id: &DurableRequestId, 354 _secret: &[u8], 355 ) -> Result<(), CreateError> { 356 Err(CreateError::Unavailable) 357 } 358 359 #[cfg(not(any(target_os = "linux", target_os = "macos")))] 360 fn platform_read(_account: &str) -> Result<Vec<u8>, ReadError> { 361 Err(ReadError::Unavailable) 362 } 363 364 #[cfg(not(any(target_os = "linux", target_os = "macos")))] 365 fn platform_delete(_account: &str) -> Result<(), ReadError> { 366 Err(ReadError::Unavailable) 367 } 368 369 const fn credential_exists() -> SafeError { 370 SafeError::new( 371 SafeErrorCode::IdentityAlreadyExists, 372 SafeMessage::new("The Nostr identity credential already exists."), 373 ) 374 } 375 376 const fn credential_missing() -> SafeError { 377 SafeError::new( 378 SafeErrorCode::CredentialMissing, 379 SafeMessage::new("The Nostr identity credential is missing."), 380 ) 381 } 382 383 const fn keyring_unavailable() -> SafeError { 384 SafeError::new( 385 SafeErrorCode::KeyringUnavailable, 386 SafeMessage::new("The operating system credential store is unavailable."), 387 ) 388 } 389 390 #[cfg(test)] 391 mod tests { 392 use harvestcircle_application::{DurableRequestId, SecretStore}; 393 use harvestcircle_domain::{PublicKey, SafeErrorCode, SecretKeyInput}; 394 395 use super::{ 396 CREDENTIAL_ENVELOPE_DOMAIN, CREDENTIAL_SERVICE, OsKeyringSecretStore, decode_credential, 397 encode_credential, verify_existing_replay, verify_replay_binding, 398 }; 399 use zeroize::Zeroizing; 400 401 const SECRET: &str = "0000000000000000000000000000000000000000000000000000000000000001"; 402 403 fn request_id() -> DurableRequestId { 404 DurableRequestId::parse("01890f3e-7b1c-7000-8000-000000000249").expect("request") 405 } 406 407 fn public_key() -> PublicKey { 408 PublicKey::from_hex("7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7") 409 .expect("valid public key") 410 } 411 412 #[test] 413 fn credential_envelope_binds_uuidv7_operation_and_secret() { 414 let secret = SecretKeyInput::parse(SECRET.to_owned()).expect("secret"); 415 let encoded = encode_credential(&request_id(), &secret); 416 assert_eq!( 417 encoded.len(), 418 CREDENTIAL_ENVELOPE_DOMAIN.len() + 36 + 1 + 64 419 ); 420 421 let (operation, decoded) = decode_credential(encoded.as_slice()).expect("decode"); 422 assert_eq!(operation, request_id()); 423 assert!(decoded.with_exposed_secret(|value| value == SECRET)); 424 } 425 426 #[test] 427 fn malformed_credential_envelopes_fail_closed() { 428 let secret = SecretKeyInput::parse(SECRET.to_owned()).expect("secret"); 429 let encoded = encode_credential(&request_id(), &secret); 430 for candidate in [ 431 encoded[..encoded.len() - 1].to_vec(), 432 { 433 let mut value = encoded.to_vec(); 434 value[0] ^= 1; 435 value 436 }, 437 { 438 let mut value = encoded.to_vec(); 439 value[CREDENTIAL_ENVELOPE_DOMAIN.len() + 14] = b'4'; 440 value 441 }, 442 { 443 let mut value = encoded.to_vec(); 444 value[CREDENTIAL_ENVELOPE_DOMAIN.len() + 36] = b'x'; 445 value 446 }, 447 ] { 448 let error = match decode_credential(&candidate) { 449 Err(error) => error, 450 Ok(_) => panic!("malformed envelope was accepted"), 451 }; 452 assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable); 453 } 454 } 455 456 #[test] 457 fn only_exact_same_operation_replay_is_idempotent() { 458 let secret = SecretKeyInput::parse(SECRET.to_owned()).expect("secret"); 459 let encoded = encode_credential(&request_id(), &secret); 460 assert!(verify_existing_replay(&request_id(), &secret, &encoded).is_ok()); 461 462 let another_request = 463 DurableRequestId::parse("01890f3e-7b1c-7000-8000-000000000250").expect("request"); 464 let request_conflict = verify_existing_replay(&another_request, &secret, &encoded) 465 .expect_err("another operation must conflict"); 466 assert_eq!( 467 request_conflict.code(), 468 SafeErrorCode::IdentityAlreadyExists 469 ); 470 471 let another_secret = SecretKeyInput::parse( 472 "0000000000000000000000000000000000000000000000000000000000000002".to_owned(), 473 ) 474 .expect("secret"); 475 let secret_conflict = verify_existing_replay(&request_id(), &another_secret, &encoded) 476 .expect_err("same operation cannot change the secret"); 477 assert_eq!(secret_conflict.code(), SafeErrorCode::IdentityAlreadyExists); 478 } 479 480 #[test] 481 fn readonly_envelope_verification_preserves_bytes_and_safe_conflict_errors() { 482 let secret = SecretKeyInput::parse(SECRET.to_owned()).expect("secret"); 483 let request = request_id(); 484 let encoded = encode_credential(&request, &secret); 485 let before = Zeroizing::new(encoded.to_vec()); 486 let exact = verify_replay_binding(&request, &secret, encoded.as_slice()); 487 let another_request = DurableRequestId::new_v7(); 488 let changed_request = verify_replay_binding(&another_request, &secret, encoded.as_slice()) 489 .expect_err("original request required"); 490 let another_secret = SecretKeyInput::parse( 491 "0000000000000000000000000000000000000000000000000000000000000002".to_owned(), 492 ) 493 .expect("different secret"); 494 let changed_secret = verify_replay_binding(&request, &another_secret, encoded.as_slice()) 495 .expect_err("full secret required"); 496 let malformed = verify_replay_binding(&request, &secret, &encoded[..encoded.len() - 1]) 497 .expect_err("malformed native envelope"); 498 assert!(exact.is_ok()); 499 assert_eq!( 500 changed_request.code(), 501 SafeErrorCode::InvalidApplicationState 502 ); 503 assert_eq!( 504 changed_secret.code(), 505 SafeErrorCode::InvalidApplicationState 506 ); 507 assert_eq!(malformed.code(), SafeErrorCode::KeyringUnavailable); 508 assert!(encoded.as_slice() == before.as_slice()); 509 let public_evidence = format!("{changed_request:?} {changed_secret:?} {malformed:?}"); 510 assert!(!public_evidence.contains(SECRET)); 511 assert!(!another_secret.with_exposed_secret(|value| public_evidence.contains(value))); 512 } 513 514 #[tokio::test] 515 async fn poisoned_readonly_verification_fails_before_os_custody_access() { 516 let store = OsKeyringSecretStore::default(); 517 let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { 518 let _operation = store.operation_lock.lock().expect("operation lock"); 519 panic!("injected operation failure"); 520 })); 521 let error = store 522 .verify( 523 &request_id(), 524 public_key(), 525 SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), 526 ) 527 .await 528 .expect_err("poison must reject before native read"); 529 assert!(panic.is_err()); 530 assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable); 531 assert!(!format!("{error:?}").contains(SECRET)); 532 } 533 534 #[test] 535 fn keyring_coordinates_are_stable_and_public() { 536 assert_eq!(CREDENTIAL_SERVICE, "org.harvestcircle.desktop.nostr"); 537 assert_eq!( 538 public_key().to_hex(), 539 "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7" 540 ); 541 } 542 543 #[tokio::test] 544 async fn poisoned_operation_lock_fails_closed_before_keyring_access() { 545 let store = OsKeyringSecretStore::default(); 546 let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { 547 let _operation = store.operation_lock.lock().expect("operation lock"); 548 panic!("injected operation failure"); 549 })); 550 assert!(panic.is_err()); 551 552 let error = store 553 .contains(public_key()) 554 .await 555 .expect_err("poison must reject"); 556 assert_eq!(error.code(), SafeErrorCode::KeyringUnavailable); 557 } 558 559 #[tokio::test] 560 #[ignore = "mutates the current user's operating-system credential store"] 561 async fn real_keyring_smoke_round_trips_and_deletes() { 562 let store = OsKeyringSecretStore::default(); 563 let request_id = request_id(); 564 let _ = store.delete(&request_id, public_key()).await; 565 store 566 .put( 567 &request_id, 568 public_key(), 569 SecretKeyInput::parse(SECRET.to_owned()).expect("secret"), 570 ) 571 .await 572 .expect("keyring put"); 573 assert!( 574 store 575 .contains(public_key()) 576 .await 577 .expect("keyring contains") 578 ); 579 let loaded = store.load(public_key()).await.expect("keyring load"); 580 assert!(loaded.with_exposed_secret(|value| value == SECRET)); 581 store 582 .delete(&request_id, public_key()) 583 .await 584 .expect("keyring delete"); 585 } 586 }