From 9fa88ff3979a85c8d4c201f21c3656745fe5ba46 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Wed, 7 Oct 2026 02:51:28 +0200 Subject: [PATCH] feat(core,cli,gui): support two-password accounts without storing the mailbox password A two-password account signed in with its login password as the mailbox password, so the key chain never unlocked and the daemon retried forever. Login now asks for the mailbox password and checks it against the account (proton-sdk 0.8 `unlock`), so a typo is a retry. The password is never stored: the keyring blob keeps the per-key passphrases instead, and for a two-password account only when the user opts in (`--remember`, or the "Keep unlocked on this computer" switch). By default they live in the kernel keyring for the login session and the account is locked after a reboot until `pdfs unlock` or the app supplies them again. - core: kernelkey module, Error::Locked / WrongMailboxPassword, unlock, lock, key_state, ready; blobs written by 3.0.x are migrated on first start; token refresh updates only the tokens in the stored blob - cli: `pdfs unlock [--remember]`, `pdfs lock [--forget]`; status and diagnose report the key state; the daemon waits while locked and is marked non-dumpable - gui: mailbox password dialog at sign-in and on open, locked tray state - po: all catalogs translated - bump to 3.1.0, depend on proton-sdk and proton-drive-rs 0.8.0 Refs #26. --- Cargo.lock | 24 +- Cargo.toml | 7 +- crates/pdfs-cli/Cargo.toml | 1 + crates/pdfs-cli/src/main.rs | 159 ++++++- crates/pdfs-core/Cargo.toml | 2 + crates/pdfs-core/src/auth.rs | 585 +++++++++++++++++++----- crates/pdfs-core/src/error.rs | 18 +- crates/pdfs-core/src/kernelkey.rs | 177 +++++++ crates/pdfs-core/src/lib.rs | 1 + crates/pdfs-gui/src/app/main.rs | 4 + crates/pdfs-gui/src/app/pages/login.rs | 218 ++++++++- crates/pdfs-gui/src/app/pages/status.rs | 1 + crates/pdfs-gui/src/main.rs | 9 + docs/CHANGELOG.md | 36 ++ packaging/PKGBUILD | 2 +- po/de.po | 338 ++++++++------ po/en_GB.po | 338 ++++++++------ po/es.po | 338 ++++++++------ po/fr.po | 338 ++++++++------ po/it.po | 338 ++++++++------ po/nl.po | 338 ++++++++------ po/pdfs.pot | 340 ++++++++------ po/pl.po | 338 ++++++++------ po/pt_BR.po | 338 ++++++++------ 24 files changed, 2871 insertions(+), 1417 deletions(-) create mode 100644 crates/pdfs-core/src/kernelkey.rs diff --git a/Cargo.lock b/Cargo.lock index fa67445..5ecad3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3474,7 +3474,7 @@ checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" [[package]] name = "pdfs-cli" -version = "3.0.0" +version = "3.1.0" dependencies = [ "anyhow", "clap", @@ -3485,13 +3485,15 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "zeroize", ] [[package]] name = "pdfs-core" -version = "3.0.0" +version = "3.1.0" dependencies = [ "async-trait", + "base64 0.23.1", "dbus-secret-service-keyring-store", "directories", "ignore", @@ -3508,12 +3510,13 @@ dependencies = [ "thiserror 2.0.20", "tokio", "tracing", + "zeroize", "zip", ] [[package]] name = "pdfs-fuse" -version = "3.0.0" +version = "3.1.0" dependencies = [ "async-trait", "base64 0.23.1", @@ -3535,7 +3538,7 @@ dependencies = [ [[package]] name = "pdfs-gui" -version = "3.0.0" +version = "3.1.0" dependencies = [ "anyhow", "async-channel", @@ -3572,9 +3575,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pgp" -version = "0.20.0" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfa4743b28656065ff4c0ba09e46b357a65e8c00fc2341e89084b82f87cbdf1" +checksum = "ae70f4d9325a391db30d115d6191a7bb67cec856aa0bba131290f0c4e09a532a" dependencies = [ "aead 0.5.2", "aes 0.8.4", @@ -3775,9 +3778,9 @@ dependencies = [ [[package]] name = "proton-drive-rs" -version = "0.7.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6ce113d234474821d34787436ee80e1458e3d8a8f97212219615ed315eb70be" +checksum = "5bde475139afb5fc2649621e1d283e243f9df2bb412f08802cdf3f4ffaf97c44" dependencies = [ "async-trait", "base64 0.23.1", @@ -3802,9 +3805,9 @@ dependencies = [ [[package]] name = "proton-sdk" -version = "0.7.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a33c67df9694f90ce60e5b34984275956244138511b4640ca0d100e1dac8b3d" +checksum = "faf32d0a03f7d13bd9bdcc8c9503e35e9df9318ca9f2a100dcf06cf50cb7ac8c" dependencies = [ "aes-gcm 0.11.1", "async-trait", @@ -3831,6 +3834,7 @@ dependencies = [ "thiserror 2.0.20", "tokio", "tracing", + "zeroize", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index c03c1ca..df25d7c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"] [workspace.package] -version = "3.0.0" +version = "3.1.0" edition = "2024" license = "MIT" authors = ["Nils Pukropp "] @@ -15,8 +15,8 @@ keywords = ["proton", "drive", "fuse", "sync", "linux"] categories = ["filesystem", "command-line-utilities"] [workspace.dependencies] -proton-sdk = { version = "0.7.2" } -proton-drive-rs = { version = "0.7.2" } +proton-sdk = { version = "0.8.0" } +proton-drive-rs = { version = "0.8.0" } # Internal crates. pdfs-core = { path = "crates/pdfs-core" } @@ -26,6 +26,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "fs", serde = { version = "1", features = ["derive"] } serde_json = "1" base64 = "0.23" +zeroize = "1" thiserror = "2" anyhow = "1" # Non-poisoning mutexes. A handler that panics while holding a lock must not diff --git a/crates/pdfs-cli/Cargo.toml b/crates/pdfs-cli/Cargo.toml index 56985e2..f4a8eba 100644 --- a/crates/pdfs-cli/Cargo.toml +++ b/crates/pdfs-cli/Cargo.toml @@ -23,3 +23,4 @@ tracing-subscriber.workspace = true clap.workspace = true rpassword.workspace = true serde_json.workspace = true +zeroize.workspace = true diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 4d54a8d..2e629da 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -135,6 +135,24 @@ enum Command { /// Account email; prompted if omitted. #[arg(short, long)] username: Option, + /// For a two-password account, keep the mailbox password's unlock in the + /// system keyring so the daemon starts without asking after every login. + /// Without it the unlock lives in kernel memory for this session only. + #[arg(long)] + remember: bool, + }, + /// Enter the mailbox password of a two-password account so the daemon can + /// unlock your files. Needed once per login session. + Unlock { + /// Keep the unlock in the system keyring instead of kernel memory only. + #[arg(long)] + remember: bool, + }, + /// Forget the unlock held in kernel memory; the account is locked again. + Lock { + /// Also remove an unlock kept in the keyring with `--remember`. + #[arg(long)] + forget: bool, }, /// Forget the stored session. Logout, @@ -803,7 +821,9 @@ fn main() -> Result<()> { let cli = Cli::parse(); JSON_OUTPUT.store(cli.json, std::sync::atomic::Ordering::Relaxed); match cli.command { - Command::Login { username } => cmd_login(username), + Command::Login { username, remember } => cmd_login(username, remember), + Command::Unlock { remember } => cmd_unlock(remember), + Command::Lock { forget } => cmd_lock(forget), Command::Logout => cmd_logout(), Command::Status => cmd_status(), Command::Locations => cmd_locations(), @@ -1781,7 +1801,7 @@ fn prompt_line(label: &str) -> Result { Ok(line.trim().to_owned()) } -fn cmd_login(username: Option) -> Result<()> { +fn cmd_login(username: Option, remember: bool) -> Result<()> { let username = match username { Some(u) => u, None => prompt_line("Proton email")?, @@ -1793,11 +1813,14 @@ fn cmd_login(username: Option) -> Result<()> { prompt_line("2FA code").map_err(|e| pdfs_core::Error::Other(format!("read 2FA code: {e}"))) }; + // Only a two-password account is ever asked for this. + let get_mailbox = |attempt: u32| prompt_mailbox(attempt, remember); + let rt = tokio::runtime::Runtime::new()?; // Solving a CAPTCHA needs a browser engine, which the CLI has no business // carrying. Point at the app that does rather than failing with the raw API // message, which reads as "your login is broken". - rt.block_on(auth::login(&username, &password, get_totp)) + rt.block_on(auth::login(&username, &password, get_totp, get_mailbox)) .map_err(|e| match e { pdfs_core::Error::HumanVerificationRequired(_) => anyhow::anyhow!( "Proton is asking for a CAPTCHA before it will accept this sign-in.\n\ @@ -1818,9 +1841,88 @@ fn cmd_login(username: Option) -> Result<()> { } println!("Logged in as {username}. Session stored in the system keyring."); + report_key_state(); Ok(()) } +/// Ask for the mailbox password of a two-password account, without echo. +fn prompt_mailbox(attempt: u32, remember: bool) -> pdfs_core::Result { + if attempt == 0 { + eprintln!("This account has a separate mailbox password."); + } else { + eprintln!("That mailbox password did not unlock the account. Try again."); + } + let password = rpassword::prompt_password("Mailbox password: ") + .map_err(|e| pdfs_core::Error::Other(format!("read mailbox password: {e}")))?; + Ok(auth::Mailbox { + password: zeroize::Zeroizing::new(password), + remember, + }) +} + +/// Say where the account's keys are held, and what that means for the next login. +fn report_key_state() { + match auth::key_state() { + Ok(auth::KeyState::Session) => println!( + "Unlocked for this login session only. After you log out or reboot, run `pdfs unlock`." + ), + Ok(auth::KeyState::Locked) => println!("The account is locked. Run `pdfs unlock`."), + _ => {} + } +} + +fn cmd_unlock(remember: bool) -> Result<()> { + auth::load().context("not logged in (run `pdfs login` first)")?; + let rt = tokio::runtime::Runtime::new()?; + // Bounded like the login prompt: three wrong answers end the command. + let mut attempt = 0; + loop { + let mailbox = prompt_mailbox(attempt, remember)?; + match rt.block_on(auth::unlock(&mailbox.password, remember)) { + Ok(()) => break, + Err(pdfs_core::Error::WrongMailboxPassword) if attempt < 2 => attempt += 1, + Err(e) => return Err(anyhow::Error::new(e).context("unlock failed")), + } + } + // A daemon parked on the locked account would otherwise notice on its next + // poll; restarting it makes the mount appear now, as after `pdfs login`. + if service::is_active() { + service::restart(); + } + println!("Unlocked."); + report_key_state(); + Ok(()) +} + +fn cmd_lock(forget: bool) -> Result<()> { + auth::lock(forget)?; + println!( + "Locked. {}", + if forget { + "Unlock removed from memory and keyring." + } else { + "Run `pdfs unlock` to unlock again." + } + ); + // The running daemon keeps its keys until it restarts; say so, since + // "locked" would otherwise suggest the mount stopped. + if service::is_active() { + println!( + "The running daemon keeps its keys until it restarts: `systemctl --user restart proton-drive`." + ); + } + Ok(()) +} + +/// `stored`, `session` or `locked`: where the account's keys are held. +fn key_state_name() -> &'static str { + match auth::key_state() { + Ok(auth::KeyState::Stored) => "stored", + Ok(auth::KeyState::Session) => "session", + _ => "locked", + } +} + fn cmd_logout() -> Result<()> { auth::logout()?; println!("Stored session cleared."); @@ -1848,6 +1950,7 @@ fn cmd_status() -> Result<()> { "username": session.as_ref().map(|s| s.username.clone()), "user_id": session.as_ref().map(|s| s.user_id.clone()), "scopes": session.as_ref().map(|s| s.scopes.clone()), + "keys": session.as_ref().map(|_| key_state_name()), "mount": mount, }); println!("{}", serde_json::to_string_pretty(&out)?); @@ -1858,6 +1961,11 @@ fn cmd_status() -> Result<()> { Ok(s) => { println!("Logged in as {} (user {})", s.username, s.user_id); println!("Scopes: {}", s.scopes.join(", ")); + match key_state_name() { + "locked" => println!("Keys: locked — run `pdfs unlock`"), + "session" => println!("Keys: unlocked for this login session"), + _ => {} + } } Err(pdfs_core::Error::NotLoggedIn) => { println!("Not logged in. Run `pdfs login`."); @@ -1989,7 +2097,7 @@ fn login_poll_delay(attempts: u32) -> std::time::Duration { std::time::Duration::from_secs(BASE_SECS.saturating_mul(factor).min(MAX_SECS)) } -/// Block until a session is stored, backing off between checks. +/// Block until a session is stored and unlocked, backing off between checks. /// /// `load` and `sleep` are parameters rather than direct calls so the backoff /// schedule can be tested without a keyring and without real time passing. @@ -2014,6 +2122,16 @@ where sleep(delay); attempts = attempts.saturating_add(1); } + Err(pdfs_core::Error::Locked) => { + let delay = login_poll_delay(attempts); + if attempts == 0 { + tracing::info!("account is locked; waiting for `pdfs unlock`…"); + } else { + tracing::debug!(delay_secs = delay.as_secs(), "account still locked"); + } + sleep(delay); + attempts = attempts.saturating_add(1); + } Err(e) => return Err(e.into()), } } @@ -2024,10 +2142,13 @@ where /// an external unmount triggers a remount; errors back off and retry. This is /// the entry point for the systemd user service. fn cmd_daemon(mountpoint: Option) -> Result<()> { + // The daemon holds the account's key passphrases in memory; keep them out + // of core dumps and away from same-user ptrace. + pdfs_core::kernelkey::forbid_dumps(); loop { // Wait until a session is stored. The GUI enables this service on login, // but the service may also start at boot before the user has logged in. - wait_for_session(|| auth::load().map(|_| ()), pdfs_fuse::idle_sleep)?; + wait_for_session(auth::ready, pdfs_fuse::idle_sleep)?; match mount_once(mountpoint.clone()) { Ok(pdfs_fuse::MountOutcome::Shutdown) => { @@ -3224,6 +3345,19 @@ fn cmd_diagnose() -> Result<()> { match pdfs_core::auth::load() { Ok(session) => { report.finding(DiagnoseLevel::Ok, " keyring session", session.username); + match key_state_name() { + "locked" => report.finding( + DiagnoseLevel::Fail, + " account keys", + "locked — run `pdfs unlock` (the daemon waits for it)", + ), + "session" => report.finding( + DiagnoseLevel::Ok, + " account keys", + "unlocked for this login session", + ), + _ => report.finding(DiagnoseLevel::Ok, " account keys", "stored in the keyring"), + } } Err(e) => { // A locked keyring and an absent login look the same from here, so @@ -3522,6 +3656,21 @@ mod login_wait_tests { ); } + #[test] + fn a_locked_account_is_waited_on_until_it_is_unlocked() { + let mut checks = vec![ + Ok(()), + Err(pdfs_core::Error::Locked), + Err(pdfs_core::Error::NotLoggedIn), + Err(pdfs_core::Error::Locked), + ]; + let mut slept = 0; + + wait_for_session(|| checks.pop().unwrap(), |_| slept += 1).unwrap(); + + assert_eq!(slept, 3, "locked and signed-out are both waited out"); + } + #[test] fn a_stored_session_is_not_waited_for_at_all() { let mut slept = 0; diff --git a/crates/pdfs-core/Cargo.toml b/crates/pdfs-core/Cargo.toml index e51f45b..0804486 100644 --- a/crates/pdfs-core/Cargo.toml +++ b/crates/pdfs-core/Cargo.toml @@ -28,3 +28,5 @@ dbus-secret-service-keyring-store.workspace = true parking_lot.workspace = true async-trait.workspace = true libc.workspace = true +zeroize.workspace = true +base64.workspace = true diff --git a/crates/pdfs-core/src/auth.rs b/crates/pdfs-core/src/auth.rs index f4b5eed..0cc0c8e 100644 --- a/crates/pdfs-core/src/auth.rs +++ b/crates/pdfs-core/src/auth.rs @@ -1,22 +1,50 @@ //! Login, session persistence, and Drive-client construction. //! //! A successful login is persisted to the OS keyring as a single JSON blob -//! holding the resumable session tokens plus the mailbox password (needed to -//! rebuild the key chain on resume). The daemon resumes from this blob with no -//! interactive step; the refresh token auto-renews via the HTTP client's 401 -//! path, so no fresh 2FA is required until the refresh token itself expires. +//! holding the resumable session tokens, the account's key salts and — unless +//! the user chose otherwise — the per-key *passphrases* derived from the +//! mailbox password. The password itself is never stored: the passphrases are +//! what it exists to produce, and they are enough to rebuild the key chain on +//! resume. The daemon resumes from this blob with no interactive step; the +//! refresh token auto-renews via the HTTP client's 401 path, so no fresh 2FA is +//! required until the refresh token itself expires. +//! +//! # Two-password accounts +//! +//! An account with a separate mailbox password exists precisely so that no one +//! login holds the means to read the data. Storing that password in the keyring +//! would defeat it, so such an account is **locked by default**: its +//! passphrases are kept only in the kernel keyring ([`crate::kernelkey`]), for +//! the length of the user's session, and `pdfs unlock` (or the app) supplies +//! them again after each login. Storing them in the Secret Service as well is +//! an explicit opt-in ([`Mailbox::remember`]). Single-password accounts have no +//! such separation to protect and are stored, as they always were. +use std::collections::BTreeMap; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; use keyring_core::Entry; use proton_drive_rs::{KeySalt, ProtonDriveClient}; +use proton_sdk::account::KeyPassphrases; use proton_sdk::api::{HumanVerification, HumanVerificationCredential}; use proton_sdk::cache::EncryptedCacheRepository; use proton_sdk::config::ProtonClientConfiguration; +use proton_sdk::error::ProtonError; use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters}; use proton_sdk::telemetry::TracingTelemetry; use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; use crate::config::{APP_VERSION, AppDirs, KEYRING_SERVICE, USER_AGENT}; use crate::error::{Error, Result}; +use crate::kernelkey; + +/// How often a wrong mailbox password is asked for again before the login gives up. +const MAILBOX_ATTEMPTS: u32 = 3; + +/// Domain-separation label for the key that encrypts the persistent entity cache. +const ENTITY_CACHE_PURPOSE: &[u8] = b"pdfs entity cache v1"; /// Fixed keyring account name for the single stored session blob. const KEYRING_USER: &str = "session"; @@ -32,8 +60,11 @@ pub struct StoredSession { pub scopes: Vec, /// `1` = single password, `2` = dual (Proton wire value). pub password_mode: u8, - /// Mailbox (data) password — required by `ProtonDriveClient::new` to derive - /// the key chain. Lives only in the OS keyring, never on disk in cleartext. + /// The mailbox password of a blob written by 3.0.x and earlier. + /// + /// Read only, to migrate: [`resume_client`] derives the passphrases from it + /// once and writes the blob back without it. Never serialized again. + #[serde(default, skip_serializing)] pub mailbox_password: String, /// The account's key salts, captured at login. /// @@ -49,6 +80,11 @@ pub struct StoredSession { /// backfilling) them. #[serde(default)] pub key_salts: Vec, + /// Per-key passphrases (key id → base64), when the user keeps them in the + /// keyring: always for a single-password account, by opt-in for a + /// two-password one. `None` means they are not stored here. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key_passphrases: Option>, } impl StoredSession { @@ -89,6 +125,26 @@ fn keyring_entry() -> Result { Ok(Entry::new(KEYRING_SERVICE, KEYRING_USER)?) } +/// What the user answered when asked for the mailbox password. +pub struct Mailbox { + pub password: Zeroizing, + /// Keep the derived passphrases in the keyring, so the daemon unlocks by + /// itself after every login. Off by default: it trades the protection a + /// separate mailbox password gives for convenience. + pub remember: bool, +} + +/// Where the passphrases that unlock this account's keys currently live. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyState { + /// In the keyring, available after every login. + Stored, + /// In kernel memory for this login session only. + Session, + /// Nowhere: the mailbox password has to be entered (`pdfs unlock`). + Locked, +} + /// Run an interactive SRP + (optional) 2FA login and persist the session. /// /// `get_totp` is only invoked when the account requires a second factor, so @@ -96,14 +152,19 @@ fn keyring_entry() -> Result { /// more than once — [`login_interactive`] re-runs the whole login after human /// verification, and a 2FA account needs a fresh code on that second attempt. /// +/// `get_mailbox` is only invoked for a two-password account. It receives the +/// number of earlier wrong answers (so a prompt can say "try again") and is +/// asked at most [`MAILBOX_ATTEMPTS`] times; an `Err` from it aborts the login. +/// /// A gated login fails with [`Error::HumanVerificationRequired`]; a front-end /// that can present the challenge should use [`login_interactive`] instead. pub async fn login( username: &str, password: &str, get_totp: impl Fn() -> Result, + get_mailbox: impl Fn(u32) -> Result, ) -> Result<()> { - login_verified(username, password, None, get_totp).await + login_verified(username, password, None, get_totp, get_mailbox).await } /// [`login`], able to answer a human-verification gate rather than fail on it. @@ -123,12 +184,20 @@ pub async fn login_interactive( username: &str, password: &str, get_totp: impl Fn() -> Result, + get_mailbox: impl Fn(u32) -> Result, get_hv: impl FnOnce(HumanVerification) -> Result, ) -> Result<()> { - match login_verified(username, password, None, &get_totp).await { + match login_verified(username, password, None, &get_totp, &get_mailbox).await { Err(Error::HumanVerificationRequired(hv)) => { let credential = get_hv(*hv)?; - login_verified(username, password, Some(&credential), &get_totp).await + login_verified( + username, + password, + Some(&credential), + &get_totp, + &get_mailbox, + ) + .await } other => other, } @@ -145,15 +214,18 @@ pub async fn login_verified( password: &str, verification: Option<&HumanVerificationCredential>, get_totp: impl Fn() -> Result, + get_mailbox: impl Fn(u32) -> Result, ) -> Result<()> { - let mut session = ProtonApiSession::begin_verified( + let password = Zeroizing::new(password.to_owned()); + let session = ProtonApiSession::begin_verified( client_config(), username, password.as_bytes(), verification, ) .await - .map_err(classify_verification_gate)?; + .map_err(classify_verification_gate); + let mut session = session?; if session.is_waiting_for_second_factor() { let code = get_totp()?; @@ -163,38 +235,38 @@ pub async fn login_verified( // Grab the key salts while this access token still has the `locked` scope: // after its first refresh it never will again, and without them no later // resume can unlock the key chain. See `StoredSession::key_salts`. - let client = ProtonDriveClient::new(&session, password.as_bytes().to_vec()); - let key_salts = client.account().key_salts().await?; + let key_salts = ProtonDriveClient::new(&session, Vec::new()) + .account() + .key_salts() + .await?; - register_refresh_handler(&session, password.to_owned(), key_salts.clone()); - - save(&session, password, key_salts).await?; - Ok(()) -} - -/// Lift a human-verification gate out of the generic API error into the typed -/// variant a front-end can act on. -/// -/// Only a gate that names a solvable method is converted. A `9001` with no -/// `Details`, or one offering only `email`/`sms`, stays a plain API error: -/// promoting it would send the UI to open a verification page it cannot -/// complete, which reads to the user as a hang rather than a refusal. -fn classify_verification_gate(e: proton_sdk::error::ProtonError) -> Error { - let proton_sdk::error::ProtonError::Api(api) = &e else { - return e.into(); + // A single-password account's mailbox password is the login password. A + // two-password one has a second secret the user must type, and it is checked + // against the account now — a typo found at login is a retry, found later it + // is a daemon that never mounts. + let (passphrases, remember) = match session.password_mode() { + PasswordMode::Single => ( + derive_verified(&session, password.as_bytes(), key_salts.clone()).await?, + true, + ), + PasswordMode::Dual => { + let mut attempt = 0; + loop { + let mailbox = get_mailbox(attempt)?; + match derive_verified(&session, mailbox.password.as_bytes(), key_salts.clone()) + .await + { + Ok(passphrases) => break (passphrases, mailbox.remember), + Err(Error::WrongMailboxPassword) if attempt + 1 < MAILBOX_ATTEMPTS => { + attempt += 1; + } + Err(e) => return Err(e), + } + } + } }; - match api.human_verification() { - Some(hv) if hv.supports_captcha() => Error::HumanVerificationRequired(Box::new(hv)), - _ => e.into(), - } -} -/// Persist the session's current tokens, mailbox password and key salts. -pub async fn save( - session: &ProtonApiSession, - mailbox_password: &str, - key_salts: Vec, -) -> Result<()> { + register_refresh_handler(&session); let tokens = session.current_tokens().await; let stored = StoredSession { session_id: session.session_id().as_str().to_owned(), @@ -207,10 +279,103 @@ pub async fn save( PasswordMode::Single => 1, PasswordMode::Dual => 2, }, - mailbox_password: mailbox_password.to_owned(), + mailbox_password: String::new(), key_salts, + key_passphrases: None, }; - let json = serde_json::to_string(&stored)?; + keep_passphrases(stored, &passphrases, remember) +} + +/// Derive the key passphrases from `mailbox` and prove they unlock the account. +/// +/// [`Error::WrongMailboxPassword`] means the account answered and the password +/// is wrong; any other error says nothing about the password (no network, an +/// API fault) and must not be reported as a typo. +async fn derive_verified( + session: &ProtonApiSession, + mailbox: &[u8], + key_salts: Vec, +) -> Result { + let client = ProtonDriveClient::with_key_salts(session, mailbox.to_vec(), key_salts); + client.account().unlock().await.map_err(|e| match e { + ProtonError::KeysLocked => Error::WrongMailboxPassword, + other => other.into(), + })?; + Ok(client.account().key_passphrases().await?) +} + +/// Write `stored` with the passphrases in the place the user chose: the +/// keyring (`remember`) or kernel memory only. Whichever is not chosen is +/// cleared, so a revoked choice never leaves a copy behind. +/// +/// The kernel entry is written first. If it cannot be (no keyring support), the +/// secret is *not* quietly stored somewhere weaker: the blob is written locked +/// and the error says so. +fn keep_passphrases( + mut stored: StoredSession, + passphrases: &KeyPassphrases, + remember: bool, +) -> Result<()> { + let outcome = if remember { + stored.key_passphrases = Some(encode(passphrases)); + // Best-effort: a system without a kernel keyring has nothing to clear. + let _ = kernelkey::clear(); + Ok(()) + } else { + stored.key_passphrases = None; + kernelkey::store(&encode_secret(passphrases)).map_err(|e| { + Error::Other(format!( + "the kernel keyring is unavailable, so the unlock cannot be kept for this session: {e}" + )) + }) + }; + write_stored(&stored)?; + outcome +} + +fn encode(passphrases: &KeyPassphrases) -> BTreeMap { + passphrases + .iter() + .map(|(id, secret)| (id.to_owned(), BASE64.encode(secret))) + .collect() +} + +fn decode(encoded: &BTreeMap) -> Option { + let mut passphrases = KeyPassphrases::new(); + for (id, secret) in encoded { + passphrases.insert(id.clone(), BASE64.decode(secret).ok()?); + } + (!passphrases.is_empty()).then_some(passphrases) +} + +/// The kernel-keyring payload: the same map as the blob's, as JSON. +fn encode_secret(passphrases: &KeyPassphrases) -> Zeroizing> { + Zeroizing::new(serde_json::to_vec(&encode(passphrases)).unwrap_or_default()) +} + +fn decode_secret(secret: &[u8]) -> Option { + decode(&serde_json::from_slice(secret).ok()?) +} + +/// Lift a human-verification gate out of the generic API error into the typed +/// variant a front-end can act on. +/// +/// Only a gate that names a solvable method is converted. A `9001` with no +/// `Details`, or one offering only `email`/`sms`, stays a plain API error: +/// promoting it would send the UI to open a verification page it cannot +/// complete, which reads to the user as a hang rather than a refusal. +fn classify_verification_gate(e: ProtonError) -> Error { + let ProtonError::Api(api) = &e else { + return e.into(); + }; + match api.human_verification() { + Some(hv) if hv.supports_captcha() => Error::HumanVerificationRequired(Box::new(hv)), + _ => e.into(), + } +} + +fn write_stored(stored: &StoredSession) -> Result<()> { + let json = Zeroizing::new(serde_json::to_string(stored)?); keyring_entry()?.set_password(&json)?; Ok(()) } @@ -225,12 +390,93 @@ pub fn load() -> Result { } } +/// Where the passphrases for the stored session live right now. +/// +/// Reads the kernel keyring and the stored blob only; never the network. +pub fn key_state() -> Result { + Ok(key_state_of(&load()?)) +} + +fn key_state_of(stored: &StoredSession) -> KeyState { + match find_passphrases(stored) { + Ok(Some((_, origin))) => origin, + // A 3.0.x blob still holds its password; `resume_client` migrates it. + _ if !stored.mailbox_password.is_empty() => KeyState::Stored, + _ => KeyState::Locked, + } +} + +/// Whether the stored session can be resumed without asking the user: +/// `Ok` when it can, [`Error::NotLoggedIn`] or [`Error::Locked`] when not. +/// What the daemon waits on before it mounts. +pub fn ready() -> Result<()> { + match key_state_of(&load()?) { + KeyState::Locked => Err(Error::Locked), + _ => Ok(()), + } +} + +/// The passphrases the stored session can use without being asked, and which +/// of the two places they came from. Prefers the keyring blob: it is what the +/// user opted into. A corrupt entry is treated as absent, so it reads as +/// locked instead of wedging the daemon. +fn find_passphrases(stored: &StoredSession) -> Result> { + if let Some(found) = stored.key_passphrases.as_ref().and_then(decode) { + return Ok(Some((found, KeyState::Stored))); + } + match kernelkey::load() { + Ok(Some(secret)) => Ok(decode_secret(&secret).map(|p| (p, KeyState::Session))), + Ok(None) => Ok(None), + // No keyring on this system: nothing was ever kept there. + Err(e) => { + tracing::debug!(error = %e, "kernel keyring not readable; treating the account as locked"); + Ok(None) + } + } +} + +/// Supply the mailbox password of a locked account. +/// +/// Verifies it against the account, then keeps the derived passphrases in +/// kernel memory for this login session — or, with `remember`, in the keyring. +/// A daemon waiting on [`Error::Locked`] picks them up on its next attempt. +/// +/// Fails with [`Error::WrongMailboxPassword`] for a wrong password, which +/// changes nothing, and [`Error::ReloginRequired`] for a session that predates +/// stored key salts. +pub async fn unlock(mailbox: &str, remember: bool) -> Result<()> { + let stored = load()?; + if stored.key_salts.is_empty() { + return Err(Error::ReloginRequired); + } + let mailbox = Zeroizing::new(mailbox.to_owned()); + let session = ProtonApiSession::resume(client_config(), stored.to_params())?; + register_refresh_handler(&session); + let passphrases = + derive_verified(&session, mailbox.as_bytes(), stored.key_salts.clone()).await?; + keep_passphrases(stored, &passphrases, remember) +} + +/// Drop the passphrases held in kernel memory, so the account is locked again +/// until [`unlock`]. With `forget`, also those in the keyring blob. +pub fn lock(forget: bool) -> Result<()> { + kernelkey::clear()?; + if forget { + let mut stored = load()?; + if stored.key_passphrases.take().is_some() { + write_stored(&stored)?; + } + } + Ok(()) +} + /// Forget the persisted session (best-effort; absent entry is not an error). pub fn logout() -> Result<()> { // The persisted entity cache describes the account being logged out of. It - // is encrypted under that account's mailbox password, but leaving it behind - // would also mean the next account inherits a store it can only ever read as - // misses. Best-effort: failing to remove a cache must not block the logout. + // is encrypted under a key derived from that account's passphrases, but + // leaving it behind would also mean the next account inherits a store it can + // only ever read as misses. Best-effort: failing to remove a cache must not + // block the logout. if let Ok(dirs) = AppDirs::new() { let path = dirs.state_dir().join("sdk_cache.db"); if let Some(cache) = crate::sdkcache::SdkCache::opened() @@ -242,6 +488,9 @@ pub fn logout() -> Result<()> { let _ = std::fs::remove_file(format!("{}{suffix}", path.display())); } } + if let Err(e) = kernelkey::clear() { + tracing::warn!(error = %e, "clearing the kernel-keyring unlock on logout failed"); + } match keyring_entry()?.delete_credential() { Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()), Err(e) => Err(e.into()), @@ -250,44 +499,87 @@ pub fn logout() -> Result<()> { /// Resume a persisted session and build an authenticated Drive client. /// -/// Returns `Error::NotLoggedIn` when no session has been saved. The caller is -/// responsible for persisting rotated tokens over the session's lifetime via -/// [`persist`] — Proton refresh tokens are single-use, so a refresh that is not -/// written back leaves the keyring holding a stale (now-invalid) refresh token, -/// and the next resume fails with `InvalidRefreshToken`. +/// Returns `Error::NotLoggedIn` when no session has been saved and +/// [`Error::Locked`] when the account is waiting for its mailbox password. Tokens +/// the session rotates are written back to the keyring as they happen (see +/// [`register_refresh_handler`]); Proton refresh tokens are single-use, so a +/// refresh that is not written back would leave the keyring holding a stale +/// refresh token, and the next resume would fail with `InvalidRefreshToken`. +/// +/// Makes no network call for an account whose passphrases are already known, so +/// a daemon starting offline can still mount from its cache. pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> { let stored = load()?; let session = ProtonApiSession::resume(client_config(), stored.to_params())?; - let password = stored.mailbox_password.clone(); + register_refresh_handler(&session); - // Blobs written before key salts were persisted have none: fetch them once - // more and write them back, which only works while the stored access token - // still carries the `locked` scope. If it doesn't, the account must be - // logged into again — no refresh can restore that scope. - let key_salts = if stored.key_salts.is_empty() { - register_refresh_handler(&session, password.clone(), Vec::new()); - let probe = ProtonDriveClient::new(&session, password.clone().into_bytes()); - let salts = probe.account().key_salts().await.map_err(|e| match &e { - proton_sdk::error::ProtonError::Api(api) if api.is_insufficient_scope() => { - Error::ReloginRequired - } - _ => e.into(), - })?; - save(&session, &password, salts.clone()).await?; - salts - } else { - stored.key_salts + let passphrases = match find_passphrases(&stored)? { + Some((passphrases, _)) => passphrases, + None if !stored.mailbox_password.is_empty() => migrate_legacy(&session, &stored).await?, + None => return Err(Error::Locked), }; - register_refresh_handler(&session, password.clone(), key_salts.clone()); - let client = tune( - ProtonDriveClient::with_key_salts(&session, password.clone().into_bytes(), key_salts), - password.as_bytes(), + ProtonDriveClient::with_key_passphrases(&session, passphrases.clone()), + &passphrases, ); Ok((client, session)) } +/// Turn a blob written by 3.0.x — which holds the mailbox password — into one +/// that holds passphrases, so the password stops being stored. +/// +/// The old login saved whatever was typed as the mailbox password, which for a +/// two-password account is the *login* password and unlocks nothing. That is +/// checked here: a password the account rejects leaves the account locked (and +/// the stored copy gone) rather than failing every start forever. +async fn migrate_legacy( + session: &ProtonApiSession, + stored: &StoredSession, +) -> Result { + let legacy = Zeroizing::new(stored.mailbox_password.clone()); + + // Blobs from before key salts were persisted have none: fetch them once + // more, which only works while the stored access token still carries the + // `locked` scope. If it doesn't, the account must be logged into again — no + // refresh can restore that scope. + let key_salts = if stored.key_salts.is_empty() { + ProtonDriveClient::new(session, Vec::new()) + .account() + .key_salts() + .await + .map_err(|e| match &e { + ProtonError::Api(api) if api.is_insufficient_scope() => Error::ReloginRequired, + _ => e.into(), + })? + } else { + stored.key_salts.clone() + }; + + let mut migrated = stored.clone(); + migrated.mailbox_password = String::new(); + migrated.key_salts = key_salts.clone(); + + match derive_verified(session, legacy.as_bytes(), key_salts).await { + Ok(passphrases) => { + migrated.key_passphrases = Some(encode(&passphrases)); + write_stored(&migrated)?; + tracing::info!( + "migrated the stored session from a mailbox password to key passphrases" + ); + Ok(passphrases) + } + Err(Error::WrongMailboxPassword) => { + write_stored(&migrated)?; + tracing::warn!( + "the stored password does not unlock the account's keys; unlock required" + ); + Err(Error::Locked) + } + Err(e) => Err(e), + } +} + /// The client settings every front-end wants, applied wherever a /// [`ProtonDriveClient`] is built for real work. /// @@ -301,23 +593,24 @@ pub async fn resume_client() -> Result<(ProtonDriveClient, ProtonApiSession)> { /// failure point per write instead of three. /// - **A persistent entity cache** ([`crate::sdkcache`]), so a restart does not /// re-fetch and re-decrypt the tree the previous run already walked. It is -/// wrapped in the SDK's [`EncryptedCacheRepository`] keyed by the mailbox -/// password, so the decrypted node names it holds are not readable from the -/// file alone — and a password change simply reads as a cold cache, since the -/// SDK treats an undecryptable entry as a miss and clears the store. +/// wrapped in the SDK's [`EncryptedCacheRepository`] keyed by a secret derived +/// from the key passphrases, so the decrypted node names it holds are not +/// readable from the file alone — and a password change simply reads as a cold +/// cache, since the SDK treats an undecryptable entry as a miss and clears the +/// store. /// /// Opening it is best-effort: a store this process cannot open costs cache /// hits, not the session, so the client falls back to the SDK's in-memory /// default. -fn tune(client: ProtonDriveClient, mailbox_password: &[u8]) -> ProtonDriveClient { +fn tune(client: ProtonDriveClient, passphrases: &KeyPassphrases) -> ProtonDriveClient { let client = client .with_telemetry(TracingTelemetry::shared()) .with_small_file_upload(true); match AppDirs::new().and_then(|dirs| crate::sdkcache::SdkCache::shared(&dirs)) { - Ok(cache) => client.with_entity_repository(EncryptedCacheRepository::shared( - cache, - mailbox_password.to_vec(), - )), + Ok(cache) => { + let key = passphrases.derive_secret(ENTITY_CACHE_PURPOSE); + client.with_entity_repository(EncryptedCacheRepository::shared(cache, key.to_vec())) + } Err(e) => { tracing::warn!(error = %e, "persistent SDK entity cache unavailable; using memory"); client @@ -327,49 +620,105 @@ fn tune(client: ProtonDriveClient, mailbox_password: &[u8]) -> ProtonDriveClient /// Write the session's current tokens back to the keyring. /// -/// The session does not carry the mailbox password (it is only needed to rebuild -/// the key chain on resume), so it is re-read from the existing stored blob and -/// preserved. Call whenever the session may have rotated its tokens through the -/// 401-refresh path so a later [`resume_client`] presents a live refresh token. +/// Everything else in the stored blob — salts, passphrases — is preserved, so +/// this never undoes an [`unlock`] or [`lock`] made since the session started. +/// Call whenever the session may have rotated its tokens so a later +/// [`resume_client`] presents a live refresh token. pub async fn persist(session: &ProtonApiSession) -> Result<()> { - let stored = load()?; - save(session, &stored.mailbox_password, stored.key_salts).await + let tokens = session.current_tokens().await; + store_tokens(tokens.access_token, tokens.refresh_token) } -fn register_refresh_handler( - session: &ProtonApiSession, - mailbox_password: String, - key_salts: Vec, -) { - let session_id = session.session_id().as_str().to_owned(); - let username = session.username().to_owned(); - let user_id = session.user_id().as_str().to_owned(); - let scopes = session.scopes().to_vec(); - let password_mode = match session.password_mode() { - PasswordMode::Single => 1, - PasswordMode::Dual => 2, - }; +fn store_tokens(access_token: String, refresh_token: String) -> Result<()> { + let mut stored = load()?; + stored.access_token = access_token; + stored.refresh_token = refresh_token; + write_stored(&stored) +} +/// Persist rotated tokens the moment the session obtains them. +/// +/// The handler re-reads the stored blob instead of capturing one, so it cannot +/// write back a stale copy over a later unlock or lock — and cannot resurrect a +/// session that was logged out while the daemon still ran. +fn register_refresh_handler(session: &ProtonApiSession) { session.http().set_on_tokens_refreshed(move |tokens| { - let stored = StoredSession { - session_id: session_id.clone(), - username: username.clone(), - user_id: user_id.clone(), - access_token: tokens.access_token, - refresh_token: tokens.refresh_token, - scopes: scopes.clone(), - password_mode, - mailbox_password: mailbox_password.clone(), - key_salts: key_salts.clone(), - }; - if let Ok(json) = serde_json::to_string(&stored) - && let Ok(entry) = keyring_entry() - { - if let Err(e) = entry.set_password(&json) { - tracing::warn!(error = %e, "failed to auto-persist refreshed tokens in keyring"); - } else { - tracing::info!("successfully auto-persisted refreshed tokens in keyring"); + match store_tokens(tokens.access_token, tokens.refresh_token) { + Ok(()) => tracing::info!("successfully auto-persisted refreshed tokens in keyring"), + Err(Error::NotLoggedIn) => {} + Err(e) => { + tracing::warn!(error = %e, "failed to auto-persist refreshed tokens in keyring") } } }); } + +#[cfg(test)] +mod tests { + use super::*; + + fn passphrases() -> KeyPassphrases { + let mut p = KeyPassphrases::new(); + p.insert("key-a", b"alpha".to_vec()); + p.insert("key-b", vec![0, 255, 7]); + p + } + + fn blob(extra: &str) -> String { + format!( + r#"{{"session_id":"s","username":"u","user_id":"i","access_token":"a", + "refresh_token":"r","scopes":[],"password_mode":2{extra}}}"# + ) + } + + #[test] + fn passphrases_survive_the_blob_encoding() { + let decoded = decode(&encode(&passphrases())).expect("decodes"); + assert_eq!(decoded, passphrases()); + } + + #[test] + fn passphrases_survive_the_kernel_payload_encoding() { + let decoded = decode_secret(&encode_secret(&passphrases())).expect("decodes"); + assert_eq!(decoded, passphrases()); + } + + #[test] + fn corrupt_or_empty_secrets_read_as_absent() { + assert!(decode_secret(b"not json").is_none()); + assert!(decode(&BTreeMap::new()).is_none()); + assert!(decode(&BTreeMap::from([("k".into(), "!!!".into())])).is_none()); + } + + #[test] + fn legacy_blob_is_read_but_its_password_is_never_written_back() { + let stored: StoredSession = + serde_json::from_str(&blob(r#","mailbox_password":"hunter2""#)).unwrap(); + assert_eq!(stored.mailbox_password, "hunter2"); + assert!(stored.key_passphrases.is_none()); + + let written = serde_json::to_string(&stored).unwrap(); + assert!(!written.contains("hunter2")); + assert!(!written.contains("mailbox_password")); + } + + #[test] + fn locked_blob_has_no_passphrases_field() { + let stored: StoredSession = serde_json::from_str(&blob("")).unwrap(); + assert!( + serde_json::to_string(&stored) + .unwrap() + .find("key_passphrases") + .is_none() + ); + } + + #[test] + fn stored_passphrases_win_over_the_kernel_keyring() { + let mut stored: StoredSession = serde_json::from_str(&blob("")).unwrap(); + stored.key_passphrases = Some(encode(&passphrases())); + let (found, origin) = find_passphrases(&stored).unwrap().unwrap(); + assert_eq!(origin, KeyState::Stored); + assert_eq!(found, passphrases()); + } +} diff --git a/crates/pdfs-core/src/error.rs b/crates/pdfs-core/src/error.rs index 1cf84df..1d18adb 100644 --- a/crates/pdfs-core/src/error.rs +++ b/crates/pdfs-core/src/error.rs @@ -32,6 +32,16 @@ pub enum Error { #[error("saved session can no longer unlock your keys — run `pdfs login` again")] ReloginRequired, + /// The account has a separate mailbox password that has not been entered + /// since login, so its keys cannot be unlocked. The fix is + /// [`auth::unlock`](crate::auth::unlock), not a new login. + #[error("account is locked — enter the mailbox password with `pdfs unlock`")] + Locked, + + /// The mailbox password was checked against the account and is wrong. + #[error("that mailbox password does not unlock your account")] + WrongMailboxPassword, + /// The login was gated behind human verification. Not a failure the user can /// fix by retrying: the challenge has to be solved and the login restarted /// with the resulting token (see [`auth::login_verified`](crate::auth::login_verified)). @@ -199,9 +209,11 @@ impl From for CoreError { impl From for CoreError { fn from(e: Error) -> Self { let kind = match &e { - Error::NotLoggedIn | Error::ReloginRequired | Error::HumanVerificationRequired(_) => { - ErrorKind::Denied - } + Error::NotLoggedIn + | Error::ReloginRequired + | Error::Locked + | Error::WrongMailboxPassword + | Error::HumanVerificationRequired(_) => ErrorKind::Denied, Error::Proton(proton_sdk::ProtonError::Api(api)) => api_kind(api.code), // A `ProtonError` that never reached the API is a transport failure. Error::Proton(_) => ErrorKind::Offline, diff --git a/crates/pdfs-core/src/kernelkey.rs b/crates/pdfs-core/src/kernelkey.rs new file mode 100644 index 0000000..0e6fd66 --- /dev/null +++ b/crates/pdfs-core/src/kernelkey.rs @@ -0,0 +1,177 @@ +//! A secret held in the Linux kernel keyring, for as long as the user is logged in. +//! +//! This is where the key passphrases of a two-password account live by default: +//! the mailbox password is never stored, and the secrets derived from it are +//! kept in kernel memory — not on disk, not in the Secret Service — and vanish +//! when the user's last session ends or the machine reboots. Both the daemon and +//! `pdfs unlock` reach the same entry because it hangs off the *user* keyring. +//! +//! Kernel keys are not paged to swap, are invisible to other users, and are +//! wiped by [`clear`] (`KEYCTL_REVOKE`), which makes them a better home than a +//! process's heap. Processes of the same user can still read them, exactly as +//! they could ptrace the daemon: this protects against disk, backup and other +//! users, not against code already running as you. +//! +//! The keyring may be unavailable (a container, a kernel built without +//! `CONFIG_KEYS`); every operation then reports it as an error and the caller +//! falls back to asking again, never to storing the secret somewhere weaker. + +use std::io; + +use zeroize::Zeroizing; + +/// Key description. Namespaced so it cannot collide with another program's key. +#[cfg(not(test))] +const DESCRIPTION: &[u8] = b"pdfs:key-passphrases\0"; +/// Tests must never touch a real unlock. +#[cfg(test)] +const DESCRIPTION: &[u8] = b"pdfs:key-passphrases:test\0"; +const KEY_TYPE: &[u8] = b"user\0"; + +const KEY_SPEC_USER_KEYRING: i32 = -4; +const KEYCTL_REVOKE: libc::c_long = 3; +const KEYCTL_SEARCH: libc::c_long = 10; +const KEYCTL_READ: libc::c_long = 11; + +fn last_error() -> io::Result { + Err(io::Error::last_os_error()) +} + +fn find() -> io::Result> { + // SAFETY: both strings are NUL-terminated and outlive the call. + let id = unsafe { + libc::syscall( + libc::SYS_keyctl, + KEYCTL_SEARCH, + KEY_SPEC_USER_KEYRING, + KEY_TYPE.as_ptr(), + DESCRIPTION.as_ptr(), + 0, + ) + }; + if id >= 0 { + return Ok(Some(id)); + } + let err = io::Error::last_os_error(); + match err.raw_os_error() { + // ENOKEY: not stored. EKEYREVOKED/EKEYEXPIRED: stored once, now gone. + Some(libc::ENOKEY | libc::EKEYREVOKED | libc::EKEYEXPIRED) => Ok(None), + _ => Err(err), + } +} + +/// Stop this process from being core-dumped or ptraced by an unprivileged +/// process of the same user, so the secrets in its memory stay out of crash +/// reports. Call once at start of a long-lived process that holds them. +pub fn forbid_dumps() { + // SAFETY: `PR_SET_DUMPABLE` takes plain integers and only affects this process. + let rc = unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0, 0, 0, 0) }; + if rc != 0 { + tracing::warn!(error = %io::Error::last_os_error(), "could not mark the process non-dumpable"); + } +} + +/// Store `secret`, replacing any earlier one. +pub fn store(secret: &[u8]) -> io::Result<()> { + // SAFETY: strings are NUL-terminated; the payload pointer/length pair + // describes `secret`, which outlives the call. + let id = unsafe { + libc::syscall( + libc::SYS_add_key, + KEY_TYPE.as_ptr(), + DESCRIPTION.as_ptr(), + secret.as_ptr(), + secret.len(), + KEY_SPEC_USER_KEYRING, + ) + }; + if id < 0 { + return last_error(); + } + Ok(()) +} + +/// Read the stored secret, or `None` when there is none. +pub fn load() -> io::Result>>> { + let Some(id) = find()? else { + return Ok(None); + }; + // Size first, then read; retry if the key was replaced in between. + for _ in 0..3 { + // SAFETY: a null buffer asks the kernel only for the payload length. + let len = unsafe { + libc::syscall( + libc::SYS_keyctl, + KEYCTL_READ, + id, + std::ptr::null_mut::(), + 0usize, + ) + }; + if len < 0 { + return match io::Error::last_os_error().raw_os_error() { + Some(libc::ENOKEY | libc::EKEYREVOKED | libc::EKEYEXPIRED) => Ok(None), + _ => last_error(), + }; + } + let mut buf = Zeroizing::new(vec![0u8; len as usize]); + // SAFETY: `buf` is `len` writable bytes. + let read = unsafe { + libc::syscall( + libc::SYS_keyctl, + KEYCTL_READ, + id, + buf.as_mut_ptr(), + buf.len(), + ) + }; + if read < 0 { + return last_error(); + } + if read as usize <= buf.len() { + buf.truncate(read as usize); + return Ok(Some(buf)); + } + } + Err(io::Error::other("the key changed while it was being read")) +} + +/// Remove the stored secret. Succeeds when there is none. +pub fn clear() -> io::Result<()> { + let Some(id) = find()? else { + return Ok(()); + }; + // SAFETY: plain integer arguments. + let rc = unsafe { libc::syscall(libc::SYS_keyctl, KEYCTL_REVOKE, id) }; + if rc < 0 { + return match io::Error::last_os_error().raw_os_error() { + Some(libc::ENOKEY | libc::EKEYREVOKED) => Ok(()), + _ => last_error(), + }; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // The kernel keyring is process-wide state and a sandbox may not offer it, + // so one test covers the whole round trip and skips when it is unavailable. + #[test] + fn stored_secret_round_trips_and_clears() { + if store(b"probe").is_err() { + eprintln!("kernel keyring unavailable; skipping"); + return; + } + let saved = load().unwrap().expect("stored secret is readable"); + assert_eq!(saved.as_slice(), b"probe"); + + store(b"replaced").unwrap(); + assert_eq!(load().unwrap().unwrap().as_slice(), b"replaced"); + + clear().unwrap(); + assert!(load().unwrap().is_none()); + clear().unwrap(); + } +} diff --git a/crates/pdfs-core/src/lib.rs b/crates/pdfs-core/src/lib.rs index 42abbe5..5338996 100644 --- a/crates/pdfs-core/src/lib.rs +++ b/crates/pdfs-core/src/lib.rs @@ -10,6 +10,7 @@ pub mod config; pub mod control; pub mod db; pub mod error; +pub mod kernelkey; pub mod localindex; pub mod menu; pub mod mounts; diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 7db02be..0058a1f 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -119,6 +119,9 @@ struct Ui { /// keyring (a DBus round-trip). Populated at startup and updated only on /// login / logout. `None` = signed out. session: RefCell>, + /// Whether this run already asked for the mailbox password of a locked + /// account, so a refresh never re-opens a dialog the user cancelled. + unlock_offered: Cell, /// Whether the last refresh saw a live mount daemon. Gates the unpin buttons /// (which need the daemon to evict + re-hydrate) and every mutating action. mounted: RefCell, @@ -362,6 +365,7 @@ fn build_window(app: &adw::Application) { busy: Cell::new(0), opening: RefCell::new(HashSet::new()), session: RefCell::new(auth::load().ok()), + unlock_offered: Cell::new(false), mounted: RefCell::new(false), sidebar: sidebar_list.clone(), nav: split.clone(), diff --git a/crates/pdfs-gui/src/app/pages/login.rs b/crates/pdfs-gui/src/app/pages/login.rs index 7cddf30..31d406d 100644 --- a/crates/pdfs-gui/src/app/pages/login.rs +++ b/crates/pdfs-gui/src/app/pages/login.rs @@ -128,7 +128,12 @@ pub(crate) fn wire_login(ui: &Rc) { set_signing_in(&ui, true); ui.login.login_status.set_text(&gettext("Signing in…")); - let (rx, totp_req_rx, hv_req_rx) = spawn_login(username, password); + let LoginChannels { + result: rx, + totp: totp_req_rx, + hv: hv_req_rx, + mailbox: mailbox_req_rx, + } = spawn_login(username, password); // Surface the 2FA dialog only if the SDK actually asks for a code (i.e. // the account has 2FA enabled). The worker blocks until the dialog feeds @@ -157,6 +162,21 @@ pub(crate) fn wire_login(ui: &Rc) { } }); + // And for the mailbox password, which only a two-password account has. + // The worker asks again after a wrong answer, so this loops too. + let ui_mailbox = ui.clone(); + glib::spawn_future_local(async move { + while let Ok((attempt, answer_tx)) = mailbox_req_rx.recv().await { + ask_mailbox(&ui_mailbox, attempt > 0, move |answer| { + // A cancelled dialog drops the sender, which the worker + // reads as a cancelled login. + if let Some(answer) = answer { + let _ = answer_tx.send(answer); + } + }); + } + }); + let ui = ui.clone(); glib::spawn_future_local(async move { let result = rx @@ -209,6 +229,12 @@ pub(crate) fn login_error_message(error: &pdfs_core::Error) -> String { pdfs_core::Error::Other(message) if message.contains("two-factor") => { gettext("Sign-in cancelled: no two-factor code was entered.") } + pdfs_core::Error::Other(message) if message.contains("mailbox") => { + gettext("Sign-in cancelled: no mailbox password was entered.") + } + pdfs_core::Error::WrongMailboxPassword => { + gettext("That mailbox password didn't unlock your account.") + } pdfs_core::Error::Other(message) if message.contains("verification") => gettext( "Sign-in cancelled: the verification wasn't completed. Sign in again to get a new one.", ), @@ -220,26 +246,31 @@ pub(crate) fn login_error_message(error: &pdfs_core::Error) -> String { } } +/// What the login worker asks the UI for, and how it reports back. +pub(crate) struct LoginChannels { + /// The final outcome, once. + pub(crate) result: async_channel::Receiver>, + /// Fires only if the account needs a 2FA code; carries the sender for it. + pub(crate) totp: async_channel::Receiver>, + /// Fires only if the sign-in is gated: the page to show, and the sender for + /// the token the user earns on it. + pub(crate) hv: async_channel::Receiver<(String, std::sync::mpsc::Sender)>, + /// Fires only for a two-password account: how many answers were already + /// wrong, and the sender for the password and the "remember" choice. + pub(crate) mailbox: async_channel::Receiver<(u32, std::sync::mpsc::Sender<(String, bool)>)>, +} + /// Run the async SRP + optional 2FA login on a dedicated current-thread Tokio -/// runtime. Returns two channels: the first yields the final login result once; -/// the second fires *only if* the account needs a 2FA code, carrying a -/// [`std::sync::mpsc::Sender`] the UI uses to feed the code back. The login -/// closure blocks the worker on that sender until the dialog answers, so the -/// code is requested lazily and can't expire before the password proof. -#[allow(clippy::type_complexity)] -pub(crate) fn spawn_login( - username: String, - password: String, -) -> ( - async_channel::Receiver>, - async_channel::Receiver>, - async_channel::Receiver<(String, std::sync::mpsc::Sender)>, -) { +/// runtime. The returned [`LoginChannels`] carry the final result and the lazy +/// prompts. Each login closure blocks the worker on a sender the UI answers, so +/// a code is requested lazily and can't expire before the password proof. +pub(crate) fn spawn_login(username: String, password: String) -> LoginChannels { let (tx, rx) = async_channel::bounded(1); // Two, not one: a CAPTCHA-gated login restarts, so a 2FA account is asked // for a code on each attempt and a single slot would deadlock the worker. let (totp_req_tx, totp_req_rx) = async_channel::bounded(2); let (hv_req_tx, hv_req_rx) = async_channel::bounded(1); + let (mailbox_req_tx, mailbox_req_rx) = async_channel::bounded(1); std::thread::spawn(move || { let rt = match tokio::runtime::Builder::new_current_thread() .enable_all() @@ -267,6 +298,19 @@ pub(crate) fn spawn_login( .recv() .map_err(|_| pdfs_core::Error::Other("two-factor entry cancelled".into())) }, + |attempt| { + let (answer_tx, answer_rx) = std::sync::mpsc::channel::<(String, bool)>(); + mailbox_req_tx + .send_blocking((attempt, answer_tx)) + .map_err(|_| pdfs_core::Error::Other("mailbox prompt closed".into()))?; + let (password, remember) = answer_rx + .recv() + .map_err(|_| pdfs_core::Error::Other("mailbox entry cancelled".into()))?; + Ok(auth::Mailbox { + password: password.into(), + remember, + }) + }, |hv| { // Gated: hand the UI the page to show, block on the token the // user earns by solving it. @@ -287,7 +331,143 @@ pub(crate) fn spawn_login( }); let _ = tx.send_blocking(result); }); - (rx, totp_req_rx, hv_req_rx) + LoginChannels { + result: rx, + totp: totp_req_rx, + hv: hv_req_rx, + mailbox: mailbox_req_rx, + } +} + +/// Ask for the mailbox password of a two-password account. +/// +/// `done` gets the password and the "keep unlocked" choice, or `None` when the +/// dialog was cancelled. `retry` says the last answer was wrong. The choice +/// defaults to off: storing the mailbox unlock in the keyring trades away what +/// a separate mailbox password is for, so it is something to opt into. +pub(crate) fn ask_mailbox( + ui: &Rc, + retry: bool, + done: impl FnOnce(Option<(String, bool)>) + 'static, +) { + let body = if retry { + gettext("That mailbox password didn't unlock your account. Try again.") + } else { + gettext("This account has a separate mailbox password. Enter it to unlock your files.") + }; + let dialog = adw::AlertDialog::builder() + .heading(gettext("Mailbox Password")) + .body(body) + .build(); + + let group = adw::PreferencesGroup::new(); + let entry = adw::PasswordEntryRow::builder() + .title(gettext("Mailbox password")) + .activates_default(true) + .build(); + let remember = adw::SwitchRow::builder() + .title(gettext("Keep unlocked on this computer")) + .subtitle(gettext( + "Stores the unlock in the system keyring, so Proton Drive opens without asking after every login. Otherwise you enter the password once per session.", + )) + .build(); + group.add(&entry); + group.add(&remember); + dialog.set_extra_child(Some(&group)); + + dialog.add_response("cancel", &gettext("Cancel")); + dialog.add_response("unlock", &gettext("Unlock")); + dialog.set_response_appearance("unlock", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("unlock")); + dialog.set_close_response("cancel"); + + let done = RefCell::new(Some(done)); + dialog.connect_response(None, move |_, resp| { + let Some(done) = done.borrow_mut().take() else { + return; + }; + let password = entry.text().to_string(); + if resp == "unlock" && !password.is_empty() { + done(Some((password, remember.is_active()))); + } else { + done(None); + } + }); + + let parent = ui_window(ui).map(|w| w.upcast::()); + dialog.present(parent.as_ref()); +} + +/// If the signed-in account is locked, ask for its mailbox password — once per +/// run; after a cancel, a toast keeps an Unlock button within reach. +/// +/// The key state is a keyring and kernel-keyring read, so it runs on a worker. +pub(crate) fn offer_unlock(ui: &Rc) { + if ui.unlock_offered.get() { + return; + } + let ui = ui.clone(); + glib::spawn_future_local(async move { + let state = gio::spawn_blocking(|| auth::key_state().ok()).await; + if matches!(state, Ok(Some(auth::KeyState::Locked))) && !ui.unlock_offered.replace(true) { + prompt_unlock(&ui, false); + } + }); +} + +enum UnlockOutcome { + Unlocked, + Wrong, + Failed(String), +} + +/// Ask for the mailbox password and unlock the account with it. +fn prompt_unlock(ui: &Rc, retry: bool) { + let ui_done = ui.clone(); + ask_mailbox(ui, retry, move |answer| { + let Some((password, remember)) = answer else { + toast_action( + &ui_done, + &gettext("Proton Drive is locked. Enter your mailbox password to unlock it."), + &gettext("Unlock"), + |ui| prompt_unlock(ui, false), + ); + return; + }; + glib::spawn_future_local(async move { + // Verifying talks to Proton and the restart talks to systemd, so + // both run on a worker, never on the main loop. + let outcome = gio::spawn_blocking(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => return UnlockOutcome::Failed(e.to_string()), + }; + match rt.block_on(auth::unlock(&password, remember)) { + Ok(()) => { + service::restart(); + UnlockOutcome::Unlocked + } + Err(pdfs_core::Error::WrongMailboxPassword) => UnlockOutcome::Wrong, + Err(e) => UnlockOutcome::Failed(login_error_message(&e)), + } + }) + .await; + match outcome { + Ok(UnlockOutcome::Unlocked) => { + toast(&ui_done, &gettext("Unlocked.")); + refresh(&ui_done); + } + Ok(UnlockOutcome::Wrong) => prompt_unlock(&ui_done, true), + Ok(UnlockOutcome::Failed(message)) => { + toast_error(&ui_done, &gettext("Couldn't unlock"), &message) + } + Err(_) => {} + } + }); + }); } /// Show the lazy two-factor dialog and feed the entered code back to the waiting @@ -374,6 +554,12 @@ mod tests { assert!(login_error_message(&hv).contains("verification wasn't completed")); } + #[test] + fn a_cancelled_mailbox_prompt_is_reported_as_such() { + let error = pdfs_core::Error::Other("mailbox entry cancelled".into()); + assert!(login_error_message(&error).contains("no mailbox password")); + } + #[test] fn an_unmapped_error_keeps_its_text() { let error = pdfs_core::Error::Other("something odd".into()); diff --git a/crates/pdfs-gui/src/app/pages/status.rs b/crates/pdfs-gui/src/app/pages/status.rs index 34dbd47..adaece5 100644 --- a/crates/pdfs-gui/src/app/pages/status.rs +++ b/crates/pdfs-gui/src/app/pages/status.rs @@ -860,6 +860,7 @@ pub(crate) fn refresh(ui: &Rc) { } } + offer_unlock(ui); refresh_status(ui); refresh_transfers(ui); } diff --git a/crates/pdfs-gui/src/main.rs b/crates/pdfs-gui/src/main.rs index 546185e..3d7d2f6 100644 --- a/crates/pdfs-gui/src/main.rs +++ b/crates/pdfs-gui/src/main.rs @@ -166,6 +166,15 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState { // No daemon: describe login state instead so the menu is still useful. Err(_) => { let (line, phase) = match auth::load() { + Ok(s) if matches!(auth::key_state(), Ok(auth::KeyState::Locked)) => ( + // Translators: {username} is the Proton account name. The + // account has a mailbox password that has to be entered. + gettext_f( + "Signed in as {username} — locked", + &[("username", &s.username)], + ), + Phase::Attention, + ), Ok(s) => ( // Translators: {username} is the Proton account name. gettext_f( diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 68379be..98cbda7 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -11,6 +11,42 @@ scratch (user data in `staging/` and `recovery/` is never touched by this). ## [Unreleased] +## [3.1.0] — 2026-10-07 + +Schema: **42**, unchanged. + +### Added +- **Two-password accounts.** An account with a separate mailbox password now signs in: the app + and `pdfs login` ask for it after the login password and check it against the account, so a + typo is a retry and not a daemon that never mounts. The mailbox password is never stored. + What it unlocks is kept in kernel memory for the login session, so it is asked for once per + session and is gone after a reboot or when you log out of the machine. +- **`pdfs unlock` and `pdfs lock`.** `pdfs unlock` enters the mailbox password of a locked + account and starts the daemon's mount; `pdfs lock` forgets it again. `--remember` on `login` + and `unlock` (and the "Keep unlocked on this computer" switch in the app) keeps the unlock in + the system keyring instead, so the daemon starts without asking; `pdfs lock --forget` removes + it. Off by default: it gives up the separation a second password is for. +- **Locked state.** `pdfs status`, `pdfs diagnose` and the tray say when the account is waiting + for its mailbox password, and the app asks for it when it opens. + +### Changed +- **The keyring no longer holds a password.** The session blob keeps the keys' passphrases + instead of the mailbox password, and a blob written by 3.0.x is converted on the first start. + A stored password the account rejects (a two-password account that was signed in with only + its login password) leaves the account locked instead of failing every start. +- **The daemon is not dumpable.** It holds the unlock in memory, so core dumps and same-user + `ptrace` are off for it. +- **The offline cache is re-keyed** from the new secret, so the first start after the update + reads it as cold and fetches the tree again once. + +### Fixed +- **systemd killed a daemon that was only retrying.** `WatchdogSec` was answered only once a + mount existed, so a daemon waiting for a login or retrying a failed mount was aborted after + two minutes and restarted in a loop, hiding the error it logged. It now answers the watchdog + while it waits. ([#26](https://github.com/narrrl/proton-drive-linux/issues/26)) +- **A refreshed token could overwrite a newer unlock or revive a signed-out session.** Token + rotation now updates only the tokens in the stored blob. + ## [3.0.0] — 2026-10-05 Schema: **42**. The queue table is rebuilt so an id is never given out twice; queued changes diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 73bde2b..f41dce6 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -9,7 +9,7 @@ # Build + install: cd packaging && makepkg -fi pkgname=proton-drive-linux -pkgver=3.0.0 +pkgver=3.1.0 pkgrel=1 pkgdesc="Unofficial Proton Drive client for Linux: files-on-demand FUSE mount, CLI, GTK4 app + tray" arch=('x86_64') diff --git a/po/de.po b/po/de.po index e179e40..17dcc1b 100644 --- a/po/de.po +++ b/po/de.po @@ -5,7 +5,7 @@ msgid "" msgstr "" "Project-Id-Version: proton-drive-linux 2.1.0\n" "Report-Msgid-Bugs-To: https://github.com/narrrl/proton-drive-linux/issues\n" -"POT-Creation-Date: 2026-10-05 01:01+0200\n" +"POT-Creation-Date: 2026-10-07 02:39+0200\n" "PO-Revision-Date: 2026-09-23 12:00+0200\n" "Last-Translator: Proton Drive for Linux contributors\n" "Language-Team: German\n" @@ -57,7 +57,7 @@ msgstr "%-d. %b %Y" msgid "%A, %B %-d, %Y, %H:%M" msgstr "%A, %-d. %B %Y, %H:%M" -#: crates/pdfs-gui/src/app/main.rs:743 +#: crates/pdfs-gui/src/app/main.rs:747 #: crates/pdfs-gui/src/app/pages/activity_text.rs:539 #: crates/pdfs-gui/src/app/pages/browser.rs:454 #: crates/pdfs-gui/src/app/pages/browser.rs:819 @@ -70,91 +70,91 @@ msgstr "%A, %-d. %B %Y, %H:%M" msgid "My files" msgstr "Meine Dateien" -#: crates/pdfs-gui/src/app/main.rs:746 crates/pdfs-gui/src/app/main.rs:1390 +#: crates/pdfs-gui/src/app/main.rs:750 crates/pdfs-gui/src/app/main.rs:1394 #: crates/pdfs-gui/src/app/pages/albums.rs:638 #: crates/pdfs-gui/src/app/pages/photos.rs:842 #: crates/pdfs-gui/src/app/pages/places.rs:441 msgid "Photos" msgstr "Fotos" -#: crates/pdfs-gui/src/app/main.rs:751 +#: crates/pdfs-gui/src/app/main.rs:755 #: crates/pdfs-gui/src/app/pages/shared.rs:168 #: crates/pdfs-gui/src/app/pages/shared.rs:482 msgid "Shared with me" msgstr "Mit mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:756 +#: crates/pdfs-gui/src/app/main.rs:760 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "Von mir geteilt" -#: crates/pdfs-gui/src/app/main.rs:759 +#: crates/pdfs-gui/src/app/main.rs:763 #: crates/pdfs-gui/src/app/pages/devices.rs:140 #: crates/pdfs-gui/src/app/pages/devices.rs:481 msgid "Computers" msgstr "Computer" -#: crates/pdfs-gui/src/app/main.rs:760 +#: crates/pdfs-gui/src/app/main.rs:764 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "Papierkorb" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:762 +#: crates/pdfs-gui/src/app/main.rs:766 msgid "Sync" msgstr "Synchronisierung" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1079 +#: crates/pdfs-gui/src/app/main.rs:1083 #, rust-format msgid "{what}: {detail}" msgstr "{what}: {detail}" -#: crates/pdfs-gui/src/app/main.rs:1098 +#: crates/pdfs-gui/src/app/main.rs:1102 msgid "You're offline" msgstr "Keine Internetverbindung" -#: crates/pdfs-gui/src/app/main.rs:1099 +#: crates/pdfs-gui/src/app/main.rs:1103 msgid "That's not there any more" msgstr "Das ist nicht mehr vorhanden" -#: crates/pdfs-gui/src/app/main.rs:1100 +#: crates/pdfs-gui/src/app/main.rs:1104 msgid "You don't have access to that" msgstr "Kein Zugriff darauf" -#: crates/pdfs-gui/src/app/main.rs:1101 +#: crates/pdfs-gui/src/app/main.rs:1105 msgid "Something changed this first" msgstr "Etwas anderes hat dies zuerst geändert" -#: crates/pdfs-gui/src/app/main.rs:1102 +#: crates/pdfs-gui/src/app/main.rs:1106 msgid "Your Proton Drive is full" msgstr "Das Proton Drive ist voll" -#: crates/pdfs-gui/src/app/main.rs:1143 crates/pdfs-gui/src/app/main.rs:1372 +#: crates/pdfs-gui/src/app/main.rs:1147 crates/pdfs-gui/src/app/main.rs:1376 #: crates/pdfs-gui/src/app/pages/status.rs:385 msgid "Preferences" msgstr "Einstellungen" -#: crates/pdfs-gui/src/app/main.rs:1144 +#: crates/pdfs-gui/src/app/main.rs:1148 #: crates/pdfs-gui/src/app/widgets/compat.rs:262 msgid "Keyboard Shortcuts" msgstr "Tastenkürzel" -#: crates/pdfs-gui/src/app/main.rs:1146 +#: crates/pdfs-gui/src/app/main.rs:1150 msgid "About Proton Drive for Linux" msgstr "Info zu Proton Drive for Linux" -#: crates/pdfs-gui/src/app/main.rs:1151 +#: crates/pdfs-gui/src/app/main.rs:1155 msgid "Main menu" msgstr "Hauptmenü" -#: crates/pdfs-gui/src/app/main.rs:1207 +#: crates/pdfs-gui/src/app/main.rs:1211 msgid "Proton Drive for Linux" msgstr "Proton Drive for Linux" -#: crates/pdfs-gui/src/app/main.rs:1215 +#: crates/pdfs-gui/src/app/main.rs:1219 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -166,23 +166,23 @@ msgstr "" "Inoffizieller Client – nicht mit Proton AG verbunden, von Proton AG " "unterstützt oder befürwortet." -#: crates/pdfs-gui/src/app/main.rs:1220 +#: crates/pdfs-gui/src/app/main.rs:1224 msgid "Source Code" msgstr "Quellcode" -#: crates/pdfs-gui/src/app/main.rs:1224 +#: crates/pdfs-gui/src/app/main.rs:1228 msgid "Proton Terms of Service" msgstr "Nutzungsbedingungen von Proton" -#: crates/pdfs-gui/src/app/main.rs:1228 +#: crates/pdfs-gui/src/app/main.rs:1232 msgid "Proton Privacy Policy" msgstr "Datenschutzerklärung von Proton" -#: crates/pdfs-gui/src/app/main.rs:1233 +#: crates/pdfs-gui/src/app/main.rs:1237 msgid "Disclaimer" msgstr "Haftungsausschluss" -#: crates/pdfs-gui/src/app/main.rs:1237 +#: crates/pdfs-gui/src/app/main.rs:1241 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -207,7 +207,7 @@ msgstr "" "bereitgestellt. Daten, deren Verlust nicht hinnehmbar ist, sollten " "zusätzlich gesichert werden." -#: crates/pdfs-gui/src/app/main.rs:1245 +#: crates/pdfs-gui/src/app/main.rs:1249 msgid "" "Places are named with data from GeoNames, licensed under GeoNames, lizenziert unter CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1253 +#: crates/pdfs-gui/src/app/main.rs:1257 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -227,7 +227,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth gezeichnet, die " "gemeinfrei sind." -#: crates/pdfs-gui/src/app/main.rs:1261 +#: crates/pdfs-gui/src/app/main.rs:1265 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licensed under CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1253 +#: crates/pdfs-gui/src/app/main.rs:1257 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -224,7 +224,7 @@ msgstr "" "\">Natural Earth land outlines and country borders, which are in the " "public domain." -#: crates/pdfs-gui/src/app/main.rs:1261 +#: crates/pdfs-gui/src/app/main.rs:1265 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licencia CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1253 +#: crates/pdfs-gui/src/app/main.rs:1257 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -225,7 +225,7 @@ msgstr "" "fronteras de países de Natural " "Earth, que son de dominio público." -#: crates/pdfs-gui/src/app/main.rs:1261 +#: crates/pdfs-gui/src/app/main.rs:1265 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, sous licence CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1253 +#: crates/pdfs-gui/src/app/main.rs:1257 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -226,7 +226,7 @@ msgstr "" "frontières des pays de Natural " "Earth, qui sont dans le domaine public." -#: crates/pdfs-gui/src/app/main.rs:1261 +#: crates/pdfs-gui/src/app/main.rs:1265 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, con licenza CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1253 +#: crates/pdfs-gui/src/app/main.rs:1257 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -224,7 +224,7 @@ msgstr "" "confini nazionali di Natural " "Earth, che sono di pubblico dominio." -#: crates/pdfs-gui/src/app/main.rs:1261 +#: crates/pdfs-gui/src/app/main.rs:1265 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, gelicentieerd onder CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1253 +#: crates/pdfs-gui/src/app/main.rs:1257 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -225,7 +225,7 @@ msgstr "" "href=\"https://www.naturalearthdata.com/\">Natural Earth, die in het " "publieke domein vallen." -#: crates/pdfs-gui/src/app/main.rs:1261 +#: crates/pdfs-gui/src/app/main.rs:1265 msgid "" "The street map, when turned on, comes from OpenFreeMap in the \n" "Language-Team: LANGUAGE \n" @@ -60,7 +60,7 @@ msgstr "" msgid "%A, %B %-d, %Y, %H:%M" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:743 +#: crates/pdfs-gui/src/app/main.rs:747 #: crates/pdfs-gui/src/app/pages/activity_text.rs:539 #: crates/pdfs-gui/src/app/pages/browser.rs:454 #: crates/pdfs-gui/src/app/pages/browser.rs:819 @@ -73,91 +73,91 @@ msgstr "" msgid "My files" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:746 crates/pdfs-gui/src/app/main.rs:1390 +#: crates/pdfs-gui/src/app/main.rs:750 crates/pdfs-gui/src/app/main.rs:1394 #: crates/pdfs-gui/src/app/pages/albums.rs:638 #: crates/pdfs-gui/src/app/pages/photos.rs:842 #: crates/pdfs-gui/src/app/pages/places.rs:441 msgid "Photos" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:751 +#: crates/pdfs-gui/src/app/main.rs:755 #: crates/pdfs-gui/src/app/pages/shared.rs:168 #: crates/pdfs-gui/src/app/pages/shared.rs:482 msgid "Shared with me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:756 +#: crates/pdfs-gui/src/app/main.rs:760 #: crates/pdfs-gui/src/app/pages/shared_by_me.rs:120 msgid "Shared by me" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:759 +#: crates/pdfs-gui/src/app/main.rs:763 #: crates/pdfs-gui/src/app/pages/devices.rs:140 #: crates/pdfs-gui/src/app/pages/devices.rs:481 msgid "Computers" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:760 +#: crates/pdfs-gui/src/app/main.rs:764 #: crates/pdfs-gui/src/app/pages/trash.rs:101 msgid "Trash" msgstr "" #. Translators: sidebar entry for the page listing this computer's synced folders. -#: crates/pdfs-gui/src/app/main.rs:762 +#: crates/pdfs-gui/src/app/main.rs:766 msgid "Sync" msgstr "" #. Translators: a failed action's toast. {what} says what failed, such as #. "Couldn't rename"; {detail} is the reason, usually untranslated text #. from the mount service. -#: crates/pdfs-gui/src/app/main.rs:1079 +#: crates/pdfs-gui/src/app/main.rs:1083 #, rust-format msgid "{what}: {detail}" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1098 +#: crates/pdfs-gui/src/app/main.rs:1102 msgid "You're offline" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1099 +#: crates/pdfs-gui/src/app/main.rs:1103 msgid "That's not there any more" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1100 +#: crates/pdfs-gui/src/app/main.rs:1104 msgid "You don't have access to that" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1101 +#: crates/pdfs-gui/src/app/main.rs:1105 msgid "Something changed this first" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1102 +#: crates/pdfs-gui/src/app/main.rs:1106 msgid "Your Proton Drive is full" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1143 crates/pdfs-gui/src/app/main.rs:1372 +#: crates/pdfs-gui/src/app/main.rs:1147 crates/pdfs-gui/src/app/main.rs:1376 #: crates/pdfs-gui/src/app/pages/status.rs:385 msgid "Preferences" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1144 +#: crates/pdfs-gui/src/app/main.rs:1148 #: crates/pdfs-gui/src/app/widgets/compat.rs:262 msgid "Keyboard Shortcuts" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1146 +#: crates/pdfs-gui/src/app/main.rs:1150 msgid "About Proton Drive for Linux" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1151 +#: crates/pdfs-gui/src/app/main.rs:1155 msgid "Main menu" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1207 +#: crates/pdfs-gui/src/app/main.rs:1211 msgid "Proton Drive for Linux" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1215 +#: crates/pdfs-gui/src/app/main.rs:1219 msgid "" "Files-on-demand Proton Drive for the Linux desktop.\n" "\n" @@ -165,23 +165,23 @@ msgid "" "AG." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1220 +#: crates/pdfs-gui/src/app/main.rs:1224 msgid "Source Code" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1224 +#: crates/pdfs-gui/src/app/main.rs:1228 msgid "Proton Terms of Service" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1228 +#: crates/pdfs-gui/src/app/main.rs:1232 msgid "Proton Privacy Policy" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1233 +#: crates/pdfs-gui/src/app/main.rs:1237 msgid "Disclaimer" msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1237 +#: crates/pdfs-gui/src/app/main.rs:1241 msgid "" "This is an unofficial, community-built client. It is not affiliated with, " "endorsed by, or supported by Proton AG.\n" @@ -194,21 +194,21 @@ msgid "" "of any data you cannot afford to lose." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1245 +#: crates/pdfs-gui/src/app/main.rs:1249 msgid "" "Places are named with data from GeoNames, licensed under CC BY 4.0." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1253 +#: crates/pdfs-gui/src/app/main.rs:1257 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " "public domain." msgstr "" -#: crates/pdfs-gui/src/app/main.rs:1261 +#: crates/pdfs-gui/src/app/main.rs:1265 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames udostępnionych na licencji CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1253 +#: crates/pdfs-gui/src/app/main.rs:1257 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -228,7 +228,7 @@ msgstr "" "www.naturalearthdata.com/\">Natural Earth, które należą do domeny " "publicznej." -#: crates/pdfs-gui/src/app/main.rs:1261 +#: crates/pdfs-gui/src/app/main.rs:1265 msgid "" "The street map, when turned on, comes from OpenFreeMap in the GeoNames, licensed under GeoNames, licenciados sob CC BY 4.0." -#: crates/pdfs-gui/src/app/main.rs:1253 +#: crates/pdfs-gui/src/app/main.rs:1257 msgid "" "The places map is drawn from Natural Earth land outlines and country borders, which are in the " @@ -224,7 +224,7 @@ msgstr "" "fronteiras de países do Natural Earth, que são de domínio público." -#: crates/pdfs-gui/src/app/main.rs:1261 +#: crates/pdfs-gui/src/app/main.rs:1265 msgid "" "The street map, when turned on, comes from OpenFreeMap in the