Files
DarkRoom/platform/dr-plat/src/secrets.rs
T
dtourolleandClaude Opus 5 721efbc371 Drop three trait imports Android does not need
The Linux store is built on `keyring::Entry`, whose set_password,
get_password and delete_credential are inherent methods. The Android one
is built on `keyring_core::Entry`, where they are inherent too -- so the
`CredentialApi` import that each of its three methods opened with was
doing nothing, and said so on every Android build.

`CredentialStoreApi` a few lines above is a different matter and stays:
`build` really does come from that trait.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 12:16:17 +02:00

408 lines
14 KiB
Rust

//! 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<String>, login: impl Into<String>) -> 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<String, SecretError>;
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, SecretError> {
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<String, SecretError> {
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<Result<std::sync::Arc<android_native_keyring_store::Store>, String>>,
}
#[cfg(target_os = "android")]
impl PlatformSecretStore {
pub fn new() -> Self {
Self {
store: std::sync::OnceLock::new(),
}
}
fn store(&self) -> Result<&std::sync::Arc<android_native_keyring_store::Store>, 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<keyring_core::Entry, SecretError> {
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<String, SecretError> {
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<String, SecretError> {
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<std::collections::HashMap<String, String>>,
}
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<String, SecretError> {
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");
}
}