//! Storage for shares's key material and the client's own secrets. use crate::ShareId; use std::fs; use std::io; use std::path::PathBuf; /// A secret's storage key. #[derive(Clone, Debug, PartialEq, Eq, uniffi::Enum)] pub enum SecretScope { /// Key material of one share. Share { id: ShareId }, /// The client's own secrets, like the browser pairing secret. Client, } /// Generic secret storage. Apps implement this on the platform's secure store. #[uniffi::export(with_foreign)] pub trait SecretStore: Send + Sync { fn load(&self, scope: SecretScope, key: String) -> Result>, SecretStoreError>; fn save(&self, scope: SecretScope, key: String, value: Vec) -> Result<(), SecretStoreError>; /// Deleting what is not there is not an error. fn delete(&self, scope: SecretScope, key: String) -> Result<(), SecretStoreError>; } #[derive(Debug, thiserror::Error, uniffi::Error)] #[uniffi(flat_error)] pub enum SecretStoreError { #[error("{0}")] Failed(String), } impl From for SecretStoreError { fn from(e: io::Error) -> Self { SecretStoreError::Failed(e.to_string()) } } impl From for SecretStoreError { fn from(e: uniffi::UnexpectedUniFFICallbackError) -> Self { SecretStoreError::Failed(e.to_string()) } } /// The default store with one file per secret - `//` for a /// share's and `/client/` for the client's own, readable by the owner /// only. For desktop, and for tests. pub struct FileSecretStore { dir: PathBuf, } impl FileSecretStore { pub fn new(dir: PathBuf) -> Self { Self { dir } } fn path(&self, scope: &SecretScope, key: &str) -> PathBuf { debug_assert!(!key.contains(['/', '\\']), "secret keys are plain names"); self.scope_dir(scope).join(key) } fn scope_dir(&self, scope: &SecretScope) -> PathBuf { match scope { SecretScope::Share { id } => self.dir.join(id.as_str()), SecretScope::Client => self.dir.join("client"), } } } impl SecretStore for FileSecretStore { fn load(&self, scope: SecretScope, key: String) -> Result>, SecretStoreError> { match fs::read(self.path(&scope, &key)) { Ok(bytes) => Ok(Some(bytes)), Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), Err(e) => Err(e.into()), } } fn save( &self, scope: SecretScope, key: String, value: Vec, ) -> Result<(), SecretStoreError> { let path = self.path(&scope, &key); let dir = path .parent() .expect("a secret's path has a scope directory"); fs::create_dir_all(dir)?; // Write beside the target and rename, so a crash leaves the old // secret or the new one, never a torn file. let staging = path.with_extension("staging"); let mut options = fs::OpenOptions::new(); options.write(true).create(true).truncate(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; options.mode(0o600); } { use io::Write; let mut file = options.open(&staging)?; file.write_all(&value)?; file.sync_all()?; } fs::rename(&staging, &path)?; Ok(()) } fn delete(&self, scope: SecretScope, key: String) -> Result<(), SecretStoreError> { let path = self.path(&scope, &key); match fs::remove_file(&path) { Ok(()) => {} Err(e) if e.kind() == io::ErrorKind::NotFound => {} Err(e) => return Err(e.into()), } // The scope's directory goes with its last secret. Not being empty // yet is fine. let _ = fs::remove_dir(self.scope_dir(&scope)); Ok(()) } } /// A new secret for the SDK's tokens, cookies and credentials: 32 random bytes /// from the OS, as lowercase hex. pub(crate) fn mint_secret() -> String { let mut bytes = [0u8; 32]; getrandom::fill(&mut bytes).expect("the OS random source is available"); bytes.iter().map(|b| format!("{b:02x}")).collect() } /// Whether two secrets are equal, compared in time that does not depend on /// where they differ. pub(crate) fn constant_time_eq(a: &str, b: &str) -> bool { let (a, b) = (a.as_bytes(), b.as_bytes()); if a.len() != b.len() { return false; } a.iter().zip(b).fold(0u8, |diff, (x, y)| diff | (x ^ y)) == 0 } #[cfg(test)] mod tests { use super::*; #[test] fn secrets_round_trip_and_leave_nothing_behind() { let dir = std::env::temp_dir().join(format!("wispers-access-secrets-{}", uuid::Uuid::new_v4())); let store = FileSecretStore::new(dir.clone()); let share_id = ShareId::mint(); let share = || SecretScope::Share { id: share_id.clone(), }; let key = || "root_key".to_owned(); assert_eq!(store.load(share(), key()).unwrap(), None); store.save(share(), key(), b"one".to_vec()).unwrap(); store.save(share(), key(), b"two".to_vec()).unwrap(); assert_eq!( store.load(share(), key()).unwrap().as_deref(), Some(&b"two"[..]) ); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; let mode = fs::metadata(dir.join(share_id.as_str()).join("root_key")) .unwrap() .permissions() .mode(); assert_eq!(mode & 0o777, 0o600); } store.delete(share(), key()).unwrap(); store.delete(share(), key()).unwrap(); assert!(!dir.join(share_id.as_str()).exists()); // The client's own secrets live beside the shares', and are not a share's. let pairing = || "browser_pairing_secret".to_owned(); store .save(SecretScope::Client, pairing(), b"s3cret".to_vec()) .unwrap(); assert_eq!( store .load(SecretScope::Client, pairing()) .unwrap() .as_deref(), Some(&b"s3cret"[..]) ); assert_eq!(store.load(share(), pairing()).unwrap(), None); assert!(dir.join("client").join("browser_pairing_secret").exists()); store.delete(SecretScope::Client, pairing()).unwrap(); assert!(!dir.join("client").exists()); fs::remove_dir_all(dir).unwrap(); } }