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
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
+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)),
sessions,
};
routes::seal::spawn_rekey(state.clone());
if let Some(sftp_bind) = config.sftp.bind {
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)) {
// 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) {
+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?;
}
if let Some(key) = &key {
seal_in_place(state, key, &uploads).await?;
seal_in_place(state, key.current(), &uploads).await?;
}
if uploads.is_empty() {
return Err(AppError::BadRequest(
@@ -1067,7 +1067,8 @@ mod tests {
.unwrap();
assert!(encrypted && sub_sealed);
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()
.read_to_string(&mut back)
.unwrap();
+2 -2
View File
@@ -35,7 +35,7 @@ use crate::{
auth::Session,
error::{AppError, AppResult},
state::AppState,
vault::{self, Password},
vault::Password,
};
/// 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());
match key {
Some(key) => {
vault::Reader::open(&key, &media_id, &path)?
key.reader(&media_id, &path)?
.take(MAX_INSPECT)
.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 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<vault::Vault>,
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)?;
+312 -17
View File
@@ -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<Resealed> {
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<Resealed> {
})
}
/// 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<vault::Keyring>,
media_id: &str,
how: Rewrite,
) -> AppResult<String> {
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<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(())
}
#[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<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(
"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))
}
+13 -9
View File
@@ -387,7 +387,7 @@ impl SpaceFs {
&self,
parent: i64,
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")
.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<Arc<vault::Key>> {
fn vault_key(&self) -> SftpResult<Arc<vault::Keyring>> {
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<u64> {
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,
+320 -91
View File
@@ -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<Key> {
}
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 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<Key> {
/// `None` when `kek` is not the key it was sealed under.
fn unwrap(kek: &Key, nonce: &[u8], sealed: &[u8], aad: &[u8]) -> Option<Key> {
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<u8> {
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<R> {
inner: R,
opener: Opener,
@@ -312,24 +323,6 @@ pub struct Reader<R> {
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> {
fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
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<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.
pub type Password = Zeroizing<String>;
@@ -391,13 +494,24 @@ pub struct Vault {
db: SqlitePool,
idle: Duration,
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 {
key: Arc<Key>,
keys: Arc<Keyring>,
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<u64>,
/// 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<Rekey>,
}
/// 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<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>> {
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<Arc<Key>> {
pub fn key(&self) -> Option<Arc<Keyring>> {
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<Arc<Key>> {
pub fn peek_key(&self) -> Option<Arc<Keyring>> {
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<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")
.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<Option<sqlx::sqlite::SqliteRow>> {
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<Option<Option<Key>>> {
/// The keys the password stands for, if it is the right one.
async fn check(&self, password: Password) -> Result<Option<Option<Keyring>>> {
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<u8> = row.get("nonce");
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)?;
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??;
Ok(Some(key))
Ok(Some(keys))
}
pub async fn unlock(&self, password: Password) -> Result<Unlock> {
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<Unlock> {
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<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
/** 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. */
+14 -3
View File
@@ -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',
)
}),
},
{