Skip to main content

tauri_plugin_keyring_store/
store.rs

1//! High-level keyring access for a fixed service name.
2//!
3//! Account strings are deterministic hashes so snapshot paths and binary ids stay short and safe for OS limits.
4//!
5//! ## Example
6//!
7//! ```rust,no_run
8//! use tauri_plugin_keyring_store::KeyringStore;
9//! use tauri_plugin_keyring_store::BytesDto;
10//!
11//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
12//! let store = KeyringStore::new("com.example.app");
13//! let client = BytesDto::Text("my-client".into());
14//! let account = store.account_store_key("/data/main", &client, "settings.json");
15//! store.set_bytes(&account, b"{\"theme\":\"dark\"}")?;
16//! # Ok(())
17//! # }
18//! ```
19
20use std::collections::HashSet;
21#[cfg(target_os = "ios")]
22use std::collections::HashMap;
23use std::sync::{Arc, Mutex};
24
25use keyring_core::Entry;
26use sha2::{Digest, Sha256};
27
28use crate::backend::{ensure_init, map_keyring_err};
29#[cfg(target_os = "ios")]
30use crate::backend::is_keychain_locked_error;
31use crate::error::{Error, Result};
32use crate::models::BytesDto;
33
34fn digest16(data: &[u8]) -> String {
35    let mut h = Sha256::new();
36    h.update(data);
37    let out = h.finalize();
38    hex::encode(&out[..8])
39}
40
41/// iOS/macOS Data Protection policy applied when **creating** credentials ([`KeyringStore::set_password`]).
42///
43/// Only honored on iOS (`access-policy` modifier). macOS uses Login Keychain and ignores this for writes.
44#[cfg(any(target_os = "ios", target_os = "macos"))]
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
46pub enum WriteAccessibility {
47    /// Accessible after the first device unlock (default on iOS for background-friendly secrets).
48    #[default]
49    AfterFirstUnlock,
50    WhenUnlocked,
51    AfterFirstUnlockThisDeviceOnly,
52    WhenUnlockedThisDeviceOnly,
53    WhenPasscodeSetThisDeviceOnly,
54    RequireUserPresence,
55}
56
57#[cfg(any(target_os = "ios", target_os = "macos"))]
58impl WriteAccessibility {
59    /// Modifier value for [`keyring_core::Entry::new_with_modifiers`] on iOS protected store.
60    pub fn as_access_policy_modifier(self) -> &'static str {
61        match self {
62            Self::AfterFirstUnlock => "after-first-unlock",
63            Self::WhenUnlocked => "when-unlocked",
64            Self::AfterFirstUnlockThisDeviceOnly => "after-first-unlock-this-device-only",
65            Self::WhenUnlockedThisDeviceOnly => "when-unlocked-this-device-only",
66            Self::WhenPasscodeSetThisDeviceOnly => "when-passcode-set-this-device-only",
67            Self::RequireUserPresence => "require-user-presence",
68        }
69    }
70}
71
72/// Result of a lightweight keychain availability probe ([`KeyringStore::availability`]).
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum KeyringAvailability {
75    /// Credentials can be read (device unlocked or policy allows access).
76    Available,
77    /// Protected data / keychain is not readable (e.g. device locked on iOS).
78    Locked,
79}
80
81#[cfg(target_os = "ios")]
82const AVAILABILITY_PROBE_ACCOUNT: &str = "__tauri_keyring_store_availability_probe__";
83
84/// Managed snapshot sessions (Stronghold-compatible “initialized paths”).
85#[derive(Default, Clone)]
86pub struct SessionRegistry(pub Arc<Mutex<HashSet<String>>>);
87
88impl SessionRegistry {
89    /// Marks `path` as initialized for this process.
90    pub fn insert(&self, path: String) {
91        self.0.lock().expect("session mutex poisoned").insert(path);
92    }
93
94    /// Removes a path; returns whether it was present.
95    pub fn remove(&self, path: &str) -> bool {
96        self.0.lock().expect("session mutex poisoned").remove(path)
97    }
98
99    /// Returns whether `path` is currently tracked.
100    pub fn contains(&self, path: &str) -> bool {
101        self.0
102            .lock()
103            .expect("session mutex poisoned")
104            .contains(path)
105    }
106}
107
108/// OS-backed credential storage scoped to one service identifier (bundle id / custom).
109#[derive(Debug, Clone)]
110pub struct KeyringStore {
111    service: String,
112    #[cfg(target_os = "ios")]
113    write_accessibility: WriteAccessibility,
114}
115
116impl KeyringStore {
117    /// Creates a store handle; no I/O until the first read/write (backend registers lazily).
118    pub fn new(service: impl Into<String>) -> Self {
119        Self {
120            service: service.into(),
121            #[cfg(target_os = "ios")]
122            write_accessibility: WriteAccessibility::default(),
123        }
124    }
125
126    /// Overrides the iOS Data Protection policy used for new writes ([`Self::set_password`] / [`Self::set_bytes`]).
127    #[cfg(target_os = "ios")]
128    pub fn with_write_accessibility(mut self, policy: WriteAccessibility) -> Self {
129        self.write_accessibility = policy;
130        self
131    }
132
133    /// Keyring **service** / namespace string passed to the native backend.
134    pub fn service(&self) -> &str {
135        &self.service
136    }
137
138    fn entry(&self, account: &str) -> Result<Entry> {
139        ensure_init().map_err(Error::Init)?;
140        Entry::new(&self.service, account).map_err(|e| Error::Keyring(e.to_string()))
141    }
142
143    fn entry_for_write(&self, account: &str) -> Result<Entry> {
144        #[cfg(target_os = "ios")]
145        {
146            ensure_init().map_err(Error::Init)?;
147            let policy = self.write_accessibility.as_access_policy_modifier();
148            let modifiers = HashMap::from([("access-policy", policy)]);
149            Entry::new_with_modifiers(&self.service, account, &modifiers)
150                .map_err(|e| Error::Keyring(e.to_string()))
151        }
152        #[cfg(not(target_os = "ios"))]
153        {
154            self.entry(account)
155        }
156    }
157
158    /// Probes whether the OS keyring is readable without surfacing an error to callers.
159    ///
160    /// On iOS, performs a read of a dedicated probe account: missing entry means available;
161    /// [`keyring_core::Error::NoStorageAccess`] means locked. Other platforms always return
162    /// [`KeyringAvailability::Available`].
163    pub fn availability(&self) -> KeyringAvailability {
164        #[cfg(target_os = "ios")]
165        {
166            let entry = match self.entry(AVAILABILITY_PROBE_ACCOUNT) {
167                Ok(e) => e,
168                Err(e) => {
169                    log::warn!("keyring availability probe: entry creation failed: {e}");
170                    return KeyringAvailability::Available;
171                }
172            };
173            match entry.get_password() {
174                Ok(_) | Err(keyring_core::error::Error::NoEntry) => KeyringAvailability::Available,
175                Err(e) if is_keychain_locked_error(&e) => KeyringAvailability::Locked,
176                Err(e) => {
177                    log::warn!("keyring availability probe: unexpected error: {e}");
178                    KeyringAvailability::Available
179                }
180            }
181        }
182        #[cfg(not(target_os = "ios"))]
183        {
184            KeyringAvailability::Available
185        }
186    }
187
188    /// Persists a UTF-8 secret (use [`Self::set_bytes`] for arbitrary bytes).
189    pub fn set_password(&self, account: &str, password: &str) -> Result<()> {
190        let entry = self.entry_for_write(account)?;
191        entry.set_password(password).map_err(map_keyring_err)
192    }
193
194    /// Encodes `value` as Base64 and stores it via [`Self::set_password`].
195    pub fn set_bytes(&self, account: &str, value: &[u8]) -> Result<()> {
196        let encoded = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, value);
197        self.set_password(account, &encoded)
198    }
199
200    /// Returns stored UTF-8, or [`None`] if the entry is missing.
201    pub fn get_password(&self, account: &str) -> Result<Option<String>> {
202        let entry = self.entry(account)?;
203        match entry.get_password() {
204            Ok(p) => Ok(Some(p)),
205            Err(e) => {
206                if matches!(&e, keyring_core::error::Error::NoEntry) {
207                    Ok(None)
208                } else {
209                    Err(map_keyring_err(e))
210                }
211            }
212        }
213    }
214
215    /// Like [`Self::get_password`], but returns [`Ok(None)`] when [`Self::availability`] is [`KeyringAvailability::Locked`]
216    /// (intended for background sync without treating lock as a hard error).
217    pub fn get_password_for_background(&self, account: &str) -> Result<Option<String>> {
218        if self.availability() == KeyringAvailability::Locked {
219            return Ok(None);
220        }
221        self.get_password(account)
222    }
223
224    /// Decodes Base64 from [`Self::get_password`]; returns [`None`] if missing.
225    pub fn get_bytes(&self, account: &str) -> Result<Option<Vec<u8>>> {
226        match self.get_password(account)? {
227            None => Ok(None),
228            Some(s) => {
229                let raw =
230                    base64::Engine::decode(&base64::engine::general_purpose::STANDARD, s.trim())
231                        .map_err(|e| Error::Encoding(e.to_string()))?;
232                Ok(Some(raw))
233            }
234        }
235    }
236
237    /// Like [`Self::get_bytes`], but returns [`Ok(None)`] when the keychain is locked ([`Self::get_password_for_background`]).
238    pub fn get_bytes_for_background(&self, account: &str) -> Result<Option<Vec<u8>>> {
239        match self.get_password_for_background(account)? {
240            None => Ok(None),
241            Some(s) => {
242                let raw =
243                    base64::Engine::decode(&base64::engine::general_purpose::STANDARD, s.trim())
244                        .map_err(|e| Error::Encoding(e.to_string()))?;
245                Ok(Some(raw))
246            }
247        }
248    }
249
250    /// Deletes the credential if present; missing entries are treated as success.
251    pub fn delete(&self, account: &str) -> Result<()> {
252        let entry = self.entry(account)?;
253        match entry.delete_credential() {
254            Ok(()) => Ok(()),
255            Err(e) => {
256                if matches!(&e, keyring_core::error::Error::NoEntry) {
257                    Ok(())
258                } else {
259                    Err(map_keyring_err(e))
260                }
261            }
262        }
263    }
264
265    /// `true` if a non-empty password exists for `account`.
266    pub fn exists_nonempty(&self, account: &str) -> Result<bool> {
267        Ok(self
268            .get_password(account)?
269            .map(|v| !v.trim().is_empty())
270            .unwrap_or(false))
271    }
272
273    /// Like [`Self::exists_nonempty`], but returns `false` when the keychain is locked.
274    pub fn exists_nonempty_for_background(&self, account: &str) -> Result<bool> {
275        if self.availability() == KeyringAvailability::Locked {
276            return Ok(false);
277        }
278        self.exists_nonempty(account)
279    }
280
281    /// Stable account id for an unstructured secret key under a snapshot + client namespace.
282    ///
283    /// # Example
284    ///
285    /// ```
286    /// use tauri_plugin_keyring_store::{BytesDto, KeyringStore};
287    ///
288    /// let store = KeyringStore::new("com.example.app");
289    /// let client = BytesDto::Text("cli".into());
290    /// let account = store.account_raw("/data/main", &client, "token");
291    /// assert!(account.starts_with("kp:v1:"));
292    /// ```
293    pub fn account_raw(&self, snapshot_path: &str, client: &BytesDto, suffix: &str) -> String {
294        let sd = digest16(snapshot_path.as_bytes());
295        let cd = digest16(client.as_ref());
296        let xd = digest16(suffix.as_bytes());
297        format!("kp:v1:{sd}:{cd}:x:{xd}")
298    }
299
300    /// Account key for a JSON **store record** (`store_key` is a logical filename).
301    pub fn account_store_key(
302        &self,
303        snapshot_path: &str,
304        client: &BytesDto,
305        store_key: &str,
306    ) -> String {
307        let sd = digest16(snapshot_path.as_bytes());
308        let cd = digest16(client.as_ref());
309        let kd = digest16(store_key.as_bytes());
310        format!("kp:v1:{sd}:{cd}:st:{kd}")
311    }
312
313    /// Account key for binary **vault** payload at `vault` / `record_path`.
314    pub fn account_vault_record(
315        &self,
316        snapshot_path: &str,
317        client: &BytesDto,
318        vault: &BytesDto,
319        record_path: &BytesDto,
320    ) -> String {
321        let sd = digest16(snapshot_path.as_bytes());
322        let cd = digest16(client.as_ref());
323        let vd = digest16(vault.as_ref());
324        let rd = digest16(record_path.as_ref());
325        format!("kp:v1:{sd}:{cd}:v:{vd}:{rd}")
326    }
327}