backup: keep Proton key passphrases, never the password

The stored session used to hold the mailbox password, which on a
single-password account is the login password. It now holds the per-key
passphrases derived from it at login, which unlock the keys but cannot
log in, change the password or turn 2FA off. Login also checks them
against the account's keys, so a wrong mailbox password fails there
rather than at the first backup.

A session stored by an older build is upgraded on the next start: its
key salts are already known, so the passphrases derive locally and the
file is rewritten without the password.
This commit is contained in:
nvrl committed 2026-10-19 16:48:43 +02:00
1 parent 13551469a0
commit cd18f80c04
5 files changed
+233 -50

No files matched your search

Generated
+1
View File
@@ -6089,6 +6089,7 @@ version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [
"serde",
"zeroize_derive",
]
+1 -1
View File
@@ -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"
+8 -4
View File
@@ -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.
+106 -26
View File
@@ -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<Option<ProtonDriveClient>> {
let Some(stored) = store.load()? else {
let Some(mut stored) = store.load()? else {
return Ok(None);
};
let session = ProtonApiSession::resume(client_config(), stored.resume_parameters())
.context("the stored Proton session could not be resumed")?;
if stored.key_salts.is_empty() {
return Err(anyhow!(
"the stored Proton session has no key salts — run `narl-workspace proton-login` again"
));
if stored.key_passphrases.is_empty() {
stored = upgrade(store, &session, stored).await?;
}
// Every 401 refresh mints a new refresh token and invalidates the old one,
@@ -111,18 +113,50 @@ pub async fn resume(store: &SessionStore) -> Result<Option<ProtonDriveClient>> {
}
});
let client = ProtonDriveClient::with_key_salts(
&session,
stored.mailbox_password.clone().into_bytes(),
stored.key_salts.clone(),
)
// A workspace is mostly small files; one atomic request beats the
// draft/block/commit dance for anything that fits in a single block.
.with_small_file_upload(true);
let client = ProtonDriveClient::with_key_passphrases(&session, stored.key_passphrases)
// A workspace is mostly small files; one atomic request beats the
// draft/block/commit dance for anything that fits in a single block.
.with_small_file_upload(true);
Ok(Some(client))
}
/// Rewrite a session stored before the passphrases were, which kept the
/// mailbox password and the key salts instead. The salts are already known, so
/// the passphrases derive locally, with no request and no new login, and the
/// password is gone from the store once the new session is written.
async fn upgrade(
store: &SessionStore,
session: &ProtonApiSession,
stored: StoredSession,
) -> Result<StoredSession> {
let (Some(password), false) = (&stored.mailbox_password, stored.key_salts.is_empty()) else {
return Err(anyhow!(
"the stored Proton session has no key passphrases — run `narl-workspace proton-login` again"
));
};
let account = AccountClient::with_key_salts(
session,
password.clone().into_bytes(),
stored.key_salts.clone(),
);
let key_passphrases = account
.key_passphrases()
.await
.context("could not derive key passphrases from the stored session")?;
let upgraded = StoredSession {
key_passphrases,
mailbox_password: None,
key_salts: Vec::new(),
..stored
};
store.save(&upgraded)?;
tracing::info!("the stored Proton session now keeps key passphrases instead of the password");
Ok(upgraded)
}
/// The device this server owns, registered on first use.
///
/// A device is a sync root with its own share, which is what makes the workspace
@@ -152,7 +186,53 @@ pub async fn ensure_device(client: &ProtonDriveClient, name: &str) -> Result<Dev
#[cfg(test)]
mod tests {
use super::app_version;
use super::*;
/// No request leaves for this: resuming only builds the HTTP client, and
/// with the salts already stored the passphrases derive locally.
#[tokio::test]
async fn an_old_session_trades_its_password_for_passphrases() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("proton-session.json");
std::fs::write(
&path,
r#"{"session_id":"s","username":"u","user_id":"i","access_token":"a",
"refresh_token":"r","scopes":[],"password_mode":1,
"mailbox_password":"hunter2hunter2",
"key_salts":[{"ID":"key-1","KeySalt":"c2FsdHNhbHRzYWx0c2FsdA=="}]}"#,
)
.unwrap();
let store = SessionStore::new(&path);
let stored = store.load().unwrap().unwrap();
let session = ProtonApiSession::resume(client_config(), stored.resume_parameters()).unwrap();
let upgraded = upgrade(&store, &session, stored).await.unwrap();
assert_eq!(upgraded.key_passphrases.len(), 1);
assert!(upgraded.key_passphrases.get("key-1").is_some());
let json = std::fs::read_to_string(&path).unwrap();
assert!(!json.contains("hunter2"), "{json}");
let reloaded = store.load().unwrap().unwrap();
assert!(reloaded.key_passphrases == upgraded.key_passphrases);
}
#[tokio::test]
async fn a_session_with_nothing_to_unlock_with_asks_for_a_login() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("proton-session.json");
std::fs::write(
&path,
r#"{"session_id":"s","username":"u","user_id":"i","access_token":"a",
"refresh_token":"r","scopes":[],"password_mode":1}"#,
)
.unwrap();
let store = SessionStore::new(&path);
let stored = store.load().unwrap().unwrap();
let session = ProtonApiSession::resume(client_config(), stored.resume_parameters()).unwrap();
let error = upgrade(&store, &session, stored).await.err().unwrap();
assert!(error.to_string().contains("proton-login"), "{error}");
}
#[test]
fn identifies_itself_the_way_proton_requires() {
+117 -19
View File
@@ -1,16 +1,22 @@
//! The stored Proton session: everything needed to come back up unattended.
//!
//! A login is worth persisting whole, because none of it can be re-derived
//! without a human: the tokens (the session itself), the mailbox password
//! (which rebuilds the key chain on every start — the SDK needs it in memory to
//! decrypt anything), and the account's key salts.
//! without a human: the tokens (the session itself) and the key passphrases
//! (which rebuild the key chain on every start — the SDK needs them in memory
//! to decrypt anything).
//!
//! The salts matter more than they look. `core/v4/keys/salts` requires the
//! `locked` scope, and only the access token minted by a *password* login
//! carries it; once that token has been rotated through `auth/v4/refresh` — as
//! it is on every restart after the first — the endpoint answers 403 and the
//! key chain can no longer be unlocked. So the salts are captured at login and
//! handed back on resume, exactly as the desktop client does it.
//! The passphrases are what the password is for: each is
//! `bcrypt(mailbox password, key salt)` and unlocks one account key. Keeping
//! them instead of the password means the store never holds the password
//! itself, which on a single-password account is the login password. They
//! read the account all the same, so they are no less secret — but they cannot
//! log in, change the password or turn 2FA off.
//!
//! They are derived at login and only then. Deriving needs the key salts, and
//! `core/v4/keys/salts` requires the `locked` scope, which only the access
//! token minted by a *password* login carries; once that token has been rotated
//! through `auth/v4/refresh` — as it is on every restart after the first — the
//! endpoint answers 403.
//!
//! Refresh tokens are single-use: a rotation that is not written back leaves a
//! dead token on disk and the next resume fails. Hence the refresh hook in
@@ -20,6 +26,7 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use proton_drive_rs::KeySalt;
use proton_sdk::account::KeyPassphrases;
use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters};
use serde::{Deserialize, Serialize};
@@ -33,12 +40,16 @@ pub struct StoredSession {
pub scopes: Vec<String>,
/// `1` = single password, `2` = dual (Proton's wire values).
pub password_mode: u8,
/// Mailbox (data) password. `ProtonDriveClient` derives the key chain from
/// it on every construction, so a backup that runs unattended has to hold
/// it — there is no token-only mode.
pub mailbox_password: String,
/// Captured at login, because a refreshed token may no longer fetch them.
#[serde(default)]
/// Key id → the passphrase that unlocks it, derived at login. Empty only
/// in a session written before they were, which still has the two fields
/// below.
#[serde(default, with = "passphrases")]
pub key_passphrases: KeyPassphrases,
/// What a session stored before the passphrases kept instead of them.
/// Read once, to derive the passphrases, and never written again.
#[serde(default, skip_serializing)]
pub mailbox_password: Option<String>,
#[serde(default, skip_serializing)]
pub key_salts: Vec<KeySalt>,
}
@@ -46,8 +57,7 @@ impl StoredSession {
pub fn from_session(
session: &ProtonApiSession,
tokens: proton_sdk::http::Tokens,
mailbox_password: &str,
key_salts: Vec<KeySalt>,
key_passphrases: KeyPassphrases,
) -> Self {
Self {
session_id: session.session_id().as_str().to_owned(),
@@ -60,8 +70,9 @@ impl StoredSession {
PasswordMode::Single => 1,
PasswordMode::Dual => 2,
},
mailbox_password: mailbox_password.to_owned(),
key_salts,
key_passphrases,
mailbox_password: None,
key_salts: Vec::new(),
}
}
@@ -82,6 +93,31 @@ impl StoredSession {
}
}
/// Passphrases are bytes; the session file is JSON. Each one is kept as
/// base64 under its key id.
mod passphrases {
use std::collections::BTreeMap;
use base64::{engine::general_purpose::STANDARD, Engine};
use proton_sdk::account::KeyPassphrases;
use serde::{de::Error, Deserialize, Deserializer, Serializer};
use zeroize::Zeroizing;
pub fn serialize<S: Serializer>(value: &KeyPassphrases, out: S) -> Result<S::Ok, S::Error> {
out.collect_map(value.iter().map(|(id, passphrase)| (id, STANDARD.encode(passphrase))))
}
pub fn deserialize<'de, D: Deserializer<'de>>(input: D) -> Result<KeyPassphrases, D::Error> {
let encoded = BTreeMap::<String, Zeroizing<String>>::deserialize(input)?;
let mut passphrases = KeyPassphrases::new();
for (id, passphrase) in encoded {
let bytes = STANDARD.decode(passphrase.as_bytes()).map_err(D::Error::custom)?;
passphrases.insert(id, bytes);
}
Ok(passphrases)
}
}
/// Where a session is kept.
///
/// The OS keyring is the right home for this and is what the desktop client
@@ -202,3 +238,65 @@ fn keyring_clear() -> Result<()> {
Ok(Err(e)) => Err(e.into()),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn stored(key_passphrases: KeyPassphrases) -> StoredSession {
StoredSession {
session_id: "session".into(),
username: "someone".into(),
user_id: "user".into(),
access_token: "access".into(),
refresh_token: "refresh".into(),
scopes: vec!["full".into()],
password_mode: 1,
key_passphrases,
mailbox_password: None,
key_salts: Vec::new(),
}
}
#[test]
fn passphrases_survive_the_file_and_the_password_never_reaches_it() {
let dir = tempfile::tempdir().unwrap();
let store = SessionStore::new(dir.path().join("proton-session.json"));
let passphrases =
KeyPassphrases::from_iter([("key-1", b"one\0\xff".to_vec()), ("key-2", b"two".to_vec())]);
// Even a session that still holds the old fields in memory writes
// neither of them.
let mut session = stored(passphrases.clone());
session.mailbox_password = Some("hunter2hunter2".into());
store.save(&session).unwrap();
let json = std::fs::read_to_string(store.path()).unwrap();
assert!(!json.contains("hunter2"), "{json}");
assert!(!json.contains("mailbox_password"), "{json}");
assert!(!json.contains("key_salts"), "{json}");
let loaded = store.load().unwrap().unwrap();
assert!(loaded.key_passphrases == passphrases);
assert!(loaded.mailbox_password.is_none());
}
#[test]
fn a_session_from_before_the_passphrases_still_loads() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("proton-session.json");
std::fs::write(
&path,
r#"{"session_id":"s","username":"u","user_id":"i","access_token":"a",
"refresh_token":"r","scopes":[],"password_mode":1,
"mailbox_password":"hunter2hunter2",
"key_salts":[{"ID":"key-1","KeySalt":"c2FsdHNhbHRzYWx0c2FsdA=="}]}"#,
)
.unwrap();
let loaded = SessionStore::new(path).load().unwrap().unwrap();
assert!(loaded.key_passphrases.is_empty());
assert_eq!(loaded.mailbox_password.as_deref(), Some("hunter2hunter2"));
assert_eq!(loaded.key_salts.len(), 1);
}
}