From cc7d05aaf25b796a4438391af9a5c11d7a5c03c8 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Mon, 19 Oct 2026 16:57:02 +0200 Subject: [PATCH] backup: seal the Proton session under a key kept off the volume WORKSPACE_SESSION_KEY_FILE names a file holding a 32-byte key, meant to be a Docker secret. When it is set, proton-session.json is written as AES-256-GCM ciphertext, so a copy of the data volume alone no longer carries a usable session. That is what the OS keyring gives a native host, for a container that has no Secret Service. A plain session found while a key is set is sealed the first time it is read. A sealed file without its key, or a malformed key file, is an error at startup rather than a quiet fall back to plain JSON. --- .env.example | 6 ++ .gitignore | 1 + README.md | 16 ++++ compose.override.yml.example | 10 ++ src/auth.rs | 1 + src/backup/mod.rs | 10 +- src/backup/session.rs | 178 +++++++++++++++++++++++++++++++++-- src/cli.rs | 6 +- src/config.rs | 5 + src/main.rs | 2 +- 10 files changed, 218 insertions(+), 17 deletions(-) diff --git a/.env.example b/.env.example index b9bd9b6..5af89a7 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,12 @@ WORKSPACE_BACKUP_DEBOUNCE_SEC=300 # default: a backup that forgets on command is one accident from being none. WORKSPACE_BACKUP_PRUNE=0 +# Seal the stored Proton session under a key kept off the data volume, so a +# copy of the volume alone carries no usable session. Point it at a Docker +# secret (see compose.override.yml.example); `openssl rand -base64 32` makes +# the key. Losing the key costs a new `proton-login`, nothing more. +# WORKSPACE_SESSION_KEY_FILE=/run/secrets/session_key + # --- Notification mail ------------------------------------------------------- # Off until WORKSPACE_SMTP_URL is set. Nothing is even queued while it is unset. diff --git a/.gitignore b/.gitignore index 85dda3e..8f6102f 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ # secrets .env +/secrets # host-specific compose overlay compose.override.yml diff --git a/README.md b/README.md index 5d53ab1..e644e98 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,22 @@ without it on the next start; no new login is needed. Built with the OS keyring instead and the file is only a fallback; the container has no session bus, so there it is always the file. +In a container, `WORKSPACE_SESSION_KEY_FILE` does what the keyring would: the +file is sealed with AES-256-GCM under a key read from somewhere other than the +volume, so a copy of the volume alone is no longer a session. A Docker secret is +the place for it: + +```sh +mkdir -p secrets +openssl rand -base64 32 > secrets/session.key +sudo chown 10001 secrets/session.key && sudo chmod 400 secrets/session.key +``` + +then the `secrets:` lines from `compose.override.yml.example`, and +`WORKSPACE_SESSION_KEY_FILE=/run/secrets/session_key` in `.env`. A plain session +already on the volume is sealed the next time it is read. Lose the key and the +session goes with it; `proton-login` makes a new one. + Afterwards the server resumes on its own and nothing prompts again. Refresh tokens are single-use, so every rotation is written back immediately — which is also why two servers must not share one session file. diff --git a/compose.override.yml.example b/compose.override.yml.example index 182a22e..4c18b9c 100644 --- a/compose.override.yml.example +++ b/compose.override.yml.example @@ -17,6 +17,10 @@ services: - "traefik.http.routers.workspace.entrypoints=websecure" - "traefik.http.routers.workspace.tls.certresolver=https_resolver" - "traefik.http.services.workspace.loadbalancer.server.port=4242" + # The key the Proton session is sealed under, mounted from the host at + # /run/secrets/session_key. Needs WORKSPACE_SESSION_KEY_FILE in .env. + secrets: + - session_key # Mail, locally. Mailpit accepts everything on 1025 and shows it on 8025, so # the reminders can be read before a real relay is ever involved: @@ -33,6 +37,12 @@ services: ports: - "127.0.0.1:8025:8025" +secrets: + # Outside compose is a plain bind mount, so the file must be readable by + # the container's user: `chown 10001 secrets/session.key`. + session_key: + file: ./secrets/session.key + networks: narl: external: true diff --git a/src/auth.rs b/src/auth.rs index efe2f1b..44bc02e 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -118,6 +118,7 @@ mod tests { interval: None, debounce: std::time::Duration::from_secs(0), prune: false, + session_key_file: None, }, mail: MailConfig { url: None, diff --git a/src/backup/mod.rs b/src/backup/mod.rs index 9633db7..2aa9fb7 100644 --- a/src/backup/mod.rs +++ b/src/backup/mod.rs @@ -65,10 +65,10 @@ pub struct Backup { } impl Backup { - pub fn new(db: SqlitePool, config: Arc) -> Arc { - let store = SessionStore::new(config.proton_session_path()); + pub fn new(db: SqlitePool, config: Arc) -> Result> { + let store = SessionStore::open(&config)?; let configured = matches!(store.load(), Ok(Some(_))); - Arc::new(Self { + Ok(Arc::new(Self { db, config, store, @@ -81,7 +81,7 @@ impl Backup { ..Status::default() }), running: tokio::sync::Mutex::new(()), - }) + })) } pub fn status(&self) -> Status { @@ -333,7 +333,7 @@ pub fn spawn(backup: Arc) { /// One-shot mirror for `narl-workspace backup-now`, without starting a server. pub async fn run_once(db: SqlitePool, config: Arc) -> Result { - let backup = Backup::new(db, config); + let backup = Backup::new(db, config)?; backup .run_now() .await? diff --git a/src/backup/session.rs b/src/backup/session.rs index 0cbb8c7..bd1fb91 100644 --- a/src/backup/session.rs +++ b/src/backup/session.rs @@ -23,12 +23,22 @@ //! [`super::proton`], which persists on every rotation. use std::path::{Path, PathBuf}; +use std::sync::Arc; -use anyhow::{Context, Result}; +use aes_gcm::{ + aead::{Aead, KeyInit, Payload}, + Aes256Gcm, Nonce, +}; +use anyhow::{anyhow, bail, Context, Result}; +use base64::{engine::general_purpose::STANDARD, Engine}; +use rand::RngCore; use proton_drive_rs::KeySalt; use proton_sdk::account::KeyPassphrases; use proton_sdk::session::{PasswordMode, ProtonApiSession, ResumeParameters}; use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::config::Config; #[derive(Serialize, Deserialize, Clone)] pub struct StoredSession { @@ -127,14 +137,39 @@ mod passphrases { /// protecting, and no weaker than the thing it sits beside. Build with /// `--features keyring` on a host that has a session bus to use the keyring /// instead, with the file as the fallback when it cannot be reached. +/// +/// What a keyring adds is that a copy of the volume does not carry the +/// session. `WORKSPACE_SESSION_KEY_FILE` gives a container the same: the file +/// is sealed with AES-256-GCM under a key that lives somewhere else, a Docker +/// secret being the obvious place. A plain file found while a key is set is +/// sealed the first time it is read. #[derive(Clone)] pub struct SessionStore { path: PathBuf, + key: Option>>, } +/// Starts a sealed session file. Nothing that is JSON can start with it. +const MAGIC: &[u8; 8] = b"NARLSES1"; +const NONCE: usize = 12; + impl SessionStore { pub fn new(path: impl Into) -> Self { - Self { path: path.into() } + Self { + path: path.into(), + key: None, + } + } + + /// The store the configuration describes: the usual path, sealed when a + /// key file is set. A key file that is missing or malformed is an error + /// rather than a quiet fall back to plain JSON. + pub fn open(config: &Config) -> Result { + let mut store = Self::new(config.proton_session_path()); + if let Some(file) = &config.backup.session_key_file { + store.key = Some(Arc::new(read_key(file)?)); + } + Ok(store) } pub fn path(&self) -> &Path { @@ -147,27 +182,50 @@ impl SessionStore { return Ok(Some(stored)); } - let json = match std::fs::read_to_string(&self.path) { - Ok(json) => json, + let bytes = match std::fs::read(&self.path) { + Ok(bytes) => Zeroizing::new(bytes), Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), Err(e) => { return Err(e).with_context(|| format!("could not read {}", self.path.display())) } }; - let stored = serde_json::from_str(&json) + + let sealed = bytes.starts_with(MAGIC); + let json = match (sealed, &self.key) { + (true, Some(key)) => open(key, &bytes).with_context(|| { + format!( + "{} does not open with WORKSPACE_SESSION_KEY_FILE — was the key replaced?", + self.path.display() + ) + })?, + (true, None) => bail!( + "{} is sealed — set WORKSPACE_SESSION_KEY_FILE to the key it was sealed with", + self.path.display() + ), + (false, _) => bytes, + }; + let stored = serde_json::from_slice(&json) .with_context(|| format!("{} is not a session this build understands", self.path.display()))?; + + if !sealed && self.key.is_some() { + self.save(&stored)?; + tracing::info!("the stored Proton session is now sealed under WORKSPACE_SESSION_KEY_FILE"); + } Ok(Some(stored)) } pub fn save(&self, stored: &StoredSession) -> Result<()> { - let json = serde_json::to_string(stored)?; + let json = Zeroizing::new(serde_json::to_vec(stored)?); #[cfg(feature = "keyring")] - if keyring_save(&json).is_ok() { + if keyring_save(std::str::from_utf8(&json)?).is_ok() { return Ok(()); } - write_private(&self.path, json.as_bytes()) + match &self.key { + Some(key) => write_private(&self.path, &seal(key, &json)), + None => write_private(&self.path, &json), + } } pub fn clear(&self) -> Result<()> { @@ -182,6 +240,49 @@ impl SessionStore { } } +/// 32 random bytes, base64 — what `openssl rand -base64 32` writes. +fn read_key(file: &Path) -> Result> { + let text = Zeroizing::new( + std::fs::read_to_string(file) + .with_context(|| format!("could not read the session key {}", file.display()))?, + ); + let bytes = Zeroizing::new(STANDARD.decode(text.trim()).unwrap_or_default()); + let key: [u8; 32] = bytes.as_slice().try_into().map_err(|_| { + anyhow!( + "{} must hold 32 random bytes in base64 — `openssl rand -base64 32` makes one", + file.display() + ) + })?; + Ok(Zeroizing::new(key)) +} + +/// `MAGIC ‖ nonce ‖ ciphertext`, with the magic bound in as associated data. +fn seal(key: &[u8; 32], json: &[u8]) -> Vec { + let mut nonce = [0u8; NONCE]; + rand::thread_rng().fill_bytes(&mut nonce); + let ciphertext = Aes256Gcm::new(key.into()) + .encrypt(Nonce::from_slice(&nonce), Payload { msg: json, aad: MAGIC }) + .expect("AES-GCM seals any input this size"); + + let mut out = Vec::with_capacity(MAGIC.len() + NONCE + ciphertext.len()); + out.extend_from_slice(MAGIC); + out.extend_from_slice(&nonce); + out.extend_from_slice(&ciphertext); + out +} + +fn open(key: &[u8; 32], sealed: &[u8]) -> Result>> { + let rest = &sealed[MAGIC.len()..]; + if rest.len() < NONCE { + bail!("the sealed session is cut short"); + } + let (nonce, ciphertext) = rest.split_at(NONCE); + Aes256Gcm::new(key.into()) + .decrypt(Nonce::from_slice(nonce), Payload { msg: ciphertext, aad: MAGIC }) + .map(Zeroizing::new) + .map_err(|_| anyhow!("the key does not open it, or it was altered")) +} + /// Write `bytes` to `path` as `0600`, and atomically: a crash mid-write must /// not leave a truncated session behind, because a half-written refresh token /// is indistinguishable from a revoked one and costs an interactive re-login. @@ -299,4 +400,65 @@ mod tests { assert_eq!(loaded.mailbox_password.as_deref(), Some("hunter2hunter2")); assert_eq!(loaded.key_salts.len(), 1); } + + fn keyed(path: &Path, key: [u8; 32]) -> SessionStore { + SessionStore { + path: path.to_owned(), + key: Some(Arc::new(Zeroizing::new(key))), + } + } + + #[test] + fn a_keyed_store_seals_the_file_and_only_its_key_opens_it() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("proton-session.json"); + let passphrases = KeyPassphrases::from_iter([("key-1", b"secret".to_vec())]); + + keyed(&path, [7; 32]).save(&stored(passphrases.clone())).unwrap(); + let raw = std::fs::read(&path).unwrap(); + assert!(raw.starts_with(MAGIC)); + assert!(!raw.windows(7).any(|w| w == b"refresh"), "tokens left in the clear"); + + let loaded = keyed(&path, [7; 32]).load().unwrap().unwrap(); + assert!(loaded.key_passphrases == passphrases); + + let wrong = keyed(&path, [8; 32]).load().err().unwrap(); + assert!(format!("{wrong:#}").contains("does not open"), "{wrong:#}"); + let none = SessionStore::new(&path).load().err().unwrap(); + assert!(none.to_string().contains("WORKSPACE_SESSION_KEY_FILE"), "{none}"); + + let mut flipped = raw.clone(); + *flipped.last_mut().unwrap() ^= 1; + std::fs::write(&path, &flipped).unwrap(); + assert!(keyed(&path, [7; 32]).load().is_err()); + } + + #[test] + fn a_plain_file_is_sealed_the_first_time_a_key_reads_it() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("proton-session.json"); + SessionStore::new(&path).save(&stored(KeyPassphrases::new())).unwrap(); + assert!(std::fs::read(&path).unwrap().starts_with(b"{")); + + let loaded = keyed(&path, [7; 32]).load().unwrap().unwrap(); + assert_eq!(loaded.refresh_token, "refresh"); + assert!(std::fs::read(&path).unwrap().starts_with(MAGIC)); + assert!(keyed(&path, [7; 32]).load().unwrap().is_some()); + } + + #[test] + fn a_key_file_must_hold_32_bytes_of_base64() { + let dir = tempfile::tempdir().unwrap(); + let file = dir.path().join("session.key"); + + std::fs::write(&file, format!("{}\n", STANDARD.encode([3u8; 32]))).unwrap(); + assert_eq!(*read_key(&file).unwrap(), [3u8; 32]); + + for bad in ["", "not base64 at all", &STANDARD.encode([3u8; 16])] { + std::fs::write(&file, bad).unwrap(); + let error = read_key(&file).err().unwrap(); + assert!(error.to_string().contains("openssl rand -base64 32"), "{error}"); + } + assert!(read_key(&dir.path().join("missing")).is_err()); + } } diff --git a/src/cli.rs b/src/cli.rs index b29d904..185d715 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -31,7 +31,7 @@ narl-workspace — a terminal-themed personal workspace "; pub async fn proton_login(config: &Config) -> Result<()> { - let store = backup::session::SessionStore::new(config.proton_session_path()); + let store = backup::session::SessionStore::open(config)?; // Said before anything is asked for, not after: Proton requires a // third-party client to be honest about what it is at the point where @@ -68,7 +68,7 @@ pub async fn proton_login(config: &Config) -> Result<()> { } pub fn proton_logout(config: &Config) -> Result<()> { - let store = backup::session::SessionStore::new(config.proton_session_path()); + let store = backup::session::SessionStore::open(config)?; store.clear()?; println!("Forgot the Proton session. Backups are off until the next login."); println!( @@ -78,7 +78,7 @@ pub fn proton_logout(config: &Config) -> Result<()> { } pub async fn proton_status(db: &SqlitePool, config: &Config) -> Result<()> { - let store = backup::session::SessionStore::new(config.proton_session_path()); + let store = backup::session::SessionStore::open(config)?; let Some(stored) = store.load()? else { println!("Proton Drive backups are not configured."); println!("Run `narl-workspace proton-login` to enable them."); diff --git a/src/config.rs b/src/config.rs index 74a0768..e1674b2 100644 --- a/src/config.rs +++ b/src/config.rs @@ -145,6 +145,10 @@ pub struct BackupConfig { /// Whether a file deleted here is trashed there. Off by default: a backup /// that forgets on command is one accident away from being no backup. pub prune: bool, + /// A file holding the key `proton-session.json` is sealed under, kept off + /// the data volume (a Docker secret, say) so a copy of the volume alone + /// does not carry a usable session. `None` leaves the file as plain JSON. + pub session_key_file: Option, } /// Look a setting up in the environment. @@ -331,6 +335,7 @@ impl BackupConfig { interval, debounce, prune, + session_key_file: var("SESSION_KEY_FILE").map(PathBuf::from), }) } } diff --git a/src/main.rs b/src/main.rs index 24a64ec..2ad7ca2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -104,7 +104,7 @@ async fn main() -> Result<()> { let bind = config.bind; let max_upload = config.max_upload_bytes; let config = Arc::new(config); - let backup = backup::Backup::new(db.clone(), Arc::clone(&config)); + let backup = backup::Backup::new(db.clone(), Arc::clone(&config))?; backup::spawn(Arc::clone(&backup)); let notify = notify::Notifier::new(db.clone(), Arc::clone(&config))?;