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>
408 lines
14 KiB
Rust
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");
|
|
}
|
|
}
|