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.
This commit is contained in:
nvrl committed 2026-10-07 08:17:50 +02:00
1 parent f105159186
commit 9fa88ff397
24 files changed
+2871 -1417

No files matched your search

+1
View File
@@ -23,3 +23,4 @@ tracing-subscriber.workspace = true
clap.workspace = true
rpassword.workspace = true
serde_json.workspace = true
zeroize.workspace = true
+154 -5
View File
@@ -135,6 +135,24 @@ enum Command {
/// Account email; prompted if omitted.
#[arg(short, long)]
username: Option<String>,
/// 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<String> {
Ok(line.trim().to_owned())
}
fn cmd_login(username: Option<String>) -> Result<()> {
fn cmd_login(username: Option<String>, remember: bool) -> Result<()> {
let username = match username {
Some(u) => u,
None => prompt_line("Proton email")?,
@@ -1793,11 +1813,14 @@ fn cmd_login(username: Option<String>) -> 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<String>) -> 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<auth::Mailbox> {
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<PathBuf>) -> 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;
+2
View File
@@ -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
+467 -118
View File
@@ -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<String>,
/// `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<KeySalt>,
/// 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<BTreeMap<String, String>>,
}
impl StoredSession {
@@ -89,6 +125,26 @@ fn keyring_entry() -> Result<Entry> {
Ok(Entry::new(KEYRING_SERVICE, KEYRING_USER)?)
}
/// What the user answered when asked for the mailbox password.
pub struct Mailbox {
pub password: Zeroizing<String>,
/// 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<Entry> {
/// 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<String>,
get_mailbox: impl Fn(u32) -> Result<Mailbox>,
) -> 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<String>,
get_mailbox: impl Fn(u32) -> Result<Mailbox>,
get_hv: impl FnOnce(HumanVerification) -> Result<HumanVerificationCredential>,
) -> 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<String>,
get_mailbox: impl Fn(u32) -> Result<Mailbox>,
) -> 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<KeySalt>,
) -> 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<KeySalt>,
) -> Result<KeyPassphrases> {
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<String, String> {
passphrases
.iter()
.map(|(id, secret)| (id.to_owned(), BASE64.encode(secret)))
.collect()
}
fn decode(encoded: &BTreeMap<String, String>) -> Option<KeyPassphrases> {
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<Vec<u8>> {
Zeroizing::new(serde_json::to_vec(&encode(passphrases)).unwrap_or_default())
}
fn decode_secret(secret: &[u8]) -> Option<KeyPassphrases> {
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<StoredSession> {
}
}
/// 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<KeyState> {
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<Option<(KeyPassphrases, KeyState)>> {
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<KeyPassphrases> {
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<KeySalt>,
) {
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());
}
}
+15 -3
View File
@@ -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<serde_json::Error> for CoreError {
impl From<Error> 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,
+177
View File
@@ -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<T>() -> io::Result<T> {
Err(io::Error::last_os_error())
}
fn find() -> io::Result<Option<libc::c_long>> {
// 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<Option<Zeroizing<Vec<u8>>>> {
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::<u8>(),
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();
}
}
+1
View File
@@ -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;
+4
View File
@@ -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<Option<auth::StoredSession>>,
/// 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<bool>,
/// 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<bool>,
@@ -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(),
+202 -16
View File
@@ -128,7 +128,12 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
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<Ui>) {
}
});
// 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<Result<(), String>>,
/// Fires only if the account needs a 2FA code; carries the sender for it.
pub(crate) totp: async_channel::Receiver<std::sync::mpsc::Sender<String>>,
/// 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<String>)>,
/// 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<Result<(), String>>,
async_channel::Receiver<std::sync::mpsc::Sender<String>>,
async_channel::Receiver<(String, std::sync::mpsc::Sender<String>)>,
) {
/// 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<Ui>,
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::<gtk4::Window>());
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<Ui>) {
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<Ui>, 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());
+1
View File
@@ -860,6 +860,7 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
}
}
offer_unlock(ui);
refresh_status(ui);
refresh_transfers(ui);
}
+9
View File
@@ -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(