backup: the Proton login runs from the workspace

:backup login <username> 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.
This commit is contained in:
nvrl committed 2026-10-20 12:54:03 +02:00
1 parent 3387db1c4d
commit e0a4c01654
15 files changed
+638 -39

No files matched your search

+2 -1
View File
@@ -44,7 +44,8 @@ WORKSPACE_SECURE_COOKIE=0
# WORKSPACE_SFTP_BIND=127.0.0.1:2222 # WORKSPACE_SFTP_BIND=127.0.0.1:2222
# --- Proton Drive backup ----------------------------------------------------- # --- Proton Drive backup -----------------------------------------------------
# Off until `narl-workspace proton-login` stores a session under WORKSPACE_DATA_DIR. # Off until `:backup login <username>` in the workspace (or `narl-workspace
# proton-login`) stores a session under WORKSPACE_DATA_DIR.
# These only shape how often the mirror runs. # These only shape how often the mirror runs.
# The device the server registers in Proton Drive; its sync root is the folder # The device the server registers in Proton Drive; its sync root is the folder
+18 -1
View File
@@ -410,7 +410,22 @@ It is a third-party client: it identifies itself to Proton as
`external-drive-narl_workspace@<version>-alpha` and says so before it asks for `external-drive-narl_workspace@<version>-alpha` and says so before it asks for
account details. It carries no Proton branding and is not supported by Proton. 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 <proton-username>
```
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 ```sh
narl-workspace proton-login # or, in Docker: 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 | | `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` | `/api/export` | the whole workspace, boards and dates included, as a zip |
| `GET`/`POST` | `/api/backup` | Proton Drive mirror: status / run now | | `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`/`POST` | `/api/media` | list / upload (multipart) |
| `GET`/`DELETE` | `/api/media/{id}` | serve (with `Range`) / delete a file nothing in the tree owns | | `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 | | `GET` | `/api/spaces/overview` | every space with what was last written in it |
+107
View File
@@ -29,6 +29,7 @@ use proton_sdk::ids::NodeUid;
use serde::Serialize; use serde::Serialize;
use sqlx::{Row, SqlitePool}; use sqlx::{Row, SqlitePool};
use tokio::sync::Notify; use tokio::sync::Notify;
use zeroize::Zeroizing;
use crate::config::Config; use crate::config::Config;
use session::SessionStore; use session::SessionStore;
@@ -66,6 +67,21 @@ pub struct Backup {
/// but unreadable — when that is so. The server still starts; the mirror /// but unreadable — when that is so. The server still starts; the mirror
/// says why it is off, every time it is asked. /// says why it is off, every time it is asked.
disabled: Option<String>, disabled: Option<String>,
/// A login from the workspace, waiting for its second factor or its
/// human check; see [`Backup::login`].
pending: Mutex<Option<proton::Pending>>,
}
/// 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 { impl Backup {
@@ -104,9 +120,100 @@ impl Backup {
..Status::default() ..Status::default()
}), }),
running: tokio::sync::Mutex::new(()), 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<String>) -> Result<LoginStep> {
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<LoginStep> {
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<LoginStep> {
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<proton::Pending> {
self.pending
.lock()
.unwrap_or_else(|e| e.into_inner())
.take()
}
fn waiting(&self) -> Result<proton::Pending> {
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 { pub fn status(&self) -> Status {
let mut status = self let mut status = self
.status .status
+135 -18
View File
@@ -3,14 +3,17 @@
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use proton_drive_rs::{Device, DeviceType, ProtonDriveClient}; use proton_drive_rs::{Device, DeviceType, ProtonDriveClient};
use proton_sdk::account::AccountClient; use proton_sdk::account::AccountClient;
use proton_sdk::api::{HumanVerificationCredential, ResponseCode};
use proton_sdk::config::ProtonClientConfiguration; use proton_sdk::config::ProtonClientConfiguration;
use proton_sdk::error::ProtonError; use proton_sdk::error::ProtonError;
use proton_sdk::session::ProtonApiSession; use proton_sdk::session::ProtonApiSession;
use zeroize::Zeroizing;
use super::session::{SessionStore, StoredSession}; use super::session::{SessionStore, StoredSession};
/// What Proton asks a third-party client to tell anyone it takes credentials /// 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 = pub const DISCLOSURE: &str =
"This is a third-party application not officially supported by Proton."; "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) ProtonClientConfiguration::new(version).with_user_agent(agent)
} }
/// Run an SRP login, complete 2FA if the account asks for one, and persist the /// How long a login may wait for its second factor or its human check. The
/// result. `get_totp` is only called when a second factor is actually required, /// password is held in memory for that long and no longer.
/// so accounts without one are never prompted. const PENDING_FOR: std::time::Duration = std::time::Duration::from_secs(5 * 60);
pub async fn login(
/// 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<String>,
session: Option<ProtonApiSession>,
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, store: &SessionStore,
username: &str, username: &str,
password: &str, password: Zeroizing<String>,
get_totp: impl FnOnce() -> Result<String>, verified: Option<&HumanVerificationCredential>,
) -> Result<()> { ) -> Result<Option<(Next, Pending)>> {
let mut session = ProtonApiSession::begin(client_config(), username, password.as_bytes()) let begun =
.await ProtonApiSession::begin_verified(client_config(), username, password.as_bytes(), verified)
.context("Proton rejected the login")?; .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() { if session.is_waiting_for_second_factor() {
let code = get_totp()?; if !session.second_factor_methods().totp {
session return Err(anyhow!(
.apply_second_factor_code(code.trim()) "this account's second factor is a security key only — add an authenticator \
.await app to it in Proton's settings, which this server can use"
.context("Proton rejected the second-factor code")?; ));
}
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<Box<Pending>>)> {
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<Option<(Next, Pending)>> {
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 // 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 // carries the `locked` scope the key salts need, and no refreshed one ever
// will. Unlocking checks them against the account's keys before anything // 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 // is stored, so a wrong mailbox password fails now and not at the first
// backup. // 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 account = client.account();
let key_passphrases = account let key_passphrases = account
.key_passphrases() .key_passphrases()
@@ -84,13 +186,28 @@ pub async fn login(
let tokens = session.current_tokens().await; let tokens = session.current_tokens().await;
store.save(&StoredSession::from_session( store.save(&StoredSession::from_session(
&session, session,
tokens, tokens,
key_passphrases, key_passphrases,
))?; ))?;
Ok(()) 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 /// 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 /// been stored yet — that is the ordinary "backups are not set up" state, not
/// an error. /// an error.
+5
View File
@@ -178,6 +178,11 @@ impl SessionStore {
Ok(store) 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 { pub fn path(&self) -> &Path {
&self.path &self.path
} }
+18 -7
View File
@@ -55,19 +55,30 @@ pub async fn proton_login(config: &Config) -> Result<()> {
} }
}; };
backup::proton::login(&store, username.trim(), &password, || { let password = zeroize::Zeroizing::new(password);
match env("PROTON_TOTP") { use backup::proton::Next;
Some(code) => Ok(code), match backup::proton::start(&store, username.trim(), password, None).await? {
None => prompt("Two-factor code: "), 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)?;
} }
}) Some((Next::Captcha(_), _)) => bail!(
.await?; "Proton wants a human check before this login, which a terminal cannot show — \
log in from the workspace instead, with `:backup login <username>`"
),
}
println!( println!(
"Logged in. The session is stored at {}.", "Logged in. The session is stored at {}.",
store.path().display() 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(()) Ok(())
} }
+4 -1
View File
@@ -132,6 +132,7 @@ async fn main() -> Result<()> {
config: Arc::clone(&config), config: Arc::clone(&config),
login_throttle: Arc::default(), login_throttle: Arc::default(),
vault_throttle: Arc::default(), vault_throttle: Arc::default(),
proton_throttle: Arc::default(),
backup, backup,
notify, notify,
epoch: Arc::new(std::sync::atomic::AtomicI64::new(epoch)), 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/media"
|| path == "/api/import" || path == "/api/import"
|| path == "/api/backup" || 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("/import")
|| path.ends_with("/files") || path.ends_with("/files")
|| path.ends_with("/encrypt") || path.ends_with("/encrypt")
@@ -383,7 +386,7 @@ fn compressible(
/// runtime; scripts stay confined to the bundled files. /// runtime; scripts stay confined to the bundled files.
const CSP: &str = "default-src 'self'; img-src 'self' data:; media-src 'self'; \ const CSP: &str = "default-src 'self'; img-src 'self' data:; media-src 'self'; \
style-src 'self' 'unsafe-inline'; script-src 'self'; connect-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<HeaderValue>; type HeaderLayer = SetResponseHeaderLayer<HeaderValue>;
+168 -4
View File
@@ -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 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<AppState>) -> AppResult<Json<Status>> { #[derive(Serialize)]
Ok(Json(state.backup.status())) 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<AppState>) -> AppResult<Json<Shown>> {
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 /// 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<AppState>) -> AppResult<Json<St
state.backup.run_now().await?; state.backup.run_now().await?;
Ok(Json(state.backup.status())) Ok(Json(state.backup.status()))
} }
#[derive(Deserialize)]
pub struct LoginInput {
pub username: String,
pub password: Zeroizing<String>,
}
#[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<Output = anyhow::Result<LoginStep>>,
) -> AppResult<Json<Answer>> {
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<AppState>,
Json(input): Json<LoginInput>,
) -> AppResult<Json<Answer>> {
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<AppState>,
Json(input): Json<CodeInput>,
) -> AppResult<Json<Answer>> {
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<AppState>,
Json(input): Json<TokenInput>,
) -> AppResult<Json<Answer>> {
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<AppState>) -> AppResult<Json<Status>> {
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}"
);
}
}
+4
View File
@@ -45,6 +45,9 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
.route("/sessions/{id}", delete(session::revoke)) .route("/sessions/{id}", delete(session::revoke))
.route("/export", get(export::export)) .route("/export", get(export::export))
.route("/backup", get(backup::status).post(backup::run)) .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", get(nodes::spaces).post(nodes::create_space))
.route("/spaces/overview", get(nodes::overview)) .route("/spaces/overview", get(nodes::overview))
.route("/agenda", get(boards::agenda)) .route("/agenda", get(boards::agenda))
@@ -141,6 +144,7 @@ pub(crate) mod tests {
notify: Notifier::new(db.clone(), Arc::clone(&config)).unwrap(), notify: Notifier::new(db.clone(), Arc::clone(&config)).unwrap(),
login_throttle: Arc::default(), login_throttle: Arc::default(),
vault_throttle: Arc::default(), vault_throttle: Arc::default(),
proton_throttle: Arc::default(),
epoch: Arc::new(AtomicI64::new(0)), epoch: Arc::new(AtomicI64::new(0)),
sessions: Arc::default(), sessions: Arc::default(),
config, config,
+2
View File
@@ -19,6 +19,8 @@ pub struct AppState {
/// The vault password gets the same slowing-down as the login, kept apart /// The vault password gets the same slowing-down as the login, kept apart
/// so guessing at one does not slow the other. /// so guessing at one does not slow the other.
pub vault_throttle: Arc<LoginThrottle>, pub vault_throttle: Arc<LoginThrottle>,
/// Logins to Proton from the workspace, slowed apart from the rest.
pub proton_throttle: Arc<LoginThrottle>,
/// Sealed files. Always present; holds no key until it is unlocked. /// Sealed files. Always present; holds no key until it is unlocked.
pub vault: Arc<Vault>, pub vault: Arc<Vault>,
/// The Proton Drive mirror. Always present; dormant until it has a session. /// The Proton Drive mirror. Always present; dormant until it has a session.
+2 -1
View File
@@ -8,6 +8,7 @@
import DocumentPane from './lib/DocumentPane.svelte' import DocumentPane from './lib/DocumentPane.svelte'
import FilePane from './lib/FilePane.svelte' import FilePane from './lib/FilePane.svelte'
import Help from './lib/Help.svelte' import Help from './lib/Help.svelte'
import Verify from './lib/Verify.svelte'
import Login from './lib/Login.svelte' import Login from './lib/Login.svelte'
import Overview from './lib/Overview.svelte' import Overview from './lib/Overview.svelte'
import MediaBrowser from './lib/MediaBrowser.svelte' import MediaBrowser from './lib/MediaBrowser.svelte'
@@ -740,7 +741,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex --> <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay = 'none')} <div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay = 'none')}
> >
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'agenda'}<Agenda />{:else if workspace.overlay === 'actions'}<Actions />{:else}<MediaBrowser />{/if} {#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'agenda'}<Agenda />{:else if workspace.overlay === 'actions'}<Actions />{:else if workspace.overlay === 'verify'}<Verify />{:else}<MediaBrowser />{/if}
</div> </div>
{/if} {/if}
</div> </div>
+73
View File
@@ -0,0 +1,73 @@
<script lang="ts">
/*
* Proton's human check, when a login from `:backup login` runs into one:
* Proton's own page in a frame, which hands back a token once it is solved.
* Only messages from that page are listened to. Closing the sheet — Escape,
* or a click beside it — abandons the login.
*/
import { onDestroy } from 'svelte'
import { workspace } from './store.svelte'
const ORIGIN = 'https://verify.proton.me'
const asked = workspace.verify
let height = $state(560)
function onmessage(event: MessageEvent) {
if (event.origin !== ORIGIN) return
let data = event.data
if (typeof data === 'string') {
try {
data = JSON.parse(data)
} catch {
return
}
}
const payload = data?.payload
if (data?.type === 'HUMAN_VERIFICATION_SUCCESS' && typeof payload?.token === 'string') {
asked?.resolve(payload.token)
workspace.overlay = 'none'
} else if (data?.type === 'RESIZE' && typeof payload?.height === 'number') {
height = Math.min(Math.max(payload.height, 320), 900)
} else if (data?.type === 'CLOSE') {
workspace.overlay = 'none'
}
}
onDestroy(() => asked?.resolve(null))
</script>
<svelte:window {onmessage} />
<div class="sheet">
<header>
<span class="label rule"><span class="marker"></span><span class="title">proton</span> prove you are human to log in</span>
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
</header>
{#if asked}
<iframe
title="Proton human verification"
src={asked.url}
style:height="{height}px"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
referrerpolicy="no-referrer"
></iframe>
{/if}
</div>
<style>
.sheet {
display: flex;
flex-direction: column;
gap: 16px;
width: min(100%, 64ch);
max-height: 100%;
overflow-y: auto;
background: color-mix(in oklab, var(--hull) 97%, transparent);
border: 1px solid var(--surface-alt);
border-radius: var(--frame-radius);
padding: 20px 24px;
}
header { display: flex; align-items: center; gap: 12px; }
header .label { flex: 1; }
iframe { width: 100%; border: 0; border-radius: 6px; background: #fff; }
</style>
+22
View File
@@ -216,6 +216,18 @@ export interface BackupStatus {
last_success_at: number | null last_success_at: number | null
last_error: string | null last_error: string | null
last: { uploaded: number; skipped: number; pruned: number; bytes: number } | 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 { export class ApiError extends Error {
@@ -472,6 +484,16 @@ export const api = {
backupNow: () => request<BackupStatus>('/backup', { method: 'POST' }), backupNow: () => request<BackupStatus>('/backup', { method: 'POST' }),
backupLogin: (username: string, password: string) =>
request<BackupLogin>('/backup/login', { method: 'POST', body: body({ username, password }) }),
backupTotp: (code: string) => request<BackupLogin>('/backup/login/totp', { method: 'POST', body: body({ code }) }),
backupCaptcha: (token: string) =>
request<BackupLogin>('/backup/login/captcha', { method: 'POST', body: body({ token }) }),
backupLogout: () => request<BackupStatus>('/backup/login', { method: 'DELETE' }),
readShared: (token: string, key: string) => readShared: (token: string, key: string) =>
request<SharedDocument>(`/share/${encodeURIComponent(token)}`, { method: 'POST', body: body({ key }) }), request<SharedDocument>(`/share/${encodeURIComponent(token)}`, { method: 'POST', body: body({ key }) }),
+56 -5
View File
@@ -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 { workspace } from './store.svelte'
import { formatDay, parseDay, parseDue, relative, sealedNote, type ScrollAmount } from './util' import { formatDay, parseDay, parseDue, relative, sealedNote, type ScrollAmount } from './util'
@@ -131,7 +131,7 @@ function browserOf(agent: string): string {
const describeBackup = (status: BackupStatus) => { const describeBackup = (status: BackupStatus) => {
if (!status.configured) { 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 <proton-username> sets them up', 'error')
} }
if (status.last_error) { if (status.last_error) {
return workspace.say(`backup failed: ${status.last_error}`, '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)}`) 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`. */ /** A filename that survives a download folder: `2026-09-05-first-light.md`. */
const exportName = (title: string, at: number) => { const exportName = (title: string, at: number) => {
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
@@ -787,8 +825,21 @@ export const commands: CommandSpec[] = [
{ {
name: 'backup', name: 'backup',
group: 'view', group: 'view',
help: 'when the workspace was last mirrored to proton drive', args: '[login <proton-username> | logout]',
run: () => workspace.guard(async () => describeBackup(await api.backupStatus())), 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 <proton-username> | logout]', 'error')
}),
bang: { bang: {
help: 'back up to proton drive now, rather than when the workspace falls quiet', help: 'back up to proton drive now, rather than when the workspace falls quiet',
run: () => run: () =>
@@ -803,7 +854,7 @@ export const commands: CommandSpec[] = [
workspace.say( workspace.say(
last last
? `backed up · ${last.uploaded} file${last.uploaded === 1 ? '' : 's'} uploaded, ${last.skipped} unchanged` ? `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 <proton-username> sets them up',
last ? 'info' : 'error', last ? 'info' : 'error',
) )
}), }),
+22 -1
View File
@@ -101,7 +101,7 @@ class Workspace {
vimMode = $state('normal') vimMode = $state('normal')
flash = $state<Flash>(null) flash = $state<Flash>(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 * 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 } 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<string | null> {
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. */ /** A password, or null when it was waved away with Escape. */
askSecret(text: string): Promise<string | null> { askSecret(text: string): Promise<string | null> {
this.secret?.resolve(null) this.secret?.resolve(null)