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 425a03a..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(|_| ()), std::thread::sleep)?; + wait_for_session(auth::ready, pdfs_fuse::idle_sleep)?; match mount_once(mountpoint.clone()) { Ok(pdfs_fuse::MountOutcome::Shutdown) => { @@ -2036,13 +2157,13 @@ fn cmd_daemon(mountpoint: Option) -> Result<()> { } Ok(pdfs_fuse::MountOutcome::Unmounted) => { tracing::warn!("mount ended externally; remounting in 2s"); - std::thread::sleep(std::time::Duration::from_secs(2)); + pdfs_fuse::idle_sleep(std::time::Duration::from_secs(2)); } Err(e) => { // `{:#}` so the whole anyhow chain lands in the log — the top // context alone ("mount failed") never says *why*. tracing::error!(error = format!("{e:#}"), "mount failed; retrying in 5s"); - std::thread::sleep(std::time::Duration::from_secs(5)); + pdfs_fuse::idle_sleep(std::time::Duration::from_secs(5)); } } } @@ -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..a090cb8 --- /dev/null +++ b/crates/pdfs-core/src/kernelkey.rs @@ -0,0 +1,204 @@ +//! 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_SETPERM: libc::c_long = 5; +const KEYCTL_SEARCH: libc::c_long = 10; +const KEYCTL_READ: libc::c_long = 11; + +/// Possessor and owner may view, read, write, search, link and set attributes; +/// group and others get nothing. +/// +/// A new key's default grants the owner only *view*, and read access to the +/// possessor. A process possesses the user keyring only when its session +/// keyring links to it, which a systemd user service or a container does not +/// guarantee — so with the default, the daemon could store a key it then +/// cannot read back. The owner is the same user that could ptrace the daemon +/// anyway, so granting it read access gives up nothing. +const PERM_OWNER_AND_POSSESSOR: u32 = 0x3f3f_0000; + +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(); + } + // SAFETY: plain integer arguments. + let rc = unsafe { + libc::syscall( + libc::SYS_keyctl, + KEYCTL_SETPERM, + id, + PERM_OWNER_AND_POSSESSOR, + ) + }; + if rc < 0 { + // A key whose permissions are unknown is not one to leave behind. + let err = io::Error::last_os_error(); + let _ = clear(); + return Err(err); + } + 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-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index f53bea0..7e75e3b 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -123,6 +123,7 @@ use mount::{ }; use reads::{BlockFlight, BlockRing, PREFETCH_BUDGET, Prefetch, ReaderSlot, STREAM_BYPASS_MIN}; use state::{Entry, Intervals, PendingRevision, State, StateGuard, WriteHandle}; +pub use systemd::idle_sleep; use tracing::{debug, error, info, warn}; use transfers::{CountingWriter, JobGuard, OwnedCountingReader, TransferRegistry}; use r#virtual::{ diff --git a/crates/pdfs-fuse/src/systemd.rs b/crates/pdfs-fuse/src/systemd.rs index 741df5c..22683bc 100644 --- a/crates/pdfs-fuse/src/systemd.rs +++ b/crates/pdfs-fuse/src/systemd.rs @@ -74,6 +74,26 @@ pub(crate) fn watchdog_ping() { let _ = notify("WATCHDOG=1\n"); } +/// Sleep for `duration`, pinging the watchdog on the way. +/// +/// For the stretches where the daemon is alive but has no mount, and so no +/// supervisor: waiting for a login, or backing off after a failed mount. The +/// manager arms the watchdog when the process starts, so without these pings a +/// daemon that is merely retrying is aborted after `WatchdogSec` and leaves a +/// coredump instead of the error it was logging. Never use this while mounted: +/// there the ping must prove the control socket answers. +pub fn idle_sleep(duration: Duration) { + const CHUNK: Duration = Duration::from_secs(10); + let mut left = duration; + watchdog_ping(); + while !left.is_zero() { + let step = left.min(CHUNK); + std::thread::sleep(step); + left -= step; + watchdog_ping(); + } +} + /// Tell the manager the daemon is stopping, so a slow teardown is not mistaken /// for a hang by the watchdog it is racing. pub(crate) fn stopping() { 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