diff --git a/.env.example b/.env.example index 452f777..b9bd9b6 100644 --- a/.env.example +++ b/.env.example @@ -90,3 +90,7 @@ WORKSPACE_DIGEST_AT=sun 18:00 # Which of the four kinds may be sent. Drop one from the list to switch it off. WORKSPACE_MAIL_KINDS=reminder,card_due,digest,login + +# Minutes the vault stays open with nothing using it, before it locks itself. +# It also locks on logout and whenever the server restarts. +WORKSPACE_VAULT_IDLE_MIN=15 diff --git a/Cargo.lock b/Cargo.lock index 08e9f19..3d306dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2743,12 +2743,15 @@ dependencies = [ name = "narl-workspace" version = "0.1.0" dependencies = [ + "aes-gcm 0.10.3", "anyhow", + "argon2 0.5.3", "axum", "base64 0.22.1", "chrono", "chrono-tz", "dotenvy", + "futures-util", "hex", "hmac 0.12.1", "keyring", @@ -2776,6 +2779,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "zeroize", "zip", ] diff --git a/Cargo.toml b/Cargo.toml index e6941cc..13249e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,14 @@ lettre = { version = "0.11", default-features = false, features = ["smtp-transpo chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } chrono-tz = "0.10" +# The vault: files encrypted at rest under a key only a password unlocks. +# AES-256-GCM in the STREAM construction, so a file of any size is sealed and +# opened a chunk at a time; Argon2id between the password and the key. +aes-gcm = { version = "0.10", features = ["stream"] } +argon2 = "0.5" +zeroize = "1" +futures-util = { version = "0.3", default-features = false, features = ["std"] } + proton-drive-rs = "0.6.2" proton-sdk = "0.6.2" rpassword = "7" diff --git a/README.md b/README.md index 3881b11..6d66b38 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ one binary. It was called `narl-diary` until it grew everything but the diary. - Files of any kind — PDFs, archives, recordings — live in the tree beside the notes about them, streamed to disk however large. - Share any document behind an unguessable link — revocable at any time. +- A vault: files sealed with AES-256-GCM under a password that is never stored, + readable only while it is unlocked. - Backs itself up to Proton Drive, end-to-end encrypted, as a registered device. - Mails you — a reminder when the diary is empty, due cards, a weekly digest. - Real vim keybindings on the desktop (CodeMirror 6 + vim mode), plain taps on @@ -174,6 +176,48 @@ limit at least that large too. The SFTP mount does not show files yet; a folder that holds only files lists as empty there. +## The vault + +Some files deserve more than the login: scans of documents, contracts, health +records. The vault encrypts them at rest under a password of their own, so +they stay unreadable to someone who has the data directory or the Proton +account the mirror writes to, and to a logged-in browser while the vault is +shut — to anyone, in short, who does not know the vault password. + +`:vault` sets the password the first time (ten characters at least) and says +whether the vault is open afterwards. `:upload!` puts files into the folder +being browsed sealed — encrypted as they stream in, so the plaintext never +touches the disk. `:encrypt` seals the file under the cursor, or every file in +the folder under it; `:decrypt` takes them 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. + +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 +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 +opened. + +How it works: Argon2id turns the password into a key that seals a random +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. + +**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 +mirror. + +Sealing a file that already exists writes it anew under a new id, rewrites the +links of documents that embed it, and deletes the plaintext from the disk. The +mirror then deletes the old copy from Proton Drive outright — trash and all — +whatever `WORKSPACE_BACKUP_PRUNE` says, and takes the file out of the readable +`files/` view. The sealed copy goes up to `data/uploads/` like any other. +Proton cannot read either; only the vault password can read the sealed one. + ## Importing a folder `:import` picks a folder and uploads it whole — every file carries the path it diff --git a/migrations/0012_vault.sql b/migrations/0012_vault.sql new file mode 100644 index 0000000..b15d769 --- /dev/null +++ b/migrations/0012_vault.sql @@ -0,0 +1,25 @@ +-- The vault: a password that is never stored, unlocking a master key that +-- seals files at rest. One row at most. The master key is kept here only +-- sealed under a key Argon2id derives from the password, with the costs it was +-- derived at, so raising them later does not lock an existing vault out. +CREATE TABLE vault ( + id INTEGER PRIMARY KEY CHECK (id = 1), + salt BLOB NOT NULL, + m_cost INTEGER NOT NULL, + t_cost INTEGER NOT NULL, + p_cost INTEGER NOT NULL, + nonce BLOB NOT NULL, + sealed BLOB NOT NULL, + created_at INTEGER NOT NULL +); + +-- A sealed file's bytes on disk are ciphertext; `size` stays the size of what +-- was uploaded, which is what a download of it is. +ALTER TABLE media ADD COLUMN encrypted INTEGER NOT NULL DEFAULT 0; + +-- Remote copies the mirror has to take off Drive whatever +-- WORKSPACE_BACKUP_PRUNE says: the plaintext of a file that has since been +-- sealed. Keeping it would undo the sealing for anyone who reads the mirror. +CREATE TABLE backup_retired ( + path TEXT PRIMARY KEY +) WITHOUT ROWID; diff --git a/src/auth.rs b/src/auth.rs index 5a65379..efe2f1b 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -112,6 +112,7 @@ mod tests { session_days, max_upload_bytes: 1, secure_cookie: false, + vault_idle: std::time::Duration::from_secs(60), backup: BackupConfig { device_name: "narl-diary".into(), interval: None, diff --git a/src/backup/sync.rs b/src/backup/sync.rs index 6637215..4fe5836 100644 --- a/src/backup/sync.rs +++ b/src/backup/sync.rs @@ -54,6 +54,11 @@ consistent and carries no write-ahead log: it can be opened directly. Each file above keeps its history — Proton Drive holds the previous revisions of diary.db, so an accident that was mirrored can still be undone. +Files sealed in the workspace's vault are in uploads/ as ciphertext. A +restore brings them back exactly as they were; opening them takes the vault +password, which is in nobody's keeping but its owner's — not in diary.db, not +here. + The files/ folder next to data/ holds the same uploaded files again, laid out by space, folder and name the way the workspace shows them, so they can be opened from Proton Drive directly. It is a view kept in step with the @@ -90,6 +95,7 @@ pub async fn run( mirror.database(config, data_folder).await?; mirror.media(config, uploads_folder).await?; + mirror.retire().await?; if config.backup.prune { mirror.prune().await?; } @@ -149,21 +155,23 @@ impl Mirror<'_> { .map(|row| (row.get("id"), row.get("size"))) .collect(); - for (id, size) in media { + for (id, _) in media { let key = format!("uploads/{id}"); - if let Some(tracked) = self.tracked(&key).await? { - if tracked.size == size { - self.summary.skipped += 1; - continue; - } - } - let path = config.uploads_dir().join(&id); - if !path.exists() { + // The size on disk, not the one in the row: a sealed file is larger + // than what was uploaded, and comparing against that would re-hash + // it on every run. + let Ok(metadata) = tokio::fs::metadata(&path).await else { // A row whose file has gone missing is a local inconsistency, // not a reason to abandon the rest of the backup. tracing::warn!(media = %id, "media row has no file on disk; skipping"); continue; + }; + if let Some(tracked) = self.tracked(&key).await? { + if tracked.size == metadata.len() as i64 { + self.summary.skipped += 1; + continue; + } } self.file(uploads_folder, &key, &id, "application/octet-stream", &path) @@ -173,6 +181,40 @@ impl Mirror<'_> { Ok(()) } + /// Take the plaintext of files that have since been sealed off Drive for + /// good: into the trash and straight out of it, whatever + /// `WORKSPACE_BACKUP_PRUNE` says. A sealed file whose old bytes stay on + /// Drive was never sealed for anyone who can read the mirror. + async fn retire(&mut self) -> Result<()> { + let retired: Vec<(String, Option)> = sqlx::query( + "SELECT r.path, f.node_uid FROM backup_retired r + LEFT JOIN backup_files f ON f.path = r.path", + ) + .fetch_all(self.db) + .await? + .into_iter() + .map(|row| (row.get("path"), row.get("node_uid"))) + .collect(); + + for (path, uid) in retired { + if let Some(uid) = uid.as_deref().and_then(parse_uid) { + purge(self.client, &uid) + .await + .with_context(|| format!("could not remove {path} from Proton Drive"))?; + self.summary.pruned += 1; + } + sqlx::query("DELETE FROM backup_files WHERE path = ?") + .bind(&path) + .execute(self.db) + .await?; + sqlx::query("DELETE FROM backup_retired WHERE path = ?") + .bind(&path) + .execute(self.db) + .await?; + } + Ok(()) + } + /// Trash remote files whose local rows are gone. Off unless asked for. async fn prune(&mut self) -> Result<()> { let orphans: Vec<(String, String)> = sqlx::query( @@ -363,6 +405,21 @@ struct Tracked { size: i64, } +/// Trash a node and then delete it from the trash, so nothing of it is left +/// to restore. A node already gone counts as done. +pub(crate) async fn purge(client: &ProtonDriveClient, uid: &NodeUid) -> Result<()> { + if client.get_node(uid).await?.is_none() { + return Ok(()); + } + for (_, result) in client.trash_nodes(std::slice::from_ref(uid)).await? { + result?; + } + for (_, result) in client.delete_nodes(std::slice::from_ref(uid)).await? { + result?; + } + Ok(()) +} + /// `~`, the form [`NodeUid`] prints. Anything else is a row this /// build did not write and is treated as untracked rather than trusted. pub(crate) fn parse_uid(text: &str) -> Option { diff --git a/src/backup/view.rs b/src/backup/view.rs index 229a299..8a0ec83 100644 --- a/src/backup/view.rs +++ b/src/backup/view.rs @@ -174,8 +174,12 @@ pub async fn run( root: &NodeUid, summary: &mut Summary, ) -> Result<()> { + // A sealed file has no place in a view meant to be opened by name from + // anywhere: it is left out, so it leaves the view when it is sealed. let tree: Vec = sqlx::query( - "SELECT n.id, n.parent_id, n.kind, n.name, n.slug, n.media_id, COALESCE(m.mime, '') AS mime + "SELECT n.id, n.parent_id, n.kind, n.name, n.slug, + CASE WHEN m.encrypted = 0 THEN n.media_id END AS media_id, + COALESCE(m.mime, '') AS mime FROM nodes n LEFT JOIN media m ON m.id = n.media_id", ) .fetch_all(db) @@ -217,6 +221,14 @@ pub async fn run( } } + let sealed: HashSet = sqlx::query_scalar( + "SELECT n.id FROM nodes n JOIN media m ON m.id = n.media_id WHERE m.encrypted <> 0", + ) + .fetch_all(db) + .await? + .into_iter() + .collect(); + let mut view = View { db, config, client, root, dirs, summary }; // What is gone, or is no longer the same bytes, goes first: it frees the @@ -229,7 +241,16 @@ pub async fn run( .collect(); for id in stale { if let Some(gone) = tracked.remove(&id) { - view.trash(&gone.uid).await; + // What was sealed since leaves no copy in Drive's trash either. + if sealed.contains(&id) { + if let Err(e) = super::sync::purge(client, &gone.uid).await { + tracing::warn!(file = %gone.path, error = %format!("{e:#}"), "could not purge a sealed file from files/"); + continue; + } + view.summary.pruned += 1; + } else { + view.trash(&gone.uid).await; + } forget(db, id).await?; } } diff --git a/src/config.rs b/src/config.rs index 5706a2c..74a0768 100644 --- a/src/config.rs +++ b/src/config.rs @@ -16,6 +16,8 @@ pub struct Config { pub session_days: i64, pub max_upload_bytes: usize, pub secure_cookie: bool, + /// How long the vault stays open with nothing using it. + pub vault_idle: std::time::Duration, pub backup: BackupConfig, pub mail: MailConfig, pub sftp: SftpConfig, @@ -198,6 +200,15 @@ impl Config { * 1024 * 1024; + let vault_idle = std::time::Duration::from_secs( + var("VAULT_IDLE_MIN") + .map(|v| v.parse::()) + .transpose() + .context("WORKSPACE_VAULT_IDLE_MIN must be a number")? + .unwrap_or(15) + * 60, + ); + let secure_cookie = var("SECURE_COOKIE") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false); @@ -211,6 +222,7 @@ impl Config { session_days, max_upload_bytes, secure_cookie, + vault_idle, backup: BackupConfig::from_env()?, mail: MailConfig::from_env()?, sftp: SftpConfig::from_env()?, diff --git a/src/error.rs b/src/error.rs index 196165c..3148c19 100644 --- a/src/error.rs +++ b/src/error.rs @@ -12,6 +12,8 @@ pub enum AppError { BadRequest(String), /// The caller must wait this many seconds before trying again. TooManyRequests(i64), + /// The file is sealed and the vault is closed. + Locked, Internal(anyhow::Error), } @@ -23,6 +25,10 @@ impl IntoResponse for AppError { AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".to_string()), AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()), AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m), + AppError::Locked => ( + StatusCode::LOCKED, + "the vault is locked — :unlock opens it".to_string(), + ), AppError::TooManyRequests(wait) => ( StatusCode::TOO_MANY_REQUESTS, format!("too many attempts — wait {wait}s"), diff --git a/src/main.rs b/src/main.rs index 602231a..24a64ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod sftp; mod state; mod static_files; mod throttle; +mod vault; use std::sync::Arc; @@ -110,9 +111,11 @@ async fn main() -> Result<()> { notify::spawn(Arc::clone(¬ify)); let state = AppState { + vault: vault::Vault::new(db.clone(), config.vault_idle), db, config: Arc::clone(&config), login_throttle: Arc::default(), + vault_throttle: Arc::default(), backup, notify, }; diff --git a/src/routes/export.rs b/src/routes/export.rs index 3ca0f05..bdb61bf 100644 --- a/src/routes/export.rs +++ b/src/routes/export.rs @@ -13,7 +13,11 @@ use sqlx::Row; use tokio_util::io::ReaderStream; use zip::{write::SimpleFileOptions, CompressionMethod, ZipWriter}; -use crate::{auth::Session, error::AppResult, state::AppState}; +use crate::{ + auth::Session, + error::{AppError, AppResult}, + state::AppState, +}; /// One node, flattened out of the database ahead of the blocking zip work. struct ExportNode { @@ -159,12 +163,27 @@ pub async fn export(_: Session, State(state): State) -> AppResult = sqlx::query("SELECT id, filename FROM media") + let rows = sqlx::query("SELECT id, filename, encrypted FROM media") .fetch_all(&state.db) - .await? - .into_iter() + .await?; + let media: Vec<(String, String)> = rows + .iter() .map(|row| (row.get("id"), row.get("filename"))) .collect(); + let sealed: std::collections::HashSet = rows + .iter() + .filter(|row| row.get::("encrypted")) + .map(|row| row.get("id")) + .collect(); + + // The archive is meant to be read without this application, so sealed + // files go in opened — which needs the vault open. Leaving them out + // instead would make an export that silently is not complete. + let key = if sealed.is_empty() { + None + } else { + Some(state.vault.key().ok_or(AppError::Locked)?) + }; let uploads = state.config.uploads_dir(); @@ -243,11 +262,19 @@ pub async fn export(_: Session, State(state): State) -> AppResult = match (&key, sealed.contains(id)) { + (Some(key), true) => match crate::vault::Reader::open(key, id, &path) { + Ok(reader) => Box::new(reader), + Err(_) => continue, + }, + _ => match std::fs::File::open(&path) { + Ok(file) => Box::new(file), + Err(_) => continue, + }, }; zip.start_file(entry, blob)?; - std::io::copy(&mut source, &mut zip)?; + std::io::copy(&mut { source }, &mut zip)?; } let mut file = zip.finish()?; diff --git a/src/routes/import.rs b/src/routes/import.rs index fc4d42f..af4607d 100644 --- a/src/routes/import.rs +++ b/src/routes/import.rs @@ -252,7 +252,7 @@ async fn collect( // The archive itself is only passing through: it lands under a // dotted name no media row can carry, and goes once it is unpacked. let id = format!(".import-{}", Uuid::new_v4()); - let size = media::store_field(state, &mut field, &id, &mut budget).await?; + let size = media::store_field(state, &mut field, &id, &mut budget, None).await?; if size == 0 { skipped.push(name); continue; @@ -309,7 +309,7 @@ async fn collect( let mime = media::mime_of(&path, field.content_type()); let id = Uuid::new_v4().to_string(); - let size = media::store_field(state, &mut field, &id, &mut budget).await?; + let size = media::store_field(state, &mut field, &id, &mut budget, None).await?; if size == 0 { skipped.push(path); continue; diff --git a/src/routes/media.rs b/src/routes/media.rs index 8f2c6a6..96211e1 100644 --- a/src/routes/media.rs +++ b/src/routes/media.rs @@ -2,13 +2,13 @@ use std::{collections::BTreeSet, sync::LazyLock}; use axum::{ body::Body, - extract::{multipart::Field, Multipart, Path, State}, + extract::{multipart::Field, Multipart, Path, Query, State}, http::header, response::{IntoResponse, Response}, Json, }; use regex::Regex; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use sqlx::{Row, SqlitePool}; use tokio::io::AsyncWriteExt; use tokio_util::io::ReaderStream; @@ -19,6 +19,7 @@ use crate::{ error::{AppError, AppResult}, routes::nodes, state::AppState, + vault, }; /// Media referenced from a document body always looks like `/api/media/`, @@ -69,6 +70,8 @@ pub struct Media { pub size: i64, pub created_at: i64, pub url: String, + /// Sealed in the vault: its bytes on disk are ciphertext. + pub encrypted: bool, /// Every document that embeds this file, which may be none or several. pub node_ids: Vec, } @@ -91,6 +94,7 @@ fn row_to_media(row: &sqlx::sqlite::SqliteRow) -> Media { mime: row.get("mime"), size: row.get("size"), created_at: row.get("created_at"), + encrypted: row.try_get("encrypted").unwrap_or(false), node_ids, } } @@ -118,17 +122,29 @@ pub(crate) fn mime_of(filename: &str, declared: Option<&str>) -> String { /// field has arrived whole, so a dropped connection never leaves a truncated /// file under a name the database might come to know. Returns the size; an /// empty field writes nothing and returns 0. +/// +/// With `seal`, the bytes are encrypted as they pass, so the plaintext never +/// touches the disk at all; the size returned is still the plaintext's. pub(crate) async fn store_field( state: &AppState, field: &mut Field<'_>, id: &str, budget: &mut u64, + seal: Option<&vault::Key>, ) -> AppResult { let path = state.config.uploads_dir().join(id); let partial = path.with_extension("part"); let result = async { let mut file = tokio::fs::File::create(&partial).await?; + let mut sealer = match seal { + Some(key) => { + let (sealer, header) = vault::Sealer::new(key, id)?; + file.write_all(&header).await?; + Some(sealer) + } + None => None, + }; let mut size = 0u64; while let Some(chunk) = field .chunk() @@ -139,7 +155,13 @@ pub(crate) async fn store_field( if size > *budget { return Err(too_large(state)); } - file.write_all(&chunk).await?; + match sealer.as_mut() { + Some(sealer) => file.write_all(&sealer.push(&chunk)?).await?, + None => file.write_all(&chunk).await?, + } + } + if let Some(sealer) = sealer { + file.write_all(&sealer.finish()?).await?; } file.flush().await?; Ok(size) @@ -186,7 +208,7 @@ pub async fn upload( 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).await?; + let size = store_field(&state, &mut field, &id, &mut budget, None).await?; if size == 0 { continue; } @@ -214,15 +236,29 @@ pub async fn upload( Ok(Json(uploaded)) } +#[derive(Deserialize)] +pub struct UploadQuery { + /// Seal the files into the vault as they arrive. + #[serde(default)] + pub encrypt: bool, +} + /// `POST /api/nodes/{id}/files` — every file in the body becomes a file node /// in that space or folder, under the name it was uploaded with. +/// `?encrypt=true` seals them on the way in, which needs the vault open. pub async fn upload_into( _: Session, State(state): State, Path(parent): Path, + Query(query): Query, mut multipart: Multipart, ) -> AppResult>> { nodes::container(&state.db, parent).await?; + let key = if query.encrypt { + Some(state.vault.key().ok_or(AppError::Locked)?) + } else { + None + }; let mut created = Vec::new(); let mut budget = state.config.max_upload_bytes as u64; @@ -242,12 +278,12 @@ 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).await?; + let size = store_field(&state, &mut field, &id, &mut budget, key.as_deref()).await?; if size == 0 { continue; } - match insert_file(&state.db, parent, &id, &filename, &mime, size).await { + match insert_file(&state.db, parent, &id, &filename, &mime, size, key.is_some()).await { Ok(node) => created.push(node), Err(e) => { let _ = tokio::fs::remove_file(state.config.uploads_dir().join(&id)).await; @@ -277,19 +313,22 @@ async fn insert_file( filename: &str, mime: &str, size: u64, + encrypted: bool, ) -> AppResult { let slug = nodes::unique_slug(db, Some(parent), &nodes::slugify(filename), None).await?; let now = crate::now(); let mut tx = db.begin().await?; sqlx::query( - "INSERT INTO media (id, filename, mime, size, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", + "INSERT INTO media (id, filename, mime, size, created_at, encrypted) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", ) .bind(media_id) .bind(filename) .bind(mime) .bind(size as i64) .bind(now) + .bind(encrypted) .execute(&mut *tx) .await?; @@ -319,7 +358,7 @@ async fn insert_file( pub async fn list(_: Session, State(state): State) -> AppResult>> { let rows = sqlx::query( - "SELECT m.id, m.filename, m.mime, m.size, m.created_at, + "SELECT m.id, m.filename, m.mime, m.size, m.created_at, m.encrypted, (SELECT group_concat(nm.node_id) FROM node_media nm WHERE nm.media_id = m.id) AS node_ids FROM media m ORDER BY m.created_at DESC LIMIT 500", @@ -334,7 +373,7 @@ pub async fn serve( State(state): State, Path(id): Path, ) -> AppResult { - stream_media(&state, &id).await + stream_media(&state, &id, true).await } pub async fn remove( @@ -347,9 +386,11 @@ pub async fn remove( Ok(Json(serde_json::json!({ "ok": true }))) } -/// Shared by the private and the public-share media handlers. -pub async fn stream_media(state: &AppState, id: &str) -> AppResult { - let row = sqlx::query("SELECT mime, filename, size FROM media WHERE id = ?1") +/// Shared by the private and the public-share media handlers. A sealed file +/// is only ever served to the owner, and only while the vault is open — a +/// share link is a door for strangers, and the vault is not behind it. +pub async fn stream_media(state: &AppState, id: &str, private: bool) -> AppResult { + let row = sqlx::query("SELECT mime, filename, size, encrypted FROM media WHERE id = ?1") .bind(id) .fetch_optional(&state.db) .await? @@ -357,14 +398,25 @@ pub async fn stream_media(state: &AppState, id: &str) -> AppResult { let filename: String = row.get("filename"); let size: i64 = row.get("size"); + let encrypted: bool = row.get("encrypted"); // Re-checked on the way out, not just on the way in, so rows written before // the allowlist existed cannot be served as something executable either. let mime = sanitize_mime(&row.get::("mime")); let disposition = if is_inline(&mime) { "inline" } else { "attachment" }; + let key = match (encrypted, private) { + (false, _) => None, + (true, false) => return Err(AppError::NotFound), + (true, true) => Some(state.vault.key().ok_or(AppError::Locked)?), + }; + let file = tokio::fs::File::open(state.config.uploads_dir().join(id)) .await .map_err(|_| AppError::NotFound)?; + let body = match key { + None => Body::from_stream(ReaderStream::new(file)), + Some(key) => Body::from_stream(open_stream(file, &key, id).await?), + }; Ok(( [ @@ -386,11 +438,38 @@ pub async fn stream_media(state: &AppState, id: &str) -> AppResult { "sandbox; default-src 'none'".to_string(), ), ], - Body::from_stream(ReaderStream::new(file)), + body, ) .into_response()) } +/// A sealed file as a stream of its plaintext, a record at a time. The +/// header is checked before the first byte goes out, so a file that will not +/// open is an error response rather than a download that breaks off. +async fn open_stream( + mut file: tokio::fs::File, + key: &vault::Key, + id: &str, +) -> AppResult>> { + use tokio::io::AsyncReadExt; + + let len = file.metadata().await?.len(); + let mut header = [0u8; vault::HEADER]; + file.read_exact(&mut header).await?; + let opener = vault::Opener::new(key, id, &header, len)?; + + Ok(futures_util::stream::try_unfold((file, opener), |(mut file, mut opener)| async move { + let len = opener.next_len(); + if len == 0 { + return Ok(None); + } + let mut record = vec![0u8; len]; + file.read_exact(&mut record).await?; + let plain = opener.open(&record).map_err(std::io::Error::other)?; + Ok(Some((axum::body::Bytes::from(plain), (file, opener)))) + })) +} + pub async fn delete_media(state: &AppState, id: &str) -> AppResult<()> { sqlx::query("DELETE FROM media WHERE id = ?1") .bind(id) diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 2e5eea4..598036b 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -4,9 +4,11 @@ pub mod nodes; pub mod export; pub mod import; pub mod media; +pub mod seal; pub mod session; pub mod share; pub mod ssh_keys; +pub mod vault; use axum::{ routing::{delete, get, post, put}, @@ -39,6 +41,11 @@ pub fn api_router() -> Router { .route("/nodes/{id}/move", post(nodes::move_node)) .route("/nodes/{id}/import", post(import::import)) .route("/nodes/{id}/files", post(media::upload_into)) + .route("/nodes/{id}/encrypt", post(seal::encrypt)) + .route("/nodes/{id}/decrypt", post(seal::decrypt)) + .route("/vault", get(vault::status).post(vault::setup).put(vault::change_password)) + .route("/vault/unlock", post(vault::unlock)) + .route("/vault/lock", post(vault::lock)) .route("/nodes/{id}/share", post(share::enable)) .route("/nodes/{id}/share", delete(share::disable)) .route("/media", post(media::upload).get(media::list)) diff --git a/src/routes/nodes.rs b/src/routes/nodes.rs index 8edef41..9ef8bec 100644 --- a/src/routes/nodes.rs +++ b/src/routes/nodes.rs @@ -35,6 +35,8 @@ pub struct FileInfo { pub mime: String, pub size: i64, pub url: String, + /// Sealed in the vault: it opens only while the vault does. + pub encrypted: bool, } #[derive(Serialize)] @@ -158,7 +160,8 @@ const SUMMARY_COLUMNS: &str = "n.id, n.parent_id, n.kind, n.name, n.slug, n.posi n.created_at, n.updated_at, n.share_token, n.media_id, (SELECT COUNT(*) FROM nodes c WHERE c.parent_id = n.id) AS child_count, (SELECT m.mime FROM media m WHERE m.id = n.media_id) AS file_mime, - (SELECT m.size FROM media m WHERE m.id = n.media_id) AS file_size"; + (SELECT m.size FROM media m WHERE m.id = n.media_id) AS file_size, + (SELECT m.encrypted FROM media m WHERE m.id = n.media_id) AS file_encrypted"; /// A short, plain-ish preview of the body for the list pane. `head` is only the /// leading `HEAD_CHARS` of the document, so truncation is inferred from its @@ -286,6 +289,7 @@ fn row_to_summary(row: &sqlx::sqlite::SqliteRow, head: &str) -> NodeSummary { media_id, mime: row.get::, _>("file_mime").unwrap_or_default(), size: row.get::, _>("file_size").unwrap_or_default(), + encrypted: row.get::, _>("file_encrypted").unwrap_or_default(), }); NodeSummary { id: row.get("id"), diff --git a/src/routes/seal.rs b/src/routes/seal.rs new file mode 100644 index 0000000..9cbf8fe --- /dev/null +++ b/src/routes/seal.rs @@ -0,0 +1,190 @@ +//! Moving files that already exist into the vault, and back out of it. +//! +//! `POST /api/nodes/{id}/encrypt` seals a file node, or every file node under +//! a folder or space; `/decrypt` does the opposite. Either needs the vault open. +//! +//! A file is re-written under a **new** media id rather than in place. The +//! mirror treats `uploads/` as immutable, so rewriting one would leave +//! the old bytes on Drive as the previous revision of the same file — the +//! plaintext, in the case that matters. Under a new id the sealed copy is a new +//! 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. + +use axum::{ + extract::{Path, State}, + Json, +}; +use serde::Serialize; +use sqlx::Row; +use uuid::Uuid; + +use crate::{ + auth::Session, + error::{AppError, AppResult}, + routes::nodes, + state::AppState, + vault, +}; + +#[derive(Serialize)] +pub struct Resealed { + /// How many files changed. + pub files: usize, + /// Of what was asked about, how many were already that way. + pub unchanged: usize, +} + +pub async fn encrypt( + _: Session, + State(state): State, + Path(id): Path, +) -> AppResult> { + reseal(&state, id, true).await.map(Json) +} + +pub async fn decrypt( + _: Session, + State(state): State, + Path(id): Path, +) -> AppResult> { + reseal(&state, id, false).await.map(Json) +} + +struct Target { + node_id: i64, + media_id: String, + encrypted: bool, +} + +async fn reseal(state: &AppState, id: i64, seal: bool) -> AppResult { + let key = state.vault.key().ok_or(AppError::Locked)?; + + let subtree = nodes::descendants(&state.db, id).await?; + if subtree.is_empty() { + return Err(AppError::NotFound); + } + let list = subtree.iter().map(i64::to_string).collect::>().join(","); + let targets: Vec = sqlx::query(&format!( + "SELECT n.id, n.media_id, m.encrypted + FROM nodes n JOIN media m ON m.id = n.media_id + WHERE n.kind = 'file' AND n.id IN ({list})" + )) + .fetch_all(&state.db) + .await? + .iter() + .map(|row| Target { + node_id: row.get("id"), + media_id: row.get("media_id"), + encrypted: row.get("encrypted"), + }) + .collect(); + + if targets.is_empty() { + return Err(AppError::BadRequest("there are no files there".into())); + } + + let mut files = 0; + let mut unchanged = 0; + for target in targets { + if target.encrypted == seal { + unchanged += 1; + continue; + } + rewrite(state, &key, &target, seal).await?; + files += 1; + } + + if files > 0 { + state.backup.signal(); + } + Ok(Resealed { files, unchanged }) +} + +/// 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<()> { + let uploads = state.config.uploads_dir(); + let old = target.media_id.clone(); + let new = Uuid::new_v4().to_string(); + let source = uploads.join(&old); + let partial = uploads.join(format!("{new}.part")); + + let written = { + let (key, old, new, source, partial) = + (key.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()?; + } + Ok(()) + }) + .await + .map_err(anyhow::Error::from) + .and_then(|done| done) + }; + if let Err(e) = written { + let _ = tokio::fs::remove_file(&partial).await; + return Err(e.into()); + } + tokio::fs::rename(&partial, uploads.join(&new)).await?; + + let committed = async { + let mut tx = state.db.begin().await?; + 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", + ) + .bind(&new) + .bind(seal) + .bind(&old) + .execute(&mut *tx) + .await?; + 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)", + ) + .bind(&new) + .bind(&old) + .execute(&mut *tx) + .await?; + sqlx::query("UPDATE node_media SET media_id = ?1 WHERE media_id = ?2") + .bind(&new) + .bind(&old) + .execute(&mut *tx) + .await?; + sqlx::query("INSERT OR IGNORE INTO backup_retired (path) VALUES ('uploads/' || ?1)") + .bind(&old) + .execute(&mut *tx) + .await?; + sqlx::query("DELETE FROM media WHERE id = ?1") + .bind(&old) + .execute(&mut *tx) + .await?; + tx.commit().await?; + AppResult::Ok(()) + } + .await; + + if let Err(e) = committed { + let _ = tokio::fs::remove_file(uploads.join(&new)).await; + return Err(e); + } + + // 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(()) +} diff --git a/src/routes/session.rs b/src/routes/session.rs index b5db126..35e1c90 100644 --- a/src/routes/session.rs +++ b/src/routes/session.rs @@ -58,6 +58,8 @@ pub async fn login( } pub async fn logout(State(state): State) -> impl IntoResponse { + // Leaving is the moment the vault should not outlive. + state.vault.lock(); ( StatusCode::OK, [(header::SET_COOKIE, auth::clear_cookie(&state.config))], diff --git a/src/routes/share.rs b/src/routes/share.rs index f209a15..5e0ff98 100644 --- a/src/routes/share.rs +++ b/src/routes/share.rs @@ -160,7 +160,7 @@ pub async fn serve_media( if !key_matches(stored.as_deref(), &key) { return Err(AppError::NotFound); } - media::stream_media(&state, &id).await + media::stream_media(&state, &id, false).await } #[cfg(test)] diff --git a/src/routes/vault.rs b/src/routes/vault.rs new file mode 100644 index 0000000..e3ba04f --- /dev/null +++ b/src/routes/vault.rs @@ -0,0 +1,105 @@ +//! `/api/vault`: set the vault up, open it, close it, change its password. + +use axum::{extract::State, Json}; +use serde::Deserialize; + +use crate::{ + auth::Session, + error::{AppError, AppResult}, + state::AppState, + vault::{Status, Unlock, MIN_PASSWORD}, +}; + +#[derive(Deserialize)] +pub struct PasswordInput { + pub password: String, +} + +#[derive(Deserialize)] +pub struct ChangeInput { + pub current: String, + pub password: String, +} + +fn strong_enough(password: &str) -> AppResult<()> { + if password.chars().count() < MIN_PASSWORD { + return Err(AppError::BadRequest(format!( + "a vault password needs at least {MIN_PASSWORD} characters" + ))); + } + Ok(()) +} + +pub async fn status(_: Session, State(state): State) -> AppResult> { + Ok(Json(state.vault.status().await?)) +} + +/// `POST /api/vault` — make the vault. Only once. +pub async fn setup( + _: Session, + State(state): State, + Json(input): Json, +) -> AppResult> { + strong_enough(&input.password)?; + if !state.vault.setup(input.password).await? { + return Err(AppError::BadRequest( + "there is a vault already — :passwd changes its password".into(), + )); + } + tracing::info!("vault set up"); + state.backup.signal(); + Ok(Json(state.vault.status().await?)) +} + +/// Wrong guesses are slowed down exactly like logins. Argon2 already makes +/// each one cost a fraction of a second; this makes a run of them cost minutes. +fn throttle(state: &AppState) -> AppResult<()> { + state.vault_throttle.check().map_err(|wait| { + tracing::warn!("vault unlock refused, {wait}s of backoff remaining"); + AppError::TooManyRequests(wait) + }) +} + +fn answer(state: &AppState, outcome: Unlock) -> AppResult<()> { + match outcome { + Unlock::Opened => { + state.vault_throttle.record_success(); + Ok(()) + } + Unlock::WrongPassword => { + state.vault_throttle.record_failure(); + Err(AppError::BadRequest("wrong vault password".into())) + } + Unlock::NotConfigured => Err(AppError::BadRequest( + "there is no vault yet — :vault sets one up".into(), + )), + } +} + +pub async fn unlock( + _: Session, + State(state): State, + Json(input): Json, +) -> AppResult> { + throttle(&state)?; + answer(&state, state.vault.unlock(input.password).await?)?; + Ok(Json(state.vault.status().await?)) +} + +pub async fn lock(_: Session, State(state): State) -> AppResult> { + state.vault.lock(); + Ok(Json(state.vault.status().await?)) +} + +pub async fn change_password( + _: Session, + State(state): State, + Json(input): Json, +) -> AppResult> { + throttle(&state)?; + strong_enough(&input.password)?; + answer(&state, state.vault.change_password(input.current, input.password).await?)?; + tracing::info!("vault password changed"); + state.backup.signal(); + Ok(Json(state.vault.status().await?)) +} diff --git a/src/state.rs b/src/state.rs index f0eac76..867cfe9 100644 --- a/src/state.rs +++ b/src/state.rs @@ -2,13 +2,18 @@ use std::sync::Arc; use sqlx::SqlitePool; -use crate::{backup::Backup, config::Config, notify::Notifier, throttle::LoginThrottle}; +use crate::{backup::Backup, config::Config, notify::Notifier, throttle::LoginThrottle, vault::Vault}; #[derive(Clone)] pub struct AppState { pub db: SqlitePool, pub config: Arc, pub login_throttle: Arc, + /// The vault password gets the same slowing-down as the login, kept apart + /// so guessing at one does not slow the other. + pub vault_throttle: Arc, + /// Sealed files. Always present; holds no key until it is unlocked. + pub vault: Arc, /// The Proton Drive mirror. Always present; dormant until it has a session. pub backup: Arc, /// Notification mail. Always present; dormant until WORKSPACE_SMTP_URL is set. diff --git a/src/vault.rs b/src/vault.rs new file mode 100644 index 0000000..1d36efa --- /dev/null +++ b/src/vault.rs @@ -0,0 +1,585 @@ +//! 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**, 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 **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::{DecryptorBE32, EncryptorBE32}, + 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; +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"; + +/// 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. +const M_COST: u32 = 64 * 1024; +const T_COST: u32 = 3; +const P_COST: u32 = 1; + +pub type Key = Zeroizing<[u8; 32]>; + +fn random() -> [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 { + 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)> { + let nonce = random::(); + let sealed = Aes256Gcm::new(kek.as_ref().into()) + .encrypt(Nonce::from_slice(&nonce), Payload { msg: master.as_ref(), aad: MASTER_AAD }) + .map_err(|_| anyhow!("could not seal the master key"))?; + Ok((nonce, sealed)) +} + +/// `None` is a wrong password: the tag does not verify. +fn open_master(kek: &Key, nonce: &[u8], sealed: &[u8]) -> Option { + let plain = Zeroizing::new( + Aes256Gcm::new(kek.as_ref().into()) + .decrypt(Nonce::from_slice(nonce), Payload { msg: sealed, aad: MASTER_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, + pending: Vec, +} + +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)> { + let file_key: Key = Zeroizing::new(random::<32>()); + let wrap_nonce = random::(); + let stream_nonce = random::(); + + 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> { + 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> { + self.stream + .encrypt_last(self.pending.as_slice()) + .map_err(|_| anyhow!("could not seal the last record")) + } +} + +fn file_aad(media_id: &str) -> Vec { + let mut aad = MAGIC.to_vec(); + aad.extend_from_slice(media_id.as_bytes()); + aad +} + +/// Decrypts one sealed file, a record at a time. +pub struct Opener { + stream: Option>, + /// Records still to come, the last of them included. + remaining: 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 { + 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, 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 stream = DecryptorBE32::from_aead( + Aes256Gcm::new(file_key.as_slice().into()), + stream_nonce.into(), + ); + Ok(Self { stream: Some(stream), remaining, last }) + } + + /// How many bytes the next record takes on disk; 0 once all are read. + pub fn next_len(&self) -> usize { + match self.remaining { + 0 => 0, + 1 => self.last, + _ => RECORD, + } + } + + /// Decrypt the record `next_len` asked for. + pub fn open(&mut self, record: &[u8]) -> Result> { + let stream = self.stream.as_mut().ok_or_else(|| anyhow!("sealed file already read"))?; + let plain = if self.remaining == 1 { + self.stream + .take() + .expect("present: checked above") + .decrypt_last(record) + } else { + stream.decrypt_next(record) + } + .map_err(|_| anyhow!("sealed file is damaged or was tampered with"))?; + self.remaining -= 1; + Ok(plain) + } +} + +/// A sealed file read as its plaintext, for code that wants `std::io::Read`: +/// the export's zip writer, re-sealing. +pub struct Reader { + inner: R, + opener: Opener, + buffer: Vec, + at: usize, +} + +impl Reader { + pub fn open(master: &Key, media_id: &str, path: &std::path::Path) -> Result { + 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 Read for Reader { + fn read(&mut self, out: &mut [u8]) -> std::io::Result { + 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 { + 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 vault's state: whether it exists, and its key while it is open. +pub struct Vault { + db: SqlitePool, + idle: Duration, + open: Mutex>, +} + +struct Unlocked { + key: Arc, + touched: Instant, +} + +#[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, +} + +/// What a password check came to. +pub enum Unlock { + Opened, + WrongPassword, + NotConfigured, +} + +impl Vault { + pub fn new(db: SqlitePool, idle: Duration) -> Arc { + Arc::new(Self { db, idle, open: Mutex::new(None) }) + } + + fn slot(&self) -> std::sync::MutexGuard<'_, Option> { + 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. + /// A vault left alone past the idle time closes on the next look. + pub fn key(&self) -> Option> { + 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(_) => { + *slot = None; + None + } + None => None, + } + } + + pub fn lock(&self) { + *self.slot() = None; + } + + pub async fn status(&self) -> Result { + let configured = self.row().await?.is_some(); + 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()), + }) + } + + async fn row(&self) -> Result> { + Ok(sqlx::query("SELECT salt, m_cost, t_cost, p_cost, nonce, 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: String) -> Result { + 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.slot() = Some(Unlocked { key: Arc::new(master), touched: Instant::now() }); + Ok(true) + } + + /// The master key the password stands for, if it is the right one. + async fn check(&self, password: String) -> Result>> { + let Some(row) = self.row().await? else { + return Ok(None); + }; + let salt: Vec = 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 = row.get("nonce"); + let sealed: Vec = row.get("sealed"); + let key = tokio::task::spawn_blocking(move || -> Result> { + let kek = derive(&password, &salt, m as u32, t as u32, p as u32)?; + Ok(open_master(&kek, &nonce, &sealed)) + }) + .await??; + Ok(Some(key)) + } + + pub async fn unlock(&self, password: String) -> Result { + 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() }); + 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. + pub async fn change_password(&self, current: String, new: String) -> Result { + let master = match self.check(current).await? { + None => return Ok(Unlock::NotConfigured), + Some(None) => return Ok(Unlock::WrongPassword), + Some(Some(key)) => key, + }; + let salt = random::<16>(); + let (nonce, sealed, master) = tokio::task::spawn_blocking(move || -> Result<_> { + let kek = derive(&new, &salt, M_COST, T_COST, P_COST)?; + let (nonce, sealed) = seal_master(&kek, &master)?; + Ok((nonce, sealed, master)) + }) + .await??; + sqlx::query( + "UPDATE vault SET salt = ?1, m_cost = ?2, t_cost = ?3, p_cost = ?4, nonce = ?5, sealed = ?6 + 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) + .execute(&self.db) + .await?; + *self.slot() = Some(Unlocked { key: Arc::new(master), touched: Instant::now() }); + Ok(Unlock::Opened) + } +} + +#[cfg(test)] +mod tests { + use std::io::Read; + + use zeroize::Zeroizing; + + use super::{derive, open_master, seal_master, Opener, Reader, 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 { + 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> { + 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) + } + + #[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 = (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 = (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(); + Reader::open(&master, "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()); + } +} diff --git a/web/src/App.svelte b/web/src/App.svelte index 5dd2a98..674ef82 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -12,6 +12,7 @@ import Overview from './lib/Overview.svelte' import MediaBrowser from './lib/MediaBrowser.svelte' import Prompt from './lib/Prompt.svelte' + import SecretPrompt from './lib/SecretPrompt.svelte' import Rail from './lib/Rail.svelte' import Shared from './lib/Shared.svelte' import Sidebar from './lib/Sidebar.svelte' @@ -31,6 +32,8 @@ let cmdGlobal = $state(false) let fileInput = $state() let treeInput = $state() + /** Whether the files being picked go into the vault — `:upload!`. */ + let sealUpload = false let folderInput = $state() let importInput = $state() let shell = $state() @@ -45,7 +48,10 @@ $effect(() => { hooks.pickFiles = () => fileInput?.click() - hooks.pickTreeFiles = () => treeInput?.click() + hooks.pickTreeFiles = (encrypt) => { + sealUpload = encrypt + treeInput?.click() + } hooks.pickFolder = () => folderInput?.click() hooks.pickArchive = () => importInput?.click() hooks.focusList = () => shell?.focus() @@ -92,7 +98,10 @@ $effect(() => { if (!workspace.user) return const refresh = () => { - if (document.visibilityState === 'visible') void workspace.loadAgenda().then(() => workspace.openAgendaIfDue()) + if (document.visibilityState !== 'visible') return + void workspace.loadAgenda().then(() => workspace.openAgendaIfDue()) + // The vault locks itself when left alone; the bar should say so. + void workspace.loadVault() } const timer = setInterval(refresh, 5 * 60 * 1000) document.addEventListener('visibilitychange', refresh) @@ -102,6 +111,15 @@ } }) + // The vault closes itself after a quiet spell; look again just after it + // would have, so the bar does not go on saying it is open. + $effect(() => { + const vault = workspace.vault + if (!vault?.unlocked || vault.locks_in === null) return + const timer = setTimeout(() => void workspace.loadVault(), (vault.locks_in + 2) * 1000) + return () => clearTimeout(timer) + }) + // Guard against losing a half-written entry to a stray refresh. $effect(() => { if (!workspace.dirty) return @@ -126,7 +144,7 @@ const files = Array.from(input.files ?? []) input.value = '' if (files.length === 0) return - await workspace.guard(() => workspace.putFiles(files)) + await workspace.guard(() => workspace.putFiles(files, sealUpload)) } /* @@ -687,6 +705,7 @@ {/if} + {#if workspace.overlay !== 'none'} diff --git a/web/src/lib/FilePane.svelte b/web/src/lib/FilePane.svelte index fa50365..4b05342 100644 --- a/web/src/lib/FilePane.svelte +++ b/web/src/lib/FilePane.svelte @@ -6,6 +6,8 @@ const node = $derived(workspace.open!) const file = $derived(node.file!) + /* A sealed file cannot be fetched while the vault is shut. */ + const locked = $derived(file.encrypted && !workspace.vault?.unlocked) const kind = $derived(file.mime.split('/')[0]) /* What the browser can show by itself; everything else is a download. */ const viewable = $derived(file.mime !== 'application/octet-stream') @@ -18,7 +20,7 @@ let text = $state(null) $effect(() => { text = null - if (file.mime !== 'text/plain') return + if (file.mime !== 'text/plain' || locked) return const url = file.url void head(url, TEXT_PREVIEW).then((body) => { if (body !== null && url === file.url) text = body @@ -77,13 +79,24 @@
{file.mime}
uploaded
{relative(node.created_at)}
+ {#if file.encrypted} +
vault
+
{locked ? 'sealed, locked' : 'sealed, open'}
+ {/if}
- {#if viewable} - open + {#if locked} + + {:else} + {#if viewable} + open + {/if} + download {/if} - download +
@@ -92,8 +105,13 @@

{node.name}

- {#key file.url} - {#if kind === 'image'} + {#key `${file.url}:${locked}`} + {#if locked} +

+ + sealed in the vault — to open it. +

+ {:else if kind === 'image'} {node.name} {:else if kind === 'video'} @@ -197,6 +215,9 @@ overflow-wrap: anywhere; } + .light { color: var(--light); } + .sealed { display: flex; align-items: center; gap: 8px; color: var(--subtle); } + .back { display: none; } @media (max-width: 767px) { diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index db0098e..4e0672f 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -59,6 +59,7 @@ documents: 'documents', editing: 'writing', sharing: 'sharing & export', + vault: 'vault', view: 'session', } diff --git a/web/src/lib/Icon.svelte b/web/src/lib/Icon.svelte index 94217e1..01efa77 100644 --- a/web/src/lib/Icon.svelte +++ b/web/src/lib/Icon.svelte @@ -18,6 +18,7 @@ shrink: ['M9 4v5H4', 'M15 4v5h5', 'M9 20v-5H4', 'M15 20v-5h5'], search: ['M11 4a7 7 0 1 0 0 14a7 7 0 1 0 0-14z', 'M20 20l-4-4'], prompt: ['M5 7l5 5-5 5', 'M12 18h7'], + lock: ['M6 11h12a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1z', 'M8 11V8a4 4 0 0 1 8 0v3'], help: [ 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18z', 'M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6v.6', diff --git a/web/src/lib/MediaBrowser.svelte b/web/src/lib/MediaBrowser.svelte index 5d93340..0ae058d 100644 --- a/web/src/lib/MediaBrowser.svelte +++ b/web/src/lib/MediaBrowser.svelte @@ -29,7 +29,9 @@ {#each workspace.media as file (file.id)}
  • - {#if file.mime.startsWith('image/')} + {#if file.encrypted} + sealed + {:else if file.mime.startsWith('image/')} {file.filename} {:else} {file.mime.split('/')[0]} diff --git a/web/src/lib/SecretPrompt.svelte b/web/src/lib/SecretPrompt.svelte new file mode 100644 index 0000000..69e8436 --- /dev/null +++ b/web/src/lib/SecretPrompt.svelte @@ -0,0 +1,88 @@ + + +{#if workspace.secret} + +{/if} + + diff --git a/web/src/lib/Sidebar.svelte b/web/src/lib/Sidebar.svelte index 4c23004..2594c41 100644 --- a/web/src/lib/Sidebar.svelte +++ b/web/src/lib/Sidebar.svelte @@ -1,5 +1,6 @@