vault: files sealed under a password that is never stored

Argon2id turns the vault password into a key that seals a random master
key; only the sealed copy is kept. Each file gets its own key, sealed
under the master key in its header with its media id, and its bytes are
AES-256-GCM STREAM records, so a swapped, cut or altered file refuses
to open. Uploads with ?encrypt=true are sealed as they stream in.

:vault, :unlock, :lock, :passwd, :encrypt, :decrypt and :upload! drive
it. The vault locks after WORKSPACE_VAULT_IDLE_MIN idle minutes, on
logout and on restart; while locked, sealed files answer 423, share
links never serve them, and :export! refuses rather than leave them out.

Sealing an existing file rewrites it under a new id and retires the old
one: the mirror deletes the plaintext from Proton Drive outright,
trash included, whatever WORKSPACE_BACKUP_PRUNE says, and drops it from
the readable files/ view. The mirror now compares on-disk sizes, so
sealed files are not re-hashed every run.
This commit is contained in:
nvrl committed 2026-10-19 16:03:59 +02:00
1 parent 364ba61963
commit a641d9b6ba
33 files changed
+1573 -54

No files matched your search

+4
View File
@@ -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. # 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 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
Generated
+4
View File
@@ -2743,12 +2743,15 @@ dependencies = [
name = "narl-workspace" name = "narl-workspace"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"aes-gcm 0.10.3",
"anyhow", "anyhow",
"argon2 0.5.3",
"axum", "axum",
"base64 0.22.1", "base64 0.22.1",
"chrono", "chrono",
"chrono-tz", "chrono-tz",
"dotenvy", "dotenvy",
"futures-util",
"hex", "hex",
"hmac 0.12.1", "hmac 0.12.1",
"keyring", "keyring",
@@ -2776,6 +2779,7 @@ dependencies = [
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"uuid", "uuid",
"zeroize",
"zip", "zip",
] ]
+8
View File
@@ -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 = { version = "0.4", default-features = false, features = ["clock", "std"] }
chrono-tz = "0.10" 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-drive-rs = "0.6.2"
proton-sdk = "0.6.2" proton-sdk = "0.6.2"
rpassword = "7" rpassword = "7"
+44
View File
@@ -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 - Files of any kind — PDFs, archives, recordings — live in the tree beside the
notes about them, streamed to disk however large. notes about them, streamed to disk however large.
- Share any document behind an unguessable link — revocable at any time. - 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. - 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. - 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 - 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 The SFTP mount does not show files yet; a folder that holds only files lists
as empty there. 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 ## Importing a folder
`:import` picks a folder and uploads it whole — every file carries the path it `:import` picks a folder and uploads it whole — every file carries the path it
+25
View File
@@ -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;
+1
View File
@@ -112,6 +112,7 @@ mod tests {
session_days, session_days,
max_upload_bytes: 1, max_upload_bytes: 1,
secure_cookie: false, secure_cookie: false,
vault_idle: std::time::Duration::from_secs(60),
backup: BackupConfig { backup: BackupConfig {
device_name: "narl-diary".into(), device_name: "narl-diary".into(),
interval: None, interval: None,
+66 -9
View File
@@ -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 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. 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 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 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 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.database(config, data_folder).await?;
mirror.media(config, uploads_folder).await?; mirror.media(config, uploads_folder).await?;
mirror.retire().await?;
if config.backup.prune { if config.backup.prune {
mirror.prune().await?; mirror.prune().await?;
} }
@@ -149,21 +155,23 @@ impl Mirror<'_> {
.map(|row| (row.get("id"), row.get("size"))) .map(|row| (row.get("id"), row.get("size")))
.collect(); .collect();
for (id, size) in media { for (id, _) in media {
let key = format!("uploads/{id}"); 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); 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, // A row whose file has gone missing is a local inconsistency,
// not a reason to abandon the rest of the backup. // not a reason to abandon the rest of the backup.
tracing::warn!(media = %id, "media row has no file on disk; skipping"); tracing::warn!(media = %id, "media row has no file on disk; skipping");
continue; 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) self.file(uploads_folder, &key, &id, "application/octet-stream", &path)
@@ -173,6 +181,40 @@ impl Mirror<'_> {
Ok(()) 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<String>)> = 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. /// Trash remote files whose local rows are gone. Off unless asked for.
async fn prune(&mut self) -> Result<()> { async fn prune(&mut self) -> Result<()> {
let orphans: Vec<(String, String)> = sqlx::query( let orphans: Vec<(String, String)> = sqlx::query(
@@ -363,6 +405,21 @@ struct Tracked {
size: i64, 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(())
}
/// `<volume>~<link>`, the form [`NodeUid`] prints. Anything else is a row this /// `<volume>~<link>`, the form [`NodeUid`] prints. Anything else is a row this
/// build did not write and is treated as untracked rather than trusted. /// build did not write and is treated as untracked rather than trusted.
pub(crate) fn parse_uid(text: &str) -> Option<NodeUid> { pub(crate) fn parse_uid(text: &str) -> Option<NodeUid> {
+23 -2
View File
@@ -174,8 +174,12 @@ pub async fn run(
root: &NodeUid, root: &NodeUid,
summary: &mut Summary, summary: &mut Summary,
) -> Result<()> { ) -> 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<TreeNode> = sqlx::query( let tree: Vec<TreeNode> = 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", FROM nodes n LEFT JOIN media m ON m.id = n.media_id",
) )
.fetch_all(db) .fetch_all(db)
@@ -217,6 +221,14 @@ pub async fn run(
} }
} }
let sealed: HashSet<i64> = 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 }; 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 // What is gone, or is no longer the same bytes, goes first: it frees the
@@ -229,7 +241,16 @@ pub async fn run(
.collect(); .collect();
for id in stale { for id in stale {
if let Some(gone) = tracked.remove(&id) { 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?; forget(db, id).await?;
} }
} }
+12
View File
@@ -16,6 +16,8 @@ pub struct Config {
pub session_days: i64, pub session_days: i64,
pub max_upload_bytes: usize, pub max_upload_bytes: usize,
pub secure_cookie: bool, pub secure_cookie: bool,
/// How long the vault stays open with nothing using it.
pub vault_idle: std::time::Duration,
pub backup: BackupConfig, pub backup: BackupConfig,
pub mail: MailConfig, pub mail: MailConfig,
pub sftp: SftpConfig, pub sftp: SftpConfig,
@@ -198,6 +200,15 @@ impl Config {
* 1024 * 1024
* 1024; * 1024;
let vault_idle = std::time::Duration::from_secs(
var("VAULT_IDLE_MIN")
.map(|v| v.parse::<u64>())
.transpose()
.context("WORKSPACE_VAULT_IDLE_MIN must be a number")?
.unwrap_or(15)
* 60,
);
let secure_cookie = var("SECURE_COOKIE") let secure_cookie = var("SECURE_COOKIE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false); .unwrap_or(false);
@@ -211,6 +222,7 @@ impl Config {
session_days, session_days,
max_upload_bytes, max_upload_bytes,
secure_cookie, secure_cookie,
vault_idle,
backup: BackupConfig::from_env()?, backup: BackupConfig::from_env()?,
mail: MailConfig::from_env()?, mail: MailConfig::from_env()?,
sftp: SftpConfig::from_env()?, sftp: SftpConfig::from_env()?,
+6
View File
@@ -12,6 +12,8 @@ pub enum AppError {
BadRequest(String), BadRequest(String),
/// The caller must wait this many seconds before trying again. /// The caller must wait this many seconds before trying again.
TooManyRequests(i64), TooManyRequests(i64),
/// The file is sealed and the vault is closed.
Locked,
Internal(anyhow::Error), Internal(anyhow::Error),
} }
@@ -23,6 +25,10 @@ impl IntoResponse for AppError {
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".to_string()), AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".to_string()),
AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()), AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m), AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
AppError::Locked => (
StatusCode::LOCKED,
"the vault is locked — :unlock opens it".to_string(),
),
AppError::TooManyRequests(wait) => ( AppError::TooManyRequests(wait) => (
StatusCode::TOO_MANY_REQUESTS, StatusCode::TOO_MANY_REQUESTS,
format!("too many attempts — wait {wait}s"), format!("too many attempts — wait {wait}s"),
+3
View File
@@ -10,6 +10,7 @@ mod sftp;
mod state; mod state;
mod static_files; mod static_files;
mod throttle; mod throttle;
mod vault;
use std::sync::Arc; use std::sync::Arc;
@@ -110,9 +111,11 @@ async fn main() -> Result<()> {
notify::spawn(Arc::clone(&notify)); notify::spawn(Arc::clone(&notify));
let state = AppState { let state = AppState {
vault: vault::Vault::new(db.clone(), config.vault_idle),
db, db,
config: Arc::clone(&config), config: Arc::clone(&config),
login_throttle: Arc::default(), login_throttle: Arc::default(),
vault_throttle: Arc::default(),
backup, backup,
notify, notify,
}; };
+34 -7
View File
@@ -13,7 +13,11 @@ use sqlx::Row;
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
use zip::{write::SimpleFileOptions, CompressionMethod, ZipWriter}; 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. /// One node, flattened out of the database ahead of the blocking zip work.
struct ExportNode { struct ExportNode {
@@ -159,12 +163,27 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
}) })
.collect(); .collect();
let media: Vec<(String, String)> = sqlx::query("SELECT id, filename FROM media") let rows = sqlx::query("SELECT id, filename, encrypted FROM media")
.fetch_all(&state.db) .fetch_all(&state.db)
.await? .await?;
.into_iter() let media: Vec<(String, String)> = rows
.iter()
.map(|row| (row.get("id"), row.get("filename"))) .map(|row| (row.get("id"), row.get("filename")))
.collect(); .collect();
let sealed: std::collections::HashSet<String> = rows
.iter()
.filter(|row| row.get::<bool, _>("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(); let uploads = state.config.uploads_dir();
@@ -243,11 +262,19 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
.chain(in_tree.iter().map(|(id, path)| (id, path.clone()))); .chain(in_tree.iter().map(|(id, path)| (id, path.clone())));
for (id, entry) in entries { for (id, entry) in entries {
// A row whose file has gone missing must not fail the whole backup. // A row whose file has gone missing must not fail the whole backup.
let Ok(mut source) = std::fs::File::open(uploads.join(id)) else { let path = uploads.join(id);
continue; let source: Box<dyn std::io::Read> = 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)?; zip.start_file(entry, blob)?;
std::io::copy(&mut source, &mut zip)?; std::io::copy(&mut { source }, &mut zip)?;
} }
let mut file = zip.finish()?; let mut file = zip.finish()?;
+2 -2
View File
@@ -252,7 +252,7 @@ async fn collect(
// The archive itself is only passing through: it lands under a // The archive itself is only passing through: it lands under a
// dotted name no media row can carry, and goes once it is unpacked. // dotted name no media row can carry, and goes once it is unpacked.
let id = format!(".import-{}", Uuid::new_v4()); 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 { if size == 0 {
skipped.push(name); skipped.push(name);
continue; continue;
@@ -309,7 +309,7 @@ async fn collect(
let mime = media::mime_of(&path, field.content_type()); let mime = media::mime_of(&path, field.content_type());
let id = Uuid::new_v4().to_string(); 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 { if size == 0 {
skipped.push(path); skipped.push(path);
continue; continue;
+92 -13
View File
@@ -2,13 +2,13 @@ use std::{collections::BTreeSet, sync::LazyLock};
use axum::{ use axum::{
body::Body, body::Body,
extract::{multipart::Field, Multipart, Path, State}, extract::{multipart::Field, Multipart, Path, Query, State},
http::header, http::header,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
Json, Json,
}; };
use regex::Regex; use regex::Regex;
use serde::Serialize; use serde::{Deserialize, Serialize};
use sqlx::{Row, SqlitePool}; use sqlx::{Row, SqlitePool};
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
use tokio_util::io::ReaderStream; use tokio_util::io::ReaderStream;
@@ -19,6 +19,7 @@ use crate::{
error::{AppError, AppResult}, error::{AppError, AppResult},
routes::nodes, routes::nodes,
state::AppState, state::AppState,
vault,
}; };
/// Media referenced from a document body always looks like `/api/media/<uuid>`, /// Media referenced from a document body always looks like `/api/media/<uuid>`,
@@ -69,6 +70,8 @@ pub struct Media {
pub size: i64, pub size: i64,
pub created_at: i64, pub created_at: i64,
pub url: String, 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. /// Every document that embeds this file, which may be none or several.
pub node_ids: Vec<i64>, pub node_ids: Vec<i64>,
} }
@@ -91,6 +94,7 @@ fn row_to_media(row: &sqlx::sqlite::SqliteRow) -> Media {
mime: row.get("mime"), mime: row.get("mime"),
size: row.get("size"), size: row.get("size"),
created_at: row.get("created_at"), created_at: row.get("created_at"),
encrypted: row.try_get("encrypted").unwrap_or(false),
node_ids, 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 /// 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 /// file under a name the database might come to know. Returns the size; an
/// empty field writes nothing and returns 0. /// 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( pub(crate) async fn store_field(
state: &AppState, state: &AppState,
field: &mut Field<'_>, field: &mut Field<'_>,
id: &str, id: &str,
budget: &mut u64, budget: &mut u64,
seal: Option<&vault::Key>,
) -> AppResult<u64> { ) -> AppResult<u64> {
let path = state.config.uploads_dir().join(id); let path = state.config.uploads_dir().join(id);
let partial = path.with_extension("part"); let partial = path.with_extension("part");
let result = async { let result = async {
let mut file = tokio::fs::File::create(&partial).await?; 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; let mut size = 0u64;
while let Some(chunk) = field while let Some(chunk) = field
.chunk() .chunk()
@@ -139,7 +155,13 @@ pub(crate) async fn store_field(
if size > *budget { if size > *budget {
return Err(too_large(state)); 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?; file.flush().await?;
Ok(size) Ok(size)
@@ -186,7 +208,7 @@ pub async fn upload(
let mime = mime_of(&filename, field.content_type()); let mime = mime_of(&filename, field.content_type());
let id = Uuid::new_v4().to_string(); 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 { if size == 0 {
continue; continue;
} }
@@ -214,15 +236,29 @@ pub async fn upload(
Ok(Json(uploaded)) 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 /// `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. /// 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( pub async fn upload_into(
_: Session, _: Session,
State(state): State<AppState>, State(state): State<AppState>,
Path(parent): Path<i64>, Path(parent): Path<i64>,
Query(query): Query<UploadQuery>,
mut multipart: Multipart, mut multipart: Multipart,
) -> AppResult<Json<Vec<nodes::Node>>> { ) -> AppResult<Json<Vec<nodes::Node>>> {
nodes::container(&state.db, parent).await?; 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 created = Vec::new();
let mut budget = state.config.max_upload_bytes as u64; 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 mime = mime_of(&filename, field.content_type());
let id = Uuid::new_v4().to_string(); 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 { if size == 0 {
continue; 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), Ok(node) => created.push(node),
Err(e) => { Err(e) => {
let _ = tokio::fs::remove_file(state.config.uploads_dir().join(&id)).await; let _ = tokio::fs::remove_file(state.config.uploads_dir().join(&id)).await;
@@ -277,19 +313,22 @@ async fn insert_file(
filename: &str, filename: &str,
mime: &str, mime: &str,
size: u64, size: u64,
encrypted: bool,
) -> AppResult<i64> { ) -> AppResult<i64> {
let slug = nodes::unique_slug(db, Some(parent), &nodes::slugify(filename), None).await?; let slug = nodes::unique_slug(db, Some(parent), &nodes::slugify(filename), None).await?;
let now = crate::now(); let now = crate::now();
let mut tx = db.begin().await?; let mut tx = db.begin().await?;
sqlx::query( 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(media_id)
.bind(filename) .bind(filename)
.bind(mime) .bind(mime)
.bind(size as i64) .bind(size as i64)
.bind(now) .bind(now)
.bind(encrypted)
.execute(&mut *tx) .execute(&mut *tx)
.await?; .await?;
@@ -319,7 +358,7 @@ async fn insert_file(
pub async fn list(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<Media>>> { pub async fn list(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<Media>>> {
let rows = sqlx::query( 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) (SELECT group_concat(nm.node_id)
FROM node_media nm WHERE nm.media_id = m.id) AS node_ids FROM node_media nm WHERE nm.media_id = m.id) AS node_ids
FROM media m ORDER BY m.created_at DESC LIMIT 500", FROM media m ORDER BY m.created_at DESC LIMIT 500",
@@ -334,7 +373,7 @@ pub async fn serve(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<String>, Path(id): Path<String>,
) -> AppResult<Response> { ) -> AppResult<Response> {
stream_media(&state, &id).await stream_media(&state, &id, true).await
} }
pub async fn remove( pub async fn remove(
@@ -347,9 +386,11 @@ pub async fn remove(
Ok(Json(serde_json::json!({ "ok": true }))) Ok(Json(serde_json::json!({ "ok": true })))
} }
/// Shared by the private and the public-share media handlers. /// Shared by the private and the public-share media handlers. A sealed file
pub async fn stream_media(state: &AppState, id: &str) -> AppResult<Response> { /// is only ever served to the owner, and only while the vault is open — a
let row = sqlx::query("SELECT mime, filename, size FROM media WHERE id = ?1") /// 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<Response> {
let row = sqlx::query("SELECT mime, filename, size, encrypted FROM media WHERE id = ?1")
.bind(id) .bind(id)
.fetch_optional(&state.db) .fetch_optional(&state.db)
.await? .await?
@@ -357,14 +398,25 @@ pub async fn stream_media(state: &AppState, id: &str) -> AppResult<Response> {
let filename: String = row.get("filename"); let filename: String = row.get("filename");
let size: i64 = row.get("size"); 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 // 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. // the allowlist existed cannot be served as something executable either.
let mime = sanitize_mime(&row.get::<String, _>("mime")); let mime = sanitize_mime(&row.get::<String, _>("mime"));
let disposition = if is_inline(&mime) { "inline" } else { "attachment" }; 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)) let file = tokio::fs::File::open(state.config.uploads_dir().join(id))
.await .await
.map_err(|_| AppError::NotFound)?; .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(( Ok((
[ [
@@ -386,11 +438,38 @@ pub async fn stream_media(state: &AppState, id: &str) -> AppResult<Response> {
"sandbox; default-src 'none'".to_string(), "sandbox; default-src 'none'".to_string(),
), ),
], ],
Body::from_stream(ReaderStream::new(file)), body,
) )
.into_response()) .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<impl futures_util::Stream<Item = std::io::Result<axum::body::Bytes>>> {
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<()> { pub async fn delete_media(state: &AppState, id: &str) -> AppResult<()> {
sqlx::query("DELETE FROM media WHERE id = ?1") sqlx::query("DELETE FROM media WHERE id = ?1")
.bind(id) .bind(id)
+7
View File
@@ -4,9 +4,11 @@ pub mod nodes;
pub mod export; pub mod export;
pub mod import; pub mod import;
pub mod media; pub mod media;
pub mod seal;
pub mod session; pub mod session;
pub mod share; pub mod share;
pub mod ssh_keys; pub mod ssh_keys;
pub mod vault;
use axum::{ use axum::{
routing::{delete, get, post, put}, routing::{delete, get, post, put},
@@ -39,6 +41,11 @@ pub fn api_router() -> Router<AppState> {
.route("/nodes/{id}/move", post(nodes::move_node)) .route("/nodes/{id}/move", post(nodes::move_node))
.route("/nodes/{id}/import", post(import::import)) .route("/nodes/{id}/import", post(import::import))
.route("/nodes/{id}/files", post(media::upload_into)) .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", post(share::enable))
.route("/nodes/{id}/share", delete(share::disable)) .route("/nodes/{id}/share", delete(share::disable))
.route("/media", post(media::upload).get(media::list)) .route("/media", post(media::upload).get(media::list))
+5 -1
View File
@@ -35,6 +35,8 @@ pub struct FileInfo {
pub mime: String, pub mime: String,
pub size: i64, pub size: i64,
pub url: String, pub url: String,
/// Sealed in the vault: it opens only while the vault does.
pub encrypted: bool,
} }
#[derive(Serialize)] #[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, 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 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.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 /// 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 /// 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, media_id,
mime: row.get::<Option<String>, _>("file_mime").unwrap_or_default(), mime: row.get::<Option<String>, _>("file_mime").unwrap_or_default(),
size: row.get::<Option<i64>, _>("file_size").unwrap_or_default(), size: row.get::<Option<i64>, _>("file_size").unwrap_or_default(),
encrypted: row.get::<Option<bool>, _>("file_encrypted").unwrap_or_default(),
}); });
NodeSummary { NodeSummary {
id: row.get("id"), id: row.get("id"),
+190
View File
@@ -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/<uuid>` 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<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Resealed>> {
reseal(&state, id, true).await.map(Json)
}
pub async fn decrypt(
_: Session,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Resealed>> {
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<Resealed> {
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::<Vec<_>>().join(",");
let targets: Vec<Target> = 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(())
}
+2
View File
@@ -58,6 +58,8 @@ pub async fn login(
} }
pub async fn logout(State(state): State<AppState>) -> impl IntoResponse { pub async fn logout(State(state): State<AppState>) -> impl IntoResponse {
// Leaving is the moment the vault should not outlive.
state.vault.lock();
( (
StatusCode::OK, StatusCode::OK,
[(header::SET_COOKIE, auth::clear_cookie(&state.config))], [(header::SET_COOKIE, auth::clear_cookie(&state.config))],
+1 -1
View File
@@ -160,7 +160,7 @@ pub async fn serve_media(
if !key_matches(stored.as_deref(), &key) { if !key_matches(stored.as_deref(), &key) {
return Err(AppError::NotFound); return Err(AppError::NotFound);
} }
media::stream_media(&state, &id).await media::stream_media(&state, &id, false).await
} }
#[cfg(test)] #[cfg(test)]
+105
View File
@@ -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<AppState>) -> AppResult<Json<Status>> {
Ok(Json(state.vault.status().await?))
}
/// `POST /api/vault` — make the vault. Only once.
pub async fn setup(
_: Session,
State(state): State<AppState>,
Json(input): Json<PasswordInput>,
) -> AppResult<Json<Status>> {
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<AppState>,
Json(input): Json<PasswordInput>,
) -> AppResult<Json<Status>> {
throttle(&state)?;
answer(&state, state.vault.unlock(input.password).await?)?;
Ok(Json(state.vault.status().await?))
}
pub async fn lock(_: Session, State(state): State<AppState>) -> AppResult<Json<Status>> {
state.vault.lock();
Ok(Json(state.vault.status().await?))
}
pub async fn change_password(
_: Session,
State(state): State<AppState>,
Json(input): Json<ChangeInput>,
) -> AppResult<Json<Status>> {
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?))
}
+6 -1
View File
@@ -2,13 +2,18 @@ use std::sync::Arc;
use sqlx::SqlitePool; 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)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub db: SqlitePool, pub db: SqlitePool,
pub config: Arc<Config>, pub config: Arc<Config>,
pub login_throttle: Arc<LoginThrottle>, pub login_throttle: Arc<LoginThrottle>,
/// 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<LoginThrottle>,
/// Sealed files. Always present; holds no key until it is unlocked.
pub vault: Arc<Vault>,
/// The Proton Drive mirror. Always present; dormant until it has a session. /// The Proton Drive mirror. Always present; dormant until it has a session.
pub backup: Arc<Backup>, pub backup: Arc<Backup>,
/// Notification mail. Always present; dormant until WORKSPACE_SMTP_URL is set. /// Notification mail. Always present; dormant until WORKSPACE_SMTP_URL is set.
+585
View File
@@ -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<const N: usize>() -> [u8; N] {
let mut out = [0u8; N];
rand::thread_rng().fill_bytes(&mut out);
out
}
/// The key the password stands for, at the costs the vault was made with.
fn derive(password: &str, salt: &[u8], m: u32, t: u32, p: u32) -> Result<Key> {
let params = argon2::Params::new(m, t, p, Some(32)).map_err(|e| anyhow!("argon2 params: {e}"))?;
let argon = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
let mut key = Zeroizing::new([0u8; 32]);
argon
.hash_password_into(password.as_bytes(), salt, key.as_mut())
.map_err(|e| anyhow!("argon2: {e}"))?;
Ok(key)
}
fn seal_master(kek: &Key, master: &Key) -> Result<([u8; WRAP_NONCE], Vec<u8>)> {
let nonce = random::<WRAP_NONCE>();
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<Key> {
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<Aes256Gcm>,
pending: Vec<u8>,
}
impl Sealer {
/// A sealer for `media_id`, and the header that has to be written first.
pub fn new(master: &Key, media_id: &str) -> Result<(Self, Vec<u8>)> {
let file_key: Key = Zeroizing::new(random::<32>());
let wrap_nonce = random::<WRAP_NONCE>();
let stream_nonce = random::<STREAM_NONCE>();
let sealed_key = Aes256Gcm::new(master.as_ref().into())
.encrypt(
Nonce::from_slice(&wrap_nonce),
Payload { msg: file_key.as_ref(), aad: &file_aad(media_id) },
)
.map_err(|_| anyhow!("could not seal a file key"))?;
let mut header = Vec::with_capacity(HEADER);
header.extend_from_slice(MAGIC);
header.extend_from_slice(&wrap_nonce);
header.extend_from_slice(&sealed_key);
header.extend_from_slice(&stream_nonce);
let stream = EncryptorBE32::from_aead(
Aes256Gcm::new(file_key.as_ref().into()),
stream_nonce.as_slice().into(),
);
Ok((Self { stream, pending: Vec::with_capacity(CHUNK) }, header))
}
/// Take more plaintext; returns whatever whole records it completes. A
/// record is only sealed once more data follows it, because only the last
/// one may be marked last and the last is not known until `finish`.
pub fn push(&mut self, mut data: &[u8]) -> Result<Vec<u8>> {
let mut out = Vec::new();
while !data.is_empty() {
if self.pending.len() == CHUNK {
let record = self
.stream
.encrypt_next(self.pending.as_slice())
.map_err(|_| anyhow!("could not seal a record"))?;
out.extend_from_slice(&record);
self.pending.clear();
}
let take = (CHUNK - self.pending.len()).min(data.len());
self.pending.extend_from_slice(&data[..take]);
data = &data[take..];
}
Ok(out)
}
/// The last record. Always present, empty for an empty file, so even a
/// file cut off exactly at a record boundary is caught.
pub fn finish(self) -> Result<Vec<u8>> {
self.stream
.encrypt_last(self.pending.as_slice())
.map_err(|_| anyhow!("could not seal the last record"))
}
}
fn file_aad(media_id: &str) -> Vec<u8> {
let mut aad = MAGIC.to_vec();
aad.extend_from_slice(media_id.as_bytes());
aad
}
/// Decrypts one sealed file, a record at a time.
pub struct Opener {
stream: Option<DecryptorBE32<Aes256Gcm>>,
/// 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<Self> {
if header.len() != HEADER || &header[..MAGIC.len()] != MAGIC {
return Err(anyhow!("not a sealed file"));
}
let body = len
.checked_sub(HEADER as u64)
.filter(|body| *body >= TAG as u64)
.ok_or_else(|| anyhow!("sealed file is truncated"))?;
let remaining = body.div_ceil(RECORD as u64);
let last = (body - (remaining - 1) * RECORD as u64) as usize;
if last < TAG {
return Err(anyhow!("sealed file is truncated"));
}
let (wrap_nonce, 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<Vec<u8>> {
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<R> {
inner: R,
opener: Opener,
buffer: Vec<u8>,
at: usize,
}
impl Reader<std::fs::File> {
pub fn open(master: &Key, media_id: &str, path: &std::path::Path) -> Result<Self> {
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<R: Read> Read for Reader<R> {
fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
while self.at == self.buffer.len() {
let len = self.opener.next_len();
if len == 0 {
return Ok(0);
}
let mut record = vec![0u8; len];
self.inner.read_exact(&mut record)?;
self.buffer = self.opener.open(&record).map_err(std::io::Error::other)?;
self.at = 0;
}
let n = out.len().min(self.buffer.len() - self.at);
out[..n].copy_from_slice(&self.buffer[self.at..self.at + n]);
self.at += n;
Ok(n)
}
}
/// Seal `source` into `target` under `media_id`, on the calling thread.
/// Returns the plaintext size.
pub fn seal_file(master: &Key, media_id: &str, source: &mut impl Read, target: &std::path::Path) -> Result<u64> {
use std::io::Write;
let mut out = std::io::BufWriter::new(std::fs::File::create(target)?);
let (mut sealer, header) = Sealer::new(master, media_id)?;
out.write_all(&header)?;
let mut buffer = vec![0u8; CHUNK];
let mut size = 0u64;
loop {
let n = source.read(&mut buffer)?;
if n == 0 {
break;
}
size += n as u64;
out.write_all(&sealer.push(&buffer[..n])?)?;
}
out.write_all(&sealer.finish()?)?;
out.into_inner().map_err(|e| e.into_error())?.sync_all()?;
Ok(size)
}
/// The vault's state: whether it exists, and its key while it is open.
pub struct Vault {
db: SqlitePool,
idle: Duration,
open: Mutex<Option<Unlocked>>,
}
struct Unlocked {
key: Arc<Key>,
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<u64>,
}
/// What a password check came to.
pub enum Unlock {
Opened,
WrongPassword,
NotConfigured,
}
impl Vault {
pub fn new(db: SqlitePool, idle: Duration) -> Arc<Self> {
Arc::new(Self { db, idle, open: Mutex::new(None) })
}
fn slot(&self) -> std::sync::MutexGuard<'_, Option<Unlocked>> {
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<Arc<Key>> {
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<Status> {
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<Option<sqlx::sqlite::SqliteRow>> {
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<bool> {
if self.row().await?.is_some() {
return Ok(false);
}
let salt = random::<16>();
let master: Key = Zeroizing::new(random::<32>());
let (nonce, sealed, master) = tokio::task::spawn_blocking(move || -> Result<_> {
let kek = derive(&password, &salt, M_COST, T_COST, P_COST)?;
let (nonce, sealed) = seal_master(&kek, &master)?;
Ok((nonce, sealed, master))
})
.await??;
let inserted = sqlx::query(
"INSERT OR IGNORE INTO vault (id, salt, m_cost, t_cost, p_cost, nonce, sealed, created_at)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7)",
)
.bind(salt.as_slice())
.bind(M_COST as i64)
.bind(T_COST as i64)
.bind(P_COST as i64)
.bind(nonce.as_slice())
.bind(sealed)
.bind(crate::now())
.execute(&self.db)
.await?
.rows_affected();
if inserted == 0 {
return Ok(false);
}
*self.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<Option<Option<Key>>> {
let Some(row) = self.row().await? else {
return Ok(None);
};
let salt: Vec<u8> = row.get("salt");
let (m, t, p): (i64, i64, i64) = (row.get("m_cost"), row.get("t_cost"), row.get("p_cost"));
let nonce: Vec<u8> = row.get("nonce");
let sealed: Vec<u8> = row.get("sealed");
let key = tokio::task::spawn_blocking(move || -> Result<Option<Key>> {
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<Unlock> {
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<Unlock> {
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<u8> {
let (mut sealer, mut out) = Sealer::new(master, id).expect("sealer");
for piece in plain.chunks(step.max(1)) {
out.extend(sealer.push(piece).expect("push"));
}
out.extend(sealer.finish().expect("finish"));
out
}
fn open(master: &super::Key, id: &str, sealed: &[u8]) -> anyhow::Result<Vec<u8>> {
let mut opener = Opener::new(master, id, &sealed[..HEADER], sealed.len() as u64)?;
let mut at = HEADER;
let mut out = Vec::new();
loop {
let len = opener.next_len();
if len == 0 {
break;
}
out.extend(opener.open(&sealed[at..at + len])?);
at += len;
}
Ok(out)
}
#[test]
fn files_of_every_awkward_size_round_trip() {
let master = key(7);
for size in [0, 1, CHUNK - 1, CHUNK, CHUNK + 1, 3 * CHUNK, 3 * CHUNK + 17] {
let plain: Vec<u8> = (0..size).map(|i| (i % 251) as u8).collect();
for step in [1000, CHUNK, 7 * CHUNK] {
let sealed = seal(&master, "id", &plain, step);
let records = size.div_ceil(CHUNK).max(1);
assert_eq!(sealed.len(), HEADER + size + records * TAG, "size {size}");
assert_eq!(open(&master, "id", &sealed).expect("opens"), plain, "size {size} step {step}");
}
}
}
#[test]
fn the_std_reader_sees_the_plaintext() {
let master = key(1);
let plain: Vec<u8> = (0..(2 * CHUNK + 5)).map(|i| i as u8).collect();
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("x");
std::fs::write(&path, seal(&master, "x", &plain, CHUNK)).expect("write");
let mut out = Vec::new();
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());
}
}
+22 -3
View File
@@ -12,6 +12,7 @@
import Overview from './lib/Overview.svelte' import Overview from './lib/Overview.svelte'
import MediaBrowser from './lib/MediaBrowser.svelte' import MediaBrowser from './lib/MediaBrowser.svelte'
import Prompt from './lib/Prompt.svelte' import Prompt from './lib/Prompt.svelte'
import SecretPrompt from './lib/SecretPrompt.svelte'
import Rail from './lib/Rail.svelte' import Rail from './lib/Rail.svelte'
import Shared from './lib/Shared.svelte' import Shared from './lib/Shared.svelte'
import Sidebar from './lib/Sidebar.svelte' import Sidebar from './lib/Sidebar.svelte'
@@ -31,6 +32,8 @@
let cmdGlobal = $state(false) let cmdGlobal = $state(false)
let fileInput = $state<HTMLInputElement>() let fileInput = $state<HTMLInputElement>()
let treeInput = $state<HTMLInputElement>() let treeInput = $state<HTMLInputElement>()
/** Whether the files being picked go into the vault — `:upload!`. */
let sealUpload = false
let folderInput = $state<HTMLInputElement>() let folderInput = $state<HTMLInputElement>()
let importInput = $state<HTMLInputElement>() let importInput = $state<HTMLInputElement>()
let shell = $state<HTMLDivElement>() let shell = $state<HTMLDivElement>()
@@ -45,7 +48,10 @@
$effect(() => { $effect(() => {
hooks.pickFiles = () => fileInput?.click() hooks.pickFiles = () => fileInput?.click()
hooks.pickTreeFiles = () => treeInput?.click() hooks.pickTreeFiles = (encrypt) => {
sealUpload = encrypt
treeInput?.click()
}
hooks.pickFolder = () => folderInput?.click() hooks.pickFolder = () => folderInput?.click()
hooks.pickArchive = () => importInput?.click() hooks.pickArchive = () => importInput?.click()
hooks.focusList = () => shell?.focus() hooks.focusList = () => shell?.focus()
@@ -92,7 +98,10 @@
$effect(() => { $effect(() => {
if (!workspace.user) return if (!workspace.user) return
const refresh = () => { 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) const timer = setInterval(refresh, 5 * 60 * 1000)
document.addEventListener('visibilitychange', refresh) 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. // Guard against losing a half-written entry to a stray refresh.
$effect(() => { $effect(() => {
if (!workspace.dirty) return if (!workspace.dirty) return
@@ -126,7 +144,7 @@
const files = Array.from(input.files ?? []) const files = Array.from(input.files ?? [])
input.value = '' input.value = ''
if (files.length === 0) return if (files.length === 0) return
await workspace.guard(() => workspace.putFiles(files)) await workspace.guard(() => workspace.putFiles(files, sealUpload))
} }
/* /*
@@ -687,6 +705,7 @@
{/if} {/if}
<Prompt /> <Prompt />
<SecretPrompt />
{#if workspace.overlay !== 'none'} {#if workspace.overlay !== 'none'}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex --> <!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
+27 -6
View File
@@ -6,6 +6,8 @@
const node = $derived(workspace.open!) const node = $derived(workspace.open!)
const file = $derived(node.file!) 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]) const kind = $derived(file.mime.split('/')[0])
/* What the browser can show by itself; everything else is a download. */ /* What the browser can show by itself; everything else is a download. */
const viewable = $derived(file.mime !== 'application/octet-stream') const viewable = $derived(file.mime !== 'application/octet-stream')
@@ -18,7 +20,7 @@
let text = $state<string | null>(null) let text = $state<string | null>(null)
$effect(() => { $effect(() => {
text = null text = null
if (file.mime !== 'text/plain') return if (file.mime !== 'text/plain' || locked) return
const url = file.url const url = file.url
void head(url, TEXT_PREVIEW).then((body) => { void head(url, TEXT_PREVIEW).then((body) => {
if (body !== null && url === file.url) text = body if (body !== null && url === file.url) text = body
@@ -77,13 +79,24 @@
<dd>{file.mime}</dd> <dd>{file.mime}</dd>
<dt>uploaded</dt> <dt>uploaded</dt>
<dd title={formatStamp(node.created_at)}>{relative(node.created_at)}</dd> <dd title={formatStamp(node.created_at)}>{relative(node.created_at)}</dd>
{#if file.encrypted}
<dt>vault</dt>
<dd class="light">{locked ? 'sealed, locked' : 'sealed, open'}</dd>
{/if}
</dl> </dl>
<div class="actions"> <div class="actions">
{#if viewable} {#if locked}
<a class="button" href={file.url} target="_blank" rel="noopener">open</a> <button onclick={() => runCommand('unlock')}>unlock</button>
{:else}
{#if viewable}
<a class="button" href={file.url} target="_blank" rel="noopener">open</a>
{/if}
<a class="button" href={file.url} download={node.name}>download</a>
{/if} {/if}
<a class="button" href={file.url} download={node.name}>download</a> <button onclick={() => runCommand(file.encrypted ? 'decrypt' : 'encrypt')}>
{file.encrypted ? 'decrypt' : 'encrypt'}
</button>
<button class="danger" onclick={() => runCommand('d')}>delete</button> <button class="danger" onclick={() => runCommand('d')}>delete</button>
<button class="close" onclick={() => runCommand('q')}>close</button> <button class="close" onclick={() => runCommand('q')}>close</button>
</div> </div>
@@ -92,8 +105,13 @@
<div class="body" bind:this={reader}> <div class="body" bind:this={reader}>
<h1 class="name">{node.name}</h1> <h1 class="name">{node.name}</h1>
{#key file.url} {#key `${file.url}:${locked}`}
{#if kind === 'image'} {#if locked}
<p class="sealed">
<Icon name="lock" size={16} />
sealed in the vault — <button onclick={() => runCommand('unlock')}>unlock</button> to open it.
</p>
{:else if kind === 'image'}
<img src={file.url} alt={node.name} /> <img src={file.url} alt={node.name} />
{:else if kind === 'video'} {:else if kind === 'video'}
<!-- svelte-ignore a11y_media_has_caption --> <!-- svelte-ignore a11y_media_has_caption -->
@@ -197,6 +215,9 @@
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.light { color: var(--light); }
.sealed { display: flex; align-items: center; gap: 8px; color: var(--subtle); }
.back { display: none; } .back { display: none; }
@media (max-width: 767px) { @media (max-width: 767px) {
+1
View File
@@ -59,6 +59,7 @@
documents: 'documents', documents: 'documents',
editing: 'writing', editing: 'writing',
sharing: 'sharing & export', sharing: 'sharing & export',
vault: 'vault',
view: 'session', view: 'session',
} }
+1
View File
@@ -18,6 +18,7 @@
shrink: ['M9 4v5H4', 'M15 4v5h5', 'M9 20v-5H4', 'M15 20v-5h5'], 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'], 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'], 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: [ help: [
'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18z', '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', 'M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6v.6',
+3 -1
View File
@@ -29,7 +29,9 @@
{#each workspace.media as file (file.id)} {#each workspace.media as file (file.id)}
<li> <li>
<div class="thumb"> <div class="thumb">
{#if file.mime.startsWith('image/')} {#if file.encrypted}
<span class="label">sealed</span>
{:else if file.mime.startsWith('image/')}
<img src={file.url} alt={file.filename} loading="lazy" /> <img src={file.url} alt={file.filename} loading="lazy" />
{:else} {:else}
<span class="label">{file.mime.split('/')[0]}</span> <span class="label">{file.mime.split('/')[0]}</span>
+88
View File
@@ -0,0 +1,88 @@
<script lang="ts">
/*
* The password `workspace.askSecret` asks for, in the same place as the
* `[y/N]` prompt. A masked field of its own rather than the command line, so
* a password never shows on screen or lands in the command history.
*/
import { workspace } from './store.svelte'
let value = $state('')
let input = $state<HTMLInputElement>()
$effect(() => {
if (workspace.secret) {
value = ''
input?.focus()
}
})
function onkeydown(event: KeyboardEvent) {
event.stopPropagation()
if (event.key === 'Enter') {
event.preventDefault()
workspace.secret?.resolve(value)
value = ''
} else if (event.key === 'Escape') {
event.preventDefault()
workspace.secret?.resolve(null)
value = ''
}
}
</script>
{#if workspace.secret}
<div class="prompt" role="dialog" aria-label={workspace.secret.text}>
<label class="text" for="secret">{workspace.secret.text}</label>
<input
id="secret"
type="password"
autocomplete="off"
spellcheck="false"
bind:this={input}
bind:value
{onkeydown}
/>
<button
onclick={() => {
workspace.secret?.resolve(null)
value = ''
}}>esc</button
>
</div>
{/if}
<style>
.prompt {
position: absolute;
left: calc(var(--frame) + 12px);
right: calc(var(--rail-w) + 12px);
bottom: calc(var(--status-h) - 1px);
z-index: 40;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: color-mix(in oklab, var(--hull) 97%, transparent);
border: 1px solid var(--light-dim);
border-bottom: 0;
border-radius: var(--radius-l) var(--radius-l) 0 0;
font-size: 13px;
animation: rise var(--t-slow) var(--ease-snap) both;
}
.text { flex: none; color: var(--light); }
input {
flex: 1;
min-width: 0;
padding: 4px 8px;
background: var(--surface);
border: 1px solid var(--rim);
border-radius: var(--radius-s);
color: var(--light);
font-family: var(--font-mono);
}
input:focus { border-color: var(--light-dim); outline: none; }
@media (max-width: 767px) {
.prompt { left: 8px; right: 8px; flex-wrap: wrap; }
}
</style>
+4
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { isContainer, type NodeSummary } from './api' import { isContainer, type NodeSummary } from './api'
import Icon from './Icon.svelte'
import { workspace } from './store.svelte' import { workspace } from './store.svelte'
import { formatBytes, formatDay, formatTime, formatWeekday } from './util' import { formatBytes, formatDay, formatTime, formatWeekday } from './util'
@@ -92,6 +93,7 @@
<span class="title">{label(node)}{#if isContainer(node)}<span class="slash">/</span>{/if}</span> <span class="title">{label(node)}{#if isContainer(node)}<span class="slash">/</span>{/if}</span>
{#if workspace.marked?.id === node.id}<span class="badge" title="marked — p drops it in a folder">m</span>{/if} {#if workspace.marked?.id === node.id}<span class="badge" title="marked — p drops it in a folder">m</span>{/if}
{#if node.shared}<span class="shared" title="shared"></span>{/if} {#if node.shared}<span class="shared" title="shared"></span>{/if}
{#if node.file?.encrypted}<span class="sealed" title="in the vault"><Icon name="lock" size={12} /></span>{/if}
<span class="aside">{aside(node)}</span> <span class="aside">{aside(node)}</span>
</button> </button>
</li> </li>
@@ -215,6 +217,8 @@
border: 1.5px solid currentColor; border: 1.5px solid currentColor;
border-radius: 999px; border-radius: 999px;
} }
.sealed { flex: none; display: grid; color: var(--muted); }
.row.active .sealed { color: var(--ink); }
.empty { padding: 12px 10px; font-size: 12px; } .empty { padding: 12px 10px; font-size: 12px; }
/* Roomier tap targets where there is no keyboard. */ /* Roomier tap targets where there is no keyboard. */
+1
View File
@@ -29,6 +29,7 @@
<span class="msg" class:alarm={workspace.flash?.kind === 'error'}>{workspace.flash?.text ?? ''}</span> <span class="msg" class:alarm={workspace.flash?.kind === 'error'}>{workspace.flash?.text ?? ''}</span>
{#if workspace.fullscreen}<span class="flag">full</span>{/if} {#if workspace.fullscreen}<span class="flag">full</span>{/if}
{#if workspace.vault?.unlocked}<span class="flag vault" title="the vault is open — :lock closes it">vault open</span>{/if}
{#if lines !== null}<span class="readout">{lines}<span class="unit">L</span></span>{/if} {#if lines !== null}<span class="readout">{lines}<span class="unit">L</span></span>{/if}
<span class="hint"> <span class="hint">
{isTouchDevice ? 'tap : for commands' : '? help · : command · / search'} {isTouchDevice ? 'tap : for commands' : '? help · : command · / search'}
+32 -2
View File
@@ -10,6 +10,8 @@ export interface FileInfo {
mime: string mime: string
size: number size: number
url: string url: string
/** Sealed in the vault: it opens only while the vault is unlocked. */
encrypted: boolean
} }
export interface NodeSummary { export interface NodeSummary {
@@ -125,10 +127,19 @@ export interface MediaFile {
size: number size: number
created_at: number created_at: number
url: string url: string
encrypted: boolean
/** Every document that embeds this file — a file may be used by several. */ /** Every document that embeds this file — a file may be used by several. */
node_ids: number[] node_ids: number[]
} }
/** The vault, as `/api/vault` reports it. */
export interface VaultStatus {
configured: boolean
unlocked: boolean
/** Seconds until it locks itself if nothing uses it. */
locks_in: number | null
}
/** The Proton Drive mirror, as `/api/backup` reports it. */ /** The Proton Drive mirror, as `/api/backup` reports it. */
export interface BackupStatus { export interface BackupStatus {
configured: boolean configured: boolean
@@ -285,12 +296,12 @@ export const api = {
* Upload files into a space or folder as file nodes. XHR rather than fetch, * Upload files into a space or folder as file nodes. XHR rather than fetch,
* because a gigabyte going up needs to say how far along it is. * because a gigabyte going up needs to say how far along it is.
*/ */
putFiles: (parentId: number, files: File[], progress?: (fraction: number) => void) => putFiles: (parentId: number, files: File[], encrypt: boolean, progress?: (fraction: number) => void) =>
new Promise<WorkspaceNode[]>((resolve, reject) => { new Promise<WorkspaceNode[]>((resolve, reject) => {
const form = new FormData() const form = new FormData()
for (const file of files) form.append('file', file) for (const file of files) form.append('file', file)
const xhr = new XMLHttpRequest() const xhr = new XMLHttpRequest()
xhr.open('POST', `/api/nodes/${parentId}/files`) xhr.open('POST', `/api/nodes/${parentId}/files${encrypt ? '?encrypt=true' : ''}`)
xhr.responseType = 'json' xhr.responseType = 'json'
xhr.upload.onprogress = (event) => { xhr.upload.onprogress = (event) => {
if (event.lengthComputable) progress?.(event.loaded / event.total) if (event.lengthComputable) progress?.(event.loaded / event.total)
@@ -311,6 +322,25 @@ export const api = {
deleteMedia: (id: string) => request<{ ok: true }>(`/media/${id}`, { method: 'DELETE' }), deleteMedia: (id: string) => request<{ ok: true }>(`/media/${id}`, { method: 'DELETE' }),
vaultStatus: () => request<VaultStatus>('/vault'),
setupVault: (password: string) =>
request<VaultStatus>('/vault', { method: 'POST', body: body({ password }) }),
changeVaultPassword: (current: string, password: string) =>
request<VaultStatus>('/vault', { method: 'PUT', body: body({ current, password }) }),
unlockVault: (password: string) =>
request<VaultStatus>('/vault/unlock', { method: 'POST', body: body({ password }) }),
lockVault: () => request<VaultStatus>('/vault/lock', { method: 'POST' }),
/** Seal a file, or every file under a folder, into the vault — or take it back out. */
seal: (id: number, encrypt: boolean) =>
request<{ files: number; unchanged: number }>(`/nodes/${id}/${encrypt ? 'encrypt' : 'decrypt'}`, {
method: 'POST',
}),
backupStatus: () => request<BackupStatus>('/backup'), backupStatus: () => request<BackupStatus>('/backup'),
backupNow: () => request<BackupStatus>('/backup', { method: 'POST' }), backupNow: () => request<BackupStatus>('/backup', { method: 'POST' }),
+107 -3
View File
@@ -8,7 +8,7 @@ export type { ScrollAmount }
export const hooks = { export const hooks = {
pickFiles: () => {}, pickFiles: () => {},
/** Files for the folder being browsed, rather than for the document. */ /** Files for the folder being browsed, rather than for the document. */
pickTreeFiles: () => {}, pickTreeFiles: (_encrypt: boolean) => {},
pickFolder: () => {}, pickFolder: () => {},
pickArchive: () => {}, pickArchive: () => {},
focusEditor: () => {}, focusEditor: () => {},
@@ -24,7 +24,7 @@ export const hooks = {
} }
/** Help groups the command list under these headings, in this order. */ /** Help groups the command list under these headings, in this order. */
export const groups = ['tree', 'board', 'documents', 'editing', 'sharing', 'view'] as const export const groups = ['tree', 'board', 'documents', 'editing', 'sharing', 'vault', 'view'] as const
export type Group = (typeof groups)[number] export type Group = (typeof groups)[number]
export interface CommandSpec { export interface CommandSpec {
@@ -62,6 +62,29 @@ const findFolder = (target: string) => {
) )
} }
/** `:encrypt` and `:decrypt`: a file, or everything under a folder. */
const sealTarget = (encrypt: boolean) =>
workspace.guard(async () => {
const node = workspace.target
if (!node || node.kind === 'document') {
return workspace.say('only files go into the vault — put the cursor on a file or a folder', 'error')
}
if (node.file && node.file.encrypted === encrypt) {
return workspace.say(encrypt ? 'already in the vault' : 'not in the vault')
}
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
} 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
workspace.say(encrypt ? 'encrypting …' : 'decrypting …')
await workspace.seal(node.id, encrypt)
})
/** `3 minutes ago`, or `never`. Backups are read at a glance, not to the second. */ /** `3 minutes ago`, or `never`. Backups are read at a glance, not to the second. */
const ago = (at: number | null) => { const ago = (at: number | null) => {
if (!at) return 'never' if (!at) return 'never'
@@ -622,7 +645,14 @@ export const commands: CommandSpec[] = [
aliases: ['up'], aliases: ['up'],
group: 'editing', group: 'editing',
help: 'while writing, attach files at the cursor — otherwise put files into this folder', help: 'while writing, attach files at the cursor — otherwise put files into this folder',
run: () => (workspace.editing ? hooks.pickFiles() : hooks.pickTreeFiles()), run: () => (workspace.editing ? hooks.pickFiles() : hooks.pickTreeFiles(false)),
bang: {
help: 'put files into this folder sealed in the vault — encrypted before they touch the disk',
run: () =>
workspace.guard(async () => {
if (await workspace.ensureUnlocked()) hooks.pickTreeFiles(true)
}),
},
}, },
{ {
name: 'media', name: 'media',
@@ -753,6 +783,80 @@ export const commands: CommandSpec[] = [
workspace.say('usage: :sshkey [add <name> <key> | rm <name>]', 'error') workspace.say('usage: :sshkey [add <name> <key> | rm <name>]', 'error')
}), }),
}, },
{
name: 'vault',
group: 'vault',
help: 'whether the vault is open — or, the first time, set its password',
run: () =>
workspace.guard(async () => {
await workspace.loadVault()
const vault = workspace.vault
if (vault?.configured) {
const left = vault.locks_in ? ` · locks in ${Math.ceil(vault.locks_in / 60)}m unless used` : ''
return workspace.say(vault.unlocked ? `vault open${left} — :lock closes it` : 'vault locked — :unlock opens it')
}
const password = await workspace.askSecret('new vault password, 10+ characters — it cannot be recovered')
if (password === null) return
const again = await workspace.askSecret('the same again')
if (again === null) return
if (again !== password) return workspace.say('the two passwords differ — nothing was set', 'error')
workspace.vault = await api.setupVault(password)
workspace.say('vault ready and open — :encrypt seals what is under the cursor, :upload! seals new files')
}),
},
{
name: 'unlock',
group: 'vault',
help: 'open the vault with its password, so sealed files can be read',
run: () =>
workspace.guard(async () => {
await workspace.loadVault()
if (workspace.vault?.unlocked) return workspace.say('the vault is already open')
await workspace.ensureUnlocked()
}),
},
{
name: 'lock',
group: 'vault',
help: 'close the vault now — it also closes itself when left alone, and on logout',
run: () =>
workspace.guard(async () => {
workspace.vault = await api.lockVault()
if (workspace.open?.file?.encrypted) workspace.open = await api.getNode(workspace.open.id)
workspace.say('vault locked')
}),
},
{
name: 'passwd',
group: 'vault',
help: "change the vault's password — no file is rewritten",
run: () =>
workspace.guard(async () => {
const current = await workspace.askSecret('current vault password')
if (current === null) return
const password = await workspace.askSecret('new vault password, 10+ characters')
if (password === null) return
const again = await workspace.askSecret('the same again')
if (again === null) return
if (again !== password) return workspace.say('the two passwords differ — nothing was changed', 'error')
workspace.vault = await api.changeVaultPassword(current, password)
workspace.say('vault password changed')
}),
},
{
name: 'encrypt',
aliases: ['seal'],
group: 'vault',
help: 'seal the file under the cursor in the vault — on a folder, every file inside it',
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',
run: () => sealTarget(false),
},
{ {
name: 'help', name: 'help',
aliases: ['h'], aliases: ['h'],
+62 -3
View File
@@ -11,6 +11,7 @@ import {
type MediaFile, type MediaFile,
type NodeSummary, type NodeSummary,
type SpaceOverview, type SpaceOverview,
type VaultStatus,
} from './api' } from './api'
import { formatBytes } from './util' import { formatBytes } from './util'
@@ -120,6 +121,10 @@ class Workspace {
* keyboard without a modal the rest of the page cannot see past. * keyboard without a modal the rest of the page cannot see past.
*/ */
prompt = $state<{ text: string; resolve: (yes: boolean) => void } | null>(null) prompt = $state<{ text: string; resolve: (yes: boolean) => void } | null>(null)
/** A password asked for in the same place, typed into a masked field. */
secret = $state<{ text: string; resolve: (value: string | null) => void } | null>(null)
/** The vault, as last heard from the server; null until asked. */
vault = $state<VaultStatus | null>(null)
media = $state<MediaFile[]>([]) media = $state<MediaFile[]>([])
overview = $state<SpaceOverview[]>([]) overview = $state<SpaceOverview[]>([])
overviewCursor = $state(0) overviewCursor = $state(0)
@@ -198,6 +203,53 @@ class Workspace {
return { late: overdue.cards.length, today: today.cards.length } return { late: overdue.cards.length, today: today.cards.length }
} }
/** A password, or null when it was waved away with Escape. */
askSecret(text: string): Promise<string | null> {
this.secret?.resolve(null)
return new Promise((resolve) => {
this.secret = {
text,
resolve: (value) => {
this.secret = null
resolve(value)
},
}
})
}
async loadVault() {
this.vault = await api.vaultStatus()
}
/**
* Whatever needs the vault asks for the password itself rather than failing
* with "locked": opening it is the obvious next step anyway. False when
* there is no vault, or the question was declined.
*/
async ensureUnlocked(): Promise<boolean> {
await this.loadVault()
if (!this.vault?.configured) {
this.say('there is no vault yet — :vault sets one up', 'error')
return false
}
if (this.vault.unlocked) return true
const password = await this.askSecret('vault password')
if (password === null) return false
this.vault = await api.unlockVault(password)
this.say('vault unlocked')
return true
}
/** Seal what is under the cursor, or take it back out, and show the result. */
async seal(id: number, encrypt: boolean) {
const { files, unchanged } = await api.seal(id, encrypt)
const verb = encrypt ? 'encrypted' : 'decrypted'
const already = unchanged > 0 ? `, ${unchanged} already were` : ''
this.say(`${verb} ${files} file${files === 1 ? '' : 's'}${already}`)
await this.refresh()
if (this.open?.kind === 'file') this.open = await api.getNode(this.open.id)
}
ask(text: string): Promise<boolean> { ask(text: string): Promise<boolean> {
this.prompt?.resolve(false) this.prompt?.resolve(false)
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -324,6 +376,7 @@ class Workspace {
this.user = user this.user = user
await this.loadSpaces(Workspace.addressedPath()) await this.loadSpaces(Workspace.addressedPath())
await this.loadAgenda() await this.loadAgenda()
await this.loadVault()
await this.openAgendaIfDue() await this.openAgendaIfDue()
} catch { } catch {
this.user = null this.user = null
@@ -337,12 +390,14 @@ class Workspace {
this.user = user this.user = user
await this.loadSpaces(Workspace.addressedPath()) await this.loadSpaces(Workspace.addressedPath())
await this.loadAgenda() await this.loadAgenda()
await this.loadVault()
await this.openAgendaIfDue() await this.openAgendaIfDue()
} }
async logout() { async logout() {
await api.logout() await api.logout()
this.user = null this.user = null
this.vault = null
this.spaces = [] this.spaces = []
this.nodes = [] this.nodes = []
this.open = null this.open = null
@@ -1097,14 +1152,14 @@ class Workspace {
* Upload files into the folder being browsed, as nodes of their own. The * Upload files into the folder being browsed, as nodes of their own. The
* status line counts up while they go, because a large one takes a while. * status line counts up while they go, because a large one takes a while.
*/ */
async putFiles(files: File[]) { async putFiles(files: File[], encrypt = false) {
const parent = this.parentId const parent = this.parentId
if (parent === null) return if (parent === null) return
const total = files.reduce((sum, file) => sum + file.size, 0) const total = files.reduce((sum, file) => sum + file.size, 0)
const what = files.length === 1 ? files[0].name : `${files.length} files` const what = files.length === 1 ? files[0].name : `${files.length} files`
this.say(`uploading ${what} (${formatBytes(total)}) …`) this.say(`uploading ${what} (${formatBytes(total)})${encrypt ? ' into the vault' : ''} …`)
const created = await api.putFiles(parent, files, (fraction) => const created = await api.putFiles(parent, files, encrypt, (fraction) =>
this.say(`uploading ${what} — ${Math.floor(fraction * 100)}%`), this.say(`uploading ${what} — ${Math.floor(fraction * 100)}%`),
) )
this.query = '' this.query = ''
@@ -1141,6 +1196,10 @@ class Workspace {
if (error instanceof ApiError && error.status === 401) { if (error instanceof ApiError && error.status === 401) {
this.user = null this.user = null
this.say('session expired — log in again', 'error') this.say('session expired — log in again', 'error')
} else if (error instanceof ApiError && error.status === 423) {
// It locked itself while nobody was looking.
if (this.vault) this.vault = { ...this.vault, unlocked: false, locks_in: null }
this.say(error.message, 'error')
} else { } else {
this.say(error instanceof Error ? error.message : String(error), 'error') this.say(error instanceof Error ? error.message : String(error), 'error')
} }