mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
3387db1c4d
commit
e0a4c01654
15 files changed
+638
-39
No files matched your search
+2
-1
@@ -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
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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
@@ -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',
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user