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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user