mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
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:
1 parent
f105159186
commit
9fa88ff397
24 files changed
+2871
-1417
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -860,6 +860,7 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
|
||||
}
|
||||
}
|
||||
|
||||
offer_unlock(ui);
|
||||
refresh_status(ui);
|
||||
refresh_transfers(ui);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user