mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 00:47:00 +01:00
fix(core,app,android,cli,packaging): harden account sign-in and watch-history sync
- Desktop shows Proton's CAPTCHA instead of failing the sign-in: a wry webview in a child process of the app (`pstr-app::verify`), confined to https://*.proton.me, hands the token back and the sign-in is retried. Linux packages and CI gain WebKitGTK 4.1; the Windows zip and MSI ship WebView2Loader.dll, without which a -gnu build does not start. - The desktop keyring splits a secret too long for one entry across several, so a Proton session with many key salts survives Windows' 1280-unit cap. Short secrets stay one entry, so every existing secret still reads. - `WatchSync` runs against a small `HistoryDrive` trait and is tested end to end against an in-memory Drive: two devices, newer wins, no re-upload of an unchanged history, a missing file written again, an unreadable own file never overwritten, a newer-format file left alone. - An installation's file unwritten for 90 days is retired by the next device to sync, after its entries are merged and carried in that device's file. - Removing a share keeps its watch state: sync would bring it back from the other devices anyway, and re-adding the share picks up where it left off. - Watch history syncs once more on desktop exit (bounded to 3 s) and, on Android, through a WorkManager job enqueued when the app leaves the screen. - The second-factor prompts say security keys are not supported.
This commit is contained in:
1 parent
26cccd5c63
commit
d9ca8f8d60
28 files changed
+2475
-186
No files matched your search
@@ -2118,6 +2118,9 @@ impl eframe::App for App {
|
||||
self.playback = None;
|
||||
self.engine
|
||||
.shutdown_downloads(std::time::Duration::from_secs(3));
|
||||
// After the player: its last position is saved on the way out.
|
||||
self.engine
|
||||
.sync_before_exit(std::time::Duration::from_secs(3));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ use pstr_core::library::{Library, Title, TitleKind};
|
||||
use pstr_core::metadata::{EpisodeGuide, MetadataConfig, MetadataRecord, ProviderId};
|
||||
use pstr_core::prefs::PlaybackPrefs;
|
||||
use pstr_core::proton_drive_rs::ThumbnailType;
|
||||
use pstr_core::proton_sdk::api::HumanVerificationCredential;
|
||||
use pstr_core::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||
use pstr_core::shares::AccountFolder;
|
||||
use pstr_core::sync::{SyncReport, WatchSync};
|
||||
@@ -704,17 +705,38 @@ impl Engine {
|
||||
pub fn sign_in(&self, username: String, password: String) {
|
||||
let engine = self.clone();
|
||||
self.runtime.spawn(async move {
|
||||
match engine.accounts.sign_in(&username, &password, None).await {
|
||||
Ok(step) => engine.sign_in_step(step),
|
||||
Err(pstr_core::Error::HumanVerification(_)) => {
|
||||
engine.emit(Event::SignInFailed(
|
||||
"Proton wants a CAPTCHA before it accepts this sign-in, and this app \
|
||||
cannot show one. Try again later, or sign in once from the Android app."
|
||||
.to_owned(),
|
||||
));
|
||||
let mut verification = None;
|
||||
// Twice at most: once as asked, and once more with a solved
|
||||
// CAPTCHA if Proton wanted one. A second gate after that is
|
||||
// Proton refusing the answer, not a puzzle worth repeating.
|
||||
for _ in 0..2 {
|
||||
match engine
|
||||
.accounts
|
||||
.sign_in(&username, &password, verification.take())
|
||||
.await
|
||||
{
|
||||
Ok(step) => return engine.sign_in_step(step),
|
||||
Err(pstr_core::Error::HumanVerification(challenge)) => {
|
||||
let url = challenge.verification_url();
|
||||
let token = tokio::task::spawn_blocking(move || crate::verify::solve(&url))
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
let Some(token) = token else {
|
||||
return engine.emit(Event::SignInFailed(
|
||||
"Proton wants you to confirm you are human before it accepts \
|
||||
this sign-in. Sign in again to solve the puzzle."
|
||||
.to_owned(),
|
||||
));
|
||||
};
|
||||
verification = Some(HumanVerificationCredential::captcha(token));
|
||||
}
|
||||
Err(error) => return engine.emit(Event::SignInFailed(error.to_string())),
|
||||
}
|
||||
Err(error) => engine.emit(Event::SignInFailed(error.to_string())),
|
||||
}
|
||||
engine.emit(Event::SignInFailed(
|
||||
"Proton did not accept the verification. Try again in a while.".to_owned(),
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -841,6 +863,27 @@ impl Engine {
|
||||
});
|
||||
}
|
||||
|
||||
/// Sync once more before the app exits, waiting at most `timeout`.
|
||||
///
|
||||
/// Where the viewer stopped is what another device wants next, and the
|
||||
/// timer would not get to it. Bounded, because quitting must not hang on
|
||||
/// a slow network; a sync cut short loses nothing, the next launch sends
|
||||
/// the same table.
|
||||
pub fn sync_before_exit(&self, timeout: std::time::Duration) {
|
||||
let (Some(account), Some(sync)) = (self.account.lock().clone(), self.watch_sync.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let catalog = Arc::clone(&self.catalog);
|
||||
self.runtime.block_on(async move {
|
||||
match tokio::time::timeout(timeout, sync.sync(&account, &catalog)).await {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(error)) => tracing::warn!("sync watch history on exit: {error}"),
|
||||
Err(_) => tracing::warn!("sync watch history on exit: timed out"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn sync_periodically(&self) {
|
||||
let engine = self.clone();
|
||||
self.runtime.spawn(async move {
|
||||
|
||||
@@ -30,5 +30,6 @@ pub mod pacing;
|
||||
pub mod playback;
|
||||
pub mod theme;
|
||||
pub mod ui;
|
||||
pub mod verify;
|
||||
pub mod video;
|
||||
pub mod window;
|
||||
@@ -16,6 +16,12 @@ use anyhow::Context;
|
||||
use pstr_core::config::AppDirs;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
// Before anything else: a verification window is a child process of the
|
||||
// app, and must not claim the single instance or open a library.
|
||||
if let Some(code) = pstr_app::verify::run_if_requested() {
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
|
||||
@@ -207,6 +207,12 @@ fn code_form(ui: &mut egui::Ui, panel: &mut AccountPanel, actions: &mut Vec<Acti
|
||||
.desired_width(160.0),
|
||||
);
|
||||
field.request_focus();
|
||||
ui.add_space(theme::space::S);
|
||||
// Security keys need WebAuthn, which the sign-in here does not speak.
|
||||
ui.label(ui::muted(
|
||||
"Security keys are not supported. If your account has only a security key, add an \
|
||||
authenticator app in your Proton account settings to sign in here.",
|
||||
));
|
||||
ui.add_space(theme::space::L);
|
||||
error_line(ui, panel);
|
||||
let ready = !panel.code.trim().is_empty() && !panel.busy;
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Proton's CAPTCHA, in a window of its own.
|
||||
//!
|
||||
//! Proton puts a CAPTCHA in front of a sign-in it does not recognise — a new
|
||||
//! network, a VPN exit. The page reports success by posting a message to its
|
||||
//! host rather than by redirecting, so the system browser has nowhere to send
|
||||
//! it back to: it needs a webview this app controls, as on Android and in
|
||||
//! `proton-drive-linux`.
|
||||
//!
|
||||
//! The webview runs in a child process — this same executable, started with
|
||||
//! [`ARG`] — because its windowing library and eframe's cannot both own the
|
||||
//! event loop of one process. The child prints the solved token on stdout and
|
||||
//! exits; closing its window exits with nothing printed, which is a cancel.
|
||||
|
||||
use std::io::{Read as _, Write as _};
|
||||
|
||||
/// The argument that starts this executable as the verification window,
|
||||
/// followed by the page's URL.
|
||||
pub const ARG: &str = "--human-verification";
|
||||
|
||||
/// Forwards every message the page posts to this process. The page targets
|
||||
/// `window.parent`, which for a top-level page is the page itself, so a
|
||||
/// listener on its own window hears it.
|
||||
const BRIDGE: &str = r#"
|
||||
window.addEventListener('message', function (event) {
|
||||
try {
|
||||
window.ipc.postMessage(typeof event.data === 'string' ? event.data : JSON.stringify(event.data));
|
||||
} catch (e) {}
|
||||
});
|
||||
"#;
|
||||
|
||||
/// Run the verification window if this process was started for it, returning
|
||||
/// the process's exit code; `None` when it was started as the app.
|
||||
pub fn run_if_requested() -> Option<i32> {
|
||||
let mut args = std::env::args().skip(1);
|
||||
if args.next().as_deref() != Some(ARG) {
|
||||
return None;
|
||||
}
|
||||
let Some(url) = args.next().filter(|url| is_proton_page(url)) else {
|
||||
eprintln!("{ARG} needs a verification page on proton.me");
|
||||
return Some(2);
|
||||
};
|
||||
Some(match window(&url) {
|
||||
Ok(()) => 0,
|
||||
Err(error) => {
|
||||
eprintln!("the verification window failed: {error}");
|
||||
1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Present `url` in the verification window and wait for the token it yields.
|
||||
///
|
||||
/// `None` when the viewer closed the window, or it could not be shown.
|
||||
/// Blocking: call it off the UI thread and outside the async runtime.
|
||||
pub fn solve(url: &str) -> Option<String> {
|
||||
let exe = std::env::current_exe()
|
||||
.inspect_err(|error| tracing::warn!("locate this executable: {error}"))
|
||||
.ok()?;
|
||||
let mut child = std::process::Command::new(exe)
|
||||
.arg(ARG)
|
||||
.arg(url)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.inspect_err(|error| tracing::warn!("open the verification window: {error}"))
|
||||
.ok()?;
|
||||
let mut out = String::new();
|
||||
if let Some(mut stdout) = child.stdout.take() {
|
||||
let _ = stdout.read_to_string(&mut out);
|
||||
}
|
||||
let status = child.wait().ok()?;
|
||||
if !status.success() {
|
||||
tracing::warn!("the verification window exited with {status}");
|
||||
return None;
|
||||
}
|
||||
out.lines()
|
||||
.map(str::trim)
|
||||
.rfind(|line| !line.is_empty())
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
fn window(url: &str) -> anyhow::Result<()> {
|
||||
use tao::event::{Event, WindowEvent};
|
||||
use tao::event_loop::{ControlFlow, EventLoopBuilder};
|
||||
use tao::window::WindowBuilder;
|
||||
use wry::{NewWindowResponse, WebViewBuilder};
|
||||
|
||||
let event_loop = EventLoopBuilder::<String>::with_user_event().build();
|
||||
let window = WindowBuilder::new()
|
||||
.with_title("Confirm you are human — Proton Stream")
|
||||
.with_inner_size(tao::dpi::LogicalSize::new(440.0, 680.0))
|
||||
.build(&event_loop)?;
|
||||
|
||||
let proxy = event_loop.create_proxy();
|
||||
let builder = WebViewBuilder::new()
|
||||
.with_url(url)
|
||||
.with_initialization_script(BRIDGE)
|
||||
.with_ipc_handler(move |request| {
|
||||
// Every message the page posts lands here, most of them its own
|
||||
// chatter; only a completion carries a token.
|
||||
if let Some(token) = pstr_core::account::verification_token(request.body()) {
|
||||
let _ = proxy.send_event(token);
|
||||
}
|
||||
})
|
||||
// The bridge above is reachable by whatever the page shows, so the
|
||||
// page stays on Proton.
|
||||
.with_navigation_handler(|url| is_allowed_navigation(&url))
|
||||
.with_new_window_req_handler(|_, _| NewWindowResponse::Deny);
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
let _webview = {
|
||||
use tao::platform::unix::WindowExtUnix as _;
|
||||
use wry::WebViewBuilderExtUnix as _;
|
||||
let vbox = window
|
||||
.default_vbox()
|
||||
.ok_or_else(|| anyhow::anyhow!("the window has no GTK container"))?;
|
||||
builder.build_gtk(vbox)?
|
||||
};
|
||||
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||
let _webview = builder.build(&window)?;
|
||||
|
||||
let mut answered = false;
|
||||
event_loop.run(move |event, _, control_flow| {
|
||||
*control_flow = ControlFlow::Wait;
|
||||
match event {
|
||||
Event::UserEvent(token) if !answered => {
|
||||
answered = true;
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
let _ = writeln!(stdout, "{token}");
|
||||
let _ = stdout.flush();
|
||||
*control_flow = ControlFlow::Exit;
|
||||
}
|
||||
Event::WindowEvent {
|
||||
event: WindowEvent::CloseRequested,
|
||||
..
|
||||
} => *control_flow = ControlFlow::Exit,
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `url` is a page of Proton's own, over HTTPS.
|
||||
fn is_proton_page(url: &str) -> bool {
|
||||
let Some(rest) = url.strip_prefix("https://") else {
|
||||
return false;
|
||||
};
|
||||
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
|
||||
// A user-info part would let `proton.me@evil.example` pass.
|
||||
if authority.contains('@') {
|
||||
return false;
|
||||
}
|
||||
let host = authority.split(':').next().unwrap_or_default();
|
||||
host == "proton.me" || host.ends_with(".proton.me")
|
||||
}
|
||||
|
||||
/// Whether the webview may go to `url`: Proton's pages, and the documents a
|
||||
/// page makes for itself, which have no origin of their own to leave to.
|
||||
fn is_allowed_navigation(url: &str) -> bool {
|
||||
is_proton_page(url) || url == "about:blank" || url.starts_with("about:srcdoc")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_proton_pages_over_https_are_proton_pages() {
|
||||
assert!(is_proton_page(
|
||||
"https://verify.proton.me/?embed=1&methods=captcha&token=x"
|
||||
));
|
||||
assert!(is_proton_page("https://proton.me"));
|
||||
assert!(!is_proton_page("http://verify.proton.me/"));
|
||||
assert!(!is_proton_page("https://proton.me.evil.example/"));
|
||||
assert!(!is_proton_page("https://evilproton.me/"));
|
||||
assert!(!is_proton_page("https://proton.me@evil.example/"));
|
||||
assert!(!is_proton_page(
|
||||
"https://evil.example/?next=https://proton.me"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_window_stays_on_proton() {
|
||||
assert!(is_allowed_navigation(
|
||||
"https://verify-api.proton.me/core/v4/captcha"
|
||||
));
|
||||
assert!(is_allowed_navigation("about:blank"));
|
||||
assert!(!is_allowed_navigation("https://example.com/"));
|
||||
assert!(!is_allowed_navigation("file:///etc/passwd"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user