//! Platform secure storage for credentials (FR-NC-2, NFR-SEC-2). //! //! Credentials are **never** written to the catalog, to a plain file, or to //! logs. On Linux they go to the Secret Service (GNOME Keyring, or KWallet via //! `ksecretd`, which exposes the same D-Bus interface). On Android they belong //! in Keystore-backed storage. //! //! Absence of a secrets daemon is an explicit degraded mode, not a silent //! fallback to plaintext: a headless box or a minimal window manager may have //! none, and quietly writing a password to disk there would be worse than //! refusing. use std::fmt; /// Which secret is being stored, so one account can hold several. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SecretKind { /// A Nextcloud app password from Login Flow v2. Device-scoped and /// individually revocable — never the user's actual password. AppPassword, } impl SecretKind { fn as_str(self) -> &'static str { match self { SecretKind::AppPassword => "app-password", } } } /// Where a credential lives: one account on one server. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SecretRef { pub server: String, pub login: String, pub kind: SecretKind, } impl SecretRef { pub fn app_password(server: impl Into, login: impl Into) -> Self { Self { server: server.into(), login: login.into(), kind: SecretKind::AppPassword, } } /// The key under which the platform store holds this secret. /// /// Includes the server so two accounts on different servers with the same /// login do not collide. fn entry_key(&self) -> String { format!("{}@{}#{}", self.login, self.server, self.kind.as_str()) } } /// Deliberately opaque: the whole point is that a credential never appears in /// a log line or an error message (NFR-SEC-2). impl fmt::Display for SecretRef { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{} on {}", self.login, self.server) } } #[derive(Debug, thiserror::Error)] pub enum SecretError { /// No secrets daemon. The app runs in a degraded mode where the user /// re-authenticates each session, rather than storing anything in plain. #[error("no platform secret store available: {0}")] Unavailable(String), #[error("secret not found")] NotFound, #[error("secret store rejected the request: {0}")] Denied(String), #[error("secret store error: {0}")] Other(String), } /// TRACES: FR-NC-2 | NFR-SEC-2 | M-2 /// Store, retrieve and delete credentials. /// /// Implemented per platform and injected at construction, so `core/` contains /// no `#[cfg(target_os)]` (NFR-PORT-1). pub trait SecretStore: Send + Sync { fn store(&self, secret_ref: &SecretRef, secret: &str) -> Result<(), SecretError>; fn retrieve(&self, secret_ref: &SecretRef) -> Result; fn delete(&self, secret_ref: &SecretRef) -> Result<(), SecretError>; /// Whether the store is usable right now. /// /// Checked before offering to remember a login, so the UI can say /// "you will need to sign in each time" rather than failing later. fn is_available(&self) -> bool; } /// The service name entries are filed under. const SERVICE: &str = "DarkRoom"; /// Secret Service implementation (GNOME Keyring, KWallet via ksecretd). #[cfg(all(unix, not(target_os = "android")))] pub struct PlatformSecretStore; #[cfg(all(unix, not(target_os = "android")))] impl PlatformSecretStore { pub fn new() -> Self { Self } fn entry(r: &SecretRef) -> Result { keyring::Entry::new(SERVICE, &r.entry_key()).map_err(map_err) } } #[cfg(all(unix, not(target_os = "android")))] impl Default for PlatformSecretStore { fn default() -> Self { Self::new() } } #[cfg(all(unix, not(target_os = "android")))] impl SecretStore for PlatformSecretStore { fn store(&self, secret_ref: &SecretRef, secret: &str) -> Result<(), SecretError> { Self::entry(secret_ref)? .set_password(secret) .map_err(map_err) } fn retrieve(&self, secret_ref: &SecretRef) -> Result { Self::entry(secret_ref)?.get_password().map_err(map_err) } fn delete(&self, secret_ref: &SecretRef) -> Result<(), SecretError> { match Self::entry(secret_ref)?.delete_credential() { Ok(()) => Ok(()), // Deleting an absent secret is the desired end state, not a // failure — logout must be idempotent. Err(keyring::Error::NoEntry) => Ok(()), Err(e) => Err(map_err(e)), } } fn is_available(&self) -> bool { // Probing a name that will not exist distinguishes "daemon absent" // from "secret absent": the former errors, the latter reports NoEntry. match keyring::Entry::new(SERVICE, "__availability_probe__") { Ok(e) => !matches!( e.get_password(), Err(keyring::Error::PlatformFailure(_)) | Err(keyring::Error::NoStorageAccess(_)) ), Err(_) => false, } } } #[cfg(all(unix, not(target_os = "android")))] fn map_err(e: keyring::Error) -> SecretError { match e { keyring::Error::NoEntry => SecretError::NotFound, keyring::Error::NoStorageAccess(e) => SecretError::Unavailable(e.to_string()), keyring::Error::PlatformFailure(e) => SecretError::Unavailable(e.to_string()), other => SecretError::Other(other.to_string()), } } /// Keystore-backed implementation (FR-PLAT-AND-1). /// /// `android-native-keyring-store` encrypts each secret with an AES-GCM key /// held in `AndroidKeyStore` and files the ciphertext in SharedPreferences. /// The key never leaves the Keystore, so the preferences file is useless on /// its own. This is the current approach rather than the deprecated /// `EncryptedSharedPreferences` (REQ §11). /// /// It finds the JavaVM and Context through `ndk-context`, which /// `android-activity` initialises before `android_main` is called. Nothing /// here is usable before that point — hence the lazy handle below. #[cfg(target_os = "android")] pub struct PlatformSecretStore { /// Built on first use, not in `new()`: construction needs the ndk-context /// to be live, and `new()` may run early. Cached because store names are /// unique — building one per call would fail on the second call. store: std::sync::OnceLock, String>>, } #[cfg(target_os = "android")] impl PlatformSecretStore { pub fn new() -> Self { Self { store: std::sync::OnceLock::new(), } } fn store(&self) -> Result<&std::sync::Arc, SecretError> { self.store .get_or_init(|| android_native_keyring_store::Store::new().map_err(|e| e.to_string())) .as_ref() .map_err(|e| SecretError::Unavailable(e.clone())) } /// A credential specifier for one secret. Filed under the same /// service/key pair as the Linux path, so the two platforms agree on /// naming even though the backing stores differ. fn entry(&self, r: &SecretRef) -> Result { use keyring_core::api::CredentialStoreApi; self.store()? .build(SERVICE, &r.entry_key(), None) .map_err(map_err) } } #[cfg(target_os = "android")] impl Default for PlatformSecretStore { fn default() -> Self { Self::new() } } #[cfg(target_os = "android")] impl SecretStore for PlatformSecretStore { fn store(&self, secret_ref: &SecretRef, secret: &str) -> Result<(), SecretError> { self.entry(secret_ref)? .set_password(secret) .map_err(map_err) } fn retrieve(&self, secret_ref: &SecretRef) -> Result { self.entry(secret_ref)?.get_password().map_err(map_err) } fn delete(&self, secret_ref: &SecretRef) -> Result<(), SecretError> { match self.entry(secret_ref)?.delete_credential() { Ok(()) => Ok(()), // Logout must be idempotent, as on Linux. Err(keyring_core::Error::NoEntry) => Ok(()), Err(e) => Err(map_err(e)), } } fn is_available(&self) -> bool { // Unlike Linux there is no daemon to be absent: if the store builds, // Keystore is there. Building is the whole probe. self.store().is_ok() } } #[cfg(target_os = "android")] fn map_err(e: keyring_core::Error) -> SecretError { match e { keyring_core::Error::NoEntry => SecretError::NotFound, keyring_core::Error::NoStorageAccess(e) => SecretError::Unavailable(e.to_string()), keyring_core::Error::PlatformFailure(e) => SecretError::Unavailable(e.to_string()), other => SecretError::Other(other.to_string()), } } /// Placeholder for platforms without an implementation yet. /// /// Failing loudly is deliberate: a silent no-op store would look like it /// worked and then lose the credential. #[cfg(not(any(all(unix, not(target_os = "android")), target_os = "android")))] pub struct PlatformSecretStore; #[cfg(not(any(all(unix, not(target_os = "android")), target_os = "android")))] impl PlatformSecretStore { pub fn new() -> Self { Self } } #[cfg(not(any(all(unix, not(target_os = "android")), target_os = "android")))] impl Default for PlatformSecretStore { fn default() -> Self { Self::new() } } #[cfg(not(any(all(unix, not(target_os = "android")), target_os = "android")))] impl SecretStore for PlatformSecretStore { fn store(&self, _r: &SecretRef, _s: &str) -> Result<(), SecretError> { Err(SecretError::Unavailable( "no secret store is implemented for this platform".into(), )) } fn retrieve(&self, _r: &SecretRef) -> Result { Err(SecretError::Unavailable( "no secret store is implemented for this platform".into(), )) } fn delete(&self, _r: &SecretRef) -> Result<(), SecretError> { Ok(()) } fn is_available(&self) -> bool { false } } /// An in-memory store for tests and for the degraded no-daemon mode. /// /// Credentials live only as long as the process, so a user without a secrets /// daemon re-authenticates each session — which is the honest behaviour. #[derive(Default)] pub struct EphemeralSecretStore { entries: std::sync::Mutex>, } impl EphemeralSecretStore { pub fn new() -> Self { Self::default() } } impl SecretStore for EphemeralSecretStore { fn store(&self, secret_ref: &SecretRef, secret: &str) -> Result<(), SecretError> { self.entries .lock() .map_err(|e| SecretError::Other(e.to_string()))? .insert(secret_ref.entry_key(), secret.to_string()); Ok(()) } fn retrieve(&self, secret_ref: &SecretRef) -> Result { self.entries .lock() .map_err(|e| SecretError::Other(e.to_string()))? .get(&secret_ref.entry_key()) .cloned() .ok_or(SecretError::NotFound) } fn delete(&self, secret_ref: &SecretRef) -> Result<(), SecretError> { self.entries .lock() .map_err(|e| SecretError::Other(e.to_string()))? .remove(&secret_ref.entry_key()); Ok(()) } fn is_available(&self) -> bool { true } } #[cfg(test)] mod tests { use super::*; #[test] fn keys_separate_accounts_across_servers() { // Same login on two servers must not collide, or signing into the // second would overwrite the first. let a = SecretRef::app_password("https://a.example", "duncan"); let b = SecretRef::app_password("https://b.example", "duncan"); assert_ne!(a.entry_key(), b.entry_key()); } #[test] fn keys_separate_logins_on_one_server() { let a = SecretRef::app_password("https://a.example", "duncan"); let b = SecretRef::app_password("https://a.example", "someone"); assert_ne!(a.entry_key(), b.entry_key()); } #[test] fn display_never_reveals_the_secret_or_the_key() { let r = SecretRef::app_password("https://cloud.example", "duncan"); let shown = r.to_string(); assert!(shown.contains("duncan")); assert!( !shown.contains("app-password"), "internal key must not leak" ); } #[test] fn ephemeral_round_trips() { let s = EphemeralSecretStore::new(); let r = SecretRef::app_password("https://cloud.example", "duncan"); assert!(matches!(s.retrieve(&r), Err(SecretError::NotFound))); s.store(&r, "token-value").unwrap(); assert_eq!(s.retrieve(&r).unwrap(), "token-value"); } #[test] fn deleting_is_idempotent() { // Logout must succeed whether or not a credential is present. let s = EphemeralSecretStore::new(); let r = SecretRef::app_password("https://cloud.example", "duncan"); assert!(s.delete(&r).is_ok()); s.store(&r, "x").unwrap(); assert!(s.delete(&r).is_ok()); assert!(matches!(s.retrieve(&r), Err(SecretError::NotFound))); } #[test] fn storing_twice_overwrites() { let s = EphemeralSecretStore::new(); let r = SecretRef::app_password("https://cloud.example", "duncan"); s.store(&r, "first").unwrap(); s.store(&r, "second").unwrap(); assert_eq!(s.retrieve(&r).unwrap(), "second"); } }