mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
: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.
1044 lines
37 KiB
Rust
1044 lines
37 KiB
Rust
//! The vault: files encrypted at rest under a key that only a password unlocks.
|
|
//!
|
|
//! Everything else in the workspace is protected by the login and by where the
|
|
//! data directory lives. A file in the vault is protected even from someone who
|
|
//! has both — a copy of the volume, the Proton account the mirror writes to, a
|
|
//! stolen session — because its bytes on disk are ciphertext and the key is
|
|
//! held in memory only while the vault is open.
|
|
//!
|
|
//! Keys:
|
|
//!
|
|
//! - 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.
|
|
//!
|
|
//! A sealed file is
|
|
//!
|
|
//! ```text
|
|
//! "NARLVLT1" 8 bytes, format and version
|
|
//! wrap nonce 12 bytes
|
|
//! file key, sealed 48 bytes: AES-256-GCM under the master key,
|
|
//! the media id as associated data
|
|
//! stream nonce 7 bytes
|
|
//! records AES-256-GCM STREAM (BE32): 64 KiB of plaintext
|
|
//! plus a 16-byte tag each, the last one shorter
|
|
//! and marked last, so a truncated file is caught
|
|
//! ```
|
|
//!
|
|
//! Binding the media id means a sealed file renamed to another id refuses to
|
|
//! open rather than showing up as somebody else. The password is never stored
|
|
//! and cannot be recovered: forgetting it loses every file in the vault, which
|
|
//! is the point.
|
|
|
|
use std::{
|
|
io::Read,
|
|
sync::{Arc, Mutex},
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
use aes_gcm::{
|
|
aead::{
|
|
stream::{EncryptorBE32, NewStream, StreamBE32, StreamPrimitive},
|
|
Aead, KeyInit, Payload,
|
|
},
|
|
Aes256Gcm, Nonce,
|
|
};
|
|
use anyhow::{anyhow, Context, Result};
|
|
use rand::RngCore;
|
|
use sqlx::{Row, SqlitePool};
|
|
use zeroize::Zeroizing;
|
|
|
|
pub const MAGIC: &[u8; 8] = b"NARLVLT1";
|
|
/// Plaintext per record. Large enough that the tags cost nothing to speak of,
|
|
/// small enough to hold one in memory per open download.
|
|
pub const CHUNK: usize = 64 * 1024;
|
|
const TAG: usize = 16;
|
|
/// A record on disk: a chunk of plaintext and its tag.
|
|
pub const RECORD: usize = CHUNK + TAG;
|
|
const WRAP_NONCE: usize = 12;
|
|
const STREAM_NONCE: usize = 7;
|
|
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.
|
|
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;
|
|
|
|
pub type Key = Zeroizing<[u8; 32]>;
|
|
|
|
fn random<const N: usize>() -> [u8; N] {
|
|
let mut out = [0u8; N];
|
|
rand::thread_rng().fill_bytes(&mut out);
|
|
out
|
|
}
|
|
|
|
/// The key the password stands for, at the costs the vault was made with.
|
|
fn derive(password: &str, salt: &[u8], m: u32, t: u32, p: u32) -> Result<Key> {
|
|
let params =
|
|
argon2::Params::new(m, t, p, Some(32)).map_err(|e| anyhow!("argon2 params: {e}"))?;
|
|
let argon = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
|
|
let mut key = Zeroizing::new([0u8; 32]);
|
|
argon
|
|
.hash_password_into(password.as_bytes(), salt, key.as_mut())
|
|
.map_err(|e| anyhow!("argon2: {e}"))?;
|
|
Ok(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: key.as_ref(),
|
|
aad,
|
|
},
|
|
)
|
|
.map_err(|_| anyhow!("could not seal a key"))?;
|
|
Ok((nonce, sealed))
|
|
}
|
|
|
|
/// `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 })
|
|
.ok()?,
|
|
);
|
|
let mut key = Zeroizing::new([0u8; 32]);
|
|
key.copy_from_slice(plain.get(..32)?);
|
|
Some(key)
|
|
}
|
|
|
|
/// Encrypts one file as it streams past: the header first, then a record for
|
|
/// every [`CHUNK`] that has arrived, and the short last one at [`finish`](Self::finish).
|
|
pub struct Sealer {
|
|
stream: EncryptorBE32<Aes256Gcm>,
|
|
pending: Vec<u8>,
|
|
}
|
|
|
|
impl Sealer {
|
|
/// A sealer for `media_id`, and the header that has to be written first.
|
|
pub fn new(master: &Key, media_id: &str) -> Result<(Self, Vec<u8>)> {
|
|
let file_key: Key = Zeroizing::new(random::<32>());
|
|
let wrap_nonce = random::<WRAP_NONCE>();
|
|
let stream_nonce = random::<STREAM_NONCE>();
|
|
|
|
let sealed_key = Aes256Gcm::new(master.as_ref().into())
|
|
.encrypt(
|
|
Nonce::from_slice(&wrap_nonce),
|
|
Payload {
|
|
msg: file_key.as_ref(),
|
|
aad: &file_aad(media_id),
|
|
},
|
|
)
|
|
.map_err(|_| anyhow!("could not seal a file key"))?;
|
|
|
|
let mut header = Vec::with_capacity(HEADER);
|
|
header.extend_from_slice(MAGIC);
|
|
header.extend_from_slice(&wrap_nonce);
|
|
header.extend_from_slice(&sealed_key);
|
|
header.extend_from_slice(&stream_nonce);
|
|
|
|
let stream = EncryptorBE32::from_aead(
|
|
Aes256Gcm::new(file_key.as_ref().into()),
|
|
stream_nonce.as_slice().into(),
|
|
);
|
|
Ok((
|
|
Self {
|
|
stream,
|
|
pending: Vec::with_capacity(CHUNK),
|
|
},
|
|
header,
|
|
))
|
|
}
|
|
|
|
/// Take more plaintext; returns whatever whole records it completes. A
|
|
/// record is only sealed once more data follows it, because only the last
|
|
/// one may be marked last and the last is not known until `finish`.
|
|
pub fn push(&mut self, mut data: &[u8]) -> Result<Vec<u8>> {
|
|
let mut out = Vec::new();
|
|
while !data.is_empty() {
|
|
if self.pending.len() == CHUNK {
|
|
let record = self
|
|
.stream
|
|
.encrypt_next(self.pending.as_slice())
|
|
.map_err(|_| anyhow!("could not seal a record"))?;
|
|
out.extend_from_slice(&record);
|
|
self.pending.clear();
|
|
}
|
|
let take = (CHUNK - self.pending.len()).min(data.len());
|
|
self.pending.extend_from_slice(&data[..take]);
|
|
data = &data[take..];
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// The last record. Always present, empty for an empty file, so even a
|
|
/// file cut off exactly at a record boundary is caught.
|
|
pub fn finish(self) -> Result<Vec<u8>> {
|
|
self.stream
|
|
.encrypt_last(self.pending.as_slice())
|
|
.map_err(|_| anyhow!("could not seal the last record"))
|
|
}
|
|
}
|
|
|
|
/// 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());
|
|
aad
|
|
}
|
|
|
|
/// Decrypts one sealed file, a record at a time, from the first or from any
|
|
/// record a range asks to start at.
|
|
pub struct Opener {
|
|
stream: StreamBE32<Aes256Gcm>,
|
|
/// The record `open` decrypts next. STREAM binds each record to its
|
|
/// position, so one moved within the file refuses to open.
|
|
position: u32,
|
|
/// Records in the file, the last of them included.
|
|
total: u64,
|
|
/// The size of the last record, which is the only short one.
|
|
last: usize,
|
|
}
|
|
|
|
impl Opener {
|
|
/// Check the header and unseal the file key. `len` is the size of the
|
|
/// whole sealed file, which is what tells the reader where the last record
|
|
/// starts.
|
|
pub fn new(master: &Key, media_id: &str, header: &[u8], len: u64) -> Result<Self> {
|
|
if header.len() != HEADER || &header[..MAGIC.len()] != MAGIC {
|
|
return Err(anyhow!("not a sealed file"));
|
|
}
|
|
let body = len
|
|
.checked_sub(HEADER as u64)
|
|
.filter(|body| *body >= TAG as u64)
|
|
.ok_or_else(|| anyhow!("sealed file is truncated"))?;
|
|
let remaining = body.div_ceil(RECORD as u64);
|
|
let last = (body - (remaining - 1) * RECORD as u64) as usize;
|
|
if last < TAG {
|
|
return Err(anyhow!("sealed file is truncated"));
|
|
}
|
|
|
|
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(),
|
|
);
|
|
Ok(Self {
|
|
stream,
|
|
position: 0,
|
|
total: remaining,
|
|
last,
|
|
})
|
|
}
|
|
|
|
/// How many bytes the next record takes on disk; 0 once all are read.
|
|
pub fn next_len(&self) -> usize {
|
|
match self.total.saturating_sub(self.position as u64) {
|
|
0 => 0,
|
|
1 => self.last,
|
|
_ => RECORD,
|
|
}
|
|
}
|
|
|
|
/// Start at record `index` instead of the first — at byte
|
|
/// `HEADER + index * RECORD` of the file — for a read that wants only the
|
|
/// end of it.
|
|
pub fn skip_to(&mut self, index: u64) -> Result<()> {
|
|
if index >= self.total {
|
|
return Err(anyhow!("past the end of the sealed file"));
|
|
}
|
|
self.position = u32::try_from(index).map_err(|_| anyhow!("sealed file is too large"))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Decrypt the record `next_len` asked for.
|
|
pub fn open(&mut self, record: &[u8]) -> Result<Vec<u8>> {
|
|
let left = self.total.saturating_sub(self.position as u64);
|
|
if left == 0 {
|
|
return Err(anyhow!("sealed file already read"));
|
|
}
|
|
// Only the record that really is the last may be opened as last, so
|
|
// a file cut short at a record boundary still refuses to open.
|
|
let plain = self
|
|
.stream
|
|
.decrypt(self.position, left == 1, record)
|
|
.map_err(|_| anyhow!("sealed file is damaged or was tampered with"))?;
|
|
self.position += 1;
|
|
Ok(plain)
|
|
}
|
|
}
|
|
|
|
/// A sealed file read as its plaintext, for code that wants `std::io::Read`:
|
|
/// the export's zip writer, re-sealing. Made by [`Keyring::reader`].
|
|
pub struct Reader<R> {
|
|
inner: R,
|
|
opener: Opener,
|
|
buffer: Vec<u8>,
|
|
at: usize,
|
|
}
|
|
|
|
impl<R: Read> Read for Reader<R> {
|
|
fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
|
|
while self.at == self.buffer.len() {
|
|
let len = self.opener.next_len();
|
|
if len == 0 {
|
|
return Ok(0);
|
|
}
|
|
let mut record = vec![0u8; len];
|
|
self.inner.read_exact(&mut record)?;
|
|
self.buffer = self.opener.open(&record).map_err(std::io::Error::other)?;
|
|
self.at = 0;
|
|
}
|
|
let n = out.len().min(self.buffer.len() - self.at);
|
|
out[..n].copy_from_slice(&self.buffer[self.at..self.at + n]);
|
|
self.at += n;
|
|
Ok(n)
|
|
}
|
|
}
|
|
|
|
/// Seal `source` into `target` under `media_id`, on the calling thread.
|
|
/// Returns the plaintext size.
|
|
pub fn seal_file(
|
|
master: &Key,
|
|
media_id: &str,
|
|
source: &mut impl Read,
|
|
target: &std::path::Path,
|
|
) -> Result<u64> {
|
|
use std::io::Write;
|
|
let mut out = std::io::BufWriter::new(std::fs::File::create(target)?);
|
|
let (mut sealer, header) = Sealer::new(master, media_id)?;
|
|
out.write_all(&header)?;
|
|
let mut buffer = vec![0u8; CHUNK];
|
|
let mut size = 0u64;
|
|
loop {
|
|
let n = source.read(&mut buffer)?;
|
|
if n == 0 {
|
|
break;
|
|
}
|
|
size += n as u64;
|
|
out.write_all(&sealer.push(&buffer[..n])?)?;
|
|
}
|
|
out.write_all(&sealer.finish()?)?;
|
|
out.into_inner().map_err(|e| e.into_error())?.sync_all()?;
|
|
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>;
|
|
|
|
fn humantime(d: Duration) -> String {
|
|
match d.as_secs() {
|
|
s if s % 60 == 0 => format!("{}m", s / 60),
|
|
s => format!("{s}s"),
|
|
}
|
|
}
|
|
|
|
/// The vault's state: whether it exists, and its key while it is open.
|
|
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 {
|
|
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.
|
|
pub configured: bool,
|
|
pub unlocked: bool,
|
|
/// Seconds until it locks itself if nothing uses it.
|
|
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.
|
|
pub enum Unlock {
|
|
Opened,
|
|
WrongPassword,
|
|
NotConfigured,
|
|
/// The last password change is still re-sealing files.
|
|
Rekeying,
|
|
}
|
|
|
|
impl Vault {
|
|
pub fn new(db: SqlitePool, idle: Duration) -> Arc<Self> {
|
|
Arc::new(Self {
|
|
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 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<Keyring>> {
|
|
let mut slot = self.slot();
|
|
match slot.as_mut() {
|
|
Some(open) if open.touched.elapsed() < self.idle => {
|
|
open.touched = Instant::now();
|
|
Some(open.keys.clone())
|
|
}
|
|
Some(_) => {
|
|
*slot = None;
|
|
None
|
|
}
|
|
None => None,
|
|
}
|
|
}
|
|
|
|
/// Whether the vault is open, without counting as a use that keeps it so:
|
|
/// for a check that should not hold it open by being asked often.
|
|
pub fn is_open(&self) -> bool {
|
|
self.slot()
|
|
.as_ref()
|
|
.is_some_and(|open| open.touched.elapsed() < self.idle)
|
|
}
|
|
|
|
/// 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<Keyring>> {
|
|
self.slot()
|
|
.as_ref()
|
|
.filter(|open| open.touched.elapsed() < self.idle)
|
|
.map(|open| open.keys.clone())
|
|
}
|
|
|
|
pub fn lock(&self) {
|
|
*self.slot() = None;
|
|
}
|
|
|
|
/// Open with a key as it is, skipping Argon2, for tests of what an open
|
|
/// vault allows.
|
|
#[cfg(test)]
|
|
pub fn open_with(&self, key: Key) {
|
|
self.put(Keyring::new(key));
|
|
}
|
|
|
|
/// Drop the key once it has gone unused past the idle time. Without this
|
|
/// a vault nobody looks at would only notice it should be shut on the
|
|
/// next look, with the key sitting in memory all the while.
|
|
fn expire(&self) {
|
|
let mut slot = self.slot();
|
|
if slot
|
|
.as_ref()
|
|
.is_some_and(|open| open.touched.elapsed() >= self.idle)
|
|
{
|
|
*slot = None;
|
|
tracing::info!("vault locked after {} idle", humantime(self.idle));
|
|
}
|
|
}
|
|
|
|
/// Check for an idle vault every few seconds, for as long as the vault
|
|
/// itself is around.
|
|
pub fn spawn_idle_lock(self: &Arc<Self>) {
|
|
let vault = Arc::downgrade(self);
|
|
let every = (self.idle / 4).clamp(Duration::from_secs(1), Duration::from_secs(30));
|
|
tokio::spawn(async move {
|
|
let mut ticker = tokio::time::interval(every);
|
|
loop {
|
|
ticker.tick().await;
|
|
let Some(vault) = vault.upgrade() else { return };
|
|
vault.expire();
|
|
}
|
|
});
|
|
}
|
|
|
|
pub async fn status(&self) -> Result<Status> {
|
|
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?;
|
|
self.expire();
|
|
let slot = self.slot();
|
|
let remaining = slot
|
|
.as_ref()
|
|
.map(|open| self.idle.saturating_sub(open.touched.elapsed()))
|
|
.filter(|left| !left.is_zero());
|
|
Ok(Status {
|
|
configured,
|
|
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, retired_nonce, retired_sealed
|
|
FROM vault WHERE id = 1",
|
|
)
|
|
.fetch_optional(&self.db)
|
|
.await?)
|
|
}
|
|
|
|
/// Make the vault, and leave it open. Refuses when there already is one:
|
|
/// a second setup would orphan every file sealed under the first.
|
|
pub async fn setup(&self, password: Password) -> Result<bool> {
|
|
if self.row().await?.is_some() {
|
|
return Ok(false);
|
|
}
|
|
let salt = random::<16>();
|
|
let master: Key = Zeroizing::new(random::<32>());
|
|
let (nonce, sealed, master) = tokio::task::spawn_blocking(move || -> Result<_> {
|
|
let kek = derive(&password, &salt, M_COST, T_COST, P_COST)?;
|
|
let (nonce, sealed) = seal_master(&kek, &master)?;
|
|
Ok((nonce, sealed, master))
|
|
})
|
|
.await??;
|
|
|
|
let inserted = sqlx::query(
|
|
"INSERT OR IGNORE INTO vault (id, salt, m_cost, t_cost, p_cost, nonce, sealed, created_at)
|
|
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
|
)
|
|
.bind(salt.as_slice())
|
|
.bind(M_COST as i64)
|
|
.bind(T_COST as i64)
|
|
.bind(P_COST as i64)
|
|
.bind(nonce.as_slice())
|
|
.bind(sealed)
|
|
.bind(crate::now())
|
|
.execute(&self.db)
|
|
.await?
|
|
.rows_affected();
|
|
if inserted == 0 {
|
|
return Ok(false);
|
|
}
|
|
|
|
self.put(Keyring::new(master));
|
|
Ok(true)
|
|
}
|
|
|
|
/// 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);
|
|
};
|
|
let salt: Vec<u8> = row.get("salt");
|
|
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 retired: Option<(Vec<u8>, Vec<u8>)> = row
|
|
.get::<Option<Vec<u8>>, _>("retired_nonce")
|
|
.zip(row.get::<Option<Vec<u8>>, _>("retired_sealed"));
|
|
let keys = tokio::task::spawn_blocking(move || -> Result<Option<Keyring>> {
|
|
let kek = derive(&password, &salt, m as u32, t as u32, p as u32)?;
|
|
let 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(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(keys)) => {
|
|
self.put(keys);
|
|
Unlock::Opened
|
|
}
|
|
})
|
|
}
|
|
|
|
/// 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 old = match self.check(current).await? {
|
|
None => return Ok(Unlock::NotConfigured),
|
|
Some(None) => return Ok(Unlock::WrongPassword),
|
|
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, 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)?;
|
|
// 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,
|
|
retired_nonce = ?7, retired_sealed = ?8
|
|
WHERE id = 1",
|
|
)
|
|
.bind(salt.as_slice())
|
|
.bind(M_COST as i64)
|
|
.bind(T_COST as i64)
|
|
.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.set_progress(None);
|
|
self.put(keys);
|
|
Ok(Unlock::Opened)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::io::Read;
|
|
|
|
use zeroize::Zeroizing;
|
|
|
|
use super::{derive, open_master, seal_master, Opener, Sealer, CHUNK, HEADER, TAG};
|
|
|
|
fn key(byte: u8) -> super::Key {
|
|
Zeroizing::new([byte; 32])
|
|
}
|
|
|
|
fn seal(master: &super::Key, id: &str, plain: &[u8], step: usize) -> Vec<u8> {
|
|
let (mut sealer, mut out) = Sealer::new(master, id).expect("sealer");
|
|
for piece in plain.chunks(step.max(1)) {
|
|
out.extend(sealer.push(piece).expect("push"));
|
|
}
|
|
out.extend(sealer.finish().expect("finish"));
|
|
out
|
|
}
|
|
|
|
fn open(master: &super::Key, id: &str, sealed: &[u8]) -> anyhow::Result<Vec<u8>> {
|
|
let mut opener = Opener::new(master, id, &sealed[..HEADER], sealed.len() as u64)?;
|
|
let mut at = HEADER;
|
|
let mut out = Vec::new();
|
|
loop {
|
|
let len = opener.next_len();
|
|
if len == 0 {
|
|
break;
|
|
}
|
|
out.extend(opener.open(&sealed[at..at + len])?);
|
|
at += len;
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Idle means the key is gone from memory, not only that the next look
|
|
/// will refuse it.
|
|
#[tokio::test]
|
|
async fn an_idle_vault_lets_go_of_its_key() {
|
|
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 {
|
|
keys: std::sync::Arc::new(super::Keyring::new(key(1))),
|
|
touched: std::time::Instant::now(),
|
|
});
|
|
vault.expire();
|
|
assert!(vault.slot().is_some(), "still within the idle time");
|
|
std::thread::sleep(std::time::Duration::from_millis(30));
|
|
vault.expire();
|
|
assert!(vault.slot().is_none());
|
|
}
|
|
|
|
/// A peek finds the key while it is there and leaves the idle clock
|
|
/// alone; a `key()` winds it back.
|
|
#[tokio::test]
|
|
async fn a_peek_does_not_hold_the_vault_open() {
|
|
let db = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap();
|
|
let vault = super::Vault::new(db, std::time::Duration::from_millis(40));
|
|
let started = std::time::Instant::now();
|
|
*vault.slot() = Some(super::Unlocked {
|
|
keys: std::sync::Arc::new(super::Keyring::new(key(1))),
|
|
touched: started,
|
|
});
|
|
assert!(vault.peek_key().is_some());
|
|
assert_eq!(vault.slot().as_ref().unwrap().touched, started);
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
assert!(vault.peek_key().is_none(), "idle, whatever peeked");
|
|
assert!(vault.key().is_none());
|
|
}
|
|
|
|
/// Reading from record `k` on gives exactly the plaintext from
|
|
/// `k * CHUNK` on, and the last record still has to be the last.
|
|
#[test]
|
|
fn a_sealed_file_opens_from_any_record() {
|
|
let master = key(3);
|
|
let plain: Vec<u8> = (0..3 * CHUNK + 17).map(|i| (i % 249) as u8).collect();
|
|
let sealed = seal(&master, "id", &plain, CHUNK);
|
|
for first in 0..4u64 {
|
|
let mut opener =
|
|
Opener::new(&master, "id", &sealed[..HEADER], sealed.len() as u64).unwrap();
|
|
opener.skip_to(first).unwrap();
|
|
let mut at = HEADER + first as usize * super::RECORD;
|
|
let mut out = Vec::new();
|
|
while opener.next_len() > 0 {
|
|
let len = opener.next_len();
|
|
out.extend(opener.open(&sealed[at..at + len]).unwrap());
|
|
at += len;
|
|
}
|
|
assert_eq!(out, plain[first as usize * CHUNK..], "from record {first}");
|
|
}
|
|
let mut opener =
|
|
Opener::new(&master, "id", &sealed[..HEADER], sealed.len() as u64).unwrap();
|
|
assert!(opener.skip_to(4).is_err());
|
|
// A middle record presented where the last one should be does not open.
|
|
opener.skip_to(3).unwrap();
|
|
let middle = &sealed[HEADER + super::RECORD..HEADER + 2 * super::RECORD];
|
|
assert!(opener.open(&middle[..opener.next_len()]).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn files_of_every_awkward_size_round_trip() {
|
|
let master = key(7);
|
|
for size in [0, 1, CHUNK - 1, CHUNK, CHUNK + 1, 3 * CHUNK, 3 * CHUNK + 17] {
|
|
let plain: Vec<u8> = (0..size).map(|i| (i % 251) as u8).collect();
|
|
for step in [1000, CHUNK, 7 * CHUNK] {
|
|
let sealed = seal(&master, "id", &plain, step);
|
|
let records = size.div_ceil(CHUNK).max(1);
|
|
assert_eq!(sealed.len(), HEADER + size + records * TAG, "size {size}");
|
|
assert_eq!(
|
|
open(&master, "id", &sealed).expect("opens"),
|
|
plain,
|
|
"size {size} step {step}"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_std_reader_sees_the_plaintext() {
|
|
let master = key(1);
|
|
let plain: Vec<u8> = (0..(2 * CHUNK + 5)).map(|i| i as u8).collect();
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let path = dir.path().join("x");
|
|
std::fs::write(&path, seal(&master, "x", &plain, CHUNK)).expect("write");
|
|
let mut out = Vec::new();
|
|
super::Keyring::new(master)
|
|
.reader("x", &path)
|
|
.expect("reader")
|
|
.read_to_end(&mut out)
|
|
.expect("read");
|
|
assert_eq!(out, plain);
|
|
}
|
|
|
|
#[test]
|
|
fn the_wrong_key_another_id_or_a_cut_are_all_refused() {
|
|
let master = key(2);
|
|
let plain = vec![9u8; CHUNK * 2];
|
|
let sealed = seal(&master, "a", &plain, CHUNK);
|
|
|
|
assert!(open(&key(3), "a", &sealed).is_err(), "another vault");
|
|
assert!(
|
|
open(&master, "b", &sealed).is_err(),
|
|
"renamed to another id"
|
|
);
|
|
|
|
// Cut at a record boundary: what is left still parses as records, and
|
|
// only the missing "last" mark gives it away.
|
|
let cut = &sealed[..HEADER + CHUNK + TAG];
|
|
assert!(
|
|
open(&master, "a", cut).is_err(),
|
|
"truncated at a record boundary"
|
|
);
|
|
|
|
let mut flipped = sealed.clone();
|
|
flipped[HEADER + 10] ^= 1;
|
|
assert!(open(&master, "a", &flipped).is_err(), "a flipped bit");
|
|
}
|
|
|
|
#[test]
|
|
fn only_the_right_password_opens_the_master_key() {
|
|
// Small costs: this is about the sealing, not about Argon2's speed.
|
|
let kek = derive("correct horse", b"saltsaltsaltsalt", 256, 1, 1).expect("derive");
|
|
let master = key(5);
|
|
let (nonce, sealed) = seal_master(&kek, &master).expect("seal");
|
|
assert_eq!(
|
|
open_master(&kek, &nonce, &sealed).expect("opens").as_ref(),
|
|
master.as_ref()
|
|
);
|
|
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());
|
|
}
|
|
}
|