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.
This commit is contained in:
nvrl committed 2026-10-20 12:46:10 +02:00
1 parent 947cc2ba96
commit 3387db1c4d
13 files changed
+724 -143

No files matched your search

+15 -7
View File
@@ -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 password. A sealed file carries a lock in the list, and the bar says `vault
open` while it is. open` while it is.
`:passwd` wraps the same key under the new password, so no file has to be `:passwd` makes a new master key as well as a new password. The old password
rewritten — and so it does not take back what the old password opens. A copy stops opening anything at once; every sealed file is then re-sealed under the
of `diary.db` from before the change (a `diary.db.pre-*` snapshot, a backup in new key in the background, under a new name, while the vault is open — `:vault`
Proton Drive) still unwraps the key with the old password, and that key still shows how far it has come, and locking halfway only pauses it until the next
opens every sealed file. Change the password because you want a new one, not `:unlock`. The old copies are deleted from `uploads/` and, on the next mirror
to shut out someone who has the old one and an old copy. 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 `: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 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 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 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. 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 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 no copy of what it showed once the vault shuts. A sealed image in a document
+7
View File
@@ -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;
+1
View File
@@ -137,6 +137,7 @@ async fn main() -> Result<()> {
epoch: Arc::new(std::sync::atomic::AtomicI64::new(epoch)), epoch: Arc::new(std::sync::atomic::AtomicI64::new(epoch)),
sessions, sessions,
}; };
routes::seal::spawn_rekey(state.clone());
if let Some(sftp_bind) = config.sftp.bind { if let Some(sftp_bind) = config.sftp.bind {
let sftp_state = state.clone(); let sftp_state = state.clone();
+5 -7
View File
@@ -386,13 +386,11 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
let source: Box<dyn std::io::Read> = match (&key, sealed.contains(id)) { let source: Box<dyn std::io::Read> = match (&key, sealed.contains(id)) {
// A sealed file that will not open is not a file to leave out // 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. // quietly: the archive would claim to be complete and not be.
(Some(key), true) => { (Some(key), true) => Box::new(key.reader(id, &path).map_err(|e| {
Box::new(crate::vault::Reader::open(key, id, &path).map_err(|e| { AppError::BadRequest(format!(
AppError::BadRequest(format!( "{entry} is sealed and would not open ({e}) — nothing was exported"
"{entry} is sealed and would not open ({e}) — nothing was exported" ))
)) })?),
})?)
}
// A row whose file has gone missing must not fail the whole // A row whose file has gone missing must not fail the whole
// export; there is nothing to put in its place either way. // export; there is nothing to put in its place either way.
_ => match std::fs::File::open(&path) { _ => match std::fs::File::open(&path) {
+3 -2
View File
@@ -542,7 +542,7 @@ async fn run(state: &AppState, into: Into, multipart: Multipart) -> AppResult<Im
files_as_they_were(state, manifest, &mut uploads, &mut written).await?; files_as_they_were(state, manifest, &mut uploads, &mut written).await?;
} }
if let Some(key) = &key { if let Some(key) = &key {
seal_in_place(state, key, &uploads).await?; seal_in_place(state, key.current(), &uploads).await?;
} }
if uploads.is_empty() { if uploads.is_empty() {
return Err(AppError::BadRequest( return Err(AppError::BadRequest(
@@ -1067,7 +1067,8 @@ mod tests {
.unwrap(); .unwrap();
assert!(encrypted && sub_sealed); assert!(encrypted && sub_sealed);
let mut back = String::new(); let mut back = String::new();
crate::vault::Reader::open(&key, "m1", &uploads_dir.join("m1")) crate::vault::Keyring::new(key.clone())
.reader("m1", &uploads_dir.join("m1"))
.unwrap() .unwrap()
.read_to_string(&mut back) .read_to_string(&mut back)
.unwrap(); .unwrap();
+2 -2
View File
@@ -35,7 +35,7 @@ use crate::{
auth::Session, auth::Session,
error::{AppError, AppResult}, error::{AppError, AppResult},
state::AppState, state::AppState,
vault::{self, Password}, vault::Password,
}; };
/// Certificates and keys are kilobytes. Anything past this is not one, and is /// Certificates and keys are kilobytes. Anything past this is not one, and is
@@ -137,7 +137,7 @@ pub async fn inspect(
let mut bytes = Zeroizing::new(Vec::new()); let mut bytes = Zeroizing::new(Vec::new());
match key { match key {
Some(key) => { Some(key) => {
vault::Reader::open(&key, &media_id, &path)? key.reader(&media_id, &path)?
.take(MAX_INSPECT) .take(MAX_INSPECT)
.read_to_end(&mut bytes)?; .read_to_end(&mut bytes)?;
} }
+10 -3
View File
@@ -297,7 +297,14 @@ pub async fn upload_into(
let mime = mime_of(&filename, field.content_type()); let mime = mime_of(&filename, field.content_type());
let id = Uuid::new_v4().to_string(); 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 { if size == 0 {
continue; continue;
} }
@@ -598,7 +605,7 @@ pub async fn stream_media(
/// does break off, as a read over the mount does. /// does break off, as a read over the mount does.
async fn open_stream( async fn open_stream(
mut file: tokio::fs::File, mut file: tokio::fs::File,
key: &vault::Key, key: &vault::Keyring,
vault: Arc<vault::Vault>, vault: Arc<vault::Vault>,
id: &str, id: &str,
start: u64, start: u64,
@@ -609,7 +616,7 @@ async fn open_stream(
let len = file.metadata().await?.len(); let len = file.metadata().await?.len();
let mut header = [0u8; vault::HEADER]; let mut header = [0u8; vault::HEADER];
file.read_exact(&mut header).await?; 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; let first = start / vault::CHUNK as u64;
if first > 0 { if first > 0 {
opener.skip_to(first)?; opener.skip_to(first)?;
+312 -17
View File
@@ -17,6 +17,11 @@
//! file, and the old id is put on `backup_retired`, which the mirror takes off //! 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 //! 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. //! 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::{ use axum::{
extract::{Path, State}, extract::{Path, State},
@@ -229,8 +234,12 @@ async fn reseal(state: &AppState, id: i64, seal: bool) -> AppResult<Resealed> {
unchanged += 1; unchanged += 1;
continue; continue;
} }
match rewrite(state, &key, target, seal).await { let how = if seal { Rewrite::Seal } else { Rewrite::Open };
Ok(()) => files += 1, 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) => { Err(e) => {
tracing::warn!(node = target.node_id, "could not re-write a file: {e:?}"); tracing::warn!(node = target.node_id, "could not re-write a file: {e:?}");
failed.push(e); failed.push(e);
@@ -266,32 +275,52 @@ async fn reseal(state: &AppState, id: i64, seal: bool) -> AppResult<Resealed> {
}) })
} }
/// Re-write one file sealed or opened under a new id, then point everything #[derive(Clone, Copy)]
/// at the new id and retire the old one. enum Rewrite {
async fn rewrite(state: &AppState, key: &vault::Key, target: &Target, seal: bool) -> AppResult<()> { /// 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<vault::Keyring>,
media_id: &str,
how: Rewrite,
) -> AppResult<String> {
let uploads = state.config.uploads_dir(); 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 new = Uuid::new_v4().to_string();
let seal = !matches!(how, Rewrite::Open);
let source = uploads.join(&old); let source = uploads.join(&old);
let partial = uploads.join(format!("{new}.part")); let partial = uploads.join(format!("{new}.part"));
let written = { let written = {
let (key, old, new, source, partial) = ( let (keys, old, new, source, partial) = (
key.clone(), keys.clone(),
old.clone(), old.clone(),
new.clone(), new.clone(),
source.clone(), source.clone(),
partial.clone(), partial.clone(),
); );
tokio::task::spawn_blocking(move || -> anyhow::Result<()> { tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
if seal { match how {
let mut plain = std::fs::File::open(&source)?; Rewrite::Seal => {
vault::seal_file(&key, &new, &mut plain, &partial)?; let mut plain = std::fs::File::open(&source)?;
} else { vault::seal_file(keys.current(), &new, &mut plain, &partial)?;
let mut opened = vault::Reader::open(&key, &old, &source)?; }
let mut out = std::fs::File::create(&partial)?; Rewrite::Open => {
std::io::copy(&mut opened, &mut out)?; let mut opened = keys.reader(&old, &source)?;
out.sync_all()?; 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(()) 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. // The database no longer knows the old bytes; they go from the disk too.
let _ = tokio::fs::remove_file(&source).await; 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<String> =
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::<Vec<_>>()
})
.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(()) Ok(())
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::io::Read;
use zeroize::Zeroizing; use zeroize::Zeroizing;
use crate::{error::AppError, routes::media}; use crate::{error::AppError, routes::media};
@@ -594,4 +697,196 @@ mod tests {
assert!(!sealed(&w.state, inner).await.unwrap()); assert!(!sealed(&w.state, inner).await.unwrap());
plain_file(&w.state, inner, "e.txt").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<Vec<u8>>>("SELECT retired_sealed FROM vault")
.fetch_one(&w.state.db)
.await
.unwrap()
.is_some()
}
async fn sealed_ids(state: &AppState) -> Vec<String> {
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());
}
} }
+17 -2
View File
@@ -74,6 +74,15 @@ fn answer(attempt: Attempt<'_>, outcome: Unlock) -> AppResult<()> {
Unlock::NotConfigured => Err(AppError::BadRequest( Unlock::NotConfigured => Err(AppError::BadRequest(
"there is no vault yet — :vault sets one up".into(), "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) .change_password(input.current, input.password)
.await?, .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(); state.backup.signal();
Ok(Json(state.vault.status().await?)) Ok(Json(status))
} }
+13 -9
View File
@@ -387,7 +387,7 @@ impl SpaceFs {
&self, &self,
parent: i64, parent: i64,
over_sealed: bool, over_sealed: bool,
) -> SftpResult<Option<Arc<vault::Key>>> { ) -> SftpResult<Option<Arc<vault::Keyring>>> {
let sealed: bool = sqlx::query_scalar("SELECT sealed FROM nodes WHERE id = ?1") let sealed: bool = sqlx::query_scalar("SELECT sealed FROM nodes WHERE id = ?1")
.bind(parent) .bind(parent)
.fetch_optional(&self.db) .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 /// 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. /// without counting as a use, so a mount never holds the vault open.
fn vault_key(&self) -> SftpResult<Arc<vault::Key>> { fn vault_key(&self) -> SftpResult<Arc<vault::Keyring>> {
self.state self.state
.as_ref() .as_ref()
.and_then(|state| state.vault.peek_key()) .and_then(|state| state.vault.peek_key())
@@ -850,7 +850,7 @@ impl SpaceFs {
let (key, id, part) = let (key, id, part) =
(key.clone(), old.media_id.clone(), partial.to_path_buf()); (key.clone(), old.media_id.clone(), partial.to_path_buf());
tokio::task::spawn_blocking(move || -> anyhow::Result<u64> { tokio::task::spawn_blocking(move || -> anyhow::Result<u64> {
let mut plain = vault::Reader::open(&key, &id, &source)?; let mut plain = key.reader(&id, &source)?;
Ok(std::io::copy( Ok(std::io::copy(
&mut plain, &mut plain,
&mut std::fs::File::create(&part)?, &mut std::fs::File::create(&part)?,
@@ -916,7 +916,12 @@ impl SpaceFs {
let (key, id, plain, sealed) = let (key, id, plain, sealed) =
(key.clone(), media_id.clone(), partial.to_path_buf(), target); (key.clone(), media_id.clone(), partial.to_path_buf(), target);
tokio::task::spawn_blocking(move || -> anyhow::Result<()> { 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)?; std::fs::remove_file(&plain)?;
Ok(()) Ok(())
}) })
@@ -1383,11 +1388,10 @@ impl russh_sftp::server::Handler for SpaceFs {
.read_exact(&mut header) .read_exact(&mut header)
.await .await
.map_err(|_| StatusCode::Failure)?; .map_err(|_| StatusCode::Failure)?;
let opener = vault::Opener::new(&key, &file.media_id, &header, len) let opener = key.opener(&file.media_id, &header, len).map_err(|e| {
.map_err(|e| { tracing::warn!("sftp: could not open {leaf}: {e:?}");
tracing::warn!("sftp: could not open {leaf}: {e:?}"); StatusCode::Failure
StatusCode::Failure })?;
})?;
OpenHandle::ReadSealed(Box::new(SealedRead { OpenHandle::ReadSealed(Box::new(SealedRead {
file: opened, file: opened,
opener, opener,
+320 -91
View File
@@ -8,10 +8,10 @@
//! //!
//! Keys: //! Keys:
//! //!
//! - a random 256-bit **master key**, made once at setup and never stored in //! - a random 256-bit **master key**, never stored in the clear: the `vault`
//! the clear: the `vault` row holds it sealed under a key derived from the //! row holds it sealed under a key derived from the password with Argon2id.
//! password with Argon2id. Changing the password re-seals the master key and //! Changing the password makes a new master key, and every sealed file is
//! touches no file; //! then re-sealed under it in the background — see [`Keyring`];
//! - a random 256-bit **file key** per file, sealed under the master key in //! - 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 //! the file's own header, so no two files share a key and nonces never need
//! counting across files. //! 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. /// What the master key is sealed with, besides the password-derived key.
const MASTER_AAD: &[u8] = b"narl-vault-master-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 /// The shortest password the vault takes. It guards everything in it, and
/// unlike the login it cannot be reset by editing a config file. /// 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 /// 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 /// 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. /// vault, so raising them later does not lock anybody out.
#[cfg(not(test))]
const M_COST: u32 = 64 * 1024; 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 T_COST: u32 = 3;
const P_COST: u32 = 1; const P_COST: u32 = 1;
@@ -98,30 +105,37 @@ fn derive(password: &str, salt: &[u8], m: u32, t: u32, p: u32) -> Result<Key> {
} }
fn seal_master(kek: &Key, master: &Key) -> Result<([u8; WRAP_NONCE], Vec<u8>)> { fn seal_master(kek: &Key, master: &Key) -> Result<([u8; WRAP_NONCE], Vec<u8>)> {
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<Key> {
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<u8>)> {
let nonce = random::<WRAP_NONCE>(); let nonce = random::<WRAP_NONCE>();
let sealed = Aes256Gcm::new(kek.as_ref().into()) let sealed = Aes256Gcm::new(kek.as_ref().into())
.encrypt( .encrypt(
Nonce::from_slice(&nonce), Nonce::from_slice(&nonce),
Payload { Payload {
msg: master.as_ref(), msg: key.as_ref(),
aad: MASTER_AAD, aad,
}, },
) )
.map_err(|_| anyhow!("could not seal the master key"))?; .map_err(|_| anyhow!("could not seal a key"))?;
Ok((nonce, sealed)) Ok((nonce, sealed))
} }
/// `None` is a wrong password: the tag does not verify. /// `None` when `kek` is not the key it was sealed under.
fn open_master(kek: &Key, nonce: &[u8], sealed: &[u8]) -> Option<Key> { fn unwrap(kek: &Key, nonce: &[u8], sealed: &[u8], aad: &[u8]) -> Option<Key> {
if nonce.len() != WRAP_NONCE {
return None;
}
let plain = Zeroizing::new( let plain = Zeroizing::new(
Aes256Gcm::new(kek.as_ref().into()) Aes256Gcm::new(kek.as_ref().into())
.decrypt( .decrypt(Nonce::from_slice(nonce), Payload { msg: sealed, aad })
Nonce::from_slice(nonce),
Payload {
msg: sealed,
aad: MASTER_AAD,
},
)
.ok()?, .ok()?,
); );
let mut key = Zeroizing::new([0u8; 32]); 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<u8> { fn file_aad(media_id: &str) -> Vec<u8> {
let mut aad = MAGIC.to_vec(); let mut aad = MAGIC.to_vec();
aad.extend_from_slice(media_id.as_bytes()); aad.extend_from_slice(media_id.as_bytes());
@@ -239,21 +260,11 @@ impl Opener {
return Err(anyhow!("sealed file is truncated")); return Err(anyhow!("sealed file is truncated"));
} }
let (wrap_nonce, rest) = header[MAGIC.len()..].split_at(WRAP_NONCE); let (wrap_nonce, sealed_key, stream_nonce) = split_header(header);
let (sealed_key, stream_nonce) = rest.split_at(32 + TAG); let file_key =
let file_key = Zeroizing::new( unwrap(master, wrap_nonce, sealed_key, &file_aad(media_id)).ok_or_else(|| {
Aes256Gcm::new(master.as_ref().into()) anyhow!("this file was not sealed under this vault, or not as {media_id}")
.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 stream = StreamBE32::from_aead( let stream = StreamBE32::from_aead(
Aes256Gcm::new(file_key.as_slice().into()), Aes256Gcm::new(file_key.as_slice().into()),
stream_nonce.into(), stream_nonce.into(),
@@ -304,7 +315,7 @@ impl Opener {
} }
/// A sealed file read as its plaintext, for code that wants `std::io::Read`: /// 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<R> { pub struct Reader<R> {
inner: R, inner: R,
opener: Opener, opener: Opener,
@@ -312,24 +323,6 @@ pub struct Reader<R> {
at: usize, at: usize,
} }
impl Reader<std::fs::File> {
pub fn open(master: &Key, media_id: &str, path: &std::path::Path) -> Result<Self> {
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<R: Read> Read for Reader<R> { impl<R: Read> Read for Reader<R> {
fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> { fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
while self.at == self.buffer.len() { while self.at == self.buffer.len() {
@@ -376,6 +369,116 @@ pub fn seal_file(
Ok(size) 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<Key>,
}
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<Item = &Key> {
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<Opener> {
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<Reader<std::fs::File>> {
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<bool> {
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. /// A vault password on its way to Argon2, wiped from memory once it is done.
pub type Password = Zeroizing<String>; pub type Password = Zeroizing<String>;
@@ -391,13 +494,24 @@ pub struct Vault {
db: SqlitePool, db: SqlitePool,
idle: Duration, idle: Duration,
open: Mutex<Option<Unlocked>>, open: Mutex<Option<Unlocked>>,
/// Wakes whatever re-seals files after a password change.
rekey: tokio::sync::Notify,
/// How far that has come, while it runs.
progress: Mutex<Option<Rekey>>,
} }
struct Unlocked { struct Unlocked {
key: Arc<Key>, keys: Arc<Keyring>,
touched: Instant, 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)] #[derive(serde::Serialize)]
pub struct Status { pub struct Status {
/// Whether a vault password has been set at all. /// Whether a vault password has been set at all.
@@ -407,6 +521,9 @@ pub struct Status {
pub locks_in: Option<u64>, pub locks_in: Option<u64>,
/// How many files are sealed: what an export needs the vault open for. /// How many files are sealed: what an export needs the vault open for.
pub sealed: i64, 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<Rekey>,
} }
/// What a password check came to. /// What a password check came to.
@@ -414,6 +531,8 @@ pub enum Unlock {
Opened, Opened,
WrongPassword, WrongPassword,
NotConfigured, NotConfigured,
/// The last password change is still re-sealing files.
Rekeying,
} }
impl Vault { impl Vault {
@@ -422,21 +541,65 @@ impl Vault {
db, db,
idle, idle,
open: Mutex::new(None), 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<Rekey>) {
*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<Keyring>) -> 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<Unlocked>> { fn slot(&self) -> std::sync::MutexGuard<'_, Option<Unlocked>> {
self.open.lock().unwrap_or_else(|e| e.into_inner()) 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. /// A vault left alone past the idle time closes on the next look.
pub fn key(&self) -> Option<Arc<Key>> { pub fn key(&self) -> Option<Arc<Keyring>> {
let mut slot = self.slot(); let mut slot = self.slot();
match slot.as_mut() { match slot.as_mut() {
Some(open) if open.touched.elapsed() < self.idle => { Some(open) if open.touched.elapsed() < self.idle => {
open.touched = Instant::now(); open.touched = Instant::now();
Some(open.key.clone()) Some(open.keys.clone())
} }
Some(_) => { Some(_) => {
*slot = None; *slot = None;
@@ -457,11 +620,11 @@ impl Vault {
/// The key while the vault is open, without counting as a use: for what /// 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 /// is not the owner at the browser — a mount — which may use the vault
/// while it is open but must not keep it so. /// while it is open but must not keep it so.
pub fn peek_key(&self) -> Option<Arc<Key>> { pub fn peek_key(&self) -> Option<Arc<Keyring>> {
self.slot() self.slot()
.as_ref() .as_ref()
.filter(|open| open.touched.elapsed() < self.idle) .filter(|open| open.touched.elapsed() < self.idle)
.map(|open| open.key.clone()) .map(|open| open.keys.clone())
} }
pub fn lock(&self) { pub fn lock(&self) {
@@ -472,10 +635,7 @@ impl Vault {
/// vault allows. /// vault allows.
#[cfg(test)] #[cfg(test)]
pub fn open_with(&self, key: Key) { pub fn open_with(&self, key: Key) {
*self.slot() = Some(Unlocked { self.put(Keyring::new(key));
key: Arc::new(key),
touched: Instant::now(),
});
} }
/// Drop the key once it has gone unused past the idle time. Without this /// 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<Status> { pub async fn status(&self) -> Result<Status> {
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::<Option<Vec<u8>>, _>("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") let sealed: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM media WHERE encrypted")
.fetch_one(&self.db) .fetch_one(&self.db)
.await?; .await?;
@@ -523,12 +687,14 @@ impl Vault {
unlocked: remaining.is_some(), unlocked: remaining.is_some(),
locks_in: remaining.map(|left| left.as_secs()), locks_in: remaining.map(|left| left.as_secs()),
sealed, sealed,
rekey: rekeying.then(|| progress.unwrap_or_default()),
}) })
} }
async fn row(&self) -> Result<Option<sqlx::sqlite::SqliteRow>> { async fn row(&self) -> Result<Option<sqlx::sqlite::SqliteRow>> {
Ok(sqlx::query( 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) .fetch_optional(&self.db)
.await?) .await?)
@@ -567,15 +733,12 @@ impl Vault {
return Ok(false); return Ok(false);
} }
*self.slot() = Some(Unlocked { self.put(Keyring::new(master));
key: Arc::new(master),
touched: Instant::now(),
});
Ok(true) Ok(true)
} }
/// The master key the password stands for, if it is the right one. /// The keys the password stands for, if it is the right one.
async fn check(&self, password: Password) -> Result<Option<Option<Key>>> { async fn check(&self, password: Password) -> Result<Option<Option<Keyring>>> {
let Some(row) = self.row().await? else { let Some(row) = self.row().await? else {
return Ok(None); 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 (m, t, p): (i64, i64, i64) = (row.get("m_cost"), row.get("t_cost"), row.get("p_cost"));
let nonce: Vec<u8> = row.get("nonce"); let nonce: Vec<u8> = row.get("nonce");
let sealed: Vec<u8> = row.get("sealed"); let sealed: Vec<u8> = row.get("sealed");
let key = tokio::task::spawn_blocking(move || -> Result<Option<Key>> { let retired: Option<(Vec<u8>, Vec<u8>)> = row
.get::<Option<Vec<u8>>, _>("retired_nonce")
.zip(row.get::<Option<Vec<u8>>, _>("retired_sealed"));
let keys = tokio::task::spawn_blocking(move || -> Result<Option<Keyring>> {
let kek = derive(&password, &salt, m as u32, t as u32, p as u32)?; 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(&current, &nonce, &sealed, RETIRED_AAD)
.ok_or_else(|| anyhow!("the vault's previous key does not open"))?,
),
};
Ok(Some(Keyring { current, retired }))
}) })
.await??; .await??;
Ok(Some(key)) Ok(Some(keys))
} }
pub async fn unlock(&self, password: Password) -> Result<Unlock> { pub async fn unlock(&self, password: Password) -> Result<Unlock> {
Ok(match self.check(password).await? { Ok(match self.check(password).await? {
None => Unlock::NotConfigured, None => Unlock::NotConfigured,
Some(None) => Unlock::WrongPassword, Some(None) => Unlock::WrongPassword,
Some(Some(key)) => { Some(Some(keys)) => {
*self.slot() = Some(Unlocked { self.put(keys);
key: Arc::new(key),
touched: Instant::now(),
});
Unlock::Opened Unlock::Opened
} }
}) })
} }
/// Re-seal the master key under a new password. No file changes: they are /// A new password, and a new master key with it. The old master key is
/// sealed under the master key, which stays what it was. /// 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<Unlock> { pub async fn change_password(&self, current: Password, new: Password) -> Result<Unlock> {
let master = match self.check(current).await? { let old = match self.check(current).await? {
None => return Ok(Unlock::NotConfigured), None => return Ok(Unlock::NotConfigured),
Some(None) => return Ok(Unlock::WrongPassword), 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 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 kek = derive(&new, &salt, M_COST, T_COST, P_COST)?;
let (nonce, sealed) = seal_master(&kek, &master)?; 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??; .await??;
let (retired_nonce, retired_sealed) = retired.unzip();
sqlx::query( 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", WHERE id = 1",
) )
.bind(salt.as_slice()) .bind(salt.as_slice())
@@ -630,12 +825,12 @@ impl Vault {
.bind(P_COST as i64) .bind(P_COST as i64)
.bind(nonce.as_slice()) .bind(nonce.as_slice())
.bind(sealed) .bind(sealed)
.bind(retired_nonce.as_ref().map(|nonce| nonce.as_slice()))
.bind(retired_sealed)
.execute(&self.db) .execute(&self.db)
.await?; .await?;
*self.slot() = Some(Unlocked { self.set_progress(None);
key: Arc::new(master), self.put(keys);
touched: Instant::now(),
});
Ok(Unlock::Opened) Ok(Unlock::Opened)
} }
} }
@@ -646,7 +841,7 @@ mod tests {
use zeroize::Zeroizing; 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 { fn key(byte: u8) -> super::Key {
Zeroizing::new([byte; 32]) Zeroizing::new([byte; 32])
@@ -683,7 +878,7 @@ mod tests {
let db = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(); let db = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
let vault = super::Vault::new(db, std::time::Duration::from_millis(20)); let vault = super::Vault::new(db, std::time::Duration::from_millis(20));
*vault.slot() = Some(super::Unlocked { *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(), touched: std::time::Instant::now(),
}); });
vault.expire(); vault.expire();
@@ -701,7 +896,7 @@ mod tests {
let vault = super::Vault::new(db, std::time::Duration::from_millis(40)); let vault = super::Vault::new(db, std::time::Duration::from_millis(40));
let started = std::time::Instant::now(); let started = std::time::Instant::now();
*vault.slot() = Some(super::Unlocked { *vault.slot() = Some(super::Unlocked {
key: std::sync::Arc::new(key(1)), keys: std::sync::Arc::new(super::Keyring::new(key(1))),
touched: started, touched: started,
}); });
assert!(vault.peek_key().is_some()); assert!(vault.peek_key().is_some());
@@ -766,7 +961,8 @@ mod tests {
let path = dir.path().join("x"); let path = dir.path().join("x");
std::fs::write(&path, seal(&master, "x", &plain, CHUNK)).expect("write"); std::fs::write(&path, seal(&master, "x", &plain, CHUNK)).expect("write");
let mut out = Vec::new(); let mut out = Vec::new();
Reader::open(&master, "x", &path) super::Keyring::new(master)
.reader("x", &path)
.expect("reader") .expect("reader")
.read_to_end(&mut out) .read_to_end(&mut out)
.expect("read"); .expect("read");
@@ -811,4 +1007,37 @@ mod tests {
let wrong = derive("correct horse!", b"saltsaltsaltsalt", 256, 1, 1).expect("derive"); let wrong = derive("correct horse!", b"saltsaltsaltsalt", 256, 1, 1).expect("derive");
assert!(open_master(&wrong, &nonce, &sealed).is_none()); 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<u8> = (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());
}
} }
+5
View File
@@ -199,6 +199,11 @@ export interface VaultStatus {
locks_in: number | null locks_in: number | null
/** How many files are sealed: what an export needs the vault open for. */ /** How many files are sealed: what an export needs the vault open for. */
sealed: number 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. */ /** The Proton Drive mirror, as `/api/backup` reports it. */
+14 -3
View File
@@ -889,7 +889,14 @@ export const commands: CommandSpec[] = [
const vault = workspace.vault const vault = workspace.vault
if (vault?.configured) { if (vault?.configured) {
const left = vault.locks_in ? ` · locks in ${Math.ceil(vault.locks_in / 60)}m unless used` : '' 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') const password = await workspace.askSecret('new vault password, 10+ characters — it cannot be recovered')
if (password === null) return if (password === null) return
@@ -925,7 +932,7 @@ export const commands: CommandSpec[] = [
{ {
name: 'passwd', name: 'passwd',
group: 'vault', 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: () => run: () =>
workspace.guard(async () => { workspace.guard(async () => {
const current = await workspace.askSecret('current vault password') const current = await workspace.askSecret('current vault password')
@@ -936,7 +943,11 @@ export const commands: CommandSpec[] = [
if (again === null) return if (again === null) return
if (again !== password) return workspace.say('the two passwords differ — nothing was changed', 'error') if (again !== password) return workspace.say('the two passwords differ — nothing was changed', 'error')
workspace.vault = await api.changeVaultPassword(current, password) 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',
)
}), }),
}, },
{ {