Add secure credential storage, sessions, and a launch screen

Login now persists properly rather than through the JSON file the test
harness was using.

  dr-plat            SecretStore trait plus a Secret Service backend.
                     Verified against the live GNOME Keyring: store,
                     retrieve, delete, confirm-gone all round-trip.
  Session/SessionStore   splits credentials from settings — the app
                     password goes to the keyring (FR-NC-2), while
                     server, login, chosen root and format selection are
                     ordinary config. A test asserts the credential never
                     appears in the config file.
  LaunchModel        the launch-screen state machine, testable without a
                     display server: sign in, approve in browser, choose
                     folder, tick formats, sign out.
  launch.slint       the screen itself, in its own file.

Absence of a secrets daemon is an explicit degraded mode, not a silent
fallback to plaintext — the screen says sign-in will not persist rather
than letting the user find out next launch. Android's Keystore backend
fails loudly for the same reason: a no-op store would look like it
worked and then lose the credential.

Two bugs caught by tests rather than by running it:

  - fail() after busy() signed the user out, because busy() had already
    discarded the session. A failed *scan* would have logged you out.
    Busy now carries the session.
  - normalise_server upgrades http:// to https:// rather than accepting
    it. NFR-SEC-3 requires TLS, and silently sending a credential in the
    clear is not a decision to make on the user's behalf.

launch.slint is not yet wired into app.slint. Calling slint_build::compile
twice replaces the generated module rather than adding to it, which broke
the other in-flight work on dr-ui; I reverted that immediately. Wiring it
needs an import inside app.slint, which is that work's file to change.

419 tests passing across ten crates.
This commit is contained in:
2026-08-09 15:20:39 +02:00
parent c8bb08e661
commit 09e3043f4c
33 changed files with 3506 additions and 155 deletions
+1
View File
@@ -8,6 +8,7 @@ license.workspace = true
[dependencies]
dr-types.workspace = true
dr-sync.workspace = true
dr-plat.workspace = true
reqwest.workspace = true
rustls.workspace = true
quick-xml.workspace = true
+70 -61
View File
@@ -20,11 +20,11 @@
//! FR-NC-2 requires the real app to use platform secure storage.
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Instant;
use dr_plat::PlatformSecretStore;
use dr_sync::{RemoteBackend, RemoteId, RemotePath, SyncStrategy};
use dr_sync_nextcloud::{auth, AppCredentials, NextcloudBackend};
use dr_sync_nextcloud::{auth, AppCredentials, NextcloudBackend, Session, SessionStore};
#[tokio::main]
async fn main() {
@@ -55,26 +55,33 @@ async fn main() {
dr_types::FormatFilter::all()
};
let creds = match load_cached(&server) {
Some(c) => {
println!("using cached credentials for {}", c.login_name);
c
}
None => match authenticate(&server).await {
Ok(c) => c,
// Sessions persist across runs: credentials in the platform keyring
// (FR-NC-2), everything else as ordinary config.
let sessions = SessionStore::open(Box::new(PlatformSecretStore::new()));
if !sessions.can_remember() {
println!("note: no secrets daemon — sign-in will not persist this session");
}
let existing = sessions
.current()
.filter(|s| s.server == server.trim_end_matches('/'));
let (session, creds) = match existing {
Some(s) => match sessions.credentials(&s) {
Ok(c) => {
println!("signed in: {}", s.describe());
(s, c)
}
Err(e) => {
eprintln!("authentication failed: {e}");
std::process::exit(1);
// Revoked server-side, or the keyring was cleared.
println!("stored credential unusable ({e}); signing in again");
sign_in(&server, &sessions).await
}
},
None => sign_in(&server, &sessions).await,
};
// The DAV base needs the *user id*, which may differ from the login name
// (a login can be an email address). OCS reports the real one.
let user_id = fetch_user_id(&creds).await.unwrap_or_else(|e| {
eprintln!("could not resolve user id ({e}); falling back to login name");
creds.login_name.clone()
});
let user_id = session.user_id.clone();
println!("user id: {user_id}");
let backend = NextcloudBackend::new(&creds, &user_id).expect("build backend");
@@ -205,7 +212,17 @@ async fn main() {
println!("\n[range] skipped — no RAW over 300KB found");
}
println!("\nscan complete");
// Remember what was scanned, so the next launch resumes here.
let mut updated = session.clone();
updated.root = start_path.clone();
updated.set_format_filter(&filter);
if let Err(e) = sessions.update(&updated) {
eprintln!("could not update session: {e}");
} else {
println!("\nremembered: {}", updated.describe());
}
println!("scan complete");
}
fn describe_filter(f: &dr_types::FormatFilter) -> String {
@@ -217,24 +234,48 @@ fn describe_filter(f: &dr_types::FormatFilter) -> String {
}
}
async fn authenticate(server: &str) -> Result<AppCredentials, String> {
let client =
dr_sync_nextcloud::http_client("DarkRoom (connect example)").map_err(|e| e.to_string())?;
/// Run Login Flow v2 and persist the result.
async fn sign_in(server: &str, sessions: &SessionStore) -> (Session, AppCredentials) {
let client = match dr_sync_nextcloud::http_client("DarkRoom") {
Ok(c) => c,
Err(e) => {
eprintln!("could not build http client: {e}");
std::process::exit(1);
}
};
let flow = auth::begin(&client, server, "DarkRoom (connect example)")
.await
.map_err(|e| e.to_string())?;
let flow = match auth::begin(&client, server, "DarkRoom (connect example)").await {
Ok(f) => f,
Err(e) => {
eprintln!("could not start login: {e}");
std::process::exit(1);
}
};
println!("\n Open this in a browser and approve:\n");
println!(" {}\n", flow.login_url);
println!(" waiting (20 minute limit)…");
let creds = auth::poll(&client, &flow)
.await
.map_err(|e| e.to_string())?;
let creds = match auth::poll(&client, &flow).await {
Ok(c) => c,
Err(e) => {
eprintln!("login failed: {e}");
std::process::exit(1);
}
};
println!(" authenticated as {}", creds.login_name);
save_cached(server, &creds);
Ok(creds)
let user_id = fetch_user_id(&creds).await.unwrap_or_else(|e| {
eprintln!(" could not resolve user id ({e}); using login name");
creds.login_name.clone()
});
let session = Session::new(&creds, user_id);
match sessions.save(&session, &creds) {
Ok(()) => println!(" session saved to {}", sessions.config_path().display()),
Err(e) => eprintln!(" could not persist session: {e}"),
}
(session, creds)
}
/// Resolve the real user id, which the DAV path needs.
@@ -262,38 +303,6 @@ async fn fetch_user_id(creds: &AppCredentials) -> Result<String, String> {
.ok_or_else(|| "no id in OCS response".to_string())
}
fn cache_path(server: &str) -> PathBuf {
let dir = std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".cache"))
.join("darkroom");
let _ = std::fs::create_dir_all(&dir);
let key: String = server
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect();
dir.join(format!("{key}.json"))
}
fn load_cached(server: &str) -> Option<AppCredentials> {
let text = std::fs::read_to_string(cache_path(server)).ok()?;
serde_json::from_str(&text).ok()
}
fn save_cached(server: &str, creds: &AppCredentials) {
let path = cache_path(server);
if let Ok(json) = serde_json::to_string(creds) {
let _ = std::fs::write(&path, json);
// Testing convenience only — FR-NC-2 requires platform secure storage.
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
}
println!(" cached credentials at {}", path.display());
}
}
fn human(bytes: u64) -> String {
match bytes {
b if b >= 1_000_000_000 => format!("{:.1}GB", b as f64 / 1e9),
+2
View File
@@ -16,9 +16,11 @@ use dr_sync::{
pub mod auth;
pub mod desktop_client;
mod propfind;
pub mod session;
pub use auth::{AppCredentials, LoginFlow};
pub use desktop_client::DesktopClient;
pub use session::{Session, SessionError, SessionStore};
/// Chunk sizes Nextcloud's chunked upload v2 accepts.
const CHUNKS: ChunkConstraints = ChunkConstraints {
+419
View File
@@ -0,0 +1,419 @@
//! Account sessions — logging in once and staying logged in.
//!
//! Splits deliberately in two:
//!
//! - **Credentials** go to platform secure storage (FR-NC-2). Never the
//! catalog, never a file, never a log line.
//! - **Everything else** — server, login, chosen root, format filter — is
//! ordinary configuration, safe to write as plain JSON.
//!
//! That split is what lets the app show "signed in as duncan, watching
//! /PhotosRaw" before it has touched the keyring, and re-authenticate cleanly
//! if the credential has been revoked server-side.
use std::path::{Path, PathBuf};
use dr_plat::{SecretError, SecretRef, SecretStore};
use dr_sync::RemoteError;
use dr_types::{Format, FormatFilter};
use serde::{Deserialize, Serialize};
use crate::AppCredentials;
/// A configured account, minus its credential.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Session {
pub server: String,
pub login: String,
/// The DAV path segment, which may differ from `login` — a login can be
/// an email address while the user id is something else.
pub user_id: String,
/// The folder chosen as the library root. Empty means the account root.
#[serde(default)]
pub root: String,
/// Which formats the scan looks for (the tick-boxes).
#[serde(default)]
pub formats: Vec<String>,
/// Unix seconds of the last completed scan, for display.
#[serde(default)]
pub last_scan: Option<i64>,
}
impl Session {
pub fn new(creds: &AppCredentials, user_id: impl Into<String>) -> Self {
Self {
server: creds.server.trim_end_matches('/').to_string(),
login: creds.login_name.clone(),
user_id: user_id.into(),
root: String::new(),
formats: Vec::new(),
last_scan: None,
}
}
/// The stored format selection, defaulting to every supported format.
///
/// An unconfigured session must find everything rather than nothing.
pub fn format_filter(&self) -> FormatFilter {
if self.formats.is_empty() {
FormatFilter::all()
} else {
FormatFilter::from_formats(
self.formats
.iter()
.filter_map(|s| Format::from_extension(&s.to_ascii_lowercase())),
)
}
}
pub fn set_format_filter(&mut self, filter: &FormatFilter) {
self.formats = filter
.iter()
.map(|f| format!("{f:?}").to_lowercase())
.collect();
}
/// Where this session's credential lives.
pub fn secret_ref(&self) -> SecretRef {
SecretRef::app_password(&self.server, &self.login)
}
/// A short description for the UI.
pub fn describe(&self) -> String {
let host = self
.server
.trim_start_matches("https://")
.trim_start_matches("http://");
if self.root.is_empty() {
format!("{} on {host}", self.login)
} else {
format!("{} on {host}/{}", self.login, self.root)
}
}
}
/// TRACES: FR-NC-1 | FR-NC-2 | M-1 | M-2
/// Loads and saves sessions, keeping credentials in secure storage.
pub struct SessionStore {
config_path: PathBuf,
secrets: Box<dyn SecretStore>,
}
/// What is written to disk. Versioned so a format change is a migration
/// rather than a parse failure.
#[derive(Debug, Default, Serialize, Deserialize)]
struct ConfigFile {
#[serde(default = "one")]
version: u32,
#[serde(default)]
sessions: Vec<Session>,
}
fn one() -> u32 {
1
}
impl SessionStore {
/// Open the store at the platform config location.
///
/// Linux: `$XDG_CONFIG_HOME/darkroom/sessions.json`, falling back to
/// `~/.config` (FR-PLAT-LIN-1).
pub fn open(secrets: Box<dyn SecretStore>) -> Self {
let dir = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".config")
})
.join("darkroom");
Self::open_at(dir.join("sessions.json"), secrets)
}
/// Open at an explicit path — used by tests, and by anything wanting a
/// non-default config location.
pub fn open_at(config_path: PathBuf, secrets: Box<dyn SecretStore>) -> Self {
Self {
config_path,
secrets,
}
}
pub fn config_path(&self) -> &Path {
&self.config_path
}
/// Whether credentials can be remembered at all.
///
/// Where false the UI should say sign-in will not persist, rather than
/// letting the user discover it next launch.
pub fn can_remember(&self) -> bool {
self.secrets.is_available()
}
/// Every configured session. Missing or unreadable config yields an empty
/// list rather than an error — a first run is not a failure.
pub fn list(&self) -> Vec<Session> {
self.read_config().sessions
}
/// The most recently configured session, if any.
pub fn current(&self) -> Option<Session> {
self.read_config().sessions.into_iter().next_back()
}
/// Persist a session and its credential.
///
/// The credential goes to secure storage first: if that fails there is no
/// point recording a session that cannot authenticate.
pub fn save(&self, session: &Session, creds: &AppCredentials) -> Result<(), SessionError> {
self.secrets
.store(&session.secret_ref(), &creds.app_password)?;
let mut config = self.read_config();
config
.sessions
.retain(|s| !(s.server == session.server && s.login == session.login));
config.sessions.push(session.clone());
self.write_config(&config)
}
/// Update a session's settings, leaving its credential untouched.
pub fn update(&self, session: &Session) -> Result<(), SessionError> {
let mut config = self.read_config();
match config
.sessions
.iter_mut()
.find(|s| s.server == session.server && s.login == session.login)
{
Some(existing) => *existing = session.clone(),
None => config.sessions.push(session.clone()),
}
self.write_config(&config)
}
/// Rebuild credentials for a session from secure storage.
///
/// [`SecretError::NotFound`] means the credential was revoked or the
/// keyring was cleared — the caller re-runs the login flow.
pub fn credentials(&self, session: &Session) -> Result<AppCredentials, SessionError> {
let password = self.secrets.retrieve(&session.secret_ref())?;
Ok(AppCredentials {
server: session.server.clone(),
login_name: session.login.clone(),
app_password: password,
})
}
/// Forget a session and delete its credential.
///
/// The credential is removed even if the config write fails, so a logout
/// never leaves a usable secret behind.
pub fn forget(&self, session: &Session) -> Result<(), SessionError> {
let deleted = self.secrets.delete(&session.secret_ref());
let mut config = self.read_config();
config
.sessions
.retain(|s| !(s.server == session.server && s.login == session.login));
let written = self.write_config(&config);
deleted?;
written
}
fn read_config(&self) -> ConfigFile {
std::fs::read_to_string(&self.config_path)
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default()
}
fn write_config(&self, config: &ConfigFile) -> Result<(), SessionError> {
if let Some(parent) = self.config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(config)?;
// Write and rename, so an interrupted save cannot truncate an
// existing config.
let tmp = self.config_path.with_extension("tmp");
std::fs::write(&tmp, json)?;
std::fs::rename(&tmp, &self.config_path)?;
Ok(())
}
}
#[derive(Debug, thiserror::Error)]
pub enum SessionError {
#[error("secure storage: {0}")]
Secret(#[from] SecretError),
#[error("config io: {0}")]
Io(#[from] std::io::Error),
#[error("config format: {0}")]
Serde(#[from] serde_json::Error),
#[error(transparent)]
Remote(#[from] RemoteError),
}
#[cfg(test)]
mod tests {
use super::*;
use dr_plat::EphemeralSecretStore;
fn creds() -> AppCredentials {
AppCredentials {
server: "https://cloud.example/".into(),
login_name: "duncan".into(),
app_password: "secret-token".into(),
}
}
fn store_in(dir: &Path) -> SessionStore {
SessionStore::open_at(
dir.join("sessions.json"),
Box::new(EphemeralSecretStore::new()),
)
}
fn tmpdir(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("darkroom-test-{name}"));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
#[test]
fn a_saved_session_survives_reopening() {
let dir = tmpdir("survives");
let secrets = Box::new(EphemeralSecretStore::new());
// Same secret store instance, as a real process would have.
let store = SessionStore::open_at(dir.join("sessions.json"), secrets);
let mut s = Session::new(&creds(), "duncan");
s.root = "PhotosRaw".into();
store.save(&s, &creds()).unwrap();
let reloaded = store.current().expect("session persisted");
assert_eq!(reloaded.login, "duncan");
assert_eq!(reloaded.root, "PhotosRaw");
// Trailing slash normalised, so URLs built from it are consistent.
assert_eq!(reloaded.server, "https://cloud.example");
}
#[test]
fn the_credential_never_reaches_the_config_file() {
// NFR-SEC-2: the whole point of the split.
let dir = tmpdir("nocreds");
let store = store_in(&dir);
let s = Session::new(&creds(), "duncan");
store.save(&s, &creds()).unwrap();
let text = std::fs::read_to_string(dir.join("sessions.json")).unwrap();
assert!(!text.contains("secret-token"), "credential leaked to disk");
assert!(text.contains("duncan"), "session metadata should be there");
}
#[test]
fn credentials_round_trip_through_secure_storage() {
let dir = tmpdir("roundtrip");
let store = store_in(&dir);
let s = Session::new(&creds(), "duncan");
store.save(&s, &creds()).unwrap();
let got = store.credentials(&s).unwrap();
assert_eq!(got.app_password, "secret-token");
assert_eq!(got.login_name, "duncan");
}
#[test]
fn forgetting_removes_both_halves() {
let dir = tmpdir("forget");
let store = store_in(&dir);
let s = Session::new(&creds(), "duncan");
store.save(&s, &creds()).unwrap();
store.forget(&s).unwrap();
assert!(store.current().is_none());
assert!(matches!(
store.credentials(&s),
Err(SessionError::Secret(SecretError::NotFound))
));
}
#[test]
fn saving_the_same_account_twice_does_not_duplicate_it() {
let dir = tmpdir("dedupe");
let store = store_in(&dir);
let mut s = Session::new(&creds(), "duncan");
store.save(&s, &creds()).unwrap();
s.root = "Photos".into();
store.save(&s, &creds()).unwrap();
assert_eq!(store.list().len(), 1);
assert_eq!(store.current().unwrap().root, "Photos");
}
#[test]
fn a_missing_config_is_a_first_run_not_an_error() {
let dir = tmpdir("firstrun");
let store = store_in(&dir);
assert!(store.list().is_empty());
assert!(store.current().is_none());
}
#[test]
fn a_corrupt_config_does_not_prevent_starting() {
// Better to present a first-run state than to refuse to launch.
let dir = tmpdir("corrupt");
std::fs::write(dir.join("sessions.json"), "{ not json").unwrap();
let store = store_in(&dir);
assert!(store.list().is_empty());
}
#[test]
fn format_selection_round_trips() {
let dir = tmpdir("formats");
let store = store_in(&dir);
let mut s = Session::new(&creds(), "duncan");
s.set_format_filter(&FormatFilter::from_formats([Format::Cr2, Format::Dng]));
store.save(&s, &creds()).unwrap();
let f = store.current().unwrap().format_filter();
assert!(f.allows(Format::Cr2));
assert!(f.allows(Format::Dng));
assert!(!f.allows(Format::Nef));
}
#[test]
fn an_unset_filter_means_every_format() {
// Never "no formats", which would silently find nothing.
let s = Session::new(&creds(), "duncan");
let f = s.format_filter();
assert!(f.allows(Format::Cr2));
assert!(f.allows(Format::Jpeg));
}
#[test]
fn describe_is_readable_and_hides_the_scheme() {
let mut s = Session::new(&creds(), "duncan");
assert_eq!(s.describe(), "duncan on cloud.example");
s.root = "PhotosRaw".into();
assert_eq!(s.describe(), "duncan on cloud.example/PhotosRaw");
}
#[test]
fn updating_settings_leaves_the_credential_alone() {
let dir = tmpdir("update");
let store = store_in(&dir);
let mut s = Session::new(&creds(), "duncan");
store.save(&s, &creds()).unwrap();
s.root = "Elsewhere".into();
store.update(&s).unwrap();
assert_eq!(store.current().unwrap().root, "Elsewhere");
assert_eq!(store.credentials(&s).unwrap().app_password, "secret-token");
}
}