diff --git a/README.md b/README.md index 7a4f097..bcf088a 100644 --- a/README.md +++ b/README.md @@ -209,7 +209,8 @@ 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. The vault closes itself after `WORKSPACE_VAULT_IDLE_MIN` minutes of nobody -using it (15 by default), on logout, and whenever the server restarts. While +using it (15 by default) — the key is dropped from memory then, not merely +refused on the next look — on logout, and whenever the server restarts. While it is closed a sealed file shows as sealed and nothing more; a share link never serves one, open or not; and `:export!` refuses until it is open, because an archive meant to be read without this application has to hold the files @@ -222,6 +223,18 @@ 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. +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 +shows as a button while the vault is shut, which asks for the password and +puts the image back. `:share` says how many sealed files a document holds, +since its readers will not see them, and `:encrypt` says how many shared +documents just lost a picture to the vault. `:encrypt` on a folder seals +what it can and counts what it could not, rather than stopping at the first. + +Only the bytes are sealed. A sealed file's name, and the names and text of +the documents around it, stay readable in `diary.db` — and in the mirror of +it — like everything else outside the vault. + **There is no recovery.** Forget the vault password and every sealed file is gone — from the server and from every backup — which is exactly what makes it worth having. The password is not in `.env`, not in `diary.db`, and not in the diff --git a/src/main.rs b/src/main.rs index 0e9ba0b..3021da2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -114,8 +114,10 @@ async fn main() -> Result<()> { sweep::spawn(db.clone(), config.uploads_dir()); + let vault = vault::Vault::new(db.clone(), config.vault_idle); + vault.spawn_idle_lock(); let state = AppState { - vault: vault::Vault::new(db.clone(), config.vault_idle), + vault, db, config: Arc::clone(&config), login_throttle: Arc::default(), diff --git a/src/routes/seal.rs b/src/routes/seal.rs index 804fcab..4831db9 100644 --- a/src/routes/seal.rs +++ b/src/routes/seal.rs @@ -22,7 +22,7 @@ use uuid::Uuid; use crate::{ auth::Session, error::{AppError, AppResult}, - routes::nodes, + routes::{media::MEDIA_REF, nodes}, state::AppState, vault, }; @@ -33,6 +33,11 @@ pub struct Resealed { pub files: usize, /// Of what was asked about, how many were already that way. pub unchanged: usize, + /// How many could not be re-written; the rest still were. + pub failed: usize, + /// Shared documents that embed a file just sealed: their readers now see + /// a gap where it was, because a share link never serves a sealed file. + pub shared: usize, } pub async fn encrypt( @@ -84,21 +89,45 @@ async fn reseal(state: &AppState, id: i64, seal: bool) -> AppResult { return Err(AppError::BadRequest("there are no files there".into())); } + // A folder is many files, and one that will not re-write — gone from the + // disk, cut short — is no reason to leave the rest as they were. let mut files = 0; let mut unchanged = 0; - for target in targets { + let mut failed = Vec::new(); + for target in &targets { if target.encrypted == seal { unchanged += 1; continue; } - rewrite(state, &key, &target, seal).await?; - files += 1; + match rewrite(state, &key, target, seal).await { + Ok(()) => files += 1, + Err(e) => { + tracing::warn!(node = target.node_id, "could not re-write a file: {e:?}"); + failed.push(e); + } + } } if files > 0 { state.backup.signal(); } - Ok(Resealed { files, unchanged }) + // Asked about one file, the reason it failed is the answer. + if files == 0 && unchanged == 0 && failed.len() == 1 { + return Err(failed.remove(0)); + } + let shared: i64 = if seal && files > 0 { + sqlx::query_scalar(&format!( + "SELECT COUNT(DISTINCT d.id) FROM nodes d + JOIN node_media nm ON nm.node_id = d.id + JOIN nodes f ON f.media_id = nm.media_id + WHERE d.kind = 'document' AND d.share_token IS NOT NULL AND f.id IN ({list})" + )) + .fetch_one(&state.db) + .await? + } else { + 0 + }; + Ok(Resealed { files, unchanged, failed: failed.len(), shared: shared as usize }) } /// Re-write one file sealed or opened under a new id, then point everything @@ -136,8 +165,8 @@ async fn rewrite(state: &AppState, key: &vault::Key, target: &Target, seal: bool tokio::fs::rename(&partial, uploads.join(&new)).await?; let committed = async { - let mut tx = state.db.begin().await?; - sqlx::query( + let mut tx = crate::db::begin_write(&state.db).await?; + let copied = sqlx::query( "INSERT INTO media (id, filename, mime, size, created_at, encrypted) SELECT ?1, filename, mime, size, created_at, ?2 FROM media WHERE id = ?3", ) @@ -145,21 +174,52 @@ async fn rewrite(state: &AppState, key: &vault::Key, target: &Target, seal: bool .bind(seal) .bind(&old) .execute(&mut *tx) - .await?; + .await? + .rows_affected(); + // Another re-write of the same file got there first. + if copied == 0 { + return Err(AppError::NotFound); + } sqlx::query("UPDATE nodes SET media_id = ?1 WHERE media_id = ?2") .bind(&new) .bind(&old) .execute(&mut *tx) .await?; - // Every document that embeds the file keeps showing it. - sqlx::query( - "UPDATE nodes SET body = replace(body, '/api/media/' || ?2, '/api/media/' || ?1) - WHERE kind = 'document' AND id IN (SELECT node_id FROM node_media WHERE media_id = ?2)", + // Every document and card that embeds the file keeps showing it — + // however the link was written: an id typed in capitals is the + // same file. + let bodies = sqlx::query( + "SELECT 'nodes' AS tbl, id, body FROM nodes WHERE instr(lower(body), ?1) > 0 + UNION ALL + SELECT 'cards', id, body FROM cards WHERE instr(lower(body), ?1) > 0", ) - .bind(&new) .bind(&old) - .execute(&mut *tx) + .fetch_all(&mut *tx) .await?; + for row in bodies { + let body: String = row.get("body"); + let moved = MEDIA_REF.replace_all(&body, |caps: ®ex::Captures| { + if caps[1].eq_ignore_ascii_case(&old) { + format!("/api/media/{new}") + } else { + caps[0].to_string() + } + }); + if moved == body { + continue; + } + let table: String = row.get("tbl"); + let update = if table == "cards" { + "UPDATE cards SET body = ?1 WHERE id = ?2" + } else { + "UPDATE nodes SET body = ?1 WHERE id = ?2" + }; + sqlx::query(update) + .bind(moved.as_ref()) + .bind(row.get::("id")) + .execute(&mut *tx) + .await?; + } sqlx::query("UPDATE node_media SET media_id = ?1 WHERE media_id = ?2") .bind(&new) .bind(&old) diff --git a/src/routes/share.rs b/src/routes/share.rs index 5e0ff98..68079c1 100644 --- a/src/routes/share.rs +++ b/src/routes/share.rs @@ -82,11 +82,22 @@ pub async fn enable( .execute(&state.db) .await?; + // A share link never serves a sealed file, so a reader of this one sees + // a gap wherever the document embeds one. Worth saying while sharing. + let sealed: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM node_media nm JOIN media m ON m.id = nm.media_id + WHERE nm.node_id = ?1 AND m.encrypted", + ) + .bind(id) + .fetch_one(&state.db) + .await?; + state.backup.signal(); Ok(Json(json!({ "token": token, "key": key, "path": format!("/s/{token}#{key}"), + "sealed": sealed, }))) } diff --git a/src/routes/vault.rs b/src/routes/vault.rs index f3a3268..7082077 100644 --- a/src/routes/vault.rs +++ b/src/routes/vault.rs @@ -8,18 +8,18 @@ use crate::{ error::{AppError, AppResult}, state::AppState, throttle::Attempt, - vault::{Status, Unlock, MIN_PASSWORD}, + vault::{Password, Status, Unlock, MIN_PASSWORD}, }; #[derive(Deserialize)] pub struct PasswordInput { - pub password: String, + pub password: Password, } #[derive(Deserialize)] pub struct ChangeInput { - pub current: String, - pub password: String, + pub current: Password, + pub password: Password, } fn strong_enough(password: &str) -> AppResult<()> { diff --git a/src/vault.rs b/src/vault.rs index 1d36efa..90c7476 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -316,6 +316,16 @@ pub fn seal_file(master: &Key, media_id: &str, source: &mut impl Read, target: & Ok(size) } +/// A vault password on its way to Argon2, wiped from memory once it is done. +pub type Password = Zeroizing; + +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, @@ -335,6 +345,8 @@ pub struct Status { pub unlocked: bool, /// Seconds until it locks itself if nothing uses it. pub locks_in: Option, + /// How many files are sealed: what an export needs the vault open for. + pub sealed: i64, } /// What a password check came to. @@ -374,8 +386,38 @@ impl Vault { *self.slot() = None; } + /// 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) { + 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 { let configured = self.row().await?.is_some(); + 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() @@ -385,6 +427,7 @@ impl Vault { configured, unlocked: remaining.is_some(), locks_in: remaining.map(|left| left.as_secs()), + sealed, }) } @@ -396,7 +439,7 @@ impl Vault { /// 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: String) -> Result { + pub async fn setup(&self, password: Password) -> Result { if self.row().await?.is_some() { return Ok(false); } @@ -432,7 +475,7 @@ impl Vault { } /// The master key the password stands for, if it is the right one. - async fn check(&self, password: String) -> Result>> { + async fn check(&self, password: Password) -> Result>> { let Some(row) = self.row().await? else { return Ok(None); }; @@ -448,7 +491,7 @@ impl Vault { Ok(Some(key)) } - pub async fn unlock(&self, password: String) -> Result { + pub async fn unlock(&self, password: Password) -> Result { Ok(match self.check(password).await? { None => Unlock::NotConfigured, Some(None) => Unlock::WrongPassword, @@ -461,7 +504,7 @@ impl Vault { /// Re-seal the master key under a new password. No file changes: they are /// sealed under the master key, which stays what it was. - pub async fn change_password(&self, current: String, new: String) -> Result { + pub async fn change_password(&self, current: Password, new: Password) -> Result { let master = match self.check(current).await? { None => return Ok(Unlock::NotConfigured), Some(None) => return Ok(Unlock::WrongPassword), @@ -527,6 +570,23 @@ mod tests { 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 { + key: std::sync::Arc::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()); + } + #[test] fn files_of_every_awkward_size_round_trip() { let master = key(7); diff --git a/web/src/lib/DocumentPane.svelte b/web/src/lib/DocumentPane.svelte index cb6e91f..4d72949 100644 --- a/web/src/lib/DocumentPane.svelte +++ b/web/src/lib/DocumentPane.svelte @@ -40,6 +40,38 @@ } }) + /* + * A sealed file embedded here will not load while the vault is shut: the + * server answers 423 and the browser shows a broken image. Errors do not + * bubble, so they are caught on the way down, and the broken embed is + * swapped for a button that opens the vault and puts it back. + */ + $effect(() => { + const root = article + if (!root) return + const sealed = async (event: Event) => { + const element = event.target as HTMLElement + const media = element.closest('img, video, audio') as HTMLImageElement | HTMLMediaElement | null + if (!media || !(media.getAttribute('src') ?? media.querySelector('source')?.getAttribute('src') ?? '').includes('/api/media/')) return + await workspace.loadVault() + if (!workspace.vault?.configured || workspace.vault.unlocked || !media.isConnected) return + const button = document.createElement('button') + button.className = 'sealed-embed' + button.textContent = 'sealed — unlock to show' + button.onclick = async (click) => { + click.preventDefault() + click.stopPropagation() + if (!(await workspace.ensureUnlocked())) return + button.replaceWith(media) + if (media instanceof HTMLImageElement) media.src = media.src + else media.load() + } + media.replaceWith(button) + } + root.addEventListener('error', sealed, true) + return () => root.removeEventListener('error', sealed, true) + }) + /** * `/n//` links — the ones the importer writes between imported * documents — are followed in place rather than reloading the application. @@ -152,6 +184,19 @@