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

+107
View File
@@ -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<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 {
@@ -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<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 {
let mut status = self
.status
+135 -18
View File
@@ -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<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,
username: &str,
password: &str,
get_totp: impl FnOnce() -> Result<String>,
) -> Result<()> {
let mut session = ProtonApiSession::begin(client_config(), username, password.as_bytes())
.await
.context("Proton rejected the login")?;
password: Zeroizing<String>,
verified: Option<&HumanVerificationCredential>,
) -> Result<Option<(Next, Pending)>> {
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<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
// 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.
+5
View File
@@ -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
}
+18 -7
View File
@@ -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 <username>`"
),
}
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(())
}
+4 -1
View File
@@ -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<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 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>> {
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<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
@@ -14,3 +36,145 @@ pub async fn run(_: Session, State(state): State<AppState>) -> AppResult<Json<St
state.backup.run_now().await?;
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("/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,
+2
View File
@@ -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<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.
pub vault: Arc<Vault>,
/// The Proton Drive mirror. Always present; dormant until it has a session.