mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +01:00
fix(gui,cli,core): offer the CAPTCHA in the browser when the web view cannot start (B195)
The verification window now runs in its own process, so the app survives when WebKit cannot start its sandbox on secureblue, but the CAPTCHA still could not be solved there (#29). WebKit binds the journal socket with `--ro-bind-try`, bubblewrap only skips a missing path, not an unreadable one, and WebKit has no switch to leave the socket out. Proton Mail Bridge never uses a web view: it opens verify.proton.me in the system browser and retries with the original challenge token, typed with the offered methods. Do the same as a fallback. - core: `browser_verification_url` and `browser_verified` build the page URL and the retry credential from the challenge - gui: when the verification window fails, a dialog opens the page in the browser and continues the sign-in once the user is done - cli: `pdfs login` handles a CAPTCHA the same way instead of failing - i18n: translate the new dialog in every catalog - docs: B195, TROUBLESHOOTING.md, ARCHITECTURE.md, changelog Fixes #29
This commit is contained in:
1 parent
07c9b675d3
commit
d13e74fc12
16 files changed
+752
-426
No files matched your search
+22
-13
@@ -1816,20 +1816,29 @@ fn cmd_login(username: Option<String>, remember: bool) -> Result<()> {
|
||||
// Only a two-password account is ever asked for this.
|
||||
let get_mailbox = |attempt: u32| prompt_mailbox(attempt, remember);
|
||||
|
||||
// Proton may ask for a CAPTCHA first, usually on a VPN. The CLI has no
|
||||
// browser engine, so the user solves the page in their own browser and says
|
||||
// when they are done; the retry then carries the challenge token.
|
||||
let get_hv = |hv: pdfs_core::proton_sdk::api::HumanVerification| {
|
||||
println!(
|
||||
"Proton is asking for a CAPTCHA before it will accept this sign-in.\n\
|
||||
Open this page in your browser and complete it:\n\n {}\n",
|
||||
auth::browser_verification_url(&hv)
|
||||
);
|
||||
prompt_line("Press Enter once the page says you are verified")
|
||||
.map_err(|e| pdfs_core::Error::Other(format!("read verification answer: {e}")))?;
|
||||
Ok(auth::browser_verified(&hv))
|
||||
};
|
||||
|
||||
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, 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\
|
||||
Run `pdfs-app` and sign in there — it can show the verification page.\n\
|
||||
(This is usually triggered by a VPN or an unfamiliar IP; signing in from \
|
||||
your usual network often avoids it.)"
|
||||
),
|
||||
other => anyhow::Error::new(other).context("login failed"),
|
||||
})?;
|
||||
rt.block_on(auth::login_interactive(
|
||||
&username,
|
||||
&password,
|
||||
get_totp,
|
||||
get_mailbox,
|
||||
get_hv,
|
||||
))
|
||||
.context("login failed")?;
|
||||
|
||||
// A daemon that is already up is parked in `wait_for_session`, sleeping
|
||||
// between keyring checks; restart it so the mount appears now instead of
|
||||
|
||||
@@ -206,6 +206,44 @@ pub async fn login_interactive(
|
||||
}
|
||||
}
|
||||
|
||||
/// Proton's verification page for the user's own browser, for a front-end that
|
||||
/// cannot host it in a webview.
|
||||
///
|
||||
/// Unlike [`HumanVerification::verification_url`] this asks for the full page,
|
||||
/// not the embedded one: a browser tab has no host to post the token to, so the
|
||||
/// page has to tell the user they are done instead. Proton Mail Bridge opens the
|
||||
/// same URL.
|
||||
pub fn browser_verification_url(hv: &HumanVerification) -> String {
|
||||
let token: String = hv
|
||||
.token
|
||||
.bytes()
|
||||
.map(|b| match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
(b as char).to_string()
|
||||
}
|
||||
_ => format!("%{b:02X}"),
|
||||
})
|
||||
.collect();
|
||||
format!(
|
||||
"https://verify.proton.me/?methods={}&token={token}",
|
||||
hv.methods.join(","),
|
||||
)
|
||||
}
|
||||
|
||||
/// The credential to retry with once the user has solved the challenge in
|
||||
/// their browser.
|
||||
///
|
||||
/// The browser keeps the page's own token, so the retry carries the challenge
|
||||
/// token instead: solving the page marks that token as verified on Proton's
|
||||
/// side. The type is every offered method, as Proton Mail Bridge sends it, since
|
||||
/// the user may have picked any of them on the page.
|
||||
pub fn browser_verified(hv: &HumanVerification) -> HumanVerificationCredential {
|
||||
HumanVerificationCredential {
|
||||
token: hv.token.clone(),
|
||||
method: hv.methods.join(","),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`login`], replaying a human-verification token the user has already earned.
|
||||
///
|
||||
/// A gated login fails with [`Error::HumanVerificationRequired`] carrying the
|
||||
@@ -674,6 +712,30 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
fn challenge() -> HumanVerification {
|
||||
serde_json::from_str(
|
||||
r#"{"HumanVerificationToken":"ab+c/d=","HumanVerificationMethods":["captcha","email"]}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_browser_gets_the_full_verification_page() {
|
||||
assert_eq!(
|
||||
browser_verification_url(&challenge()),
|
||||
"https://verify.proton.me/?methods=captcha,email&token=ab%2Bc%2Fd%3D"
|
||||
);
|
||||
}
|
||||
|
||||
/// The browser keeps the page's token, so the retry replays the challenge
|
||||
/// itself, typed with every method the page offered.
|
||||
#[test]
|
||||
fn a_browser_verification_retries_with_the_challenge_token() {
|
||||
let credential = browser_verified(&challenge());
|
||||
assert_eq!(credential.token, "ab+c/d=");
|
||||
assert_eq!(credential.method, "captcha,email");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passphrases_survive_the_blob_encoding() {
|
||||
let decoded = decode(&encode(&passphrases())).expect("decodes");
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use crate::*;
|
||||
use pdfs_core::proton_sdk::api::HumanVerificationCredential;
|
||||
use pdfs_core::proton_sdk::api::{HumanVerification, HumanVerificationCredential};
|
||||
|
||||
pub(crate) struct LoginState {
|
||||
// Login page.
|
||||
@@ -150,26 +150,24 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
});
|
||||
|
||||
// Same shape for human verification: only fires when the API gates the
|
||||
// sign-in, and the worker is blocked on the token until it does. A
|
||||
// window that never came up is told apart from one the user closed, so
|
||||
// the result below can say why the sign-in stopped.
|
||||
// sign-in, and the worker is blocked on the credential until it does.
|
||||
let ui_hv = ui.clone();
|
||||
let verification_failed = Rc::new(Cell::new(false));
|
||||
let hv_failed = verification_failed.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
if let Ok((url, token_tx)) = hv_req_rx.recv().await {
|
||||
if let Ok((hv, credential_tx)) = hv_req_rx.recv().await {
|
||||
ui_hv
|
||||
.login
|
||||
.login_status
|
||||
.set_text(&gettext("Complete the verification to continue…"));
|
||||
// Anything but a token drops the sender, which the worker reads
|
||||
// as a cancelled sign-in.
|
||||
match verify_human(url).await {
|
||||
// Anything but a credential drops the sender, which the worker
|
||||
// reads as a cancelled sign-in.
|
||||
match verify_human(hv.verification_url()).await {
|
||||
Verification::Solved(token) => {
|
||||
let _ = token_tx.send(token);
|
||||
let _ = credential_tx.send(HumanVerificationCredential::captcha(token));
|
||||
}
|
||||
Verification::Cancelled => {}
|
||||
Verification::Failed => hv_failed.set(true),
|
||||
// The web view could not start, most likely its sandbox
|
||||
// (B195): the user's browser can show the page instead.
|
||||
Verification::Failed => verify_in_browser(&ui_hv, &hv, credential_tx),
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -206,9 +204,6 @@ pub(crate) fn wire_login(ui: &Rc<Ui>) {
|
||||
let _ = gio::spawn_blocking(service::enable_start).await;
|
||||
refresh(&ui);
|
||||
}
|
||||
Err(_) if verification_failed.get() => ui.login.login_status.set_text(&gettext(
|
||||
"The verification page couldn't be opened, so the sign-in was cancelled. Your system may keep the web view's sandbox from starting; the troubleshooting guide explains what to do.",
|
||||
)),
|
||||
Err(e) => ui.login.login_status.set_text(&e),
|
||||
}
|
||||
});
|
||||
@@ -267,9 +262,12 @@ pub(crate) struct LoginChannels {
|
||||
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 if the sign-in is gated: the challenge, and the sender for the
|
||||
/// credential the user earns by solving it.
|
||||
pub(crate) hv: async_channel::Receiver<(
|
||||
HumanVerification,
|
||||
std::sync::mpsc::Sender<HumanVerificationCredential>,
|
||||
)>,
|
||||
/// 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)>)>,
|
||||
@@ -327,18 +325,16 @@ pub(crate) fn spawn_login(username: String, password: String) -> LoginChannels {
|
||||
})
|
||||
},
|
||||
|hv| {
|
||||
// Gated: hand the UI the page to show, block on the token the
|
||||
// user earns by solving it.
|
||||
let (token_tx, token_rx) = std::sync::mpsc::channel::<String>();
|
||||
hv_req_tx
|
||||
.send_blocking((hv.verification_url(), token_tx))
|
||||
.map_err(|_| {
|
||||
pdfs_core::Error::Other("verification prompt closed".into())
|
||||
})?;
|
||||
let token = token_rx
|
||||
// Gated: hand the UI the challenge, block on the credential
|
||||
// the user earns by solving it.
|
||||
let (credential_tx, credential_rx) =
|
||||
std::sync::mpsc::channel::<HumanVerificationCredential>();
|
||||
hv_req_tx.send_blocking((hv, credential_tx)).map_err(|_| {
|
||||
pdfs_core::Error::Other("verification prompt closed".into())
|
||||
})?;
|
||||
credential_rx
|
||||
.recv()
|
||||
.map_err(|_| pdfs_core::Error::Other("verification cancelled".into()))?;
|
||||
Ok(HumanVerificationCredential::captcha(token))
|
||||
.map_err(|_| pdfs_core::Error::Other("verification cancelled".into()))
|
||||
},
|
||||
)
|
||||
.await
|
||||
@@ -525,6 +521,63 @@ pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>)
|
||||
dialog.present(parent.as_ref());
|
||||
}
|
||||
|
||||
/// Let the user solve the challenge in their own browser, for when the
|
||||
/// verification window could not come up. The page cannot hand its token back
|
||||
/// to us, so the user says when they are done, and the retry carries the
|
||||
/// challenge token, which the solved page made good (see
|
||||
/// [`auth::browser_verified`]).
|
||||
fn verify_in_browser(
|
||||
ui: &Rc<Ui>,
|
||||
hv: &HumanVerification,
|
||||
credential_tx: std::sync::mpsc::Sender<HumanVerificationCredential>,
|
||||
) {
|
||||
let dialog = adw::AlertDialog::builder()
|
||||
.heading(gettext("Verify in Your Browser"))
|
||||
.body(gettext(
|
||||
"The verification page can't be shown in the app on this system. Open it in your browser, complete it there, then come back and choose Continue.",
|
||||
))
|
||||
.build();
|
||||
|
||||
let url = auth::browser_verification_url(hv);
|
||||
let open = gtk4::Button::builder()
|
||||
.label(gettext("Open Verification Page"))
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
open.add_css_class("pill");
|
||||
dialog.set_extra_child(Some(&open));
|
||||
|
||||
dialog.add_response("cancel", &gettext("Cancel"));
|
||||
dialog.add_response("continue", &gettext("Continue"));
|
||||
dialog.set_response_appearance("continue", adw::ResponseAppearance::Suggested);
|
||||
// Continuing before the page was even opened would only fail the retry.
|
||||
dialog.set_response_enabled("continue", false);
|
||||
dialog.set_default_response(Some("continue"));
|
||||
dialog.set_close_response("cancel");
|
||||
|
||||
let dialog_weak = dialog.downgrade();
|
||||
open.connect_clicked(move |_| {
|
||||
open_uri(&url);
|
||||
if let Some(dialog) = dialog_weak.upgrade() {
|
||||
dialog.set_response_enabled("continue", true);
|
||||
}
|
||||
});
|
||||
|
||||
let credential = auth::browser_verified(hv);
|
||||
let credential_tx = RefCell::new(Some(credential_tx));
|
||||
dialog.connect_response(None, move |_, resp| {
|
||||
// On cancel/close we take + drop `tx` without sending, so the worker's
|
||||
// recv errors out and the login is reported as cancelled.
|
||||
if let Some(tx) = credential_tx.borrow_mut().take()
|
||||
&& resp == "continue"
|
||||
{
|
||||
let _ = tx.send(credential.clone());
|
||||
}
|
||||
});
|
||||
|
||||
let parent = ui.login.login_button.root().and_downcast::<gtk4::Window>();
|
||||
dialog.present(parent.as_ref());
|
||||
}
|
||||
|
||||
/// Sign out, after a confirmation: disable+stop the mount service (so the
|
||||
/// daemon isn't left running without credentials), forget the stored session,
|
||||
/// and drop back to the login page.
|
||||
|
||||
Reference in new issue
Block a user