From e0a4c01654427265afe9d4931b43844628af0688 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 20 Oct 2026 12:54:03 +0200 Subject: [PATCH] backup: the Proton login runs from the workspace :backup login shows Proton's third-party disclosure (and a warning when no session key file seals the stored session), asks for the password, then for a two-factor code when the account has one. If Proton answers with a human check, its own verify.proton.me page opens in a sheet; the token it posts back resumes the login. :backup logout forgets the session and ends it on Proton's side where Proton answers. The mirror picks up a new session within a minute, without a restart. The login is split into steps in backup::proton (start, second_factor, verified), and a half-done login waits in memory for five minutes at most. Every step goes through its own throttle, so the workspace cannot hammer Proton into locking the account. FIDO2-only accounts are told to add an authenticator app. The CLI runs over the same steps and points to :backup login when Proton wants a human check. The CSP lets verify.proton.me into a frame and nothing else. --- .env.example | 3 +- README.md | 19 +++- src/backup/mod.rs | 107 ++++++++++++++++++++++ src/backup/proton.rs | 153 ++++++++++++++++++++++++++++---- src/backup/session.rs | 5 ++ src/cli.rs | 25 ++++-- src/main.rs | 5 +- src/routes/backup.rs | 172 +++++++++++++++++++++++++++++++++++- src/routes/mod.rs | 4 + src/state.rs | 2 + web/src/App.svelte | 3 +- web/src/lib/Verify.svelte | 73 +++++++++++++++ web/src/lib/api.ts | 22 +++++ web/src/lib/commands.ts | 61 +++++++++++-- web/src/lib/store.svelte.ts | 23 ++++- 15 files changed, 638 insertions(+), 39 deletions(-) create mode 100644 web/src/lib/Verify.svelte diff --git a/.env.example b/.env.example index 990e09c..34ccc49 100644 --- a/.env.example +++ b/.env.example @@ -44,7 +44,8 @@ WORKSPACE_SECURE_COOKIE=0 # WORKSPACE_SFTP_BIND=127.0.0.1:2222 # --- Proton Drive backup ----------------------------------------------------- -# Off until `narl-workspace proton-login` stores a session under WORKSPACE_DATA_DIR. +# Off until `:backup login ` in the workspace (or `narl-workspace +# proton-login`) stores a session under WORKSPACE_DATA_DIR. # These only shape how often the mirror runs. # The device the server registers in Proton Drive; its sync root is the folder diff --git a/README.md b/README.md index bb9ea17..809f15d 100644 --- a/README.md +++ b/README.md @@ -410,7 +410,22 @@ It is a third-party client: it identifies itself to Proton as `external-drive-narl_workspace@-alpha` and says so before it asks for account details. It carries no Proton branding and is not supported by Proton. -Set it up once, interactively, because SRP and 2FA need a human: +Set it up once, interactively, because SRP and 2FA need a human. From the +workspace: + +```text +:backup login +``` + +It shows the disclosure above and asks before anything else, then asks for the +password and, when the account has one, the two-factor code. If Proton wants a +human check first, its own page opens in a sheet; solve it and the login goes +on. An account whose only second factor is a security key cannot log in from +here — add an authenticator app to it. `:backup logout` forgets the session +again. The mirror starts within a minute of the login; no restart is needed. + +The same login runs from the server's terminal, for a check that a terminal +can answer: ```sh narl-workspace proton-login # or, in Docker: @@ -598,6 +613,8 @@ sealed or not. | `POST`/`DELETE` | `/api/nodes/{id}/share` | mint or rotate / revoke a share link | | `GET` | `/api/export` | the whole workspace, boards and dates included, as a zip | | `GET`/`POST` | `/api/backup` | Proton Drive mirror: status / run now | +| `POST`/`DELETE` | `/api/backup/login` | log the mirror in to Proton, body `{"username","password"}` / forget its session | +| `POST` | `/api/backup/login/totp`, `/api/backup/login/captcha` | the two-factor code `{"code"}`, or the solved human check `{"token"}`, a waiting login asked for | | `GET`/`POST` | `/api/media` | list / upload (multipart) | | `GET`/`DELETE` | `/api/media/{id}` | serve (with `Range`) / delete a file nothing in the tree owns | | `GET` | `/api/spaces/overview` | every space with what was last written in it | diff --git a/src/backup/mod.rs b/src/backup/mod.rs index 814d127..c21ccf4 100644 --- a/src/backup/mod.rs +++ b/src/backup/mod.rs @@ -29,6 +29,7 @@ use proton_sdk::ids::NodeUid; use serde::Serialize; use sqlx::{Row, SqlitePool}; use tokio::sync::Notify; +use zeroize::Zeroizing; use crate::config::Config; use session::SessionStore; @@ -66,6 +67,21 @@ pub struct Backup { /// but unreadable — when that is so. The server still starts; the mirror /// says why it is off, every time it is asked. disabled: Option, + /// A login from the workspace, waiting for its second factor or its + /// human check; see [`Backup::login`]. + pending: Mutex>, +} + +/// What a login from the workspace needs next. +#[derive(Debug, Serialize)] +#[serde(tag = "next", rename_all = "lowercase")] +pub enum LoginStep { + /// Logged in; the mirror starts on its own. + Done, + /// A two-factor code. + Totp, + /// Proton's human check, on the page at `url`. + Captcha { url: String }, } impl Backup { @@ -104,9 +120,100 @@ impl Backup { ..Status::default() }), running: tokio::sync::Mutex::new(()), + pending: Mutex::new(None), }) } + /// Whether the stored session is sealed under a key kept off the data + /// volume, or lies there readable to whoever has a copy of it. + pub fn session_sealed(&self) -> bool { + self.store.sealed() + } + + /// Log in to Proton from the workspace. Any login still waiting is + /// dropped: there is only ever one. + pub async fn login(&self, username: &str, password: Zeroizing) -> Result { + self.usable()?; + self.take_pending(); + let outcome = proton::start(&self.store, username.trim(), password, None).await?; + Ok(self.step(outcome)) + } + + /// The two-factor code a waiting login asked for. + pub async fn login_totp(&self, code: &str) -> Result { + self.usable()?; + let pending = self.waiting()?; + match proton::second_factor(&self.store, pending, code).await { + Ok(()) => Ok(self.step(None)), + Err((e, pending)) => { + *self.pending.lock().unwrap_or_else(|e| e.into_inner()) = + pending.map(|pending| *pending); + Err(e) + } + } + } + + /// The token Proton's human check handed back to a waiting login. + pub async fn login_verified(&self, token: &str) -> Result { + self.usable()?; + let pending = self.waiting()?; + let outcome = proton::verified(&self.store, pending, token).await?; + Ok(self.step(outcome)) + } + + /// Forget the Proton session, ending it on Proton's side too where + /// Proton answers. The mirror on Drive stays where it is. + pub async fn logout(&self) -> Result<()> { + self.take_pending(); + if let Err(e) = proton::end(&self.store).await { + tracing::warn!("{e:#}; the session is forgotten here all the same"); + } + self.store.clear()?; + self.set(|s| s.configured = false); + tracing::info!("Proton Drive session forgotten"); + Ok(()) + } + + fn usable(&self) -> Result<()> { + match &self.disabled { + Some(reason) => anyhow::bail!("backups are off: {reason} — fix it and restart"), + None => Ok(()), + } + } + + fn take_pending(&self) -> Option { + self.pending + .lock() + .unwrap_or_else(|e| e.into_inner()) + .take() + } + + fn waiting(&self) -> Result { + match self.take_pending() { + Some(pending) if !pending.expired() => Ok(pending), + Some(_) => anyhow::bail!("that login took too long — start again with :backup login"), + None => anyhow::bail!("no Proton login is waiting — start one with :backup login"), + } + } + + /// Keep a login that still needs something, or note one that is done. + fn step(&self, outcome: Option<(proton::Next, proton::Pending)>) -> LoginStep { + let Some((next, pending)) = outcome else { + tracing::info!("logged in to Proton Drive from the workspace"); + self.set(|s| { + s.configured = true; + s.last_error = None; + }); + self.signal(); + return LoginStep::Done; + }; + *self.pending.lock().unwrap_or_else(|e| e.into_inner()) = Some(pending); + match next { + proton::Next::Totp => LoginStep::Totp, + proton::Next::Captcha(url) => LoginStep::Captcha { url }, + } + } + pub fn status(&self) -> Status { let mut status = self .status diff --git a/src/backup/proton.rs b/src/backup/proton.rs index 561d3d3..5b0acf2 100644 --- a/src/backup/proton.rs +++ b/src/backup/proton.rs @@ -3,14 +3,17 @@ use anyhow::{anyhow, Context, Result}; use proton_drive_rs::{Device, DeviceType, ProtonDriveClient}; use proton_sdk::account::AccountClient; +use proton_sdk::api::{HumanVerificationCredential, ResponseCode}; use proton_sdk::config::ProtonClientConfiguration; use proton_sdk::error::ProtonError; use proton_sdk::session::ProtonApiSession; +use zeroize::Zeroizing; use super::session::{SessionStore, StoredSession}; /// What Proton asks a third-party client to tell anyone it takes credentials -/// from. Shown by `narl-workspace proton-login` before it asks for anything. +/// from. Shown by `narl-workspace proton-login` and `:backup login` before +/// either asks for anything. pub const DISCLOSURE: &str = "This is a third-party application not officially supported by Proton."; @@ -45,33 +48,132 @@ fn client_config() -> ProtonClientConfiguration { ProtonClientConfiguration::new(version).with_user_agent(agent) } -/// Run an SRP login, complete 2FA if the account asks for one, and persist the -/// result. `get_totp` is only called when a second factor is actually required, -/// so accounts without one are never prompted. -pub async fn login( +/// How long a login may wait for its second factor or its human check. The +/// password is held in memory for that long and no longer. +const PENDING_FOR: std::time::Duration = std::time::Duration::from_secs(5 * 60); + +/// A login half done, waiting on the person at the keyboard: for a +/// two-factor code, with the session Proton opened for it, or for a solved +/// human check, after which the login starts over with the proof attached. +pub struct Pending { + username: String, + password: Zeroizing, + session: Option, + started: std::time::Instant, +} + +impl Pending { + pub fn expired(&self) -> bool { + self.started.elapsed() > PENDING_FOR + } +} + +/// What a login needs next. +pub enum Next { + /// A code from the account's authenticator app. + Totp, + /// Proton's human check, solved on the page at this address. + Captcha(String), +} + +/// Start a login: SRP with the password, and with a solved human check when +/// there is one. Stored and done when the account asks for nothing more; +/// otherwise what it asks for, and the login waiting on it. +pub async fn start( store: &SessionStore, username: &str, - password: &str, - get_totp: impl FnOnce() -> Result, -) -> Result<()> { - let mut session = ProtonApiSession::begin(client_config(), username, password.as_bytes()) - .await - .context("Proton rejected the login")?; + password: Zeroizing, + verified: Option<&HumanVerificationCredential>, +) -> Result> { + let begun = + ProtonApiSession::begin_verified(client_config(), username, password.as_bytes(), verified) + .await; + let pending = |session| Pending { + username: username.to_string(), + password: password.clone(), + session, + started: std::time::Instant::now(), + }; + let session = match begun { + Ok(session) => session, + Err(ProtonError::Api(e)) if e.is_human_verification_required() => { + let Some(check) = e + .human_verification() + .filter(|check| check.supports_captcha()) + else { + return Err(anyhow!( + "Proton wants a human check this server cannot show — log in once at \ + proton.me from this network, then try again" + )); + }; + return Ok(Some(( + Next::Captcha(check.verification_url()), + pending(None), + ))); + } + Err(ProtonError::Api(e)) if e.code == ResponseCode::IncorrectLoginCredentials => { + return Err(anyhow!("Proton rejected the username or password")); + } + Err(e @ ProtonError::Transport(_)) => { + return Err(anyhow::Error::from(e).context("could not reach Proton")) + } + Err(e) => return Err(anyhow::Error::from(e).context("Proton rejected the login")), + }; if session.is_waiting_for_second_factor() { - let code = get_totp()?; - session - .apply_second_factor_code(code.trim()) - .await - .context("Proton rejected the second-factor code")?; + if !session.second_factor_methods().totp { + return Err(anyhow!( + "this account's second factor is a security key only — add an authenticator \ + app to it in Proton's settings, which this server can use" + )); + } + return Ok(Some((Next::Totp, pending(Some(session))))); } + finish(store, &session, &password).await?; + Ok(None) +} +/// Answer a login's two-factor question. A wrong code gives the login back, +/// to be asked again. +pub async fn second_factor( + store: &SessionStore, + mut pending: Pending, + code: &str, +) -> std::result::Result<(), (anyhow::Error, Option>)> { + let Some(session) = pending.session.as_mut() else { + return Err(( + anyhow!("this login is waiting for a human check, not a code"), + Some(Box::new(pending)), + )); + }; + if let Err(e) = session.apply_second_factor_code(code.trim()).await { + let e = anyhow::Error::from(e).context("Proton rejected the two-factor code"); + return Err((e, Some(Box::new(pending)))); + } + let session = pending.session.take().expect("checked above"); + finish(store, &session, &pending.password) + .await + .map_err(|e| (e, None)) +} + +/// The login again from the start, now with a solved human check. +pub async fn verified( + store: &SessionStore, + pending: Pending, + token: &str, +) -> Result> { + let proof = HumanVerificationCredential::captcha(token); + start(store, &pending.username, pending.password, Some(&proof)).await +} + +/// Store the session of a finished login. +async fn finish(store: &SessionStore, session: &ProtonApiSession, password: &str) -> Result<()> { // The passphrases are derived here and only here: this access token still // carries the `locked` scope the key salts need, and no refreshed one ever // will. Unlocking checks them against the account's keys before anything // is stored, so a wrong mailbox password fails now and not at the first // backup. - let client = ProtonDriveClient::new(&session, password.as_bytes().to_vec()); + let client = ProtonDriveClient::new(session, password.as_bytes().to_vec()); let account = client.account(); let key_passphrases = account .key_passphrases() @@ -84,13 +186,28 @@ pub async fn login( let tokens = session.current_tokens().await; store.save(&StoredSession::from_session( - &session, + session, tokens, key_passphrases, ))?; Ok(()) } +/// Sign the stored session out on Proton's side as well, as far as Proton +/// answers; the caller forgets it locally whatever comes of this. +pub async fn end(store: &SessionStore) -> Result<()> { + let Some(stored) = store.load()? else { + return Ok(()); + }; + let session = ProtonApiSession::resume(client_config(), stored.resume_parameters()) + .context("the stored Proton session could not be resumed")?; + session + .end() + .await + .context("Proton did not end the session")?; + Ok(()) +} + /// Rebuild a Drive client from the stored session, or `None` when nothing has /// been stored yet — that is the ordinary "backups are not set up" state, not /// an error. diff --git a/src/backup/session.rs b/src/backup/session.rs index 133b672..62cbb6d 100644 --- a/src/backup/session.rs +++ b/src/backup/session.rs @@ -178,6 +178,11 @@ impl SessionStore { Ok(store) } + /// Whether what is stored is sealed under a key file. + pub fn sealed(&self) -> bool { + self.key.is_some() + } + pub fn path(&self) -> &Path { &self.path } diff --git a/src/cli.rs b/src/cli.rs index 78b5dfe..00da438 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -55,19 +55,30 @@ pub async fn proton_login(config: &Config) -> Result<()> { } }; - backup::proton::login(&store, username.trim(), &password, || { - match env("PROTON_TOTP") { - Some(code) => Ok(code), - None => prompt("Two-factor code: "), + let password = zeroize::Zeroizing::new(password); + use backup::proton::Next; + match backup::proton::start(&store, username.trim(), password, None).await? { + None => {} + Some((Next::Totp, pending)) => { + let code = match env("PROTON_TOTP") { + Some(code) => code, + None => prompt("Two-factor code: ")?, + }; + backup::proton::second_factor(&store, pending, &code) + .await + .map_err(|(e, _)| e)?; } - }) - .await?; + Some((Next::Captcha(_), _)) => bail!( + "Proton wants a human check before this login, which a terminal cannot show — \ + log in from the workspace instead, with `:backup login `" + ), + } println!( "Logged in. The session is stored at {}.", store.path().display() ); - println!("Backups start with the next server start, or run `narl-workspace backup-now`."); + println!("A running server starts backing up within a minute; `narl-workspace backup-now` runs one at once."); Ok(()) } diff --git a/src/main.rs b/src/main.rs index 71683e5..be82177 100644 --- a/src/main.rs +++ b/src/main.rs @@ -132,6 +132,7 @@ async fn main() -> Result<()> { config: Arc::clone(&config), login_throttle: Arc::default(), vault_throttle: Arc::default(), + proton_throttle: Arc::default(), backup, notify, epoch: Arc::new(std::sync::atomic::AtomicI64::new(epoch)), @@ -304,6 +305,8 @@ fn is_slow(method: &axum::http::Method, path: &str) -> bool { path == "/api/media" || path == "/api/import" || path == "/api/backup" + // A login waits on Proton's SRP and key unlocking. + || path.starts_with("/api/backup/login") || path.ends_with("/import") || path.ends_with("/files") || path.ends_with("/encrypt") @@ -383,7 +386,7 @@ fn compressible( /// runtime; scripts stay confined to the bundled files. const CSP: &str = "default-src 'self'; img-src 'self' data:; media-src 'self'; \ style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; \ - frame-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"; + frame-src 'self' https://verify.proton.me; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'"; type HeaderLayer = SetResponseHeaderLayer; diff --git a/src/routes/backup.rs b/src/routes/backup.rs index 9229fe2..221d732 100644 --- a/src/routes/backup.rs +++ b/src/routes/backup.rs @@ -1,11 +1,33 @@ -//! Looking at the Proton Drive mirror, and asking it to run now. +//! Looking at the Proton Drive mirror, asking it to run now, and logging it +//! in to Proton or out again. use axum::{extract::State, Json}; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; -use crate::{auth::Session, backup::Status, error::AppResult, state::AppState}; +use crate::{ + auth::Session, + backup::{proton::DISCLOSURE, LoginStep, Status}, + error::{AppError, AppResult}, + state::AppState, +}; -pub async fn status(_: Session, State(state): State) -> AppResult> { - Ok(Json(state.backup.status())) +#[derive(Serialize)] +pub struct Shown { + #[serde(flatten)] + pub status: Status, + /// What Proton asks a third-party client to say before it takes a login. + pub disclosure: &'static str, + /// Whether a stored session is sealed under a key file off the data volume. + pub sealed_session: bool, +} + +pub async fn status(_: Session, State(state): State) -> AppResult> { + Ok(Json(Shown { + status: state.backup.status(), + disclosure: DISCLOSURE, + sealed_session: state.backup.session_sealed(), + })) } /// Mirror now rather than when the workspace next falls quiet. Answers with the @@ -14,3 +36,145 @@ pub async fn run(_: Session, State(state): State) -> AppResult, +} + +#[derive(Deserialize)] +pub struct CodeInput { + pub code: String, +} + +#[derive(Deserialize)] +pub struct TokenInput { + pub token: String, +} + +#[derive(Serialize)] +pub struct Answer { + #[serde(flatten)] + pub step: LoginStep, + pub status: Status, +} + +/// Every step of a login goes through its own throttle, so the workspace +/// cannot be made to hammer Proton into locking the account. Proton's reasons +/// for a refusal are passed on as they are; none of them holds a secret. +async fn step( + state: &AppState, + run: impl std::future::Future>, +) -> AppResult> { + let attempt = state.proton_throttle.begin().map_err(|wait| { + tracing::warn!("Proton login refused, {wait}s of backoff remaining"); + AppError::TooManyRequests(wait) + })?; + match run.await { + Ok(step) => { + attempt.succeeded(); + Ok(Json(Answer { + step, + status: state.backup.status(), + })) + } + Err(e) => { + attempt.failed(); + tracing::warn!("Proton login: {e:#}"); + Err(AppError::BadRequest(format!("{e:#}"))) + } + } +} + +/// `POST /api/backup/login` — start a login with the account's password. +pub async fn login( + _: Session, + State(state): State, + Json(input): Json, +) -> AppResult> { + if input.username.trim().is_empty() { + return Err(AppError::BadRequest("a Proton username is needed".into())); + } + step(&state, state.backup.login(&input.username, input.password)).await +} + +/// `POST /api/backup/login/totp` — the code a waiting login asked for. +pub async fn totp( + _: Session, + State(state): State, + Json(input): Json, +) -> AppResult> { + step(&state, state.backup.login_totp(&input.code)).await +} + +/// `POST /api/backup/login/captcha` — the token Proton's human check gave. +pub async fn captcha( + _: Session, + State(state): State, + Json(input): Json, +) -> AppResult> { + step(&state, state.backup.login_verified(&input.token)).await +} + +/// `DELETE /api/backup/login` — forget the Proton session. +pub async fn logout(_: Session, State(state): State) -> AppResult> { + state.backup.logout().await?; + Ok(Json(state.backup.status())) +} + +#[cfg(test)] +mod tests { + use axum::{ + body::Body, + http::{header, Request, StatusCode}, + }; + use tower::ServiceExt; + + #[tokio::test] + async fn a_code_with_no_login_waiting_is_refused() { + let dir = tempfile::tempdir().unwrap(); + let state = crate::routes::tests::state_in(dir.path().into()).await; + crate::auth::Epoch::load(&state.db, &state.config) + .await + .unwrap(); + let app = crate::routes::api_router(1024).with_state(state); + let login = app + .clone() + .oneshot( + Request::post("/login") + .header("content-type", "application/json") + .body(Body::from(r#"{"username":"narl","password":"hunter2"}"#)) + .unwrap(), + ) + .await + .unwrap(); + let cookie = login.headers()[header::SET_COOKIE] + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_string(); + let answer = app + .oneshot( + Request::post("/backup/login/totp") + .header("content-type", "application/json") + .header(header::COOKIE, cookie) + .body(Body::from(r#"{"code":"123456"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(answer.status(), StatusCode::BAD_REQUEST); + let body = axum::body::to_bytes(answer.into_body(), usize::MAX) + .await + .unwrap(); + // A server built for tests has backups off; that is said first. + let text = String::from_utf8_lossy(&body); + assert!( + text.contains("backups are off") || text.contains("no Proton login is waiting"), + "{text}" + ); + } +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 913ccb1..6d93d62 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -45,6 +45,9 @@ pub fn api_router(max_upload: usize) -> Router { .route("/sessions/{id}", delete(session::revoke)) .route("/export", get(export::export)) .route("/backup", get(backup::status).post(backup::run)) + .route("/backup/login", post(backup::login).delete(backup::logout)) + .route("/backup/login/totp", post(backup::totp)) + .route("/backup/login/captcha", post(backup::captcha)) .route("/spaces", get(nodes::spaces).post(nodes::create_space)) .route("/spaces/overview", get(nodes::overview)) .route("/agenda", get(boards::agenda)) @@ -141,6 +144,7 @@ pub(crate) mod tests { notify: Notifier::new(db.clone(), Arc::clone(&config)).unwrap(), login_throttle: Arc::default(), vault_throttle: Arc::default(), + proton_throttle: Arc::default(), epoch: Arc::new(AtomicI64::new(0)), sessions: Arc::default(), config, diff --git a/src/state.rs b/src/state.rs index 30a2358..80b11e1 100644 --- a/src/state.rs +++ b/src/state.rs @@ -19,6 +19,8 @@ pub struct AppState { /// The vault password gets the same slowing-down as the login, kept apart /// so guessing at one does not slow the other. pub vault_throttle: Arc, + /// Logins to Proton from the workspace, slowed apart from the rest. + pub proton_throttle: Arc, /// Sealed files. Always present; holds no key until it is unlocked. pub vault: Arc, /// The Proton Drive mirror. Always present; dormant until it has a session. diff --git a/web/src/App.svelte b/web/src/App.svelte index c67cb8d..a5eae15 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -8,6 +8,7 @@ import DocumentPane from './lib/DocumentPane.svelte' import FilePane from './lib/FilePane.svelte' import Help from './lib/Help.svelte' + import Verify from './lib/Verify.svelte' import Login from './lib/Login.svelte' import Overview from './lib/Overview.svelte' import MediaBrowser from './lib/MediaBrowser.svelte' @@ -740,7 +741,7 @@
e.target === e.currentTarget && (workspace.overlay = 'none')} > - {#if workspace.overlay === 'help'}{:else if workspace.overlay === 'overview'}{:else if workspace.overlay === 'agenda'}{:else if workspace.overlay === 'actions'}{:else}{/if} + {#if workspace.overlay === 'help'}{:else if workspace.overlay === 'overview'}{:else if workspace.overlay === 'agenda'}{:else if workspace.overlay === 'actions'}{:else if workspace.overlay === 'verify'}{:else}{/if}
{/if} diff --git a/web/src/lib/Verify.svelte b/web/src/lib/Verify.svelte new file mode 100644 index 0000000..2d5d88d --- /dev/null +++ b/web/src/lib/Verify.svelte @@ -0,0 +1,73 @@ + + + + +
+
+ proton prove you are human to log in + +
+ {#if asked} + + {/if} +
+ + diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 19ee17f..183aecd 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -216,6 +216,18 @@ export interface BackupStatus { last_success_at: number | null last_error: string | null last: { uploaded: number; skipped: number; pruned: number; bytes: number } | null + /** What Proton asks a third-party client to say before it takes a login. */ + disclosure?: string + /** Whether a stored session is sealed under a key file off the data volume. */ + sealed_session?: boolean +} + +/** What a Proton login from the workspace needs next. */ +export interface BackupLogin { + next: 'done' | 'totp' | 'captcha' + /** Proton's human-check page, for `captcha`. */ + url?: string + status: BackupStatus } export class ApiError extends Error { @@ -472,6 +484,16 @@ export const api = { backupNow: () => request('/backup', { method: 'POST' }), + backupLogin: (username: string, password: string) => + request('/backup/login', { method: 'POST', body: body({ username, password }) }), + + backupTotp: (code: string) => request('/backup/login/totp', { method: 'POST', body: body({ code }) }), + + backupCaptcha: (token: string) => + request('/backup/login/captcha', { method: 'POST', body: body({ token }) }), + + backupLogout: () => request('/backup/login', { method: 'DELETE' }), + readShared: (token: string, key: string) => request(`/share/${encodeURIComponent(token)}`, { method: 'POST', body: body({ key }) }), diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index 4b01438..3f10f93 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -1,4 +1,4 @@ -import { api, isContainer, type BackupStatus } from './api' +import { api, ApiError, isContainer, type BackupLogin, type BackupStatus } from './api' import { workspace } from './store.svelte' import { formatDay, parseDay, parseDue, relative, sealedNote, type ScrollAmount } from './util' @@ -131,7 +131,7 @@ function browserOf(agent: string): string { const describeBackup = (status: BackupStatus) => { if (!status.configured) { - return workspace.say('proton drive backups are off — run `narl-workspace proton-login` on the server', 'error') + return workspace.say('proton drive backups are off — :backup login sets them up', 'error') } if (status.last_error) { return workspace.say(`backup failed: ${status.last_error}`, 'error') @@ -141,6 +141,44 @@ const describeBackup = (status: BackupStatus) => { workspace.say(`${where}${state} · last ${ago(status.last_success_at)}`) } +/** + * Log the mirror in to Proton: the disclosure Proton asks for, then the + * password, then whatever the account asks for next — a two-factor code, or + * Proton's human check in a sheet of its own. + */ +async function protonLogin(username: string) { + const status = await api.backupStatus() + const unsealed = status.sealed_session + ? '' + : ' The session will be stored unsealed on the data volume — set WORKSPACE_SESSION_KEY_FILE to seal it.' + if (!(await workspace.ask(`${status.disclosure ?? ''}${unsealed} Log in as ${username}?`))) return + const password = await workspace.askSecret(`proton password for ${username}`) + if (password === null) return + workspace.say('logging in to proton …') + let answer: BackupLogin = await api.backupLogin(username, password) + for (let round = 0; answer.next !== 'done'; round++) { + if (round === 5) return workspace.say('proton keeps asking — try again later', 'error') + if (answer.next === 'totp') { + const code = await workspace.askSecret('proton two-factor code') + if (code === null) return workspace.say('proton login abandoned') + try { + answer = await api.backupTotp(code) + } catch (error) { + // A wrong code leaves the login waiting; ask again. + if (!(error instanceof ApiError) || error.status !== 400 || !/two-factor code/.test(error.message)) throw error + workspace.say(error.message, 'error') + } + } else { + const token = await workspace.askVerification(answer.url ?? '') + if (token === null) return workspace.say('proton login abandoned') + workspace.say('logging in to proton …') + answer = await api.backupCaptcha(token) + } + } + const device = answer.status.device ? ` as ${answer.status.device}` : '' + workspace.say(`proton drive connected${device} — the first backup follows shortly, :backup! runs one now`) +} + /** A filename that survives a download folder: `2026-09-05-first-light.md`. */ const exportName = (title: string, at: number) => { const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') @@ -787,8 +825,21 @@ export const commands: CommandSpec[] = [ { name: 'backup', group: 'view', - help: 'when the workspace was last mirrored to proton drive', - run: () => workspace.guard(async () => describeBackup(await api.backupStatus())), + args: '[login | logout]', + help: 'when the workspace was last mirrored to proton drive — login sets the mirror up, logout forgets its session', + run: (arg) => + workspace.guard(async () => { + const trimmed = arg.trim() + if (trimmed === '') return describeBackup(await api.backupStatus()) + const loginMatch = /^login\s+(\S+)$/.exec(trimmed) + if (loginMatch) return protonLogin(loginMatch[1]) + if (trimmed === 'logout') { + if (!(await workspace.ask('Forget the Proton Drive session? What is mirrored stays on Drive.'))) return + await api.backupLogout() + return workspace.say('proton drive session forgotten — backups are off') + } + workspace.say('usage: :backup [login | logout]', 'error') + }), bang: { help: 'back up to proton drive now, rather than when the workspace falls quiet', run: () => @@ -803,7 +854,7 @@ export const commands: CommandSpec[] = [ workspace.say( last ? `backed up · ${last.uploaded} file${last.uploaded === 1 ? '' : 's'} uploaded, ${last.skipped} unchanged` - : 'proton drive backups are off — run `narl-workspace proton-login` on the server', + : 'proton drive backups are off — :backup login sets them up', last ? 'info' : 'error', ) }), diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index f75baa2..2d7c7de 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -101,7 +101,7 @@ class Workspace { vimMode = $state('normal') flash = $state(null) - overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions'>('none') + overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda' | 'actions' | 'verify'>('none') /* * What is due or in hand on every board, kept loaded all the time: the bar @@ -214,6 +214,27 @@ class Workspace { return { late: overdue.cards.length, today: today.cards.length } } + /** Proton's human check a login waits on, while its sheet is open. */ + verify = $state.raw<{ url: string; resolve: (token: string | null) => void } | null>(null) + + /** The token Proton's check at `url` hands back, or null when abandoned. */ + askVerification(url: string): Promise { + this.verify?.resolve(null) + return new Promise((resolve) => { + let answered = false + this.verify = { + url, + resolve: (token) => { + if (answered) return + answered = true + this.verify = null + resolve(token) + }, + } + this.overlay = 'verify' + }) + } + /** A password, or null when it was waved away with Escape. */ askSecret(text: string): Promise { this.secret?.resolve(null)