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> { - 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> { } }); - 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 { + 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, /// `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, + #[serde(default, skip_serializing)] pub key_salts: Vec, } @@ -46,8 +57,7 @@ impl StoredSession { pub fn from_session( session: &ProtonApiSession, tokens: proton_sdk::http::Tokens, - mailbox_password: &str, - key_salts: Vec, + 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(value: &KeyPassphrases, out: S) -> Result { + out.collect_map(value.iter().map(|(id, passphrase)| (id, STANDARD.encode(passphrase)))) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(input: D) -> Result { + let encoded = BTreeMap::>::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); + } +}