The Android job's "Verify minimum API level" step has never verified the minimum API level. It took the first `*.so` anywhere under the target directory, which is a host proc-macro from debug/deps — an x86-64 object built by the runner's gcc, whose .comment section cannot mention Android and so can never contradict the expected value. It now reads the artifact under the target triple, compares against MIN_API parsed from the Dockerfile rather than a second copy of the number, and fails on a mismatch. Both sides are checked non-empty first: two failed parses would otherwise compare equal and pass, which is the same silent success in a new costume. The Android image installs one SDK package per layer and keeps the output. sdkmanager is a JVM program that aborts when it cannot get memory, and the single `> /dev/null` step reported that as a bare "exit code 134" while a retry re-downloaded everything that had already succeeded. tools/ci-local.sh runs all four jobs — desktop, android, layering, traceability — against the host toolchain, which is pinned to the same 1.92.0 CI installs. Its matrix check compares regeneration against the working tree rather than against HEAD: CI starts from a clean checkout, so git's answer is the right one there and reports every local run stale here. The rest is rustfmt across the workspace, and the clippy findings that surfaced once it did: manual_contains in dr-thumbs and collections_ui, a map iterated as pairs for its keys, an index loop over a slice, and two runtime assertions on a constant now made at compile time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
414 lines
14 KiB
Rust
414 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> {
|
|
use keyring_core::api::CredentialApi;
|
|
|
|
self.entry(secret_ref)?
|
|
.set_password(secret)
|
|
.map_err(map_err)
|
|
}
|
|
|
|
fn retrieve(&self, secret_ref: &SecretRef) -> Result<String, SecretError> {
|
|
use keyring_core::api::CredentialApi;
|
|
|
|
self.entry(secret_ref)?.get_password().map_err(map_err)
|
|
}
|
|
|
|
fn delete(&self, secret_ref: &SecretRef) -> Result<(), SecretError> {
|
|
use keyring_core::api::CredentialApi;
|
|
|
|
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");
|
|
}
|
|
}
|