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
# --- 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.
# 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
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
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 |
+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.
+2 -1
View File
@@ -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 @@
<!-- 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')}
>
{#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>
{/if}
</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_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<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) =>
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 { 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 <proton-username> 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 <proton-username> | 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 <proton-username> | 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 <proton-username> sets them up',
last ? 'info' : 'error',
)
}),
+22 -1
View File
@@ -101,7 +101,7 @@ class Workspace {
vimMode = $state('normal')
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
@@ -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<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. */
askSecret(text: string): Promise<string | null> {
this.secret?.resolve(null)