From 3387db1c4db75b18625dceb709259a1c6151ff2d Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 20 Oct 2026 12:46:10 +0200 Subject: [PATCH] vault: a password change brings a new master key :passwd used to wrap the same master key under the new password, so an old diary.db (a pre-* snapshot, an old revision on Proton Drive) and the old password still opened every sealed file. It now makes a new master key. The old password opens nothing from that moment; the old master key is kept, sealed under the new one, in two new vault columns, only until every sealed file has been re-sealed. A background worker does that while the vault is open, without holding it open. Each file gets a new id with its file key wrapped under the new master key; its records are copied as they are. The move goes through the same repointing as :encrypt, so the old id is put on backup_retired and the mirror purges it from Drive. Which files are left is read off their headers, so a lock or a restart halfway only pauses the work until the next unlock. Once none is left, the old key is dropped. Readers take a Keyring that tries the current key, then the retired one. A second :passwd is refused until the first has finished. :vault shows the progress. Every sealed file is uploaded to Proton once more. --- README.md | 22 +- migrations/0018_vault_rekey.sql | 7 + src/main.rs | 1 + src/routes/export.rs | 12 +- src/routes/import.rs | 5 +- src/routes/inspect.rs | 4 +- src/routes/media.rs | 13 +- src/routes/seal.rs | 329 +++++++++++++++++++++++-- src/routes/vault.rs | 19 +- src/sftp/fs.rs | 22 +- src/vault.rs | 411 +++++++++++++++++++++++++------- web/src/lib/api.ts | 5 + web/src/lib/commands.ts | 17 +- 13 files changed, 724 insertions(+), 143 deletions(-) create mode 100644 migrations/0018_vault_rekey.sql diff --git a/README.md b/README.md index c58c909..bb9ea17 100644 --- a/README.md +++ b/README.md @@ -309,12 +309,17 @@ it back out. `:unlock` and `:lock` open and close it, `:passwd` changes the password. A sealed file carries a lock in the list, and the bar says `vault open` while it is. -`:passwd` wraps the same key under the new password, so no file has to be -rewritten — and so it does not take back what the old password opens. A copy -of `diary.db` from before the change (a `diary.db.pre-*` snapshot, a backup in -Proton Drive) still unwraps the key with the old password, and that key still -opens every sealed file. Change the password because you want a new one, not -to shut out someone who has the old one and an old copy. +`:passwd` makes a new master key as well as a new password. The old password +stops opening anything at once; every sealed file is then re-sealed under the +new key in the background, under a new name, while the vault is open — `:vault` +shows how far it has come, and locking halfway only pauses it until the next +`:unlock`. The old copies are deleted from `uploads/` and, on the next mirror +run, from Proton Drive for good. After that, a copy of `diary.db` from before +the change (a `diary.db.pre-*` snapshot, an old revision in Proton Drive) and +the old password open nothing that is still kept anywhere. What they cannot +take back is a copy someone already made of the files themselves. Re-sealing +rewrites only each file's header, but every sealed file is uploaded to Proton +Drive once more. A second `:passwd` waits until the first has finished. `:encrypt` on a folder or space seals it for good, not just once: every file in it now, and every file that lands in it later. An upload, an import, an SFTP @@ -340,7 +345,10 @@ master key, and only that sealed copy is stored. Each file gets a random key of its own, sealed under the master key in the file's header together with its id, and its bytes are AES-256-GCM in 64 KiB records, the last one marked as last — so a file that is swapped, cut short or tampered with refuses to open. -Changing the password re-seals the master key and rewrites no file. +Changing the password makes a new master key. Until every file is re-sealed, +the old one is kept sealed under the new one, so either kind of file opens and +an interrupted re-seal carries on where it stopped; re-sealing a file wraps its +own key under the new master key and copies its records as they are. A sealed file is served with `Cache-Control: no-store`, so the browser keeps no copy of what it showed once the vault shuts. A sealed image in a document diff --git a/migrations/0018_vault_rekey.sql b/migrations/0018_vault_rekey.sql new file mode 100644 index 0000000..99d319e --- /dev/null +++ b/migrations/0018_vault_rekey.sql @@ -0,0 +1,7 @@ +-- A changed vault password brings a new master key. Until every sealed file +-- has been re-sealed under it, the old master key is kept here, sealed under +-- the new one, so either password-change survivor can still be read; once +-- the last file is done both columns go back to NULL. The old password opens +-- neither: it only ever opened the old `sealed`, which is gone. +ALTER TABLE vault ADD COLUMN retired_nonce BLOB; +ALTER TABLE vault ADD COLUMN retired_sealed BLOB; diff --git a/src/main.rs b/src/main.rs index 5180aeb..71683e5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -137,6 +137,7 @@ async fn main() -> Result<()> { epoch: Arc::new(std::sync::atomic::AtomicI64::new(epoch)), sessions, }; + routes::seal::spawn_rekey(state.clone()); if let Some(sftp_bind) = config.sftp.bind { let sftp_state = state.clone(); diff --git a/src/routes/export.rs b/src/routes/export.rs index d4ac4fb..71dfbd5 100644 --- a/src/routes/export.rs +++ b/src/routes/export.rs @@ -386,13 +386,11 @@ pub async fn export(_: Session, State(state): State) -> AppResult = match (&key, sealed.contains(id)) { // A sealed file that will not open is not a file to leave out // quietly: the archive would claim to be complete and not be. - (Some(key), true) => { - Box::new(crate::vault::Reader::open(key, id, &path).map_err(|e| { - AppError::BadRequest(format!( - "{entry} is sealed and would not open ({e}) — nothing was exported" - )) - })?) - } + (Some(key), true) => Box::new(key.reader(id, &path).map_err(|e| { + AppError::BadRequest(format!( + "{entry} is sealed and would not open ({e}) — nothing was exported" + )) + })?), // A row whose file has gone missing must not fail the whole // export; there is nothing to put in its place either way. _ => match std::fs::File::open(&path) { diff --git a/src/routes/import.rs b/src/routes/import.rs index b06f7a9..29bd200 100644 --- a/src/routes/import.rs +++ b/src/routes/import.rs @@ -542,7 +542,7 @@ async fn run(state: &AppState, into: Into, multipart: Multipart) -> AppResult { - vault::Reader::open(&key, &media_id, &path)? + key.reader(&media_id, &path)? .take(MAX_INSPECT) .read_to_end(&mut bytes)?; } diff --git a/src/routes/media.rs b/src/routes/media.rs index f7e89d6..2241be3 100644 --- a/src/routes/media.rs +++ b/src/routes/media.rs @@ -297,7 +297,14 @@ pub async fn upload_into( let mime = mime_of(&filename, field.content_type()); let id = Uuid::new_v4().to_string(); - let size = store_field(&state, &mut field, &id, &mut budget, key.as_deref()).await?; + let size = store_field( + &state, + &mut field, + &id, + &mut budget, + key.as_deref().map(vault::Keyring::current), + ) + .await?; if size == 0 { continue; } @@ -598,7 +605,7 @@ pub async fn stream_media( /// does break off, as a read over the mount does. async fn open_stream( mut file: tokio::fs::File, - key: &vault::Key, + key: &vault::Keyring, vault: Arc, id: &str, start: u64, @@ -609,7 +616,7 @@ async fn open_stream( let len = file.metadata().await?.len(); let mut header = [0u8; vault::HEADER]; file.read_exact(&mut header).await?; - let mut opener = vault::Opener::new(key, id, &header, len)?; + let mut opener = key.opener(id, &header, len)?; let first = start / vault::CHUNK as u64; if first > 0 { opener.skip_to(first)?; diff --git a/src/routes/seal.rs b/src/routes/seal.rs index 8299b0d..0110d80 100644 --- a/src/routes/seal.rs +++ b/src/routes/seal.rs @@ -17,6 +17,11 @@ //! file, and the old id is put on `backup_retired`, which the mirror takes off //! Drive for good whatever `WORKSPACE_BACKUP_PRUNE` says. Documents that embed //! the file have their links moved to the new id in the same transaction. +//! +//! A vault password change re-writes every sealed file the same way, under a +//! new id with its file key sealed under the new master key — see +//! [`spawn_rekey`]. Once the old ids are off Drive, an old copy of the +//! database and the old password open nothing that is still kept anywhere. use axum::{ extract::{Path, State}, @@ -229,8 +234,12 @@ async fn reseal(state: &AppState, id: i64, seal: bool) -> AppResult { unchanged += 1; continue; } - match rewrite(state, &key, target, seal).await { - Ok(()) => files += 1, + let how = if seal { Rewrite::Seal } else { Rewrite::Open }; + match rewrite(state, &key, &target.media_id, how).await { + Ok(new) => { + tracing::info!(node = target.node_id, to = %new, sealed = seal, "file re-written"); + files += 1 + } Err(e) => { tracing::warn!(node = target.node_id, "could not re-write a file: {e:?}"); failed.push(e); @@ -266,32 +275,52 @@ async fn reseal(state: &AppState, id: i64, seal: bool) -> AppResult { }) } -/// Re-write one file sealed or opened under a new id, then point everything -/// at the new id and retire the old one. -async fn rewrite(state: &AppState, key: &vault::Key, target: &Target, seal: bool) -> AppResult<()> { +#[derive(Clone, Copy)] +enum Rewrite { + /// A plain file into the vault. + Seal, + /// A sealed file out of it. + Open, + /// A sealed file under the current master key, from whichever it is under. + Rekey, +} + +/// Re-write one file under a new id, then point everything at the new id and +/// retire the old one. Returns the new id. +async fn rewrite( + state: &AppState, + keys: &std::sync::Arc, + media_id: &str, + how: Rewrite, +) -> AppResult { let uploads = state.config.uploads_dir(); - let old = target.media_id.clone(); + let old = media_id.to_string(); let new = Uuid::new_v4().to_string(); + let seal = !matches!(how, Rewrite::Open); let source = uploads.join(&old); let partial = uploads.join(format!("{new}.part")); let written = { - let (key, old, new, source, partial) = ( - key.clone(), + let (keys, old, new, source, partial) = ( + keys.clone(), old.clone(), new.clone(), source.clone(), partial.clone(), ); tokio::task::spawn_blocking(move || -> anyhow::Result<()> { - if seal { - let mut plain = std::fs::File::open(&source)?; - vault::seal_file(&key, &new, &mut plain, &partial)?; - } else { - let mut opened = vault::Reader::open(&key, &old, &source)?; - let mut out = std::fs::File::create(&partial)?; - std::io::copy(&mut opened, &mut out)?; - out.sync_all()?; + match how { + Rewrite::Seal => { + let mut plain = std::fs::File::open(&source)?; + vault::seal_file(keys.current(), &new, &mut plain, &partial)?; + } + Rewrite::Open => { + let mut opened = keys.reader(&old, &source)?; + let mut out = std::fs::File::create(&partial)?; + std::io::copy(&mut opened, &mut out)?; + out.sync_all()?; + } + Rewrite::Rekey => vault::rewrap_file(&keys, &old, &new, &source, &partial)?, } Ok(()) }) @@ -386,13 +415,87 @@ async fn rewrite(state: &AppState, key: &vault::Key, target: &Target, seal: bool // The database no longer knows the old bytes; they go from the disk too. let _ = tokio::fs::remove_file(&source).await; - tracing::info!(node = target.node_id, from = %old, to = %new, sealed = seal, "file re-written"); + Ok(new) +} + +/// Re-seal, in the background, every file still under the master key from +/// before the last password change. It works only while the vault is open, +/// and never holds it open: locked halfway, it waits for the next unlock and +/// carries on from there — which file is done is read off each file's own +/// header. Once none is left, the old master key is forgotten. +pub fn spawn_rekey(state: AppState) { + state.vault.want_rekey(); + tokio::spawn(async move { + loop { + state.vault.rekey_wanted().await; + if let Err(e) = rekey(&state).await { + tracing::error!("re-sealing after a vault password change stopped: {e:#}"); + } + } + }); +} + +async fn rekey(state: &AppState) -> anyhow::Result<()> { + let Some(keys) = state.vault.peek_key().filter(|keys| keys.rekeying()) else { + return Ok(()); + }; + let sealed: Vec = + sqlx::query_scalar("SELECT id FROM media WHERE encrypted ORDER BY created_at, id") + .fetch_all(&state.db) + .await?; + let left = { + let (keys, uploads) = (keys.clone(), state.config.uploads_dir()); + tokio::task::spawn_blocking(move || { + sealed + .into_iter() + // One that will not even be read is no file to re-seal; the + // count of failures below is for those that should have been. + .filter(|id| { + !vault::sealed_under(keys.current(), id, &uploads.join(id)).unwrap_or(true) + }) + .collect::>() + }) + .await? + }; + + let total = left.len() as u64; + let mut progress = vault::Rekey { done: 0, total }; + state.vault.set_progress(Some(progress)); + tracing::info!("re-sealing {total} file(s) under the new vault key"); + let mut failed = 0; + for id in &left { + // Locked meanwhile: the rest waits for the next unlock. + let Some(open) = state.vault.peek_key() else { + return Ok(()); + }; + if !std::sync::Arc::ptr_eq(&open, &keys) { + return Ok(()); + } + match rewrite(state, &keys, id, Rewrite::Rekey).await { + Ok(_) => state.backup.signal(), + // Decrypted, deleted or re-written some other way meanwhile. + Err(AppError::NotFound) => {} + Err(e) => { + tracing::warn!(media = %id, "could not re-seal a file: {e:?}"); + failed += 1; + } + } + progress.done += 1; + state.vault.set_progress(Some(progress)); + } + if failed > 0 { + anyhow::bail!("{failed} file(s) would not re-seal; the old key is kept for them"); + } + state.vault.finish_rekey(&keys).await?; + tracing::info!("every sealed file is under the new vault key"); Ok(()) } #[cfg(test)] mod tests { use super::*; + use std::io::Read; + use zeroize::Zeroizing; use crate::{error::AppError, routes::media}; @@ -594,4 +697,196 @@ mod tests { assert!(!sealed(&w.state, inner).await.unwrap()); plain_file(&w.state, inner, "e.txt").await.unwrap(); } + + fn password(text: &str) -> vault::Password { + Zeroizing::new(text.to_string()) + } + + async fn row_retired(w: &World) -> bool { + sqlx::query_scalar::<_, Option>>("SELECT retired_sealed FROM vault") + .fetch_one(&w.state.db) + .await + .unwrap() + .is_some() + } + + async fn sealed_ids(state: &AppState) -> Vec { + sqlx::query_scalar("SELECT id FROM media WHERE encrypted ORDER BY id") + .fetch_all(&state.db) + .await + .unwrap() + } + + #[tokio::test] + async fn a_password_change_re_seals_every_file_under_a_new_key() { + let w = world().await; + w.state.vault.lock(); + assert!(w + .state + .vault + .setup(password("first password")) + .await + .unwrap()); + for name in ["a.txt", "b.txt", "c.txt"] { + plain_file(&w.state, w.folder, name).await.unwrap(); + } + seal_node(&w.state, w.folder).await.unwrap(); + let old_master = w.state.vault.peek_key().unwrap().current().clone(); + let before = sealed_ids(&w.state).await; + assert_eq!(before.len(), 3); + + let changed = w + .state + .vault + .change_password(password("first password"), password("second password")) + .await + .unwrap(); + assert!(matches!(changed, vault::Unlock::Opened)); + assert!(w.state.vault.status().await.unwrap().rekey.is_some()); + + // Locked before any file was re-sealed, as a restart would leave it. + w.state.vault.lock(); + let old = w + .state + .vault + .unlock(password("first password")) + .await + .unwrap(); + assert!(matches!(old, vault::Unlock::WrongPassword)); + let new = w + .state + .vault + .unlock(password("second password")) + .await + .unwrap(); + assert!(matches!(new, vault::Unlock::Opened)); + + // Every file still reads, under whichever key it is. + let keys = w.state.vault.peek_key().unwrap(); + assert!(keys.rekeying()); + let uploads = w.state.config.uploads_dir(); + for id in &before { + let mut out = Vec::new(); + keys.reader(id, &uploads.join(id)) + .unwrap() + .read_to_end(&mut out) + .unwrap(); + assert_eq!(out, b"secret bytes"); + } + + // A second change waits for the first to finish. + let again = w + .state + .vault + .change_password(password("second password"), password("third password")) + .await + .unwrap(); + assert!(matches!(again, vault::Unlock::Rekeying)); + + rekey(&w.state).await.unwrap(); + assert!(!row_retired(&w).await); + assert!(w.state.vault.status().await.unwrap().rekey.is_none()); + assert!(!w.state.vault.peek_key().unwrap().rekeying()); + + let after = sealed_ids(&w.state).await; + assert_eq!(after.len(), 3); + let current = w.state.vault.peek_key().unwrap(); + for id in &after { + assert!(!before.contains(id), "re-sealed under a new id"); + let path = uploads.join(id); + assert!(vault::sealed_under(current.current(), id, &path).unwrap()); + assert!(!vault::sealed_under(&old_master, id, &path).unwrap()); + let mut out = Vec::new(); + current + .reader(id, &path) + .unwrap() + .read_to_end(&mut out) + .unwrap(); + assert_eq!(out, b"secret bytes"); + } + // The old copies are gone from the disk and on their way off Drive. + for id in &before { + assert!(!uploads.join(id).exists()); + let retired: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM backup_retired WHERE path = 'uploads/' || ?1", + ) + .bind(id) + .fetch_one(&w.state.db) + .await + .unwrap(); + assert_eq!(retired, 1); + } + + // And the next change goes ahead. + let third = w + .state + .vault + .change_password(password("second password"), password("third password")) + .await + .unwrap(); + assert!(matches!(third, vault::Unlock::Opened)); + } + + #[tokio::test] + async fn a_vault_locked_halfway_carries_on_after_the_next_unlock() { + let w = world().await; + w.state.vault.lock(); + assert!(w + .state + .vault + .setup(password("first password")) + .await + .unwrap()); + for name in ["a.txt", "b.txt"] { + plain_file(&w.state, w.folder, name).await.unwrap(); + } + seal_node(&w.state, w.folder).await.unwrap(); + w.state + .vault + .change_password(password("first password"), password("second password")) + .await + .unwrap(); + + // One file by hand, then the lock: what is left is half and half. + let keys = w.state.vault.peek_key().unwrap(); + let first = sealed_ids(&w.state).await.remove(0); + rewrite(&w.state, &keys, &first, Rewrite::Rekey) + .await + .unwrap(); + w.state.vault.lock(); + rekey(&w.state).await.unwrap(); + assert!(row_retired(&w).await, "nothing happens while it is locked"); + + w.state + .vault + .unlock(password("second password")) + .await + .unwrap(); + rekey(&w.state).await.unwrap(); + assert!(!row_retired(&w).await); + let current = w.state.vault.peek_key().unwrap(); + for id in sealed_ids(&w.state).await { + let path = w.state.config.uploads_dir().join(&id); + assert!(vault::sealed_under(current.current(), &id, &path).unwrap()); + } + } + + #[tokio::test] + async fn with_nothing_sealed_a_change_needs_no_re_sealing() { + let w = world().await; + w.state.vault.lock(); + assert!(w + .state + .vault + .setup(password("first password")) + .await + .unwrap()); + w.state + .vault + .change_password(password("first password"), password("second password")) + .await + .unwrap(); + assert!(!row_retired(&w).await); + assert!(w.state.vault.status().await.unwrap().rekey.is_none()); + } } diff --git a/src/routes/vault.rs b/src/routes/vault.rs index d8d5053..8c95ee2 100644 --- a/src/routes/vault.rs +++ b/src/routes/vault.rs @@ -74,6 +74,15 @@ fn answer(attempt: Attempt<'_>, outcome: Unlock) -> AppResult<()> { Unlock::NotConfigured => Err(AppError::BadRequest( "there is no vault yet — :vault sets one up".into(), )), + // The password was right; the vault is open, re-sealing goes on. + Unlock::Rekeying => { + attempt.succeeded(); + Err(AppError::Conflict( + "files are still being re-sealed after the last password change — \ + leave the vault open until :vault says it is done" + .into(), + )) + } } } @@ -106,7 +115,13 @@ pub async fn change_password( .change_password(input.current, input.password) .await?, )?; - tracing::info!("vault password changed"); + let status = state.vault.status().await?; + match status.rekey { + Some(_) => { + tracing::info!("vault password changed; re-sealing every file under the new key") + } + None => tracing::info!("vault password changed"), + } state.backup.signal(); - Ok(Json(state.vault.status().await?)) + Ok(Json(status)) } diff --git a/src/sftp/fs.rs b/src/sftp/fs.rs index 9325b44..a0726aa 100644 --- a/src/sftp/fs.rs +++ b/src/sftp/fs.rs @@ -387,7 +387,7 @@ impl SpaceFs { &self, parent: i64, over_sealed: bool, - ) -> SftpResult>> { + ) -> SftpResult>> { let sealed: bool = sqlx::query_scalar("SELECT sealed FROM nodes WHERE id = ?1") .bind(parent) .fetch_optional(&self.db) @@ -402,7 +402,7 @@ impl SpaceFs { /// The vault's key, while it is open; a refusal while it is shut. Taken /// without counting as a use, so a mount never holds the vault open. - fn vault_key(&self) -> SftpResult> { + fn vault_key(&self) -> SftpResult> { self.state .as_ref() .and_then(|state| state.vault.peek_key()) @@ -850,7 +850,7 @@ impl SpaceFs { let (key, id, part) = (key.clone(), old.media_id.clone(), partial.to_path_buf()); tokio::task::spawn_blocking(move || -> anyhow::Result { - let mut plain = vault::Reader::open(&key, &id, &source)?; + let mut plain = key.reader(&id, &source)?; Ok(std::io::copy( &mut plain, &mut std::fs::File::create(&part)?, @@ -916,7 +916,12 @@ impl SpaceFs { let (key, id, plain, sealed) = (key.clone(), media_id.clone(), partial.to_path_buf(), target); tokio::task::spawn_blocking(move || -> anyhow::Result<()> { - vault::seal_file(&key, &id, &mut std::fs::File::open(&plain)?, &sealed)?; + vault::seal_file( + key.current(), + &id, + &mut std::fs::File::open(&plain)?, + &sealed, + )?; std::fs::remove_file(&plain)?; Ok(()) }) @@ -1383,11 +1388,10 @@ impl russh_sftp::server::Handler for SpaceFs { .read_exact(&mut header) .await .map_err(|_| StatusCode::Failure)?; - let opener = vault::Opener::new(&key, &file.media_id, &header, len) - .map_err(|e| { - tracing::warn!("sftp: could not open {leaf}: {e:?}"); - StatusCode::Failure - })?; + let opener = key.opener(&file.media_id, &header, len).map_err(|e| { + tracing::warn!("sftp: could not open {leaf}: {e:?}"); + StatusCode::Failure + })?; OpenHandle::ReadSealed(Box::new(SealedRead { file: opened, opener, diff --git a/src/vault.rs b/src/vault.rs index 5f039a9..e033661 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -8,10 +8,10 @@ //! //! Keys: //! -//! - a random 256-bit **master key**, made once at setup and never stored in -//! the clear: the `vault` row holds it sealed under a key derived from the -//! password with Argon2id. Changing the password re-seals the master key and -//! touches no file; +//! - a random 256-bit **master key**, never stored in the clear: the `vault` +//! row holds it sealed under a key derived from the password with Argon2id. +//! Changing the password makes a new master key, and every sealed file is +//! then re-sealed under it in the background — see [`Keyring`]; //! - a random 256-bit **file key** per file, sealed under the master key in //! the file's own header, so no two files share a key and nonces never need //! counting across files. @@ -65,6 +65,9 @@ pub const HEADER: usize = MAGIC.len() + WRAP_NONCE + 32 + TAG + STREAM_NONCE; /// What the master key is sealed with, besides the password-derived key. const MASTER_AAD: &[u8] = b"narl-vault-master-key"; +/// What the master key before a password change is sealed with, besides the +/// master key after it. +const RETIRED_AAD: &[u8] = b"narl-vault-retired-key"; /// The shortest password the vault takes. It guards everything in it, and /// unlike the login it cannot be reset by editing a config file. @@ -73,7 +76,11 @@ pub const MIN_PASSWORD: usize = 10; /// Argon2id costs for new vaults: 64 MiB, three passes, one lane. A fraction /// of a second on a small server, which is the budget for an unlock. Kept per /// vault, so raising them later does not lock anybody out. +#[cfg(not(test))] const M_COST: u32 = 64 * 1024; +/// Tests set up and change real vaults; at full cost each would take seconds. +#[cfg(test)] +const M_COST: u32 = 256; const T_COST: u32 = 3; const P_COST: u32 = 1; @@ -98,30 +105,37 @@ fn derive(password: &str, salt: &[u8], m: u32, t: u32, p: u32) -> Result { } fn seal_master(kek: &Key, master: &Key) -> Result<([u8; WRAP_NONCE], Vec)> { + wrap(kek, master, MASTER_AAD) +} + +/// `None` is a wrong password: the tag does not verify. +fn open_master(kek: &Key, nonce: &[u8], sealed: &[u8]) -> Option { + unwrap(kek, nonce, sealed, MASTER_AAD) +} + +/// Seal one 32-byte key under another. +fn wrap(kek: &Key, key: &Key, aad: &[u8]) -> Result<([u8; WRAP_NONCE], Vec)> { let nonce = random::(); let sealed = Aes256Gcm::new(kek.as_ref().into()) .encrypt( Nonce::from_slice(&nonce), Payload { - msg: master.as_ref(), - aad: MASTER_AAD, + msg: key.as_ref(), + aad, }, ) - .map_err(|_| anyhow!("could not seal the master key"))?; + .map_err(|_| anyhow!("could not seal a key"))?; Ok((nonce, sealed)) } -/// `None` is a wrong password: the tag does not verify. -fn open_master(kek: &Key, nonce: &[u8], sealed: &[u8]) -> Option { +/// `None` when `kek` is not the key it was sealed under. +fn unwrap(kek: &Key, nonce: &[u8], sealed: &[u8], aad: &[u8]) -> Option { + if nonce.len() != WRAP_NONCE { + return None; + } let plain = Zeroizing::new( Aes256Gcm::new(kek.as_ref().into()) - .decrypt( - Nonce::from_slice(nonce), - Payload { - msg: sealed, - aad: MASTER_AAD, - }, - ) + .decrypt(Nonce::from_slice(nonce), Payload { msg: sealed, aad }) .ok()?, ); let mut key = Zeroizing::new([0u8; 32]); @@ -202,6 +216,13 @@ impl Sealer { } } +/// A header's wrap nonce, sealed file key and stream nonce. +fn split_header(header: &[u8]) -> (&[u8], &[u8], &[u8]) { + let (wrap_nonce, rest) = header[MAGIC.len()..].split_at(WRAP_NONCE); + let (sealed_key, stream_nonce) = rest.split_at(32 + TAG); + (wrap_nonce, sealed_key, stream_nonce) +} + fn file_aad(media_id: &str) -> Vec { let mut aad = MAGIC.to_vec(); aad.extend_from_slice(media_id.as_bytes()); @@ -239,21 +260,11 @@ impl Opener { return Err(anyhow!("sealed file is truncated")); } - let (wrap_nonce, rest) = header[MAGIC.len()..].split_at(WRAP_NONCE); - let (sealed_key, stream_nonce) = rest.split_at(32 + TAG); - let file_key = Zeroizing::new( - Aes256Gcm::new(master.as_ref().into()) - .decrypt( - Nonce::from_slice(wrap_nonce), - Payload { - msg: sealed_key, - aad: &file_aad(media_id), - }, - ) - .map_err(|_| { - anyhow!("this file was not sealed under this vault, or not as {media_id}") - })?, - ); + let (wrap_nonce, sealed_key, stream_nonce) = split_header(header); + let file_key = + unwrap(master, wrap_nonce, sealed_key, &file_aad(media_id)).ok_or_else(|| { + anyhow!("this file was not sealed under this vault, or not as {media_id}") + })?; let stream = StreamBE32::from_aead( Aes256Gcm::new(file_key.as_slice().into()), stream_nonce.into(), @@ -304,7 +315,7 @@ impl Opener { } /// A sealed file read as its plaintext, for code that wants `std::io::Read`: -/// the export's zip writer, re-sealing. +/// the export's zip writer, re-sealing. Made by [`Keyring::reader`]. pub struct Reader { inner: R, opener: Opener, @@ -312,24 +323,6 @@ pub struct Reader { at: usize, } -impl Reader { - pub fn open(master: &Key, media_id: &str, path: &std::path::Path) -> Result { - let mut file = std::fs::File::open(path) - .with_context(|| format!("could not read {}", path.display()))?; - let len = file.metadata()?.len(); - let mut header = [0u8; HEADER]; - file.read_exact(&mut header) - .context("sealed file is truncated")?; - let opener = Opener::new(master, media_id, &header, len)?; - Ok(Self { - inner: file, - opener, - buffer: Vec::new(), - at: 0, - }) - } -} - impl Read for Reader { fn read(&mut self, out: &mut [u8]) -> std::io::Result { while self.at == self.buffer.len() { @@ -376,6 +369,116 @@ pub fn seal_file( Ok(size) } +/// The keys an open vault holds: the master key everything is sealed under +/// from now on, and — while files sealed before the last password change +/// are still being re-sealed — the master key before it, which only reads. +pub struct Keyring { + current: Key, + retired: Option, +} + +impl Keyring { + pub fn new(current: Key) -> Self { + Self { + current, + retired: None, + } + } + + /// The key to seal with. + pub fn current(&self) -> &Key { + &self.current + } + + /// Whether files sealed before the last password change may be left. + pub fn rekeying(&self) -> bool { + self.retired.is_some() + } + + fn masters(&self) -> impl Iterator { + std::iter::once(&self.current).chain(self.retired.as_ref()) + } + + /// [`Opener::new`] under whichever key the file was sealed with. + pub fn opener(&self, media_id: &str, header: &[u8], len: u64) -> Result { + let mut first = None; + for master in self.masters() { + match Opener::new(master, media_id, header, len) { + Ok(opener) => return Ok(opener), + Err(e) => { + first.get_or_insert(e); + } + } + } + Err(first.expect("a keyring holds a key")) + } + + /// [`Reader::open`] under whichever key the file was sealed with. + pub fn reader(&self, media_id: &str, path: &std::path::Path) -> Result> { + let mut file = std::fs::File::open(path) + .with_context(|| format!("could not read {}", path.display()))?; + let len = file.metadata()?.len(); + let mut header = [0u8; HEADER]; + file.read_exact(&mut header) + .context("sealed file is truncated")?; + let opener = self.opener(media_id, &header, len)?; + Ok(Reader { + inner: file, + opener, + buffer: Vec::new(), + at: 0, + }) + } +} + +/// Whether the file at `path` was sealed under `master` as `media_id`. Reads +/// the header only. +pub fn sealed_under(master: &Key, media_id: &str, path: &std::path::Path) -> Result { + let mut header = [0u8; HEADER]; + std::fs::File::open(path)?.read_exact(&mut header)?; + if &header[..MAGIC.len()] != MAGIC { + return Err(anyhow!("not a sealed file")); + } + let (wrap_nonce, sealed_key, _) = split_header(&header); + Ok(unwrap(master, wrap_nonce, sealed_key, &file_aad(media_id)).is_some()) +} + +/// Copy the sealed file `old_id` at `source` to `target` as `new_id`, its +/// file key sealed under the keyring's current master key. Only the header +/// changes: the records, under the file's own key, are copied as they are. +pub fn rewrap_file( + keys: &Keyring, + old_id: &str, + new_id: &str, + source: &std::path::Path, + target: &std::path::Path, +) -> Result<()> { + use std::io::Write; + let mut source = std::fs::File::open(source)?; + let mut header = [0u8; HEADER]; + source + .read_exact(&mut header) + .context("sealed file is truncated")?; + if &header[..MAGIC.len()] != MAGIC { + return Err(anyhow!("not a sealed file")); + } + let (wrap_nonce, sealed_key, stream_nonce) = split_header(&header); + let file_key = keys + .masters() + .find_map(|master| unwrap(master, wrap_nonce, sealed_key, &file_aad(old_id))) + .ok_or_else(|| anyhow!("{old_id} was not sealed under this vault"))?; + let (wrap_nonce, sealed_key) = wrap(&keys.current, &file_key, &file_aad(new_id))?; + + let mut out = std::io::BufWriter::new(std::fs::File::create(target)?); + out.write_all(MAGIC)?; + out.write_all(&wrap_nonce)?; + out.write_all(&sealed_key)?; + out.write_all(stream_nonce)?; + std::io::copy(&mut source, &mut out)?; + out.into_inner().map_err(|e| e.into_error())?.sync_all()?; + Ok(()) +} + /// A vault password on its way to Argon2, wiped from memory once it is done. pub type Password = Zeroizing; @@ -391,13 +494,24 @@ pub struct Vault { db: SqlitePool, idle: Duration, open: Mutex>, + /// Wakes whatever re-seals files after a password change. + rekey: tokio::sync::Notify, + /// How far that has come, while it runs. + progress: Mutex>, } struct Unlocked { - key: Arc, + keys: Arc, touched: Instant, } +/// Re-sealing after a password change: files done, out of how many. +#[derive(serde::Serialize, Clone, Copy, Default)] +pub struct Rekey { + pub done: u64, + pub total: u64, +} + #[derive(serde::Serialize)] pub struct Status { /// Whether a vault password has been set at all. @@ -407,6 +521,9 @@ pub struct Status { pub locks_in: Option, /// How many files are sealed: what an export needs the vault open for. pub sealed: i64, + /// Present while files sealed before the last password change are left. + /// `total` is 0 until they have been counted, which needs the vault open. + pub rekey: Option, } /// What a password check came to. @@ -414,6 +531,8 @@ pub enum Unlock { Opened, WrongPassword, NotConfigured, + /// The last password change is still re-sealing files. + Rekeying, } impl Vault { @@ -422,21 +541,65 @@ impl Vault { db, idle, open: Mutex::new(None), + rekey: tokio::sync::Notify::new(), + progress: Mutex::new(None), }) } + fn put(&self, keys: Keyring) { + let rekeying = keys.rekeying(); + *self.slot() = Some(Unlocked { + keys: Arc::new(keys), + touched: Instant::now(), + }); + if rekeying { + self.rekey.notify_one(); + } + } + + /// Wait until there may be files to re-seal. + pub async fn rekey_wanted(&self) { + self.rekey.notified().await; + } + + /// Ask for a look at whether files are left to re-seal: at startup, when + /// the vault may have been changed while nothing was watching. + pub fn want_rekey(&self) { + self.rekey.notify_one(); + } + + pub fn set_progress(&self, progress: Option) { + *self.progress.lock().unwrap_or_else(|e| e.into_inner()) = progress; + } + + /// Every file is under the current master key: forget the one before it, + /// on disk and in memory. `keys` is the keyring that did the work; a + /// vault that has since been locked, or changed again, is left alone. + pub async fn finish_rekey(&self, keys: &Arc) -> Result<()> { + sqlx::query("UPDATE vault SET retired_nonce = NULL, retired_sealed = NULL WHERE id = 1") + .execute(&self.db) + .await?; + let mut slot = self.slot(); + if let Some(open) = slot.as_mut().filter(|open| Arc::ptr_eq(&open.keys, keys)) { + open.keys = Arc::new(Keyring::new(keys.current.clone())); + } + drop(slot); + self.set_progress(None); + Ok(()) + } + fn slot(&self) -> std::sync::MutexGuard<'_, Option> { self.open.lock().unwrap_or_else(|e| e.into_inner()) } - /// The master key, if the vault is open — and keep it open a while longer. + /// The keys, if the vault is open — and keep it open a while longer. /// A vault left alone past the idle time closes on the next look. - pub fn key(&self) -> Option> { + pub fn key(&self) -> Option> { let mut slot = self.slot(); match slot.as_mut() { Some(open) if open.touched.elapsed() < self.idle => { open.touched = Instant::now(); - Some(open.key.clone()) + Some(open.keys.clone()) } Some(_) => { *slot = None; @@ -457,11 +620,11 @@ impl Vault { /// The key while the vault is open, without counting as a use: for what /// is not the owner at the browser — a mount — which may use the vault /// while it is open but must not keep it so. - pub fn peek_key(&self) -> Option> { + pub fn peek_key(&self) -> Option> { self.slot() .as_ref() .filter(|open| open.touched.elapsed() < self.idle) - .map(|open| open.key.clone()) + .map(|open| open.keys.clone()) } pub fn lock(&self) { @@ -472,10 +635,7 @@ impl Vault { /// vault allows. #[cfg(test)] pub fn open_with(&self, key: Key) { - *self.slot() = Some(Unlocked { - key: Arc::new(key), - touched: Instant::now(), - }); + self.put(Keyring::new(key)); } /// Drop the key once it has gone unused past the idle time. Without this @@ -508,7 +668,11 @@ impl Vault { } pub async fn status(&self) -> Result { - let configured = self.row().await?.is_some(); + let row = self.row().await?; + let configured = row.is_some(); + let rekeying = + row.is_some_and(|row| row.get::>, _>("retired_sealed").is_some()); + let progress = *self.progress.lock().unwrap_or_else(|e| e.into_inner()); let sealed: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM media WHERE encrypted") .fetch_one(&self.db) .await?; @@ -523,12 +687,14 @@ impl Vault { unlocked: remaining.is_some(), locks_in: remaining.map(|left| left.as_secs()), sealed, + rekey: rekeying.then(|| progress.unwrap_or_default()), }) } async fn row(&self) -> Result> { Ok(sqlx::query( - "SELECT salt, m_cost, t_cost, p_cost, nonce, sealed FROM vault WHERE id = 1", + "SELECT salt, m_cost, t_cost, p_cost, nonce, sealed, retired_nonce, retired_sealed + FROM vault WHERE id = 1", ) .fetch_optional(&self.db) .await?) @@ -567,15 +733,12 @@ impl Vault { return Ok(false); } - *self.slot() = Some(Unlocked { - key: Arc::new(master), - touched: Instant::now(), - }); + self.put(Keyring::new(master)); Ok(true) } - /// The master key the password stands for, if it is the right one. - async fn check(&self, password: Password) -> Result>> { + /// The keys the password stands for, if it is the right one. + async fn check(&self, password: Password) -> Result>> { let Some(row) = self.row().await? else { return Ok(None); }; @@ -583,45 +746,77 @@ impl Vault { let (m, t, p): (i64, i64, i64) = (row.get("m_cost"), row.get("t_cost"), row.get("p_cost")); let nonce: Vec = row.get("nonce"); let sealed: Vec = row.get("sealed"); - let key = tokio::task::spawn_blocking(move || -> Result> { + let retired: Option<(Vec, Vec)> = row + .get::>, _>("retired_nonce") + .zip(row.get::>, _>("retired_sealed")); + let keys = tokio::task::spawn_blocking(move || -> Result> { let kek = derive(&password, &salt, m as u32, t as u32, p as u32)?; - Ok(open_master(&kek, &nonce, &sealed)) + let Some(current) = open_master(&kek, &nonce, &sealed) else { + return Ok(None); + }; + let retired = match retired { + None => None, + Some((nonce, sealed)) => Some( + unwrap(¤t, &nonce, &sealed, RETIRED_AAD) + .ok_or_else(|| anyhow!("the vault's previous key does not open"))?, + ), + }; + Ok(Some(Keyring { current, retired })) }) .await??; - Ok(Some(key)) + Ok(Some(keys)) } pub async fn unlock(&self, password: Password) -> Result { Ok(match self.check(password).await? { None => Unlock::NotConfigured, Some(None) => Unlock::WrongPassword, - Some(Some(key)) => { - *self.slot() = Some(Unlocked { - key: Arc::new(key), - touched: Instant::now(), - }); + Some(Some(keys)) => { + self.put(keys); Unlock::Opened } }) } - /// Re-seal the master key under a new password. No file changes: they are - /// sealed under the master key, which stays what it was. + /// A new password, and a new master key with it. The old master key is + /// kept, sealed under the new one, only until every file has been + /// re-sealed; the old password opens nothing from the moment this + /// returns. Refused while the last change is still re-sealing, so there + /// are never more than two master keys to try. pub async fn change_password(&self, current: Password, new: Password) -> Result { - let master = match self.check(current).await? { + let old = match self.check(current).await? { None => return Ok(Unlock::NotConfigured), Some(None) => return Ok(Unlock::WrongPassword), - Some(Some(key)) => key, + Some(Some(keys)) if keys.rekeying() => { + self.put(keys); + return Ok(Unlock::Rekeying); + } + Some(Some(keys)) => keys.current, }; + let sealed_files: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM media WHERE encrypted") + .fetch_one(&self.db) + .await?; let salt = random::<16>(); - let (nonce, sealed, master) = tokio::task::spawn_blocking(move || -> Result<_> { + let (nonce, sealed, retired, keys) = tokio::task::spawn_blocking(move || -> Result<_> { + let master: Key = Zeroizing::new(random::<32>()); let kek = derive(&new, &salt, M_COST, T_COST, P_COST)?; let (nonce, sealed) = seal_master(&kek, &master)?; - Ok((nonce, sealed, master)) + // Nothing sealed, nothing to carry over. + let retired = match sealed_files { + 0 => None, + _ => Some(wrap(&master, &old, RETIRED_AAD)?), + }; + let keys = Keyring { + current: master, + retired: retired.is_some().then_some(old), + }; + Ok((nonce, sealed, retired, keys)) }) .await??; + let (retired_nonce, retired_sealed) = retired.unzip(); sqlx::query( - "UPDATE vault SET salt = ?1, m_cost = ?2, t_cost = ?3, p_cost = ?4, nonce = ?5, sealed = ?6 + "UPDATE vault SET salt = ?1, m_cost = ?2, t_cost = ?3, p_cost = ?4, nonce = ?5, sealed = ?6, + retired_nonce = ?7, retired_sealed = ?8 WHERE id = 1", ) .bind(salt.as_slice()) @@ -630,12 +825,12 @@ impl Vault { .bind(P_COST as i64) .bind(nonce.as_slice()) .bind(sealed) + .bind(retired_nonce.as_ref().map(|nonce| nonce.as_slice())) + .bind(retired_sealed) .execute(&self.db) .await?; - *self.slot() = Some(Unlocked { - key: Arc::new(master), - touched: Instant::now(), - }); + self.set_progress(None); + self.put(keys); Ok(Unlock::Opened) } } @@ -646,7 +841,7 @@ mod tests { use zeroize::Zeroizing; - use super::{derive, open_master, seal_master, Opener, Reader, Sealer, CHUNK, HEADER, TAG}; + use super::{derive, open_master, seal_master, Opener, Sealer, CHUNK, HEADER, TAG}; fn key(byte: u8) -> super::Key { Zeroizing::new([byte; 32]) @@ -683,7 +878,7 @@ mod tests { let db = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(); let vault = super::Vault::new(db, std::time::Duration::from_millis(20)); *vault.slot() = Some(super::Unlocked { - key: std::sync::Arc::new(key(1)), + keys: std::sync::Arc::new(super::Keyring::new(key(1))), touched: std::time::Instant::now(), }); vault.expire(); @@ -701,7 +896,7 @@ mod tests { let vault = super::Vault::new(db, std::time::Duration::from_millis(40)); let started = std::time::Instant::now(); *vault.slot() = Some(super::Unlocked { - key: std::sync::Arc::new(key(1)), + keys: std::sync::Arc::new(super::Keyring::new(key(1))), touched: started, }); assert!(vault.peek_key().is_some()); @@ -766,7 +961,8 @@ mod tests { let path = dir.path().join("x"); std::fs::write(&path, seal(&master, "x", &plain, CHUNK)).expect("write"); let mut out = Vec::new(); - Reader::open(&master, "x", &path) + super::Keyring::new(master) + .reader("x", &path) .expect("reader") .read_to_end(&mut out) .expect("read"); @@ -811,4 +1007,37 @@ mod tests { let wrong = derive("correct horse!", b"saltsaltsaltsalt", 256, 1, 1).expect("derive"); assert!(open_master(&wrong, &nonce, &sealed).is_none()); } + + #[test] + fn a_rewrapped_file_opens_under_the_new_key_only() { + let (old, new) = (key(1), key(2)); + let plain: Vec = (0..(2 * CHUNK + 9)).map(|i| (i % 253) as u8).collect(); + let dir = tempfile::tempdir().expect("tempdir"); + let (source, target) = (dir.path().join("a"), dir.path().join("b")); + std::fs::write(&source, seal(&old, "a", &plain, CHUNK)).expect("write"); + + let keys = super::Keyring { + current: new.clone(), + retired: Some(old.clone()), + }; + // Both are readable while the change is under way. + let mut out = Vec::new(); + keys.reader("a", &source) + .unwrap() + .read_to_end(&mut out) + .unwrap(); + assert_eq!(out, plain); + + super::rewrap_file(&keys, "a", "b", &source, &target).unwrap(); + assert!(super::sealed_under(&new, "b", &target).unwrap()); + assert!(!super::sealed_under(&old, "b", &target).unwrap()); + let mut out = Vec::new(); + super::Keyring::new(new) + .reader("b", &target) + .unwrap() + .read_to_end(&mut out) + .unwrap(); + assert_eq!(out, plain); + assert!(super::Keyring::new(old).reader("b", &target).is_err()); + } } diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 0887c4f..19ee17f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -199,6 +199,11 @@ export interface VaultStatus { locks_in: number | null /** How many files are sealed: what an export needs the vault open for. */ sealed: number + /** + * Present while files sealed before the last password change are still + * being re-sealed under the new key; `total` is 0 until counted. + */ + rekey: { done: number; total: number } | null } /** The Proton Drive mirror, as `/api/backup` reports it. */ diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index 8e021a1..4b01438 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -889,7 +889,14 @@ export const commands: CommandSpec[] = [ const vault = workspace.vault if (vault?.configured) { const left = vault.locks_in ? ` · locks in ${Math.ceil(vault.locks_in / 60)}m unless used` : '' - return workspace.say(vault.unlocked ? `vault open${left} — :lock closes it` : 'vault locked — :unlock opens it') + const rekey = vault.rekey + ? vault.unlocked + ? ` · re-sealing after the password change, ${vault.rekey.total ? `${vault.rekey.done}/${vault.rekey.total}` : 'counting'}` + : ' · re-sealing after the password change waits for :unlock' + : '' + return workspace.say( + vault.unlocked ? `vault open${left}${rekey} — :lock closes it` : `vault locked${rekey} — :unlock opens it`, + ) } const password = await workspace.askSecret('new vault password, 10+ characters — it cannot be recovered') if (password === null) return @@ -925,7 +932,7 @@ export const commands: CommandSpec[] = [ { name: 'passwd', group: 'vault', - help: "change the vault's password — no file is rewritten", + help: "change the vault's password — every sealed file is then re-sealed under a new key, while the vault is open", run: () => workspace.guard(async () => { const current = await workspace.askSecret('current vault password') @@ -936,7 +943,11 @@ export const commands: CommandSpec[] = [ if (again === null) return if (again !== password) return workspace.say('the two passwords differ — nothing was changed', 'error') workspace.vault = await api.changeVaultPassword(current, password) - workspace.say('vault password changed') + workspace.say( + workspace.vault.rekey + ? `vault password changed — re-sealing ${workspace.vault.sealed} file(s) under the new key; leave the vault open, :vault shows how far` + : 'vault password changed', + ) }), }, {