diff --git a/Cargo.lock b/Cargo.lock
index 4a4ca40..80c555a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6089,6 +6089,7 @@ version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
+ "serde",
"zeroize_derive",
]
diff --git a/Cargo.toml b/Cargo.toml
index bfdce48..5403e06 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -45,7 +45,7 @@ chrono-tz = "0.10"
# opened a chunk at a time; Argon2id between the password and the key.
aes-gcm = { version = "0.10", features = ["stream"] }
argon2 = "0.5"
-zeroize = "1"
+zeroize = { version = "1", features = ["serde"] }
futures-util = { version = "0.3", default-features = false, features = ["std"] }
proton-drive-rs = "0.9.2"
diff --git a/README.md b/README.md
index 6d66b38..5d53ab1 100644
--- a/README.md
+++ b/README.md
@@ -257,10 +257,14 @@ narl-workspace proton-login # or, in Docker:
docker compose exec -it workspace narl-workspace proton-login
```
-That stores a session — tokens, the mailbox password needed to rebuild the key
-chain, and the account key salts — as a `0600` file next to the database. It
-sits on the same volume as the workspace it protects, and it is enough to read the
-account, so the volume is the thing to keep private. Built with
+That checks the password against the account's keys and stores a session —
+the tokens, and the key passphrases derived from the password at login — as a
+`0600` file next to the database. The password itself is never stored: the
+passphrases unlock the keys but cannot log in, change the password or turn 2FA
+off. They are still enough to read the account, and the file sits on the same
+volume as the workspace it protects, so the volume is the thing to keep private.
+A session stored by an older build, which kept the password, is rewritten
+without it on the next start; no new login is needed. Built with
`--features keyring` on a host that has a Secret Service, the session goes to
the OS keyring instead and the file is only a fallback; the container has no
session bus, so there it is always the file.
diff --git a/src/backup/proton.rs b/src/backup/proton.rs
index 9700a46..a0ccca8 100644
--- a/src/backup/proton.rs
+++ b/src/backup/proton.rs
@@ -2,7 +2,9 @@
use anyhow::{anyhow, Context, Result};
use proton_drive_rs::{Device, DeviceType, ProtonDriveClient};
+use proton_sdk::account::AccountClient;
use proton_sdk::config::ProtonClientConfiguration;
+use proton_sdk::error::ProtonError;
use proton_sdk::session::ProtonApiSession;
use super::session::{SessionStore, StoredSession};
@@ -60,22 +62,24 @@ pub async fn login(
.context("Proton rejected the second-factor code")?;
}
- // Salts are fetched here and only here: this access token still carries the
- // `locked` scope, and no refreshed one ever will.
+ // 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 key_salts = client
- .account()
- .key_salts()
+ let account = client.account();
+ let key_passphrases = account
+ .key_passphrases()
.await
- .context("could not read the account key salts")?;
+ .context("could not derive the account key passphrases")?;
+ account.unlock().await.map_err(|e| match e {
+ ProtonError::KeysLocked => anyhow!("that password does not unlock the account's keys"),
+ e => anyhow::Error::from(e).context("could not unlock the account's keys"),
+ })?;
let tokens = session.current_tokens().await;
- store.save(&StoredSession::from_session(
- &session,
- tokens,
- password,
- key_salts,
- ))?;
+ store.save(&StoredSession::from_session(&session, tokens, key_passphrases))?;
Ok(())
}
@@ -83,17 +87,15 @@ pub async fn login(
/// been stored yet — that is the ordinary "backups are not set up" state, not
/// an error.
pub async fn resume(store: &SessionStore) -> Result