Skip to main content

coven/
keys.rs

1use tracing::info;
2
3// The key types re-exported at the crate root (see lib.rs) stay public;
4// `public_key_hex` and the SIGN_*BYTES constants are used only within coven, so
5// they stay crate-internal.
6pub(crate) use coven_core::keys::{public_key_hex, SIGN_PUBLICKEYBYTES, SIGN_SECRETKEYBYTES};
7pub use coven_core::keys::{
8    CloudHomeCredentials, DeviceIdentityCustody, KeyError, MasterKeyCustody, UserKeypair,
9};
10
11/// Why a [`CovenHandle`](crate::CovenHandle) master-key lifecycle call
12/// (`initialize_master_key`, `import_master_key`) failed.
13#[derive(Debug, thiserror::Error)]
14pub enum MasterKeyError {
15    /// `initialize_master_key` found a master key already established —
16    /// custody `unlock()` returned `Some`. coven never generates over an
17    /// existing key; the host imports or forgets it first.
18    #[error("a master key is already established for this store")]
19    AlreadyEstablished,
20    #[error("key error: {0}")]
21    Key(#[from] KeyError),
22    #[error("invalid master key material: {0}")]
23    Encryption(#[from] crate::encryption::EncryptionError),
24}
25
26/// Why a [`CovenHandle`](crate::CovenHandle) [`initialize_identity`](crate::CovenHandle::initialize_identity)
27/// call failed.
28#[derive(Debug, thiserror::Error)]
29pub enum IdentityError {
30    /// `initialize_identity` found an identity already established for this
31    /// store — custody `unlock()` returned `Some`. coven never generates over
32    /// an existing identity; a store's identity is established exactly once,
33    /// by whichever of create/join/restore established it first.
34    #[error("an identity is already established for this store")]
35    AlreadyEstablished,
36    #[error("key error: {0}")]
37    Key(#[from] KeyError),
38}
39
40static KEYRING_SERVICE: std::sync::OnceLock<String> = std::sync::OnceLock::new();
41
42/// Register the process-wide keyring: the service name every entry is stored
43/// under, and the platform keyring store that backs it. Both are one-time
44/// startup registration and must run before any key operation. The store is
45/// installed before the name is recorded, so a failed installation leaves no
46/// registration behind. Re-registering the same name is a no-op; a different
47/// name is a startup contradiction and fails. Fails with
48/// [`KeyError::UnsupportedKeyringPlatform`] on a target with no bundled store.
49pub fn set_keyring_service(name: impl Into<String>) -> Result<(), KeyError> {
50    crate::keyring_backend::install_platform_store()?;
51    let name = name.into();
52    if KEYRING_SERVICE.set(name.clone()).is_err() {
53        let registered = KEYRING_SERVICE
54            .get()
55            .map(String::as_str)
56            .expect("a keyring service is registered when set() fails");
57        if registered != name {
58            return Err(KeyError::Persistence(format!(
59                "keyring service is already registered as {registered:?}; cannot re-register as {name:?}"
60            )));
61        }
62    }
63    Ok(())
64}
65
66/// The registered keyring service name. `Err` when the host never ran the
67/// startup [`set_keyring_service`] call — surfaced so a mis-ordered host gets a
68/// typed error, not a panic deep inside a key operation.
69pub fn keyring_service() -> Result<&'static str, KeyError> {
70    KEYRING_SERVICE
71        .get()
72        .map(String::as_str)
73        .ok_or(KeyError::ServiceNotRegistered)
74}
75
76fn map_keyring_error(e: keyring_core::Error) -> KeyError {
77    #[cfg(any(target_os = "macos", target_os = "ios"))]
78    if is_missing_keychain_entitlement(&e) {
79        return KeyError::MissingKeychainEntitlement;
80    }
81    match e {
82        keyring_core::Error::NoDefaultStore => KeyError::StoreNotInstalled,
83        other => KeyError::Persistence(other.to_string()),
84    }
85}
86
87/// `errSecMissingEntitlement` (OSStatus -34018) arrives from
88/// `apple-native-keyring-store`'s `protected::decode_error` as
89/// `keyring_core::Error::PlatformFailure`, whose payload is a
90/// `Box<dyn std::error::Error + Send + Sync>` — the OSStatus is not exposed as
91/// a field on `keyring_core::Error` itself. The box's concrete type is always
92/// `security_framework::base::Error` on this path (verified by reading
93/// `apple-native-keyring-store`'s `protected.rs`: every `decode_error` arm
94/// boxes the `security_framework::base::Error` it received), so `downcast_ref`
95/// recovers it and `.code()` reads the real OSStatus — a structured match, not
96/// a string search over the formatted error.
97#[cfg(any(target_os = "macos", target_os = "ios"))]
98fn is_missing_keychain_entitlement(e: &keyring_core::Error) -> bool {
99    let keyring_core::Error::PlatformFailure(inner) = e else {
100        return false;
101    };
102    inner
103        .downcast_ref::<security_framework::base::Error>()
104        .is_some_and(|err| err.code() == -34018)
105}
106
107/// The base account name [`KeyringSlot::DeviceSigningKey`] renders as
108/// `{base}:{store_id}` under.
109const DEVICE_SIGNING_KEY_BASE: &str = "coven_user_signing_key";
110/// The base account name [`KeyringSlot::EncryptionMasterKey`] renders as
111/// `{base}:{store_id}` under.
112const ENCRYPTION_MASTER_KEY_BASE: &str = "encryption_master_key";
113/// The base account name [`KeyringSlot::CloudHomeCredentials`] renders as
114/// `{base}:{store_id}` under.
115const CLOUD_HOME_CREDENTIALS_BASE: &str = "cloud_home_credentials";
116/// The base account name [`KeyringSlot::PendingIdentity`] renders as
117/// `{base}:{request_public_key_hex}` under.
118const PENDING_IDENTITY_BASE: &str = "coven_pending_identity";
119
120/// Every name coven's own [`KeyringSlot`] variants reserve for themselves —
121/// built from the same constants [`KeyringSlot::account`] renders accounts
122/// from, so this list cannot drift from what coven actually stores under. A
123/// host secret's `name` must not equal any of these: see
124/// [`validate_host_secret_name`].
125pub(crate) const RESERVED_HOST_SECRET_NAMES: &[&str] = &[
126    DEVICE_SIGNING_KEY_BASE,
127    ENCRYPTION_MASTER_KEY_BASE,
128    CLOUD_HOME_CREDENTIALS_BASE,
129    PENDING_IDENTITY_BASE,
130];
131
132/// Which key a keyring entry holds, and the sole owner of the account name it
133/// is stored under. The device signing key, the encryption master key, the
134/// cloud-home credentials, and a host secret are all per store; a pending
135/// identity is keyed by its own join request instead of a store (it exists
136/// before the joiner knows which store the invite names — see
137/// [`crate::keys::mint_pending_identity`]). Every keyring read/write/delete
138/// names its entry with one of these variants, so the on-disk account
139/// strings live in exactly one place: [`KeyringSlot::account`].
140pub(crate) enum KeyringSlot {
141    /// A store's Ed25519 signing identity.
142    DeviceSigningKey(String),
143    /// A store's encryption master key.
144    EncryptionMasterKey(String),
145    /// A store's cloud-home credentials.
146    CloudHomeCredentials(String),
147    /// A join request's not-yet-store-scoped signing identity, keyed by the
148    /// request's own public key.
149    PendingIdentity(String),
150    /// A host's own store-scoped secret, named by the host and validated
151    /// against [`RESERVED_HOST_SECRET_NAMES`] before it ever reaches this
152    /// variant (see [`validate_host_secret_name`]).
153    HostSecret { name: String, store_id: String },
154}
155
156impl KeyringSlot {
157    /// The keyring account name this slot is stored under. These strings are a
158    /// durable storage contract: a device's already-stored keys are found only
159    /// at these exact accounts, so changing any of them strands stored keys.
160    /// `HostSecret`'s rendering is a storage contract with the *host*, not
161    /// just coven: it must stay byte-identical to whatever account a host
162    /// already wrote its secrets under before this API existed.
163    pub(crate) fn account(&self) -> String {
164        match self {
165            KeyringSlot::DeviceSigningKey(store_id) => {
166                format!("{DEVICE_SIGNING_KEY_BASE}:{store_id}")
167            }
168            KeyringSlot::EncryptionMasterKey(store_id) => {
169                format!("{ENCRYPTION_MASTER_KEY_BASE}:{store_id}")
170            }
171            KeyringSlot::CloudHomeCredentials(store_id) => {
172                format!("{CLOUD_HOME_CREDENTIALS_BASE}:{store_id}")
173            }
174            KeyringSlot::PendingIdentity(request_public_key_hex) => {
175                format!("{PENDING_IDENTITY_BASE}:{request_public_key_hex}")
176            }
177            KeyringSlot::HostSecret { name, store_id } => format!("{name}:{store_id}"),
178        }
179    }
180}
181
182/// Reject a host secret name that would collide with, or otherwise misuse,
183/// coven's own keyring account scheme: one of coven's own reserved names, the
184/// empty string, or a name containing `:` (the scheme's separator — allowing
185/// one would let a host secret's name forge another store's account). Called
186/// at the API boundary before a [`KeyringSlot::HostSecret`] is ever built.
187pub(crate) fn validate_host_secret_name(name: &str) -> Result<(), KeyError> {
188    if name.is_empty() {
189        return Err(KeyError::InvalidSecretName {
190            name: name.to_string(),
191            reason: "a host secret name must not be empty".to_string(),
192        });
193    }
194    if name.contains(':') {
195        return Err(KeyError::InvalidSecretName {
196            name: name.to_string(),
197            reason: "a host secret name must not contain ':', the keyring account scheme's \
198                     separator"
199                .to_string(),
200        });
201    }
202    if RESERVED_HOST_SECRET_NAMES.contains(&name) {
203        return Err(KeyError::InvalidSecretName {
204            name: name.to_string(),
205            reason: "reserved for coven's own keyring entries".to_string(),
206        });
207    }
208    Ok(())
209}
210
211/// The sole entry-construction point for the OS keyring: every read, write,
212/// and delete builds its [`keyring_core::Entry`] here, so a target's
213/// protection policy is applied in exactly one place.
214///
215/// On Apple targets, when the process installed the real protected-data
216/// store (as opposed to a test's mock store, or any other store reached
217/// through this same code path), entries are created device-only
218/// (`AccessPolicy::WhenUnlockedThisDeviceOnly`): an encrypted local
219/// (Finder/iTunes) backup restored onto a different device does not restore
220/// this item, because the item is bound to this device's Secure Enclave.
221/// Apple's documented modifier-string path for this policy
222/// (`Entry::new_with_modifiers(service, account, {"access-policy":
223/// "when-unlocked-this-device-only"})`) is silently accepted by
224/// `apple-native-keyring-store`'s string parser but mapped to the
225/// non-device-only `AccessPolicy::WhenUnlocked` instead — no error, just the
226/// wrong protection class — so this calls the store's own `Cred::build`
227/// directly, which takes the `AccessPolicy` enum and bypasses that string
228/// parser entirely. `AccessPolicy::RequireUserPresence` (biometric-gated
229/// access) is the policy argument a future decision would change here; it is
230/// not selected today.
231///
232/// Any other installed store (a test's mock store) gets a plain entry with
233/// no modifier — device-only protection is meaningful only under the real
234/// protected-data store.
235///
236/// Non-Apple targets always get a plain entry: Android and Windows have
237/// their own at-rest protection, and "does not survive a device-to-device
238/// backup restore" is an Apple concept tied to Apple's accessibility
239/// classes.
240///
241/// The access policy an item is created under is fixed for its lifetime;
242/// every Coven-created Apple keyring item therefore enters the device-only
243/// class at its first write.
244#[cfg(any(target_os = "macos", target_os = "ios"))]
245pub(crate) fn entry_for(account: &str) -> Result<keyring_core::Entry, KeyError> {
246    let service = keyring_service()?;
247    let store = keyring_core::get_default_store().ok_or(KeyError::StoreNotInstalled)?;
248    match store
249        .as_any()
250        .downcast_ref::<apple_native_keyring_store::protected::Store>()
251    {
252        Some(_) => apple_native_keyring_store::protected::Cred::build(
253            service,
254            account,
255            apple_native_keyring_store::protected::AccessPolicy::WhenUnlockedThisDeviceOnly,
256            None,
257            false,
258        )
259        .map_err(map_keyring_error),
260        None => keyring_core::Entry::new(service, account).map_err(map_keyring_error),
261    }
262}
263
264#[cfg(not(any(target_os = "macos", target_os = "ios")))]
265pub(crate) fn entry_for(account: &str) -> Result<keyring_core::Entry, KeyError> {
266    keyring_core::Entry::new(keyring_service()?, account).map_err(map_keyring_error)
267}
268
269/// Test-only: reaches [`entry_for`] across the crate boundary. Exists so an
270/// integration test can install a specific keyring store and assert which
271/// entry-construction path the chokepoint took, without re-implementing its
272/// dispatch.
273#[cfg(any(test, feature = "test-utils"))]
274pub fn entry_for_test(account: &str) -> Result<keyring_core::Entry, KeyError> {
275    entry_for(account)
276}
277
278pub(crate) fn read(slot: &KeyringSlot) -> Result<Option<String>, KeyError> {
279    let account = slot.account();
280    let entry = entry_for(&account)?;
281    match entry.get_password() {
282        Ok(p) if p.is_empty() => Err(KeyError::Persistence(format!(
283            "keyring entry {account} is present but empty (corrupt)"
284        ))),
285        Ok(p) => Ok(Some(p)),
286        Err(keyring_core::Error::NoEntry) => Ok(None),
287        Err(e) => Err(map_keyring_error(e)),
288    }
289}
290
291pub(crate) fn write(slot: &KeyringSlot, value: &str) -> Result<(), KeyError> {
292    entry_for(&slot.account())?
293        .set_password(value)
294        .map_err(map_keyring_error)
295}
296
297pub(crate) fn delete(slot: &KeyringSlot) -> Result<bool, KeyError> {
298    match entry_for(&slot.account())?.delete_credential() {
299        Ok(()) => Ok(true),
300        Err(keyring_core::Error::NoEntry) => Ok(false),
301        Err(e) => Err(map_keyring_error(e)),
302    }
303}
304
305/// This store's established signing identity through `custody`, or
306/// [`KeyError::NoDeviceIdentity`] when none is established — the caller must
307/// complete create/join/restore for this store first. Never mints: a
308/// connect/join precondition, not a query.
309pub(crate) fn require_identity(
310    custody: &dyn DeviceIdentityCustody,
311) -> Result<UserKeypair, KeyError> {
312    custody.unlock()?.ok_or(KeyError::NoDeviceIdentity)
313}
314
315/// A query, not a connect: `Ok(None)` when this store has no identity
316/// established, distinct from a key-store failure (`Err`). Never mints.
317pub(crate) fn identity_public_key(
318    custody: &dyn DeviceIdentityCustody,
319) -> Result<Option<[u8; SIGN_PUBLICKEYBYTES]>, KeyError> {
320    Ok(custody.unlock()?.map(|kp| kp.public_key()))
321}
322
323/// Import an already-generated signing key (a restore code's `sk`) into
324/// `custody`. Same-pubkey re-import is idempotent; importing over a
325/// DIFFERENT already-established identity is refused with
326/// [`KeyError::IdentityMismatch`] naming both — silently swapping this
327/// store's identity would strand its already-signed membership entries.
328pub(crate) fn import_identity(
329    custody: &dyn DeviceIdentityCustody,
330    signing_key_bytes: &[u8],
331) -> Result<(), KeyError> {
332    let signing_key: [u8; SIGN_SECRETKEYBYTES] = signing_key_bytes.try_into().map_err(|_| {
333        KeyError::Crypto(format!(
334            "Signing key must be {SIGN_SECRETKEYBYTES} bytes, got {}",
335            signing_key_bytes.len()
336        ))
337    })?;
338    let imported = UserKeypair::from_signing_key_bytes(&signing_key)?;
339
340    if let Some(existing) = custody.unlock()? {
341        if existing.public_key() != imported.public_key() {
342            return Err(KeyError::IdentityMismatch {
343                existing_pubkey_hex: public_key_hex(&existing),
344                imported_pubkey_hex: public_key_hex(&imported),
345            });
346        }
347    }
348    custody.persist(&imported)?;
349    info!("Imported this store's Ed25519 signing identity");
350    Ok(())
351}
352
353/// Mint a fresh identity for a join request that has not yet named a store:
354/// the joiner sends its public key before it learns which store the invite
355/// is for (`JoinRequestCode`), so this keypair is generated now and held
356/// under a pending slot keyed by its own public key. The join establishes it
357/// in the joined store's own identity custody (via [`import_identity`],
358/// before the store's completion marker) and discards the pending slot only
359/// once the whole join succeeds; [`discard_pending_identity`] also removes it
360/// if the request is abandoned instead. Always the OS keyring: unlike an
361/// established store's identity, there is no store yet to select a custody
362/// policy for, and a pending identity's lifetime is short (a join round trip,
363/// not a store's lifetime).
364pub(crate) fn mint_pending_identity() -> Result<UserKeypair, KeyError> {
365    let keypair = UserKeypair::generate();
366    write(
367        &KeyringSlot::PendingIdentity(public_key_hex(&keypair)),
368        &hex::encode(keypair.to_keypair_bytes()),
369    )?;
370    info!("Minted a pending identity for a join request");
371    Ok(keypair)
372}
373
374/// Read (without consuming) the pending identity keyed by
375/// `request_public_key_hex` — what a join in progress signs its bootstrap
376/// traffic with, and what it establishes in the store's own custody before
377/// the completion marker. [`KeyError::NoPendingIdentity`] if none is held
378/// under that key.
379pub(crate) fn peek_pending_identity(request_public_key_hex: &str) -> Result<UserKeypair, KeyError> {
380    read_pending_identity_slot(&KeyringSlot::PendingIdentity(
381        request_public_key_hex.to_string(),
382    ))
383}
384
385fn read_pending_identity_slot(slot: &KeyringSlot) -> Result<UserKeypair, KeyError> {
386    let KeyringSlot::PendingIdentity(request_public_key_hex) = slot else {
387        unreachable!("read_pending_identity_slot is only ever called with a PendingIdentity slot");
388    };
389    let sk_hex = read(slot)?.ok_or_else(|| KeyError::NoPendingIdentity {
390        request_public_key_hex: request_public_key_hex.clone(),
391    })?;
392    let signing_key: [u8; SIGN_SECRETKEYBYTES] = hex::decode(&sk_hex)
393        .map_err(|e| KeyError::Crypto(format!("invalid pending identity hex: {e}")))?
394        .try_into()
395        .map_err(|_| KeyError::Crypto("pending identity wrong length".to_string()))?;
396    UserKeypair::from_signing_key_bytes(&signing_key)
397}
398
399/// Discard the pending identity keyed by `request_public_key_hex` — a join
400/// request abandoned without completing, or one whose identity the completed
401/// join has already established in the store's own custody. `Ok` whether or
402/// not one was pending.
403pub(crate) fn discard_pending_identity(request_public_key_hex: &str) -> Result<(), KeyError> {
404    delete(&KeyringSlot::PendingIdentity(
405        request_public_key_hex.to_string(),
406    ))
407    .map(|_| ())
408}
409
410/// One store's key material: the encryption master key, cloud-home credentials,
411/// and OAuth tokens, each stored under a store-scoped keyring account
412/// (`{base}:{store_id}`). The store's signing identity is not here — it goes
413/// through [`crate::identity_custody::IdentityCustody`], the same way the
414/// master key goes through [`crate::custody::KeyCustody`].
415#[derive(Clone)]
416pub struct StoreKeys {
417    store_id: String,
418}
419
420impl StoreKeys {
421    pub fn new(store_id: String) -> Self {
422        Self { store_id }
423    }
424
425    pub fn store_id(&self) -> &str {
426        &self.store_id
427    }
428
429    pub fn get_encryption_key(&self) -> Result<Option<String>, KeyError> {
430        read(&KeyringSlot::EncryptionMasterKey(self.store_id.clone()))
431    }
432
433    pub fn set_encryption_key(&self, value: &str) -> Result<(), KeyError> {
434        write(
435            &KeyringSlot::EncryptionMasterKey(self.store_id.clone()),
436            value,
437        )?;
438        info!("Encryption key saved to keyring");
439        Ok(())
440    }
441
442    pub fn delete_encryption_key(&self) -> Result<(), KeyError> {
443        if delete(&KeyringSlot::EncryptionMasterKey(self.store_id.clone()))? {
444            info!("Encryption key deleted from keyring");
445        }
446        Ok(())
447    }
448
449    pub fn get_cloud_home_credentials(&self) -> Result<Option<CloudHomeCredentials>, KeyError> {
450        match read(&KeyringSlot::CloudHomeCredentials(self.store_id.clone()))? {
451            None => Ok(None),
452            Some(j) => serde_json::from_str(&j).map(Some).map_err(|e| {
453                KeyError::Crypto(format!("malformed cloud home credentials JSON: {e}"))
454            }),
455        }
456    }
457
458    pub fn set_cloud_home_credentials(&self, creds: &CloudHomeCredentials) -> Result<(), KeyError> {
459        let json = serde_json::to_string(creds)
460            .map_err(|e| KeyError::Crypto(format!("serialize credentials: {e}")))?;
461        write(
462            &KeyringSlot::CloudHomeCredentials(self.store_id.clone()),
463            &json,
464        )?;
465        info!("Cloud home credentials saved to keyring");
466        Ok(())
467    }
468
469    #[cfg(feature = "oauth-providers")]
470    pub fn set_cloud_home_oauth_tokens(
471        &self,
472        tokens: &crate::oauth::OAuthTokens,
473    ) -> Result<(), KeyError> {
474        let token_json = serde_json::to_string(tokens)
475            .map_err(|e| KeyError::Crypto(format!("serialize OAuth tokens: {e}")))?;
476        self.set_cloud_home_credentials(&CloudHomeCredentials::OAuth { token_json })
477    }
478
479    #[cfg(test)]
480    pub(crate) fn cloud_home_credentials_entry_for_test(
481        &self,
482    ) -> Result<keyring_core::Entry, KeyError> {
483        entry_for(&KeyringSlot::CloudHomeCredentials(self.store_id.clone()).account())
484    }
485
486    pub fn delete_cloud_home_credentials(&self) -> Result<(), KeyError> {
487        if delete(&KeyringSlot::CloudHomeCredentials(self.store_id.clone()))? {
488            info!("Cloud home credentials deleted from keyring");
489        }
490        Ok(())
491    }
492
493    fn host_secret_slot(&self, name: &str) -> KeyringSlot {
494        KeyringSlot::HostSecret {
495            name: name.to_string(),
496            store_id: self.store_id.clone(),
497        }
498    }
499
500    /// A host's own store-scoped secret — an API token, a service credential
501    /// — read from the same keyring service and access policy as coven's own
502    /// key material. `None` if never set. [`KeyError::InvalidSecretName`] if
503    /// `name` collides with one of coven's own reserved slot names, is
504    /// empty, or contains `:` (see [`validate_host_secret_name`]).
505    pub fn get_host_secret(&self, name: &str) -> Result<Option<String>, KeyError> {
506        validate_host_secret_name(name)?;
507        read(&self.host_secret_slot(name))
508    }
509
510    /// Set a host's own store-scoped secret. Same name restrictions as
511    /// [`get_host_secret`](Self::get_host_secret).
512    pub fn set_host_secret(&self, name: &str, value: &str) -> Result<(), KeyError> {
513        validate_host_secret_name(name)?;
514        write(&self.host_secret_slot(name), value)?;
515        info!("Host secret {name:?} saved to keyring");
516        Ok(())
517    }
518
519    /// Remove a host secret. `Ok` whether or not one was set. Same name
520    /// restrictions as [`get_host_secret`](Self::get_host_secret).
521    pub fn delete_host_secret(&self, name: &str) -> Result<(), KeyError> {
522        validate_host_secret_name(name)?;
523        if delete(&self.host_secret_slot(name))? {
524            info!("Host secret {name:?} deleted from keyring");
525        }
526        Ok(())
527    }
528}
529
530#[cfg(test)]
531pub(crate) mod test_keyring {
532    use std::sync::Once;
533
534    static INSTALL: Once = Once::new();
535
536    pub(crate) fn install() {
537        INSTALL.call_once(|| {
538            // Install the in-memory mock before registering the service so
539            // `set_keyring_service` keeps it instead of reaching for the OS
540            // keychain — a platform mechanism these tests never touch.
541            keyring_core::set_default_store(
542                keyring_core::mock::Store::new().expect("create mock keyring store"),
543            );
544            super::set_keyring_service("coven-tests").expect("register keyring service");
545        });
546    }
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552
553    /// Proves `map_keyring_error` — the real chokepoint every keyring
554    /// read/write/delete funnels through — recognizes `errSecMissingEntitlement`
555    /// (OSStatus -34018) when it arrives the way the real protected store
556    /// produces it: a `keyring_core::Error::PlatformFailure` boxing a
557    /// `security_framework::base::Error`. This does not exercise the real
558    /// Keychain — `cargo test` cannot reach it (see the Apple section of
559    /// `site/docs/keys.md`) — it constructs the exact error shape
560    /// `apple-native-keyring-store`'s `protected::decode_error` is documented
561    /// (and read, see `is_missing_keychain_entitlement`'s doc comment) to
562    /// produce for this OSStatus, and checks the mapping honestly, at the seam
563    /// coven controls.
564    #[cfg(any(target_os = "macos", target_os = "ios"))]
565    #[test]
566    fn missing_entitlement_os_status_maps_to_a_typed_actionable_error() {
567        let raw = keyring_core::Error::PlatformFailure(Box::new(
568            security_framework::base::Error::from_code(-34018),
569        ));
570
571        let mapped = map_keyring_error(raw);
572
573        assert!(
574            matches!(mapped, KeyError::MissingKeychainEntitlement),
575            "got {mapped:?}"
576        );
577        let message = mapped.to_string();
578        assert!(message.contains("-34018"), "{message}");
579        assert!(message.contains("errSecMissingEntitlement"), "{message}");
580        assert!(message.contains("keychain-access-groups"), "{message}");
581        assert!(message.contains("provisioning profile"), "{message}");
582        assert!(message.contains("DEVELOPMENT_TEAM"), "{message}");
583        assert!(
584            !message.contains("must be signed"),
585            "a bare 'signed binary' is not the fix and must not be implied: {message}"
586        );
587    }
588
589    /// The match is scoped to exactly -34018, not "any `PlatformFailure`" —
590    /// another OSStatus wrapped the same way must still fall through to the
591    /// generic, stringly-typed `Persistence` error rather than being
592    /// mis-reported as a missing entitlement.
593    #[cfg(any(target_os = "macos", target_os = "ios"))]
594    #[test]
595    fn a_different_platform_failure_os_status_is_not_reported_as_missing_entitlement() {
596        let raw = keyring_core::Error::PlatformFailure(Box::new(
597            security_framework::base::Error::from_code(-25291), // errSecNotAvailable
598        ));
599
600        let mapped = map_keyring_error(raw);
601
602        assert!(matches!(mapped, KeyError::Persistence(_)), "got {mapped:?}");
603    }
604
605    #[test]
606    fn empty_keyring_entry_is_an_error_not_absence() {
607        test_keyring::install();
608        let slot = KeyringSlot::EncryptionMasterKey("empty-keyring-entry-store".to_string());
609        let account = slot.account();
610        entry_for(&account)
611            .expect("create keyring entry")
612            .set_password("")
613            .expect("write empty keyring entry");
614
615        let error = read(&slot).expect_err("empty entry is corrupt");
616
617        assert!(error.to_string().contains("present but empty"));
618        assert!(error.to_string().contains(&account));
619    }
620
621    /// The keyring account names are a durable storage contract: a device's
622    /// already-stored keys are found only at these exact accounts, so
623    /// `StoreKeys` and every identity operation must keep using them
624    /// verbatim. Pin all five. `HostSecret`'s rendering (`{name}:{store_id}`)
625    /// is additionally a contract with any host that already stores a secret
626    /// at that account by name — a host's own already-stored secrets are
627    /// found only at the exact account its name renders to, so this must
628    /// stay byte-identical.
629    #[test]
630    fn keyring_account_names_are_a_stable_storage_contract() {
631        assert_eq!(
632            KeyringSlot::EncryptionMasterKey("store-42".to_string()).account(),
633            "encryption_master_key:store-42"
634        );
635        assert_eq!(
636            KeyringSlot::CloudHomeCredentials("store-42".to_string()).account(),
637            "cloud_home_credentials:store-42"
638        );
639        assert_eq!(
640            KeyringSlot::DeviceSigningKey("store-42".to_string()).account(),
641            "coven_user_signing_key:store-42"
642        );
643        assert_eq!(
644            KeyringSlot::PendingIdentity("deadbeef".to_string()).account(),
645            "coven_pending_identity:deadbeef"
646        );
647        assert_eq!(
648            KeyringSlot::HostSecret {
649                name: "discogs_api_key".to_string(),
650                store_id: "s1".to_string(),
651            }
652            .account(),
653            "discogs_api_key:s1"
654        );
655    }
656
657    // =========================================================================
658    // Host secrets
659    // =========================================================================
660
661    #[test]
662    fn host_secret_round_trips_and_absent_reads_none() {
663        test_keyring::install();
664        let keys = StoreKeys::new("host-secret-round-trip".to_string());
665
666        assert_eq!(
667            keys.get_host_secret("discogs_api_key").expect("get"),
668            None,
669            "an unset host secret reads as absent",
670        );
671
672        keys.set_host_secret("discogs_api_key", "the-discogs-key")
673            .expect("set");
674        assert_eq!(
675            keys.get_host_secret("discogs_api_key").expect("get"),
676            Some("the-discogs-key".to_string()),
677        );
678
679        keys.delete_host_secret("discogs_api_key").expect("delete");
680        assert_eq!(
681            keys.get_host_secret("discogs_api_key")
682                .expect("get after delete"),
683            None,
684        );
685    }
686
687    /// Every name coven itself reserves is refused, typed. Enumerated from
688    /// [`RESERVED_HOST_SECRET_NAMES`] rather than hand-listed, so this test
689    /// cannot drift from the validator it exercises.
690    #[test]
691    fn host_secret_refuses_every_reserved_name() {
692        test_keyring::install();
693        let keys = StoreKeys::new("host-secret-reserved-names".to_string());
694
695        for reserved in RESERVED_HOST_SECRET_NAMES {
696            let error = keys.set_host_secret(reserved, "value").expect_err(&format!(
697                "{reserved:?} must be refused as a host secret name"
698            ));
699            assert!(
700                matches!(error, KeyError::InvalidSecretName { .. }),
701                "got {error:?}",
702            );
703        }
704    }
705
706    #[test]
707    fn host_secret_refuses_a_name_containing_colon() {
708        test_keyring::install();
709        let keys = StoreKeys::new("host-secret-colon-name".to_string());
710
711        let error = keys
712            .set_host_secret("discogs:api_key", "value")
713            .expect_err("a name containing ':' must be refused");
714        assert!(
715            matches!(error, KeyError::InvalidSecretName { .. }),
716            "{error:?}"
717        );
718    }
719
720    #[test]
721    fn host_secret_refuses_an_empty_name() {
722        test_keyring::install();
723        let keys = StoreKeys::new("host-secret-empty-name".to_string());
724
725        let error = keys
726            .set_host_secret("", "value")
727            .expect_err("an empty name must be refused");
728        assert!(
729            matches!(error, KeyError::InvalidSecretName { .. }),
730            "{error:?}"
731        );
732    }
733
734    /// A host secret entry present but empty reads as corrupt, not absent —
735    /// the same discipline [`empty_keyring_entry_is_an_error_not_absence`]
736    /// pins for coven's own slots applies here too.
737    #[test]
738    fn host_secret_present_but_empty_is_an_error_not_absence() {
739        test_keyring::install();
740        let slot = KeyringSlot::HostSecret {
741            name: "discogs_api_key".to_string(),
742            store_id: "host-secret-empty-entry-store".to_string(),
743        };
744        let account = slot.account();
745        entry_for(&account)
746            .expect("create keyring entry")
747            .set_password("")
748            .expect("write empty keyring entry");
749
750        let keys = StoreKeys::new("host-secret-empty-entry-store".to_string());
751        let error = keys
752            .get_host_secret("discogs_api_key")
753            .expect_err("empty entry is corrupt");
754        assert!(error.to_string().contains("present but empty"));
755    }
756
757    /// Host secrets are store-scoped: two `StoreKeys` over different
758    /// `store_id`s see independent values for the same secret name.
759    #[test]
760    fn host_secret_is_scoped_to_its_store() {
761        test_keyring::install();
762        let store_a = StoreKeys::new("host-secret-scope-a".to_string());
763        let store_b = StoreKeys::new("host-secret-scope-b".to_string());
764
765        store_a
766            .set_host_secret("discogs_api_key", "key-for-store-a")
767            .expect("set on store a");
768
769        assert_eq!(
770            store_a.get_host_secret("discogs_api_key").expect("get"),
771            Some("key-for-store-a".to_string()),
772        );
773        assert_eq!(
774            store_b.get_host_secret("discogs_api_key").expect("get"),
775            None,
776            "store b must not see store a's secret",
777        );
778    }
779
780    /// A per-store keyring identity custody. Each test names its own
781    /// `store_id` so tests never race each other's keyring accounts.
782    fn test_identity_custody(store_id: &str) -> std::sync::Arc<dyn DeviceIdentityCustody> {
783        crate::identity_custody::IdentityCustody::Keyring.resolve(
784            store_id,
785            &crate::store_dir::StoreDir::new("unused-store-dir"),
786        )
787    }
788
789    /// A keypair written straight to the raw keyring under a store's signing-key
790    /// account reads back through `require_identity` unchanged — the account
791    /// math both sides use is the same, so the split doesn't strand an
792    /// already-stored key.
793    #[test]
794    fn require_identity_reads_a_keypair_written_at_the_stores_account() {
795        test_keyring::install();
796        let store_id = "require-identity-fixed-account-test";
797
798        let keypair = UserKeypair::generate();
799        let expected_pubkey = keypair.public_key();
800        // Write via the raw keyring under the store's signing-key account, the
801        // way the identity custody preset does — no `require_identity` involved
802        // on the write side.
803        write(
804            &KeyringSlot::DeviceSigningKey(store_id.to_string()),
805            &hex::encode(keypair.to_keypair_bytes()),
806        )
807        .expect("write signing key to the raw keyring");
808
809        let custody = test_identity_custody(store_id);
810        let read = require_identity(custody.as_ref()).expect("read the identity back");
811        assert_eq!(
812            read.public_key(),
813            expected_pubkey,
814            "require_identity must read the keypair stored at the store's account",
815        );
816    }
817
818    /// `require_identity` maps absence to the typed `KeyError::NoDeviceIdentity`
819    /// — every connect/join precondition that requires an existing identity
820    /// gets a matchable, actionable error.
821    #[test]
822    fn require_identity_maps_absence_to_no_device_identity() {
823        test_keyring::install();
824        let custody = test_identity_custody("require-identity-absent-test");
825
826        match require_identity(custody.as_ref()) {
827            Err(error) => assert!(matches!(error, KeyError::NoDeviceIdentity), "got {error:?}"),
828            Ok(_) => panic!("no identity is established"),
829        }
830    }
831
832    /// A same-pubkey re-import (the retry path a host takes if the first
833    /// import attempt's caller-side bookkeeping failed after the keyring
834    /// write) is idempotent — no error, and the identity reads back
835    /// unchanged.
836    #[test]
837    fn import_identity_same_pubkey_reimport_is_idempotent() {
838        test_keyring::install();
839        let custody = test_identity_custody("import-identity-idempotent-test");
840
841        let keypair = UserKeypair::generate();
842        import_identity(custody.as_ref(), &keypair.to_keypair_bytes())
843            .expect("first import establishes the identity");
844        import_identity(custody.as_ref(), &keypair.to_keypair_bytes())
845            .expect("re-importing the same key is idempotent");
846
847        assert_eq!(
848            require_identity(custody.as_ref())
849                .expect("identity still readable")
850                .public_key(),
851            keypair.public_key(),
852        );
853    }
854
855    /// Importing a DIFFERENT key over an already-established identity is
856    /// refused — silently swapping this store's identity would strand its
857    /// already-signed membership entries.
858    #[test]
859    fn import_identity_refuses_to_overwrite_a_different_identity() {
860        test_keyring::install();
861        let custody = test_identity_custody("import-identity-mismatch-test");
862
863        let established = UserKeypair::generate();
864        import_identity(custody.as_ref(), &established.to_keypair_bytes())
865            .expect("establish the first identity");
866
867        let different = UserKeypair::generate();
868        let error = import_identity(custody.as_ref(), &different.to_keypair_bytes())
869            .expect_err("importing a different identity must be refused");
870        match error {
871            KeyError::IdentityMismatch {
872                existing_pubkey_hex,
873                imported_pubkey_hex,
874            } => {
875                assert_eq!(existing_pubkey_hex, public_key_hex(&established));
876                assert_eq!(imported_pubkey_hex, public_key_hex(&different));
877            }
878            other => panic!("expected IdentityMismatch, got {other:?}"),
879        }
880
881        // The refusal must not have overwritten the established identity.
882        assert_eq!(
883            require_identity(custody.as_ref())
884                .expect("the original identity is untouched")
885                .public_key(),
886            established.public_key(),
887        );
888    }
889
890    /// A pending identity minted for a join request establishes into a store's
891    /// identity custody via `import_identity` while the pending slot still
892    /// serves it — the split the join relies on: establish before the
893    /// completion marker, discard the slot only once the whole join succeeds.
894    /// Re-establishing from the still-present slot is idempotent (the torn-
895    /// bootstrap retry), and the discard afterward empties the slot.
896    #[test]
897    fn pending_identity_establishes_then_discards() {
898        test_keyring::install();
899        let pending = mint_pending_identity().expect("mint pending identity");
900        let request_pubkey = public_key_hex(&pending);
901        let custody = test_identity_custody("pending-identity-establish-test");
902
903        import_identity(custody.as_ref(), &pending.to_keypair_bytes())
904            .expect("establish the pending identity in store custody");
905        assert_eq!(
906            require_identity(custody.as_ref())
907                .expect("the store now has an identity")
908                .public_key(),
909            pending.public_key(),
910        );
911
912        // The slot still serves the identity: a retry after a torn bootstrap
913        // (whose wipe removed the store custody) re-establishes from it.
914        let still_pending =
915            peek_pending_identity(&request_pubkey).expect("the pending slot is not yet consumed");
916        import_identity(custody.as_ref(), &still_pending.to_keypair_bytes())
917            .expect("re-establishing the same identity is idempotent");
918
919        discard_pending_identity(&request_pubkey).expect("discard the consumed slot");
920        let error = peek_pending_identity(&request_pubkey)
921            .map(|_| ())
922            .expect_err("the discarded slot no longer serves the identity");
923        assert!(
924            matches!(error, KeyError::NoPendingIdentity { .. }),
925            "{error:?}"
926        );
927        assert_eq!(
928            require_identity(custody.as_ref())
929                .expect("the established identity outlives the slot")
930                .public_key(),
931            pending.public_key(),
932        );
933    }
934
935    /// An abandoned join request's pending identity is removed and no longer
936    /// served; discarding is `Ok` even when nothing was pending.
937    #[test]
938    fn discard_pending_identity_removes_it_and_is_idempotent() {
939        test_keyring::install();
940        let pending = mint_pending_identity().expect("mint pending identity");
941        let request_pubkey = public_key_hex(&pending);
942
943        discard_pending_identity(&request_pubkey).expect("discard the pending identity");
944        discard_pending_identity(&request_pubkey)
945            .expect("discarding an already-absent pending identity is not an error");
946
947        let error = peek_pending_identity(&request_pubkey)
948            .map(|_| ())
949            .expect_err("a discarded pending identity is no longer served");
950        assert!(
951            matches!(error, KeyError::NoPendingIdentity { .. }),
952            "{error:?}"
953        );
954    }
955
956    /// Two concurrent join requests mint distinct pending identities, keyed by
957    /// their own public keys, and establishing one never touches the other.
958    #[test]
959    fn two_concurrent_pending_joins_do_not_cross() {
960        test_keyring::install();
961        let pending_a = mint_pending_identity().expect("mint pending identity a");
962        let pending_b = mint_pending_identity().expect("mint pending identity b");
963        assert_ne!(pending_a.public_key(), pending_b.public_key());
964
965        let custody_a = test_identity_custody("two-concurrent-joins-store-a");
966        let custody_b = test_identity_custody("two-concurrent-joins-store-b");
967        import_identity(custody_a.as_ref(), &pending_a.to_keypair_bytes())
968            .expect("establish a into store a");
969
970        assert!(
971            require_identity(custody_b.as_ref()).is_err(),
972            "store b must not see store a's established identity",
973        );
974        import_identity(custody_b.as_ref(), &pending_b.to_keypair_bytes())
975            .expect("establish b into store b");
976        assert_ne!(
977            require_identity(custody_a.as_ref())
978                .expect("store a's identity")
979                .public_key(),
980            require_identity(custody_b.as_ref())
981                .expect("store b's identity")
982                .public_key(),
983        );
984    }
985}