diff --git a/README.md b/README.md index 60567fd..40609c1 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,8 @@ The SFTP mount shows files beside the documents, under their own names — a later file that shares one is told apart by its id, `slides (42).pdf` — and anything copied in that is not a `.md` becomes a file in the tree, streamed to disk under the same size limit. A sealed file is listed but does not open -there. Editor droppings — swap files, `~` backups, dotted temp files — stay in +there. A file copied into a sealed folder is sealed while the vault is open in +the browser, and refused while it is shut. Editor droppings — swap files, `~` backups, dotted temp files — stay in memory for the session, and a dotted temp file renamed onto a real name is kept as that file, which is how `rsync` and most editors save. @@ -286,10 +287,21 @@ 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. +touches the disk. `:encrypt` seals the file under the cursor; `:decrypt` takes +it back out. `:unlock` and `:lock` open and close it, `:passwd` changes the +password. A sealed file carries a lock in the list, and the bar says `vault +open` while it is. + +`:encrypt` on a folder or space seals it for good, not just once: every file +in it now, and every file that lands in it later. An upload, an import, an SFTP +write and a file or folder moved in are all sealed on the way in. While the +vault is shut they are refused rather than stored readable, and the browser +asks for the password instead. Folders made inside are sealed too, and a +sealed folder carries a lock like a sealed file. `:decrypt` on that folder +unseals it with everything in it; a file or folder inside it cannot be +decrypted on its own. Documents are not files and stay readable, as below. +The database holds the line as well: a file node whose bytes are not sealed +cannot be put in a sealed folder by any path. The vault closes itself after `WORKSPACE_VAULT_IDLE_MIN` minutes of nobody using it (15 by default) — the key is dropped from memory then, not merely @@ -549,7 +561,7 @@ sealed or not. | `GET` | `/api/spaces/overview` | every space with what was last written in it | | `POST` | `/api/nodes/{id}/files` | upload files into this folder as file nodes (`?encrypt=true` seals them) | | `POST` | `/api/nodes/{id}/inspect` | what a certificate, key or keystore file holds (`{password}` opens a protected one) | -| `POST` | `/api/nodes/{id}/encrypt`, `/decrypt` | seal a file, or every file under a folder, or take it out | +| `POST` | `/api/nodes/{id}/encrypt`, `/decrypt` | seal a file, or a folder for good — what is in it and what lands later — or take it out | | `GET`/`POST`/`PUT` | `/api/vault` | status / set the password once / change it | | `POST` | `/api/vault/unlock`, `/api/vault/lock` | open / close the vault | | `GET`/`POST` | `/api/ssh-keys` | the keys that may mount over SFTP / add one | diff --git a/migrations/0015_sealed_folders.sql b/migrations/0015_sealed_folders.sql new file mode 100644 index 0000000..15649bd --- /dev/null +++ b/migrations/0015_sealed_folders.sql @@ -0,0 +1,39 @@ +-- A sealed folder or space keeps what lands in it sealed: an upload, an +-- import, an SFTP write, a file moved in. The flag is on every folder of a +-- sealed subtree, not only on the one `:encrypt` was given, so "is this +-- place sealed?" is one column of the parent rather than a walk up the tree. +ALTER TABLE nodes ADD COLUMN sealed INTEGER NOT NULL DEFAULT 0; + +-- A folder made inside a sealed one is sealed from the start, whatever made it. +CREATE TRIGGER nodes_sealed_inherit AFTER INSERT ON nodes +WHEN new.kind = 'folder' AND (SELECT sealed FROM nodes WHERE id = new.parent_id) = 1 +BEGIN + UPDATE nodes SET sealed = 1 WHERE id = new.id; +END; + +-- The last word on it: whichever path a file takes into a sealed place, it +-- does not get there as plaintext. The code seals first and so never meets +-- these; a path that forgot to fails here instead of leaving the bytes open. +CREATE TRIGGER nodes_sealed_insert BEFORE INSERT ON nodes +WHEN new.kind = 'file' + AND (SELECT sealed FROM nodes WHERE id = new.parent_id) = 1 + AND (SELECT encrypted FROM media WHERE id = new.media_id) = 0 +BEGIN + SELECT RAISE(ABORT, 'sealed folder: a file here must be encrypted'); +END; + +CREATE TRIGGER nodes_sealed_update BEFORE UPDATE OF parent_id, media_id ON nodes +WHEN new.kind = 'file' + AND (SELECT sealed FROM nodes WHERE id = new.parent_id) = 1 + AND (SELECT encrypted FROM media WHERE id = new.media_id) = 0 +BEGIN + SELECT RAISE(ABORT, 'sealed folder: a file here must be encrypted'); +END; + +-- A folder moves into a sealed place only once it is sealed itself. +CREATE TRIGGER nodes_sealed_move BEFORE UPDATE OF parent_id ON nodes +WHEN new.kind = 'folder' AND new.sealed = 0 + AND (SELECT sealed FROM nodes WHERE id = new.parent_id) = 1 +BEGIN + SELECT RAISE(ABORT, 'sealed folder: a folder moved here must be sealed first'); +END; diff --git a/src/error.rs b/src/error.rs index de581c5..7884d13 100644 --- a/src/error.rs +++ b/src/error.rs @@ -53,6 +53,14 @@ where E: Into, { fn from(err: E) -> Self { - AppError::Internal(err.into()) + let err: anyhow::Error = err.into(); + // The triggers of 0015 refuse plaintext in a sealed folder; that is + // an answer for the caller, not a fault of the server. + if let Some(sqlx::Error::Database(db)) = err.downcast_ref::() { + if let Some(reason) = db.message().strip_prefix("sealed folder: ") { + return AppError::BadRequest(format!("{reason} — the folder is sealed")); + } + } + AppError::Internal(err) } } diff --git a/src/routes/import.rs b/src/routes/import.rs index f2a4473..b06f7a9 100644 --- a/src/routes/import.rs +++ b/src/routes/import.rs @@ -29,6 +29,7 @@ use crate::{ nodes::{self, next_slug, slugify, DOCUMENT, FILE, FOLDER, SPACE}, }, state::AppState, + vault, }; /// Markdown inline links and images: `[text](target)`, `![alt](target "title")`, @@ -528,12 +529,21 @@ async fn run(state: &AppState, into: Into, multipart: Multipart) -> AppResult = Vec::new(); + // Into a sealed folder, everything that is not a document goes sealed, + // and with the vault shut nothing is read at all. + let key = match &into { + Into::Node(target) if target.sealed => Some(state.vault.key().ok_or(AppError::Locked)?), + _ => None, + }; let result = async { let mut uploads = collect(state, multipart, &mut skipped, &mut written).await?; let manifest = take_manifest(&mut uploads)?; if let Some(manifest) = &manifest { files_as_they_were(state, manifest, &mut uploads, &mut written).await?; } + if let Some(key) = &key { + seal_in_place(state, key, &uploads).await?; + } if uploads.is_empty() { return Err(AppError::BadRequest( "nothing importable in that upload".into(), @@ -553,6 +563,32 @@ async fn run(state: &AppState, into: Into, multipart: Multipart) -> AppResult AppResult<()> { + let ids: Vec = uploads + .iter() + .filter_map(|upload| match &upload.content { + Content::File { id, .. } => Some(id.clone()), + _ => None, + }) + .collect(); + let (key, dir) = (key.clone(), state.config.uploads_dir()); + tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + for id in ids { + let plain = dir.join(&id); + let sealed = dir.join(format!("{id}.part")); + vault::seal_file(&key, &id, &mut std::fs::File::open(&plain)?, &sealed)?; + std::fs::rename(&sealed, &plain)?; + } + Ok(()) + }) + .await + .map_err(anyhow::Error::from)??; + Ok(()) +} + /// A file that happens to end in `.md` was a file, not a document, when it /// was exported, and the manifest says so: it goes back to being one. async fn files_as_they_were( @@ -830,14 +866,15 @@ async fn build( .or(dated.map(|m| m.name.as_str())) .unwrap_or(file_name(&upload.path)); 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(created) + .bind(matches!(into, Into::Node(target) if target.sealed)) .execute(&mut *tx) .await?; landed.insert( @@ -977,6 +1014,66 @@ mod tests { split_target, split_title, take_manifest, Content, Landed, Upload, }; + /// Into a sealed folder, a file arrives sealed — readable back through + /// the vault — and the folders made for it are sealed too. + #[tokio::test] + async fn an_import_into_a_sealed_folder_arrives_sealed() { + use std::io::Read; + + let dir = tempfile::tempdir().unwrap(); + let uploads_dir = dir.path().join("uploads"); + std::fs::create_dir_all(&uploads_dir).unwrap(); + let state = crate::routes::tests::state_in(dir.path().into()).await; + let key = zeroize::Zeroizing::new([5u8; 32]); + let folder: i64 = sqlx::query_scalar( + "INSERT INTO nodes (parent_id, kind, name, slug, created_at, updated_at, sealed) + VALUES (NULL, 'space', 'locked', 'locked', 1, 1, 1) RETURNING id", + ) + .fetch_one(&state.db) + .await + .unwrap(); + + std::fs::write(uploads_dir.join("m1"), b"private bytes").unwrap(); + let uploads = vec![ + text( + "sub/a.bin", + Content::File { + id: "m1".into(), + mime: "application/octet-stream".into(), + size: 13, + }, + ), + text("notes.md", Content::Markdown("# Notes".into())), + ]; + super::seal_in_place(&state, &key, &uploads).await.unwrap(); + let target = crate::routes::nodes::load(&state, folder).await.unwrap(); + super::build( + &state, + &super::Into::Node(Box::new(target)), + uploads, + Vec::new(), + None, + ) + .await + .unwrap(); + + let (encrypted, sub_sealed): (bool, bool) = sqlx::query_as( + "SELECT m.encrypted, p.sealed FROM nodes n + JOIN media m ON m.id = n.media_id JOIN nodes p ON p.id = n.parent_id + WHERE n.kind = 'file'", + ) + .fetch_one(&state.db) + .await + .unwrap(); + assert!(encrypted && sub_sealed); + let mut back = String::new(); + crate::vault::Reader::open(&key, "m1", &uploads_dir.join("m1")) + .unwrap() + .read_to_string(&mut back) + .unwrap(); + assert_eq!(back, "private bytes"); + } + fn text(path: &str, content: Content) -> Upload { Upload { path: path.into(), diff --git a/src/routes/media.rs b/src/routes/media.rs index 4f2bdb2..dabad5b 100644 --- a/src/routes/media.rs +++ b/src/routes/media.rs @@ -17,7 +17,7 @@ use uuid::Uuid; use crate::{ auth::Session, error::{AppError, AppResult}, - routes::nodes, + routes::{nodes, seal}, state::AppState, vault, }; @@ -263,7 +263,8 @@ pub async fn upload_into( mut multipart: Multipart, ) -> AppResult>> { nodes::container(&mut *state.db.acquire().await?, parent).await?; - let key = if query.encrypt { + // A sealed folder seals what lands in it, asked or not. + let key = if query.encrypt || seal::sealed(&state, parent).await? { Some(state.vault.key().ok_or(AppError::Locked)?) } else { None diff --git a/src/routes/mod.rs b/src/routes/mod.rs index a9ede1c..8f17c7b 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -93,7 +93,7 @@ pub fn api_router(max_upload: usize) -> Router { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use std::sync::{atomic::AtomicI64, Arc}; use axum::{body::Body, http::Request}; @@ -113,13 +113,22 @@ mod tests { ]; async fn state() -> AppState { + state_in(".".into()).await + } + + /// A whole `AppState` on an in-memory database, with its uploads under + /// `data_dir`. + pub(crate) async fn state_in(data_dir: std::path::PathBuf) -> AppState { let db = sqlx::sqlite::SqlitePoolOptions::new() .max_connections(1) .connect("sqlite::memory:") .await .unwrap(); sqlx::migrate!("./migrations").run(&db).await.unwrap(); - let config = Arc::new(Config::for_tests(30)); + let config = Arc::new(Config { + data_dir, + ..Config::for_tests(30) + }); AppState { vault: Vault::new(db.clone(), config.vault_idle), backup: Backup::unavailable(db.clone(), Arc::clone(&config), "tests".into()), diff --git a/src/routes/nodes.rs b/src/routes/nodes.rs index bc8cc84..670f332 100644 --- a/src/routes/nodes.rs +++ b/src/routes/nodes.rs @@ -61,6 +61,8 @@ pub struct NodeSummary { pub updated_at: i64, pub shared: bool, pub share_token: Option, + /// Folders and spaces only: what lands here is sealed in the vault. + pub sealed: bool, /// Files only. pub file: Option, /// Search results only: where the match sits, `work/notes`, so two @@ -88,6 +90,7 @@ pub struct Node { pub updated_at: i64, pub shared: bool, pub share_token: Option, + pub sealed: bool, pub file: Option, /// This node's ancestors, the space first and the node itself last — the /// breadcrumb, and everything needed to build its /n/... path. @@ -176,7 +179,7 @@ const EXCERPT_CHARS: usize = 180; /// The columns every list query selects, so a row always converts the same way. const SUMMARY_COLUMNS: &str = "n.id, n.parent_id, n.kind, n.name, n.slug, n.position, n.has_board, n.role, - n.created_at, n.updated_at, n.share_token, n.media_id, + n.created_at, n.updated_at, n.share_token, n.media_id, n.sealed, (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, @@ -329,6 +332,7 @@ fn row_to_summary(row: &sqlx::sqlite::SqliteRow, head: &str) -> NodeSummary { updated_at: row.get("updated_at"), shared: share_token.is_some(), share_token, + sealed: row.get("sealed"), file, location: None, } @@ -762,6 +766,18 @@ pub async fn move_node( if input.parent_id == id { return Err(AppError::BadRequest("a node cannot contain itself".into())); } + // Into a sealed folder, the files go sealed or not at all — and only + // into a place it may go at all, which the transaction checks again. + { + let mut conn = state.db.acquire().await?; + container(&mut conn, input.parent_id).await?; + if descendants(&mut conn, id).await?.contains(&input.parent_id) { + return Err(AppError::BadRequest( + "a folder cannot be moved into itself".into(), + )); + } + } + crate::routes::seal::enter(&state, id, input.parent_id).await?; let mut tx = crate::db::begin_write(&state.db).await?; @@ -898,6 +914,7 @@ pub async fn load(state: &AppState, id: i64) -> AppResult { updated_at: summary.updated_at, shared: summary.shared, share_token: summary.share_token, + sealed: summary.sealed, file: summary.file, path: path_of(&state.db, id).await?, }) diff --git a/src/routes/seal.rs b/src/routes/seal.rs index 3b765a5..eb94c1a 100644 --- a/src/routes/seal.rs +++ b/src/routes/seal.rs @@ -3,6 +3,13 @@ //! `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 folder or space that is encrypted stays sealed: it and every folder under +//! it carry `sealed`, and whatever lands there later — an upload, an import, an +//! SFTP write, a file moved in — is sealed on the way in, or refused while the +//! vault is shut. `enter` is that step for a move. Decrypting the folder lifts +//! it; a file or folder inside a sealed one cannot be decrypted on its own. +//! Documents are not files and stay as they are. +//! //! 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 @@ -27,7 +34,7 @@ use crate::{ vault, }; -#[derive(Serialize)] +#[derive(Serialize, Default)] pub struct Resealed { /// How many files changed. pub files: usize, @@ -38,6 +45,8 @@ pub struct Resealed { /// 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, + /// Whether what was asked about is now a sealed folder or space. + pub sealed: bool, } pub async fn encrypt( @@ -45,7 +54,7 @@ pub async fn encrypt( State(state): State, Path(id): Path, ) -> AppResult> { - reseal(&state, id, true).await.map(Json) + seal_node(&state, id).await.map(Json) } pub async fn decrypt( @@ -53,7 +62,119 @@ pub async fn decrypt( State(state): State, Path(id): Path, ) -> AppResult> { - reseal(&state, id, false).await.map(Json) + open_node(&state, id).await.map(Json) +} + +async fn seal_node(state: &AppState, id: i64) -> AppResult { + let (kind, _) = place(state, id).await?; + if !nodes::is_container(&kind) { + return reseal(state, id, true).await; + } + state.vault.key().ok_or(AppError::Locked)?; + // Flagged first, so nothing that lands while the files are re-written + // slips in as plaintext; and an empty folder is worth sealing too. + mark(state, id, true).await?; + let mut done = reseal(state, id, true).await?; + done.sealed = true; + Ok(done) +} + +async fn open_node(state: &AppState, id: i64) -> AppResult { + let (kind, parent) = place(state, id).await?; + if let Some(parent) = parent { + if sealed(state, parent).await? { + let what = if kind == nodes::FILE { + "file" + } else { + "folder" + }; + return Err(AppError::BadRequest(format!( + "this {what} is inside a sealed folder — decrypt that folder instead" + ))); + } + } + if !nodes::is_container(&kind) { + return reseal(state, id, false).await; + } + state.vault.key().ok_or(AppError::Locked)?; + mark(state, id, false).await?; + reseal(state, id, false).await +} + +/// Put `id` under `parent`'s rules before it moves there: when `parent` is +/// sealed, flag the folders of `id` and seal its files. The caller moves it +/// only if this succeeds — the triggers of 0015 refuse it otherwise. +pub async fn enter(state: &AppState, id: i64, parent: i64) -> AppResult<()> { + if !sealed(state, parent).await? { + return Ok(()); + } + // Already sealed — a rename inside the folder, or a move between two + // sealed ones — needs nothing, not even the vault open. + let ready: Option = sqlx::query_scalar( + "SELECT CASE WHEN n.kind = 'file' THEN m.encrypted + WHEN n.kind = 'document' THEN 1 + ELSE n.sealed END + FROM nodes n LEFT JOIN media m ON m.id = n.media_id WHERE n.id = ?1", + ) + .bind(id) + .fetch_optional(&state.db) + .await?; + match ready { + None => return Err(AppError::NotFound), + // A document is not sealed; it moves in as it is. + Some(true) => return Ok(()), + Some(false) => {} + } + state.vault.key().ok_or(AppError::Locked)?; + let (kind, _) = place(state, id).await?; + if nodes::is_container(&kind) { + mark(state, id, true).await?; + } + let done = reseal(state, id, true).await?; + if done.failed > 0 { + return Err(AppError::BadRequest(format!( + "{} file(s) would not seal, so nothing moved into the sealed folder", + done.failed + ))); + } + Ok(()) +} + +/// Whether files that land in this folder or space are sealed. +pub async fn sealed(state: &AppState, id: i64) -> AppResult { + Ok(sqlx::query_scalar("SELECT sealed FROM nodes WHERE id = ?1") + .bind(id) + .fetch_optional(&state.db) + .await? + .unwrap_or(false)) +} + +async fn place(state: &AppState, id: i64) -> AppResult<(String, Option)> { + let row = sqlx::query("SELECT kind, parent_id FROM nodes WHERE id = ?1") + .bind(id) + .fetch_optional(&state.db) + .await? + .ok_or(AppError::NotFound)?; + Ok((row.get("kind"), row.get("parent_id"))) +} + +/// Set or lift `sealed` on a folder or space and every folder under it. +async fn mark(state: &AppState, id: i64, on: bool) -> AppResult<()> { + let mut tx = crate::db::begin_write(&state.db).await?; + let list = nodes::descendants(&mut tx, id) + .await? + .iter() + .map(i64::to_string) + .collect::>() + .join(","); + sqlx::query(&format!( + "UPDATE nodes SET sealed = ?1 WHERE kind IN ('space', 'folder') AND id IN ({list})" + )) + .bind(on) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) } struct Target { @@ -90,6 +211,11 @@ async fn reseal(state: &AppState, id: i64, seal: bool) -> AppResult { .collect(); if targets.is_empty() { + // An empty folder is nothing to re-write, but it is no mistake either. + let (kind, _) = place(state, id).await?; + if nodes::is_container(&kind) { + return Ok(Resealed::default()); + } return Err(AppError::BadRequest("there are no files there".into())); } @@ -136,6 +262,7 @@ async fn reseal(state: &AppState, id: i64, seal: bool) -> AppResult { unchanged, failed: failed.len(), shared: shared as usize, + sealed: false, }) } @@ -262,3 +389,144 @@ async fn rewrite(state: &AppState, key: &vault::Key, target: &Target, seal: bool tracing::info!(node = target.node_id, from = %old, to = %new, sealed = seal, "file re-written"); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use zeroize::Zeroizing; + + use crate::{error::AppError, routes::media}; + + struct World { + state: AppState, + _dir: tempfile::TempDir, + space: i64, + folder: i64, + } + + /// A space with a folder in it, and a vault that is open. + async fn world() -> World { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("uploads")).unwrap(); + let state = crate::routes::tests::state_in(dir.path().into()).await; + state.vault.open_with(Zeroizing::new([7u8; 32])); + let space = node(&state, None, "space", "vaulted").await; + let folder = node(&state, Some(space), "folder", "keys").await; + World { + state, + _dir: dir, + space, + folder, + } + } + + async fn node(state: &AppState, parent: Option, kind: &str, name: &str) -> i64 { + sqlx::query_scalar( + "INSERT INTO nodes (parent_id, kind, name, slug, created_at, updated_at) + VALUES (?1, ?2, ?3, ?3, 1, 1) RETURNING id", + ) + .bind(parent) + .bind(kind) + .bind(name) + .fetch_one(&state.db) + .await + .unwrap() + } + + /// A plaintext file, written the way an upload writes one. + async fn plain_file(state: &AppState, parent: i64, name: &str) -> AppResult { + let id = Uuid::new_v4().to_string(); + std::fs::write(state.config.uploads_dir().join(&id), b"secret bytes").unwrap(); + media::insert_file(&state.db, parent, &id, name, "text/plain", 12, false).await + } + + async fn encrypted(state: &AppState, node: i64) -> bool { + sqlx::query_scalar( + "SELECT m.encrypted FROM nodes n JOIN media m ON m.id = n.media_id WHERE n.id = ?1", + ) + .bind(node) + .fetch_one(&state.db) + .await + .unwrap() + } + + #[tokio::test] + async fn a_sealed_folder_stays_sealed() { + let w = world().await; + let file = plain_file(&w.state, w.folder, "a.txt").await.unwrap(); + + let done = seal_node(&w.state, w.folder).await.unwrap(); + assert!(done.sealed); + assert_eq!(done.files, 1); + assert!(encrypted(&w.state, file).await); + assert!(sealed(&w.state, w.folder).await.unwrap()); + assert!(!sealed(&w.state, w.space).await.unwrap()); + + // A folder made in it later is sealed from the start. + let inner = node(&w.state, Some(w.folder), "folder", "inner").await; + assert!(sealed(&w.state, inner).await.unwrap()); + + // And no path puts plaintext in it: the database refuses. + let refused = plain_file(&w.state, inner, "b.txt").await.unwrap_err(); + assert!( + matches!(&refused, AppError::BadRequest(m) if m.contains("sealed")), + "{refused:?}" + ); + } + + #[tokio::test] + async fn an_empty_folder_can_be_sealed() { + let w = world().await; + let done = seal_node(&w.state, w.folder).await.unwrap(); + assert_eq!((done.files, done.sealed), (0, true)); + } + + #[tokio::test] + async fn moving_in_seals_and_a_shut_vault_refuses() { + let w = world().await; + seal_node(&w.state, w.folder).await.unwrap(); + let outside = node(&w.state, Some(w.space), "folder", "outside").await; + let file = plain_file(&w.state, outside, "c.txt").await.unwrap(); + + w.state.vault.lock(); + assert!(matches!( + enter(&w.state, file, w.folder).await, + Err(AppError::Locked) + )); + assert!(!encrypted(&w.state, file).await); + + w.state.vault.open_with(Zeroizing::new([7u8; 32])); + enter(&w.state, outside, w.folder).await.unwrap(); + assert!(encrypted(&w.state, file).await); + assert!(sealed(&w.state, outside).await.unwrap()); + + // Already sealed, it moves about inside without the vault. + w.state.vault.lock(); + enter(&w.state, file, w.folder).await.unwrap(); + // A document is no file and goes in as it is. + let doc = node(&w.state, Some(w.space), "document", "notes").await; + enter(&w.state, doc, w.folder).await.unwrap(); + } + + #[tokio::test] + async fn only_the_sealed_folder_itself_opens() { + let w = world().await; + let inner = node(&w.state, Some(w.folder), "folder", "inner").await; + let file = plain_file(&w.state, inner, "d.txt").await.unwrap(); + seal_node(&w.state, w.folder).await.unwrap(); + + for id in [inner, file] { + assert!(matches!( + open_node(&w.state, id).await, + Err(AppError::BadRequest(m)) if m.contains("inside a sealed folder") + )); + } + + let done = open_node(&w.state, w.folder).await.unwrap(); + assert_eq!(done.files, 1); + assert!(!encrypted(&w.state, file).await); + assert!(!sealed(&w.state, w.folder).await.unwrap()); + assert!(!sealed(&w.state, inner).await.unwrap()); + plain_file(&w.state, inner, "e.txt").await.unwrap(); + } +} diff --git a/src/sftp/fs.rs b/src/sftp/fs.rs index 9db74f4..06e0d23 100644 --- a/src/sftp/fs.rs +++ b/src/sftp/fs.rs @@ -9,7 +9,9 @@ //! export writes, and a leading heading written back becomes the title again. //! A file is streamed from `uploads/` and written back to it through a //! `.part`, never held in memory. A sealed file is listed but cannot be -//! opened here: the vault is opened in the browser, for the browser. +//! opened here: the vault is opened in the browser, for the browser. A file +//! written into a sealed folder, or moved into one, is sealed while the vault +//! is open there and refused while it is shut. //! //! What an editor writes next to a document — vim's `.swp` and `4913` probe, //! a `foo.md~` backup, a dotted temp file it renames over the original — lives @@ -29,8 +31,9 @@ use crate::{ Backup, }, error::{AppError, AppResult}, - routes::{media, nodes}, + routes::{media, nodes, seal}, state::AppState, + vault, }; type SftpResult = Result; @@ -243,13 +246,21 @@ pub struct SpaceFs { handles: HashMap, next_handle: u64, scratch: HashMap, + /// For the vault: a file written into a sealed folder is sealed with its + /// key. `None` only in tests, where a sealed folder takes nothing. + state: Option, } impl SpaceFs { pub fn new(state: AppState, key_id: Option) -> Self { - let mut fs = Self::with_db(state.db, state.config.uploads_dir(), Some(state.backup)); + let mut fs = Self::with_db( + state.db.clone(), + state.config.uploads_dir(), + Some(state.backup.clone()), + ); fs.key_id = key_id; fs.max_upload = state.config.max_upload_bytes as u64; + fs.state = Some(state); fs } @@ -264,6 +275,39 @@ impl SpaceFs { handles: HashMap::new(), next_handle: 0, scratch: HashMap::new(), + state: None, + } + } + + /// The key to seal a file written into `parent` with: none when the folder + /// is not sealed, and a refusal when it is and the vault is shut. + async fn seal_key(&self, parent: i64) -> SftpResult>> { + let sealed: bool = sqlx::query_scalar("SELECT sealed FROM nodes WHERE id = ?1") + .bind(parent) + .fetch_optional(&self.db) + .await + .map_err(|_| StatusCode::Failure)? + .unwrap_or(false); + if !sealed { + return Ok(None); + } + match self.state.as_ref().and_then(|state| state.vault.key()) { + Some(key) => Ok(Some(key)), + None => Err(StatusCode::PermissionDenied), + } + } + + /// Seal what moves into a sealed folder before it moves; see `seal::enter`. + async fn enter(&self, node: i64, parent: i64) -> SftpResult<()> { + match &self.state { + Some(state) => seal::enter(state, node, parent).await.map_err(|e| match e { + AppError::Locked => StatusCode::PermissionDenied, + e => { + tracing::warn!("sftp: could not seal what moved into a sealed folder: {e:?}"); + StatusCode::Failure + } + }), + None => self.seal_key(parent).await.map(|_| ()), } } @@ -603,6 +647,8 @@ impl SpaceFs { replaces: Option<&FileNode>, truncate: bool, ) -> SftpResult { + // Refused at the open, not at the close, when the vault is shut. + self.seal_key(parent).await?; let media_id = uuid::Uuid::new_v4().to_string(); let partial = self.uploads.join(format!("{media_id}.part")); let mut size = 0; @@ -646,17 +692,45 @@ impl SpaceFs { file, size, } = upload; + // Asked again: the vault may have locked while the bytes came in. + let key = match self.seal_key(parent).await { + Ok(key) => key, + Err(e) => { + let _ = tokio::fs::remove_file(&partial).await; + return Err(e); + } + }; let landed = async { file.sync_all().await?; drop(file); - tokio::fs::rename(&partial, self.uploads.join(&media_id)).await?; + let target = self.uploads.join(&media_id); + match &key { + // The plaintext sat only in the `.part` file, which no row + // names and the mirror never reads. + Some(key) => { + let (key, id, plain, sealed) = + (key.clone(), media_id.clone(), partial.clone(), target); + tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + vault::seal_file(&key, &id, &mut std::fs::File::open(&plain)?, &sealed)?; + std::fs::remove_file(&plain)?; + Ok(()) + }) + .await + .map_err(anyhow::Error::from)??; + } + None => tokio::fs::rename(&partial, target).await?, + } let mime = media::mime_of(&name, None); + let sealed = key.is_some(); match replaces { None => { - media::insert_file(&self.db, parent, &media_id, &name, &mime, size, false) + media::insert_file(&self.db, parent, &media_id, &name, &mime, size, sealed) .await?; } - Some(node) => self.replace_bytes(node, &media_id, &mime, size).await?, + Some(node) => { + self.replace_bytes(node, &media_id, &mime, size, sealed) + .await? + } } AppResult::Ok(()) } @@ -677,6 +751,7 @@ impl SpaceFs { media_id: &str, mime: &str, size: u64, + encrypted: bool, ) -> AppResult<()> { let mut tx = crate::db::begin_write(&self.db).await?; let old: String = sqlx::query_scalar("SELECT media_id FROM nodes WHERE id = ?1") @@ -684,14 +759,15 @@ impl SpaceFs { .fetch_one(&mut *tx) .await?; sqlx::query( - "INSERT INTO media (id, filename, mime, size, created_at) - SELECT ?1, filename, ?2, ?3, ?4 FROM media WHERE id = ?5", + "INSERT INTO media (id, filename, mime, size, created_at, encrypted) + SELECT ?1, filename, ?2, ?3, ?4, ?6 FROM media WHERE id = ?5", ) .bind(media_id) .bind(mime) .bind(size as i64) .bind(crate::now()) .bind(&old) + .bind(encrypted) .execute(&mut *tx) .await?; sqlx::query("UPDATE nodes SET media_id = ?1, updated_at = ?2 WHERE id = ?3") @@ -752,6 +828,7 @@ impl SpaceFs { leaf: &str, shown: &str, ) -> SftpResult<()> { + self.enter(file.id, parent).await?; let mut tx = crate::db::begin_write(&self.db) .await .map_err(|_| StatusCode::Failure)?; @@ -1320,6 +1397,7 @@ impl russh_sftp::server::Handler for SpaceFs { if below.contains(&parent) { return Err(StatusCode::Failure); } + self.enter(node, parent).await?; } } let new_slug = nodes::unique_slug( @@ -1649,6 +1727,66 @@ mod tests { ); } + /// A write into a sealed folder lands sealed while the vault is open, and + /// is refused at the open while it is shut; a file moved in is sealed. + #[tokio::test] + async fn a_sealed_folder_takes_only_sealed_files() { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("uploads")).unwrap(); + let state = crate::routes::tests::state_in(dir.path().into()).await; + state.vault.open_with(zeroize::Zeroizing::new([9u8; 32])); + let mut fs = SpaceFs::new(state.clone(), None); + fs.max_upload = 1024; + for path in ["/proj", "/proj/vault", "/proj/open"] { + fs.mkdir(0, path.into(), FileAttributes::empty()) + .await + .expect("mkdir"); + } + sqlx::query("UPDATE nodes SET sealed = 1 WHERE slug = 'vault'") + .execute(&state.db) + .await + .unwrap(); + let sealed_files = || async { + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM nodes n JOIN media m ON m.id = n.media_id + WHERE m.encrypted = 1", + ) + .fetch_one(&state.db) + .await + .unwrap() + }; + + put(&mut fs, "/proj/vault/key.pem", "-----BEGIN-----").await; + assert_eq!(sealed_files().await, 1); + let on_disk = std::fs::read_dir(dir.path().join("uploads")) + .unwrap() + .map(|e| std::fs::read(e.unwrap().path()).unwrap()) + .collect::>(); + assert_eq!(on_disk.len(), 1); + assert!(!on_disk[0].windows(5).any(|w| w == b"BEGIN")); + + put(&mut fs, "/proj/open/notes.txt", "plain").await; + fs.rename( + 0, + "/proj/open/notes.txt".into(), + "/proj/vault/notes.txt".into(), + ) + .await + .expect("move in"); + assert_eq!(sealed_files().await, 2); + + state.vault.lock(); + let refused = fs + .open( + 0, + "/proj/vault/more.txt".into(), + CREATE, + FileAttributes::empty(), + ) + .await; + assert!(matches!(refused, Err(StatusCode::PermissionDenied))); + } + const CREATE: OpenFlags = OpenFlags::WRITE .union(OpenFlags::CREATE) .union(OpenFlags::TRUNCATE); diff --git a/src/vault.rs b/src/vault.rs index c0b1c59..b708afc 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -450,6 +450,16 @@ impl Vault { *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.slot() = Some(Unlocked { + key: Arc::new(key), + touched: Instant::now(), + }); + } + /// 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. diff --git a/web/src/lib/Actions.svelte b/web/src/lib/Actions.svelte index d37e6b9..8293f03 100644 --- a/web/src/lib/Actions.svelte +++ b/web/src/lib/Actions.svelte @@ -15,7 +15,7 @@ const actions = $derived.by((): Action[] => { if (!node) return [] - const sealed = node.file?.encrypted ?? false + const sealed = node.file?.encrypted ?? node.sealed const holdsFiles = isContainer(node) || node.kind === 'file' const list: Action[] = [ { label: isContainer(node) ? 'open' : 'read', run: () => workspace.guard(() => workspace.openSelected()) }, @@ -32,11 +32,12 @@ ) if (node.shared) list.push({ label: 'new share link — the old one stops working', run: () => runCommand('link') }) } - if (holdsFiles && node.kind !== 'space') { + // Inside a sealed folder only that folder opens, as a whole. + if (holdsFiles && node.kind !== 'space' && !(sealed && workspace.parentSealed)) { list.push( sealed - ? { label: 'take out of the vault', run: () => runCommand('decrypt') } - : { label: node.kind === 'file' ? 'seal in the vault' : 'seal every file in it', run: () => runCommand('encrypt') }, + ? { label: node.kind === 'file' ? 'take out of the vault' : 'unseal — take every file out', run: () => runCommand('decrypt') } + : { label: node.kind === 'file' ? 'seal in the vault' : 'seal it — now and what lands later', run: () => runCommand('encrypt') }, ) } list.push({ label: 'delete', run: () => runCommand('delete'), danger: true }) diff --git a/web/src/lib/Sidebar.svelte b/web/src/lib/Sidebar.svelte index 196696d..e465027 100644 --- a/web/src/lib/Sidebar.svelte +++ b/web/src/lib/Sidebar.svelte @@ -119,6 +119,7 @@ {#if workspace.marked?.id === node.id}m{/if} {#if node.shared}{/if} {#if node.file?.encrypted}{/if} + {#if node.sealed}{/if} {aside(node)} {#if isTouchDevice} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 3a11ec0..6b869b6 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -30,6 +30,8 @@ export interface NodeSummary { updated_at: number shared: boolean share_token: string | null + /** Folders and spaces only: whatever lands here is sealed in the vault. */ + sealed: boolean /** Files only. */ file: FileInfo | null /** Search results only: where the match sits, `work/notes`. */ @@ -433,9 +435,12 @@ export const api = { lockVault: () => request('/vault/lock', { method: 'POST' }), - /** Seal a file, or every file under a folder, into the vault — or take it back out. */ + /** + * Seal a file into the vault, or a folder with everything in it and all + * that lands there later — or take it back out. + */ seal: (id: number, encrypt: boolean) => - request<{ files: number; unchanged: number; failed: number; shared: number }>(`/nodes/${id}/${encrypt ? 'encrypt' : 'decrypt'}`, { + request<{ files: number; unchanged: number; failed: number; shared: number; sealed: boolean }>(`/nodes/${id}/${encrypt ? 'encrypt' : 'decrypt'}`, { method: 'POST', }), diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index c375dd2..35f6de4 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -75,12 +75,16 @@ const sealTarget = (encrypt: boolean) => if (node.file && node.file.encrypted === encrypt) { return workspace.say(encrypt ? 'already in the vault' : 'not in the vault') } + if (!encrypt && workspace.parentSealed) { + return workspace.say(`this ${node.kind} is inside a sealed folder — decrypt that folder instead`, 'error') + } + const name = node.name || node.slug if (isContainer(node)) { - const verb = encrypt ? 'seal every file in' : 'take every file in' - const tail = encrypt ? 'into the vault' : 'out of the vault' - if (!(await workspace.ask(`${verb} ${node.kind} "${node.name || node.slug}" ${tail}?`))) return + const question = encrypt + ? `seal ${node.kind} "${name}" — every file in it now, and every file added later? documents stay readable` + : `unseal ${node.kind} "${name}" and take every file in it out of the vault?` + if (!(await workspace.ask(question))) return } else if (!encrypt) { - const name = node.name || node.slug if (!(await workspace.ask(`store "${name}" unencrypted again, and in the readable files/ view on proton drive?`))) return } if (!(await workspace.ensureUnlocked())) return @@ -865,14 +869,14 @@ export const commands: CommandSpec[] = [ name: 'encrypt', aliases: ['seal'], group: 'vault', - help: 'seal the file under the cursor in the vault — on a folder, every file inside it', + help: 'seal the file under the cursor in the vault — a folder stays sealed: every file in it, and every file added later', run: () => sealTarget(true), }, { name: 'decrypt', aliases: ['unseal'], group: 'vault', - help: 'take the file under the cursor back out of the vault — on a folder, every file inside it', + help: 'take the file under the cursor back out of the vault — on a sealed folder, unseal it and every file inside', run: () => sealTarget(false), }, { diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index 0b6d0ca..6c26c9f 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -39,6 +39,8 @@ class Workspace { parentId = $state(null) /** Ancestors of the current folder, the space first and the folder last. */ path = $state([]) + /** The current folder is sealed: uploads into it go into the vault. */ + parentSealed = $state(false) /** Children of the current folder, or the results of a search. */ nodes = $state([]) @@ -250,14 +252,15 @@ class Workspace { /** Seal what is under the cursor, or take it back out, and show the result. */ async seal(id: number, encrypt: boolean) { - const { files, unchanged, failed, shared } = await api.seal(id, encrypt) + const { files, unchanged, failed, shared, sealed } = await api.seal(id, encrypt) const verb = encrypt ? 'encrypted' : 'decrypted' const already = unchanged > 0 ? `, ${unchanged} already were` : '' const broken = failed > 0 ? `, ${failed} could not be` : '' + const later = sealed ? ' — what lands in the folder from now on is sealed too' : '' const readers = shared > 0 ? ` — ${shared} shared document${shared === 1 ? '' : 's'} embed it, and their readers no longer see it` : '' this.say( - `${verb} ${files} file${files === 1 ? '' : 's'}${already}${broken}${readers}`, + `${verb} ${files} file${files === 1 ? '' : 's'}${already}${broken}${later}${readers}`, failed > 0 || shared > 0 ? 'error' : undefined, ) await this.refresh() @@ -573,6 +576,7 @@ class Workspace { // Walking into another space by a link leaves this one where it was. if (this.spaceId !== null && folder.path[0]?.id !== this.spaceId) this.#rememberSpot() this.parentId = id + this.parentSealed = folder.sealed this.query = '' this.globalSearch = false this.path = folder.path @@ -1199,6 +1203,7 @@ class Workspace { async importFolder(files: File[], root = false) { const parent = this.parentId if (parent === null && !root) return + if (!root && this.parentSealed && !(await this.ensureUnlocked())) return this.say(`importing ${files.length} file${files.length === 1 ? '' : 's'} …`) const report = root ? await api.importRoot(files) : await api.importFolder(parent!, files) @@ -1224,6 +1229,11 @@ class Workspace { async putFiles(files: File[], encrypt = false) { const parent = this.parentId if (parent === null) return + // A sealed folder seals them anyway, and needs the vault open for it. + if (this.parentSealed) { + if (!(await this.ensureUnlocked())) return + encrypt = true + } const total = files.reduce((sum, file) => sum + file.size, 0) const what = files.length === 1 ? files[0].name : `${files.length} files` this.say(`uploading ${what} (${formatBytes(total)})${encrypt ? ' into the vault' : ''} …`)