mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
947cc2ba96
commit
3387db1c4d
13 files changed
+724
-143
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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(¤t, &nonce, &sealed, RETIRED_AAD)
|
||||
.ok_or_else(|| anyhow!("the vault's previous key does not open"))?,
|
||||
),
|
||||
};
|
||||
Ok(Some(Keyring { current, retired }))
|
||||
})
|
||||
.await??;
|
||||
Ok(Some(key))
|
||||
Ok(Some(keys))
|
||||
}
|
||||
|
||||
pub async fn unlock(&self, password: Password) -> Result<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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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',
|
||||
)
|
||||
}),
|
||||
},
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user