vault: a sealed folder stays sealed

`:encrypt` on a folder sealed the files in it once; whatever arrived
afterwards was plaintext. A folder or space that is encrypted now
carries `sealed` (0015), and so does every folder under it, so "is
this place sealed?" is one column of the parent. What lands there is
sealed on the way in, or refused with 423 while the vault is shut:

- uploads, with or without `?encrypt`, encrypted as they stream
- imports, sealed where they lie before any row names them
- SFTP writes, sealed out of their `.part`, and refused at the open
  when the vault is shut
- files and folders moved in, through the web or SFTP: seal::enter
  seals them first and the move happens only if that worked

Triggers make it hold for any path: a folder made inside a sealed one
is sealed, and a file node whose media is not encrypted cannot be
inserted into or moved into a sealed folder (a 400, not a 500).
Decrypting the sealed folder lifts the flag on its subtree and opens
its files; a file or folder inside it cannot be decrypted on its own.
An empty folder can be sealed. Documents are not files and stay as
they are.

The list shows a lock on sealed folders, the touch menu offers to
unseal one, and an upload or import into one asks for the vault
password first. Tests cover sealing, inheritance, the trigger, moves
with the vault open and shut, unsealing, an SFTP write and an import.
This commit is contained in:
nvrl committed 2026-10-20 10:14:36 +02:00
1 parent 1ae0665206
commit 889f99f8d6
15 files changed
+659 -39

No files matched your search

+9 -1
View File
@@ -53,6 +53,14 @@ where
E: Into<anyhow::Error>,
{
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::<sqlx::Error>() {
if let Some(reason) = db.message().strip_prefix("sealed folder: ") {
return AppError::BadRequest(format!("{reason} — the folder is sealed"));
}
}
AppError::Internal(err)
}
}
+99 -2
View File
@@ -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<Im
// nothing to the database; the orphans are swept up on the way out.
let mut skipped = Vec::new();
let mut written: Vec<String> = 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<Im
Ok(report)
}
/// Seal every file of the upload where it lies, under the id it already has.
/// They were written plain, an archive's contents can only be — but no row
/// names them yet, so neither the app nor the mirror has seen them.
async fn seal_in_place(state: &AppState, key: &vault::Key, uploads: &[Upload]) -> AppResult<()> {
let ids: Vec<String> = 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(),
+3 -2
View File
@@ -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<Json<Vec<nodes::Node>>> {
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
+11 -2
View File
@@ -93,7 +93,7 @@ pub fn api_router(max_upload: usize) -> Router<AppState> {
}
#[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()),
+18 -1
View File
@@ -61,6 +61,8 @@ pub struct NodeSummary {
pub updated_at: i64,
pub shared: bool,
pub share_token: Option<String>,
/// Folders and spaces only: what lands here is sealed in the vault.
pub sealed: bool,
/// Files only.
pub file: Option<FileInfo>,
/// 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<String>,
pub sealed: bool,
pub file: Option<FileInfo>,
/// 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<Node> {
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?,
})
+271 -3
View File
@@ -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/<uuid>` 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<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Resealed>> {
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<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Resealed>> {
reseal(&state, id, false).await.map(Json)
open_node(&state, id).await.map(Json)
}
async fn seal_node(state: &AppState, id: i64) -> AppResult<Resealed> {
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<Resealed> {
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<bool> = 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<bool> {
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<i64>)> {
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::<Vec<_>>()
.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<Resealed> {
.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<Resealed> {
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<i64>, 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<i64> {
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();
}
}
+146 -8
View File
@@ -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<T> = Result<T, StatusCode>;
@@ -243,13 +246,21 @@ pub struct SpaceFs {
handles: HashMap<String, OpenHandle>,
next_handle: u64,
scratch: HashMap<String, Scratch>,
/// 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<AppState>,
}
impl SpaceFs {
pub fn new(state: AppState, key_id: Option<i64>) -> 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<Option<Arc<vault::Key>>> {
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<Upload> {
// 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::<Vec<_>>();
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);
+10
View File
@@ -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.