app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

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 }