Skip to main content

coven_keys/keys/
platform.rs

1use tracing::{error, info};
2
3use super::core::{
4    public_key_hex, CloudHomeCredentials, DeviceIdentityCustody, KeyError, UserKeypair,
5    SIGN_SECRETKEYBYTES,
6};
7
8/// Why importing or staging a master key failed.
9#[derive(Debug, thiserror::Error)]
10pub enum MasterKeyError {
11    /// Cloud-home setup found a master key already established while staging
12    /// a fresh one. coven never generates over an existing key.
13    #[error("a master key is already established for this store")]
14    AlreadyEstablished,
15    #[error("cannot import a master key while a cloud home is connected")]
16    CloudHomeConnected,
17    #[error("key error: {0}")]
18    Key(#[from] KeyError),
19    #[error("invalid master key material: {0}")]
20    Encryption(#[from] crate::encryption::EncryptionError),
21}
22
23/// Why a host's `initialize_identity` call failed.
24#[derive(Debug, thiserror::Error)]
25pub enum IdentityError {
26    /// `initialize_identity` found an identity already established for this
27    /// store — custody `unlock()` returned `Some`. coven never generates over
28    /// an existing identity; a store's identity is established exactly once,
29    /// by whichever of create/join/restore established it first.
30    #[error("an identity is already established for this store")]
31    AlreadyEstablished,
32    #[error("key error: {0}")]
33    Key(#[from] KeyError),
34}
35
36struct KeyringService {
37    name: String,
38    worker: KeyringWorker,
39}
40
41/// Serializes platform credential-store calls on a stack Coven controls.
42/// Hosts can enter the synchronous key API from foreign runtimes whose worker
43/// stacks are smaller than Security.framework's call chain requires.
44struct KeyringWorker {
45    operations: Option<std::sync::mpsc::Sender<KeyringOperation>>,
46    thread: Option<std::thread::JoinHandle<()>>,
47}
48
49enum KeyringOperation {
50    Read {
51        account: String,
52        reply: std::sync::mpsc::SyncSender<Result<Option<String>, KeyError>>,
53    },
54    Write {
55        account: String,
56        value: String,
57        reply: std::sync::mpsc::SyncSender<Result<(), KeyError>>,
58    },
59    Delete {
60        account: String,
61        reply: std::sync::mpsc::SyncSender<Result<bool, KeyError>>,
62    },
63    #[cfg(all(
64        any(test, feature = "test-utils"),
65        any(target_os = "macos", target_os = "ios")
66    ))]
67    AppleEntryFacts {
68        account: String,
69        reply: std::sync::mpsc::SyncSender<Result<AppleKeyringEntryFacts, KeyError>>,
70    },
71    #[cfg(any(test, feature = "test-utils"))]
72    SetNextError {
73        account: String,
74        error: keyring_core::Error,
75        reply: std::sync::mpsc::SyncSender<Result<(), KeyError>>,
76    },
77}
78
79struct KeyringBackend {
80    name: String,
81    store: std::sync::Arc<keyring_core::CredentialStore>,
82}
83
84impl KeyringWorker {
85    /// Matches Coven's provider runtimes: enough stack for platform SDK call
86    /// chains without depending on the host thread's stack allocation.
87    const STACK_SIZE: usize = 16 * 1024 * 1024;
88
89    fn start(backend: KeyringBackend) -> Result<Self, KeyError> {
90        let (operations, receiver) = std::sync::mpsc::channel();
91        let thread = std::thread::Builder::new()
92            .name("coven-keyring".to_string())
93            .stack_size(Self::STACK_SIZE)
94            .spawn(move || {
95                while let Ok(operation) = receiver.recv() {
96                    backend.execute(operation);
97                }
98            })
99            .map_err(KeyError::KeyringWorkerStart)?;
100        Ok(Self {
101            operations: Some(operations),
102            thread: Some(thread),
103        })
104    }
105
106    fn execute<T: Send + 'static>(
107        &self,
108        operation_name: &'static str,
109        operation: impl FnOnce(std::sync::mpsc::SyncSender<Result<T, KeyError>>) -> KeyringOperation,
110    ) -> Result<T, KeyError> {
111        let (reply, receiver) = std::sync::mpsc::sync_channel(1);
112        self.operations
113            .as_ref()
114            .expect("the keyring worker sender exists until drop")
115            .send(operation(reply))
116            .map_err(|_| KeyError::KeyringWorkerStopped {
117                operation: operation_name,
118            })?;
119        receiver
120            .recv()
121            .map_err(|_| KeyError::KeyringWorkerStopped {
122                operation: operation_name,
123            })?
124    }
125}
126
127impl Drop for KeyringWorker {
128    fn drop(&mut self) {
129        self.operations.take();
130        if let Some(thread) = self.thread.take() {
131            if thread.join().is_err() {
132                error!("Keyring worker terminated with a panic");
133            }
134        }
135    }
136}
137
138#[derive(Clone, Copy)]
139enum KeyringBinding {
140    Registered(&'static KeyringService),
141    Unregistered,
142}
143
144impl KeyringBinding {
145    fn service(self) -> Result<&'static KeyringService, KeyError> {
146        match self {
147            Self::Registered(service) => Ok(service),
148            Self::Unregistered => Err(KeyError::ServiceNotRegistered),
149        }
150    }
151}
152
153static KEYRING_SERVICE: std::sync::OnceLock<KeyringService> = std::sync::OnceLock::new();
154
155/// Register the process-wide keyring: the service name every entry is stored
156/// under, and the platform keyring store that backs it. Both are one-time
157/// startup registration and must run before any key operation. The store is
158/// installed before the name is recorded, so a failed installation leaves no
159/// registration behind. Re-registering the same name is a no-op; a different
160/// name is a startup contradiction and fails. Fails with
161/// [`KeyError::UnsupportedKeyringPlatform`] on a target with no bundled store.
162pub fn set_keyring_service(name: impl Into<String>) -> Result<(), KeyError> {
163    crate::keyring_backend::install_platform_store()?;
164    let name = name.into();
165    let store = keyring_core::get_default_store().ok_or(KeyError::StoreNotInstalled)?;
166    let service = KeyringService::new(name.clone(), store)?;
167    if KEYRING_SERVICE.set(service).is_err() {
168        let registered = KEYRING_SERVICE
169            .get()
170            .map(|service| service.name.as_str())
171            .expect("a keyring service is registered when set() fails");
172        if registered != name {
173            return Err(KeyError::ServiceAlreadyRegistered {
174                registered: registered.to_string(),
175                requested: name,
176            });
177        }
178    }
179    Ok(())
180}
181
182/// The registered keyring service name. `Err` when the host never ran the
183/// startup [`set_keyring_service`] call — surfaced so a mis-ordered host gets a
184/// typed error, not a panic deep inside a key operation.
185pub fn keyring_service() -> Result<&'static str, KeyError> {
186    KEYRING_SERVICE
187        .get()
188        .map(|service| service.name.as_str())
189        .ok_or(KeyError::ServiceNotRegistered)
190}
191
192fn registered_keyring() -> Result<&'static KeyringService, KeyError> {
193    KEYRING_SERVICE.get().ok_or(KeyError::ServiceNotRegistered)
194}
195
196/// The process has no keychain-access-groups entitlement behind a provisioning
197/// profile. Nothing the caller does at runtime changes this.
198#[cfg(any(target_os = "macos", target_os = "ios"))]
199const ERR_SEC_MISSING_ENTITLEMENT: i32 = -34018;
200/// The OS refused to let this process touch the keychain right now — the
201/// keychain is locked, the display is asleep, or the login session cannot show
202/// UI. The same operation succeeds once the session unlocks.
203#[cfg(any(target_os = "macos", target_os = "ios"))]
204const ERR_SEC_INTERACTION_NOT_ALLOWED: i32 = -25308;
205
206fn map_keyring_error(e: keyring_core::Error) -> KeyError {
207    #[cfg(any(target_os = "macos", target_os = "ios"))]
208    match keychain_os_status(&e) {
209        Some(ERR_SEC_MISSING_ENTITLEMENT) => return KeyError::MissingKeychainEntitlement,
210        Some(ERR_SEC_INTERACTION_NOT_ALLOWED) => return KeyError::KeychainTemporarilyUnavailable,
211        _ => {}
212    }
213    match e {
214        keyring_core::Error::NoDefaultStore => KeyError::StoreNotInstalled,
215        other => KeyError::Keyring(other),
216    }
217}
218
219/// The OSStatus behind a Keychain refusal, when there is one.
220///
221/// `apple-native-keyring-store`'s `protected::decode_error` reports every
222/// OSStatus it has no dedicated `keyring_core::Error` variant for as
223/// `keyring_core::Error::PlatformFailure`, whose payload is a
224/// `Box<dyn std::error::Error + Send + Sync>` — the OSStatus is not exposed as
225/// a field on `keyring_core::Error` itself. The box's concrete type is always
226/// `security_framework::base::Error` on this path (verified by reading
227/// `apple-native-keyring-store`'s `protected.rs`: every `decode_error` arm
228/// boxes the `security_framework::base::Error` it received), so `downcast_ref`
229/// recovers it and `.code()` reads the real OSStatus — a structured match, not
230/// a string search over the formatted error.
231#[cfg(any(target_os = "macos", target_os = "ios"))]
232fn keychain_os_status(e: &keyring_core::Error) -> Option<i32> {
233    let keyring_core::Error::PlatformFailure(inner) = e else {
234        return None;
235    };
236    inner
237        .downcast_ref::<security_framework::base::Error>()
238        .map(|err| err.code())
239}
240
241/// The base account name [`KeyringSlot::DeviceSigningKey`] renders as
242/// `{base}:{store_id}` under.
243const DEVICE_SIGNING_KEY_BASE: &str = "coven_user_signing_key";
244/// The base account name [`KeyringSlot::EncryptionMasterKey`] renders as
245/// `{base}:{store_id}` under.
246const ENCRYPTION_MASTER_KEY_BASE: &str = "encryption_master_key";
247/// The base account name [`KeyringSlot::CloudHomeCredentials`] renders as
248/// `{base}:{store_id}` under.
249const CLOUD_HOME_CREDENTIALS_BASE: &str = "cloud_home_credentials";
250/// The base account name [`KeyringSlot::PendingIdentity`] renders as
251/// `{base}:{pending_public_key_hex}` under.
252const PENDING_IDENTITY_BASE: &str = "coven_pending_identity";
253
254/// Every name coven's own [`KeyringSlot`] variants reserve for themselves —
255/// built from the same constants [`KeyringSlot::account`] renders accounts
256/// from, so this list cannot drift from what coven actually stores under. A
257/// host secret's `name` must not equal any of these: see
258/// [`validate_host_secret_name`].
259pub(crate) const RESERVED_HOST_SECRET_NAMES: &[&str] = &[
260    DEVICE_SIGNING_KEY_BASE,
261    ENCRYPTION_MASTER_KEY_BASE,
262    CLOUD_HOME_CREDENTIALS_BASE,
263    PENDING_IDENTITY_BASE,
264];
265
266/// Which key a keyring entry holds, and the sole owner of the account name it
267/// is stored under. The device signing key, the encryption master key, the
268/// cloud-home credentials, and a host secret are all per store; a pending
269/// identity is keyed by its own public key instead of a store (it exists while
270/// the device pairing has not established the Store-scoped identity — see
271/// [`crate::keys::mint_pending_identity`]). Every keyring read/write/delete
272/// names its entry with one of these variants, so the on-disk account
273/// strings live in exactly one place: [`KeyringSlot::account`].
274pub(crate) enum KeyringSlot {
275    /// A store's Ed25519 signing identity.
276    DeviceSigningKey(String),
277    /// A store's encryption master key.
278    EncryptionMasterKey(String),
279    /// A store's cloud-home credentials.
280    CloudHomeCredentials(String),
281    /// A pairing attempt's not-yet-store-scoped signing identity, keyed by its
282    /// own public key.
283    PendingIdentity(String),
284    /// A host's own store-scoped secret, named by the host and validated
285    /// against [`RESERVED_HOST_SECRET_NAMES`] before it ever reaches this
286    /// variant (see [`validate_host_secret_name`]).
287    HostSecret { name: String, store_id: String },
288}
289
290impl KeyringSlot {
291    /// The keyring account name this slot is stored under. These strings are a
292    /// durable storage contract: a device's already-stored keys are found only
293    /// at these exact accounts, so changing any of them strands stored keys.
294    /// `HostSecret`'s rendering is a storage contract with the *host*, not
295    /// just coven: it must stay byte-identical to whatever account a host
296    /// already wrote its secrets under before this API existed.
297    pub(crate) fn account(&self) -> String {
298        match self {
299            KeyringSlot::DeviceSigningKey(store_id) => {
300                format!("{DEVICE_SIGNING_KEY_BASE}:{store_id}")
301            }
302            KeyringSlot::EncryptionMasterKey(store_id) => {
303                format!("{ENCRYPTION_MASTER_KEY_BASE}:{store_id}")
304            }
305            KeyringSlot::CloudHomeCredentials(store_id) => {
306                format!("{CLOUD_HOME_CREDENTIALS_BASE}:{store_id}")
307            }
308            KeyringSlot::PendingIdentity(pending_public_key_hex) => {
309                format!("{PENDING_IDENTITY_BASE}:{pending_public_key_hex}")
310            }
311            KeyringSlot::HostSecret { name, store_id } => format!("{name}:{store_id}"),
312        }
313    }
314}
315
316/// Reject a host secret name that would collide with, or otherwise misuse,
317/// coven's own keyring account scheme: one of coven's own reserved names, the
318/// empty string, or a name containing `:` (the scheme's separator — allowing
319/// one would let a host secret's name forge another store's account). Called
320/// at the API boundary before a [`KeyringSlot::HostSecret`] is ever built.
321pub(crate) fn validate_host_secret_name(name: &str) -> Result<(), KeyError> {
322    if name.is_empty() {
323        return Err(KeyError::InvalidSecretName {
324            name: name.to_string(),
325            reason: "a host secret name must not be empty".to_string(),
326        });
327    }
328    if name.contains(':') {
329        return Err(KeyError::InvalidSecretName {
330            name: name.to_string(),
331            reason: "a host secret name must not contain ':', the keyring account scheme's \
332                     separator"
333                .to_string(),
334        });
335    }
336    if RESERVED_HOST_SECRET_NAMES.contains(&name) {
337        return Err(KeyError::InvalidSecretName {
338            name: name.to_string(),
339            reason: "reserved for coven's own keyring entries".to_string(),
340        });
341    }
342    Ok(())
343}
344
345/// The sole entry-construction point for the OS keyring. Every read, write,
346/// and delete delegates entry construction to the installed credential store,
347/// so the platform configuration selected during registration applies to every
348/// Coven key and host secret.
349impl KeyringBackend {
350    fn execute(&self, operation: KeyringOperation) {
351        match operation {
352            KeyringOperation::Read { account, reply } => {
353                drop(reply.send(self.read(account)));
354            }
355            KeyringOperation::Write {
356                account,
357                value,
358                reply,
359            } => {
360                drop(reply.send(self.write(account, value)));
361            }
362            KeyringOperation::Delete { account, reply } => {
363                drop(reply.send(self.delete(account)));
364            }
365            #[cfg(all(
366                any(test, feature = "test-utils"),
367                any(target_os = "macos", target_os = "ios")
368            ))]
369            KeyringOperation::AppleEntryFacts { account, reply } => {
370                drop(reply.send(self.apple_entry_facts(account)));
371            }
372            #[cfg(any(test, feature = "test-utils"))]
373            KeyringOperation::SetNextError {
374                account,
375                error,
376                reply,
377            } => {
378                drop(reply.send(self.set_next_error(account, error)));
379            }
380        }
381    }
382
383    fn entry(&self, account: &str) -> Result<keyring_core::Entry, KeyError> {
384        self.store
385            .build(&self.name, account, None)
386            .map_err(map_keyring_error)
387    }
388
389    fn read(&self, account: String) -> Result<Option<String>, KeyError> {
390        let entry = self.entry(&account)?;
391        match entry.get_password() {
392            Ok(password) if password.is_empty() => Err(KeyError::EmptyKeyringEntry { account }),
393            Ok(password) => Ok(Some(password)),
394            Err(keyring_core::Error::NoEntry) => Ok(None),
395            Err(error) => Err(map_keyring_error(error)),
396        }
397    }
398
399    fn write(&self, account: String, value: String) -> Result<(), KeyError> {
400        self.entry(&account)?
401            .set_password(&value)
402            .map_err(map_keyring_error)
403    }
404
405    fn delete(&self, account: String) -> Result<bool, KeyError> {
406        match self.entry(&account)?.delete_credential() {
407            Ok(()) => Ok(true),
408            Err(keyring_core::Error::NoEntry) => Ok(false),
409            Err(error) => Err(map_keyring_error(error)),
410        }
411    }
412
413    #[cfg(all(
414        any(test, feature = "test-utils"),
415        any(target_os = "macos", target_os = "ios")
416    ))]
417    fn apple_entry_facts(&self, account: String) -> Result<AppleKeyringEntryFacts, KeyError> {
418        let entry = self.entry(&account)?;
419        let credential = entry
420            .as_any()
421            .downcast_ref::<apple_native_keyring_store::protected::Cred>()
422            .ok_or(KeyError::UnexpectedAppleKeyringEntry)?;
423        Ok(AppleKeyringEntryFacts {
424            access_policy: credential.access_policy.clone(),
425            cloud_synchronize: credential.cloud_synchronize,
426            service: credential.service.clone(),
427            account: credential.account.clone(),
428        })
429    }
430
431    #[cfg(any(test, feature = "test-utils"))]
432    fn set_next_error(&self, account: String, error: keyring_core::Error) -> Result<(), KeyError> {
433        let entry = self.entry(&account)?;
434        let credential = entry
435            .as_any()
436            .downcast_ref::<keyring_core::mock::Cred>()
437            .ok_or(KeyError::UnexpectedTestKeyringEntry)?;
438        credential.set_error(error);
439        Ok(())
440    }
441}
442
443impl KeyringService {
444    fn new(
445        name: String,
446        store: std::sync::Arc<keyring_core::CredentialStore>,
447    ) -> Result<Self, KeyError> {
448        Ok(Self {
449            name: name.clone(),
450            worker: KeyringWorker::start(KeyringBackend { name, store })?,
451        })
452    }
453
454    fn read(&self, slot: &KeyringSlot) -> Result<Option<String>, KeyError> {
455        let account = slot.account();
456        self.worker.execute("read a keyring entry", move |reply| {
457            KeyringOperation::Read { account, reply }
458        })
459    }
460
461    fn write(&self, slot: &KeyringSlot, value: &str) -> Result<(), KeyError> {
462        let account = slot.account();
463        let value = value.to_string();
464        self.worker.execute("write a keyring entry", move |reply| {
465            KeyringOperation::Write {
466                account,
467                value,
468                reply,
469            }
470        })
471    }
472
473    fn delete(&self, slot: &KeyringSlot) -> Result<bool, KeyError> {
474        let account = slot.account();
475        self.worker.execute("delete a keyring entry", move |reply| {
476            KeyringOperation::Delete { account, reply }
477        })
478    }
479
480    #[cfg(all(
481        any(test, feature = "test-utils"),
482        any(target_os = "macos", target_os = "ios")
483    ))]
484    fn apple_entry_facts(&self, account: String) -> Result<AppleKeyringEntryFacts, KeyError> {
485        self.worker
486            .execute("inspect an Apple keyring entry", move |reply| {
487                KeyringOperation::AppleEntryFacts { account, reply }
488            })
489    }
490
491    #[cfg(any(test, feature = "test-utils"))]
492    fn set_next_error(
493        &self,
494        slot: &KeyringSlot,
495        error: keyring_core::Error,
496    ) -> Result<(), KeyError> {
497        let account = slot.account();
498        self.worker
499            .execute("configure a test keyring entry", move |reply| {
500                KeyringOperation::SetNextError {
501                    account,
502                    error,
503                    reply,
504                }
505            })
506    }
507}
508
509#[cfg(all(
510    any(test, feature = "test-utils"),
511    any(target_os = "macos", target_os = "ios")
512))]
513pub struct AppleKeyringEntryFacts {
514    pub access_policy: apple_native_keyring_store::protected::AccessPolicy,
515    pub cloud_synchronize: bool,
516    pub service: String,
517    pub account: String,
518}
519
520/// Test-only facts about the entry produced by the real Apple construction
521/// boundary. The raw keyring entry remains inside the keys module.
522#[cfg(all(
523    any(test, feature = "test-utils"),
524    any(target_os = "macos", target_os = "ios")
525))]
526pub fn apple_keyring_entry_facts_for_test(
527    account: &str,
528) -> Result<AppleKeyringEntryFacts, KeyError> {
529    registered_keyring()?.apple_entry_facts(account.to_string())
530}
531
532/// This store's established signing identity through `custody`, or
533/// [`KeyError::NoDeviceIdentity`] when none is established — the caller must
534/// complete create/join/restore for this store first. Never mints: a
535/// connect/join precondition, not a query.
536pub fn require_identity(custody: &dyn DeviceIdentityCustody) -> Result<UserKeypair, KeyError> {
537    custody.unlock()?.ok_or(KeyError::NoDeviceIdentity)
538}
539
540/// Mint a fresh identity for a device-pairing attempt that has not joined a
541/// store yet. The joiner signs its pairing request with this keypair and holds it
542/// under a pending slot keyed by its own public key. The join establishes it
543/// in the joined store's own identity custody (via
544/// [`DeviceIdentityCustody::establish`],
545/// before the store's completion marker) and discards the pending slot only
546/// once the whole join succeeds; [`discard_pending_identity`] also removes it
547/// if the pairing is abandoned instead. Always the OS keyring: unlike an
548/// established store's identity, there is no store yet to select a custody
549/// policy for, and a pending identity's lifetime is short (a join round trip,
550/// not a store's lifetime).
551pub fn mint_pending_identity() -> Result<UserKeypair, KeyError> {
552    let keypair = UserKeypair::generate();
553    registered_keyring()?.write(
554        &KeyringSlot::PendingIdentity(public_key_hex(&keypair)),
555        &hex::encode(keypair.to_keypair_bytes()),
556    )?;
557    info!("Minted a pending identity for device pairing");
558    Ok(keypair)
559}
560
561/// Read (without consuming) the pending identity keyed by
562/// `pending_public_key_hex` — what a pairing in progress signs its bootstrap
563/// traffic with, and what it establishes in the store's own custody before
564/// the completion marker. [`KeyError::NoPendingIdentity`] if none is held
565/// under that key.
566pub fn peek_pending_identity(pending_public_key_hex: &str) -> Result<UserKeypair, KeyError> {
567    read_pending_identity_slot(&KeyringSlot::PendingIdentity(
568        pending_public_key_hex.to_string(),
569    ))
570}
571
572fn read_pending_identity_slot(slot: &KeyringSlot) -> Result<UserKeypair, KeyError> {
573    let KeyringSlot::PendingIdentity(pending_public_key_hex) = slot else {
574        unreachable!("read_pending_identity_slot is only ever called with a PendingIdentity slot");
575    };
576    let sk_hex = registered_keyring()?
577        .read(slot)?
578        .ok_or_else(|| KeyError::NoPendingIdentity {
579            pending_public_key_hex: pending_public_key_hex.clone(),
580        })?;
581    let signing_key = hex::decode(&sk_hex).map_err(|source| KeyError::Hex {
582        subject: "pending identity",
583        source,
584    })?;
585    let actual = signing_key.len();
586    let signing_key: [u8; SIGN_SECRETKEYBYTES] =
587        signing_key
588            .try_into()
589            .map_err(|_| KeyError::InvalidLength {
590                subject: "pending identity",
591                expected: SIGN_SECRETKEYBYTES,
592                actual,
593            })?;
594    UserKeypair::from_signing_key_bytes(&signing_key)
595}
596
597/// Discard the pending identity keyed by `pending_public_key_hex` — a pairing
598/// abandoned without completing, or one whose identity the completed
599/// join has already established in the store's own custody. `Ok` whether or
600/// not one was pending.
601pub fn discard_pending_identity(pending_public_key_hex: &str) -> Result<(), KeyError> {
602    registered_keyring()?
603        .delete(&KeyringSlot::PendingIdentity(
604            pending_public_key_hex.to_string(),
605        ))
606        .map(|_| ())
607}
608
609/// One store's key material: the encryption master key, cloud-home credentials,
610/// and OAuth tokens, each stored under a store-scoped keyring account
611/// (`{base}:{store_id}`). The store's signing identity is not here — it goes
612/// through [`crate::identity_custody::IdentityCustody`], the same way the
613/// master key goes through [`crate::custody::KeyCustody`].
614#[derive(Clone)]
615pub struct StoreKeys {
616    keyring: KeyringBinding,
617    store_id: String,
618}
619
620impl StoreKeys {
621    pub fn bind(store_id: String) -> Self {
622        let keyring = match KEYRING_SERVICE.get() {
623            Some(service) => KeyringBinding::Registered(service),
624            None => KeyringBinding::Unregistered,
625        };
626        Self { keyring, store_id }
627    }
628
629    pub fn store_id(&self) -> &str {
630        &self.store_id
631    }
632
633    pub fn get_encryption_key(&self) -> Result<Option<String>, KeyError> {
634        self.keyring
635            .service()?
636            .read(&KeyringSlot::EncryptionMasterKey(self.store_id.clone()))
637    }
638
639    pub fn set_encryption_key(&self, value: &str) -> Result<(), KeyError> {
640        self.keyring.service()?.write(
641            &KeyringSlot::EncryptionMasterKey(self.store_id.clone()),
642            value,
643        )?;
644        info!("Encryption key saved to keyring");
645        Ok(())
646    }
647
648    pub fn delete_encryption_key(&self) -> Result<(), KeyError> {
649        if self
650            .keyring
651            .service()?
652            .delete(&KeyringSlot::EncryptionMasterKey(self.store_id.clone()))?
653        {
654            info!("Encryption key deleted from keyring");
655        }
656        Ok(())
657    }
658
659    pub fn get_cloud_home_credentials(&self) -> Result<Option<CloudHomeCredentials>, KeyError> {
660        match self
661            .keyring
662            .service()?
663            .read(&KeyringSlot::CloudHomeCredentials(self.store_id.clone()))?
664        {
665            None => Ok(None),
666            Some(j) => serde_json::from_str(&j)
667                .map(Some)
668                .map_err(|source| KeyError::Json {
669                    operation: "parse cloud home credentials JSON",
670                    source,
671                }),
672        }
673    }
674
675    pub fn set_cloud_home_credentials(&self, creds: &CloudHomeCredentials) -> Result<(), KeyError> {
676        let json = serde_json::to_string(creds).map_err(|source| KeyError::Json {
677            operation: "serialize cloud home credentials",
678            source,
679        })?;
680        self.keyring.service()?.write(
681            &KeyringSlot::CloudHomeCredentials(self.store_id.clone()),
682            &json,
683        )?;
684        info!("Cloud home credentials saved to keyring");
685        Ok(())
686    }
687
688    #[cfg(feature = "oauth-providers")]
689    pub fn get_cloud_home_oauth_tokens(
690        &self,
691    ) -> Result<Option<crate::keys::OAuthTokens>, KeyError> {
692        Ok(match self.get_cloud_home_credentials()? {
693            Some(CloudHomeCredentials::OAuth { tokens }) => Some(tokens),
694            _ => None,
695        })
696    }
697
698    #[cfg(feature = "oauth-providers")]
699    pub fn set_cloud_home_oauth_tokens(
700        &self,
701        tokens: &crate::keys::OAuthTokens,
702    ) -> Result<(), KeyError> {
703        self.set_cloud_home_credentials(&CloudHomeCredentials::OAuth {
704            tokens: tokens.clone(),
705        })
706    }
707
708    pub fn delete_cloud_home_credentials(&self) -> Result<(), KeyError> {
709        if self
710            .keyring
711            .service()?
712            .delete(&KeyringSlot::CloudHomeCredentials(self.store_id.clone()))?
713        {
714            info!("Cloud home credentials deleted from keyring");
715        }
716        Ok(())
717    }
718
719    fn host_secret_slot(&self, name: &str) -> KeyringSlot {
720        KeyringSlot::HostSecret {
721            name: name.to_string(),
722            store_id: self.store_id.clone(),
723        }
724    }
725
726    /// A host's own store-scoped secret — an API token, a service credential
727    /// — read from the same keyring service and access policy as coven's own
728    /// key material. `None` if never set. [`KeyError::InvalidSecretName`] if
729    /// `name` collides with one of coven's own reserved slot names, is
730    /// empty, or contains `:` (see `validate_host_secret_name`).
731    pub fn get_host_secret(&self, name: &str) -> Result<Option<String>, KeyError> {
732        validate_host_secret_name(name)?;
733        self.keyring.service()?.read(&self.host_secret_slot(name))
734    }
735
736    /// Set a host's own store-scoped secret. Same name restrictions as
737    /// [`get_host_secret`](Self::get_host_secret).
738    pub fn set_host_secret(&self, name: &str, value: &str) -> Result<(), KeyError> {
739        validate_host_secret_name(name)?;
740        self.keyring
741            .service()?
742            .write(&self.host_secret_slot(name), value)?;
743        info!("Host secret {name:?} saved to keyring");
744        Ok(())
745    }
746
747    /// Remove a host secret. `Ok` whether or not one was set. Same name
748    /// restrictions as [`get_host_secret`](Self::get_host_secret).
749    pub fn delete_host_secret(&self, name: &str) -> Result<(), KeyError> {
750        validate_host_secret_name(name)?;
751        if self
752            .keyring
753            .service()?
754            .delete(&self.host_secret_slot(name))?
755        {
756            info!("Host secret {name:?} deleted from keyring");
757        }
758        Ok(())
759    }
760
761    #[cfg(test)]
762    pub(crate) fn write_empty_encryption_key_for_test(&self) -> Result<(), KeyError> {
763        self.keyring
764            .service()?
765            .write(&KeyringSlot::EncryptionMasterKey(self.store_id.clone()), "")
766    }
767
768    #[cfg(any(test, feature = "test-utils"))]
769    pub fn write_cloud_home_credentials_json_for_test(&self, json: &str) -> Result<(), KeyError> {
770        self.keyring.service()?.write(
771            &KeyringSlot::CloudHomeCredentials(self.store_id.clone()),
772            json,
773        )
774    }
775
776    #[cfg(any(test, feature = "test-utils"))]
777    pub fn fail_next_cloud_home_credentials_operation_for_test(
778        &self,
779        error: keyring_core::Error,
780    ) -> Result<(), KeyError> {
781        self.keyring.service()?.set_next_error(
782            &KeyringSlot::CloudHomeCredentials(self.store_id.clone()),
783            error,
784        )
785    }
786}
787
788impl DeviceIdentityCustody for StoreKeys {
789    fn unlock(&self) -> Result<Option<UserKeypair>, KeyError> {
790        let slot = KeyringSlot::DeviceSigningKey(self.store_id.clone());
791        let Some(signing_key_hex) = self.keyring.service()?.read(&slot)? else {
792            return Ok(None);
793        };
794        let signing_key = hex::decode(&signing_key_hex).map_err(|source| KeyError::Hex {
795            subject: "signing key",
796            source,
797        })?;
798        let actual = signing_key.len();
799        let signing_key: [u8; SIGN_SECRETKEYBYTES] =
800            signing_key
801                .try_into()
802                .map_err(|_| KeyError::InvalidLength {
803                    subject: "signing key",
804                    expected: SIGN_SECRETKEYBYTES,
805                    actual,
806                })?;
807        Ok(Some(UserKeypair::from_signing_key_bytes(&signing_key)?))
808    }
809
810    fn persist(&self, keypair: &UserKeypair) -> Result<(), KeyError> {
811        self.keyring.service()?.write(
812            &KeyringSlot::DeviceSigningKey(self.store_id.clone()),
813            &hex::encode(keypair.to_keypair_bytes()),
814        )
815    }
816
817    fn forget(&self) -> Result<(), KeyError> {
818        self.keyring
819            .service()?
820            .delete(&KeyringSlot::DeviceSigningKey(self.store_id.clone()))
821            .map(|_| ())
822    }
823}
824
825/// Installs keyring-core's in-memory mock store and registers the key service
826/// against it, once per process. Every crate that tests against the key
827/// service uses this rather than mirroring the mock, so no test reaches the
828/// real OS keychain.
829#[cfg(any(test, feature = "test-utils", debug_assertions))]
830pub mod test_keyring {
831    use std::sync::Once;
832
833    static INSTALL: Once = Once::new();
834
835    pub fn install() {
836        install_for_service("coven-tests").expect("register test keyring service");
837    }
838
839    pub fn install_for_service(service_name: &str) -> Result<(), super::KeyError> {
840        INSTALL.call_once(|| {
841            // Install the in-memory mock before registering the service so
842            // `set_keyring_service` keeps it instead of reaching for the OS
843            // keychain — a platform mechanism these tests never touch.
844            keyring_core::set_default_store(
845                keyring_core::mock::Store::new().expect("create mock keyring store"),
846            );
847        });
848        super::set_keyring_service(service_name)
849    }
850}
851
852#[cfg(test)]
853#[path = "platform_tests.rs"]
854mod tests;