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
+1569 -50

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.
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"
version = "0.1.0"
dependencies = [
"aes-gcm 0.10.3",
"anyhow",
"argon2 0.5.3",
"axum",
"base64 0.22.1",
"chrono",
"chrono-tz",
"dotenvy",
"futures-util",
"hex",
"hmac 0.12.1",
"keyring",
@@ -2776,6 +2779,7 @@ dependencies = [
"tracing",
"tracing-subscriber",
"uuid",
"zeroize",
"zip",
]
+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-tz = "0.10"
# The vault: files encrypted at rest under a key only a password unlocks.
# AES-256-GCM in the STREAM construction, so a file of any size is sealed and
# opened a chunk at a time; Argon2id between the password and the key.
aes-gcm = { version = "0.10", features = ["stream"] }
argon2 = "0.5"
zeroize = "1"
futures-util = { version = "0.3", default-features = false, features = ["std"] }
proton-drive-rs = "0.6.2"
proton-sdk = "0.6.2"
rpassword = "7"
+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
notes about them, streamed to disk however large.
- Share any document behind an unguessable link — revocable at any time.
- A vault: files sealed with AES-256-GCM under a password that is never stored,
readable only while it is unlocked.
- Backs itself up to Proton Drive, end-to-end encrypted, as a registered device.
- Mails you — a reminder when the diary is empty, due cards, a weekly digest.
- Real vim keybindings on the desktop (CodeMirror 6 + vim mode), plain taps on
@@ -174,6 +176,48 @@ limit at least that large too.
The SFTP mount does not show files yet; a folder that holds only files lists
as empty there.
## The vault
Some files deserve more than the login: scans of documents, contracts, health
records. The vault encrypts them at rest under a password of their own, so
they stay unreadable to someone who has the data directory or the Proton
account the mirror writes to, and to a logged-in browser while the vault is
shut — to anyone, in short, who does not know the vault password.
`:vault` sets the password the first time (ten characters at least) and says
whether the vault is open afterwards. `:upload!` puts files into the folder
being browsed sealed — encrypted as they stream in, so the plaintext never
touches the disk. `:encrypt` seals the file under the cursor, or every file in
the folder under it; `:decrypt` takes them back out. `:unlock` and `:lock` open
and close it, `:passwd` changes the password. A sealed file carries a lock in
the list, and the bar says `vault open` while it is.
The vault closes itself after `WORKSPACE_VAULT_IDLE_MIN` minutes of nobody
using it (15 by default), on logout, and whenever the server restarts. While
it is closed a sealed file shows as sealed and nothing more; a share link never
serves one, open or not; and `:export!` refuses until it is open, because an
archive meant to be read without this application has to hold the files
opened.
How it works: Argon2id turns the password into a key that seals a random
master key, and only that sealed copy is stored. Each file gets a random key of
its own, sealed under the master key in the file's header together with its
id, and its bytes are AES-256-GCM in 64 KiB records, the last one marked as
last — so a file that is swapped, cut short or tampered with refuses to open.
Changing the password re-seals the master key and rewrites no file.
**There is no recovery.** Forget the vault password and every sealed file is
gone — from the server and from every backup — which is exactly what makes it
worth having. The password is not in `.env`, not in `diary.db`, and not in the
mirror.
Sealing a file that already exists writes it anew under a new id, rewrites the
links of documents that embed it, and deletes the plaintext from the disk. The
mirror then deletes the old copy from Proton Drive outright — trash and all —
whatever `WORKSPACE_BACKUP_PRUNE` says, and takes the file out of the readable
`files/` view. The sealed copy goes up to `data/uploads/` like any other.
Proton cannot read either; only the vault password can read the sealed one.
## Importing a folder
`:import` picks a folder and uploads it whole — every file carries the path it
+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,
max_upload_bytes: 1,
secure_cookie: false,
vault_idle: std::time::Duration::from_secs(60),
backup: BackupConfig {
device_name: "narl-diary".into(),
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
of diary.db, so an accident that was mirrored can still be undone.
Files sealed in the workspace's vault are in uploads/ as ciphertext. A
restore brings them back exactly as they were; opening them takes the vault
password, which is in nobody's keeping but its owner's — not in diary.db, not
here.
The files/ folder next to data/ holds the same uploaded files again, laid out
by space, folder and name the way the workspace shows them, so they can be
opened from Proton Drive directly. It is a view kept in step with the
@@ -90,6 +95,7 @@ pub async fn run(
mirror.database(config, data_folder).await?;
mirror.media(config, uploads_folder).await?;
mirror.retire().await?;
if config.backup.prune {
mirror.prune().await?;
}
@@ -149,21 +155,23 @@ impl Mirror<'_> {
.map(|row| (row.get("id"), row.get("size")))
.collect();
for (id, size) in media {
for (id, _) in media {
let key = format!("uploads/{id}");
if let Some(tracked) = self.tracked(&key).await? {
if tracked.size == size {
self.summary.skipped += 1;
continue;
}
}
let path = config.uploads_dir().join(&id);
if !path.exists() {
// The size on disk, not the one in the row: a sealed file is larger
// than what was uploaded, and comparing against that would re-hash
// it on every run.
let Ok(metadata) = tokio::fs::metadata(&path).await else {
// A row whose file has gone missing is a local inconsistency,
// not a reason to abandon the rest of the backup.
tracing::warn!(media = %id, "media row has no file on disk; skipping");
continue;
};
if let Some(tracked) = self.tracked(&key).await? {
if tracked.size == metadata.len() as i64 {
self.summary.skipped += 1;
continue;
}
}
self.file(uploads_folder, &key, &id, "application/octet-stream", &path)
@@ -173,6 +181,40 @@ impl Mirror<'_> {
Ok(())
}
/// Take the plaintext of files that have since been sealed off Drive for
/// good: into the trash and straight out of it, whatever
/// `WORKSPACE_BACKUP_PRUNE` says. A sealed file whose old bytes stay on
/// Drive was never sealed for anyone who can read the mirror.
async fn retire(&mut self) -> Result<()> {
let retired: Vec<(String, Option<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.
async fn prune(&mut self) -> Result<()> {
let orphans: Vec<(String, String)> = sqlx::query(
@@ -363,6 +405,21 @@ struct Tracked {
size: i64,
}
/// Trash a node and then delete it from the trash, so nothing of it is left
/// to restore. A node already gone counts as done.
pub(crate) async fn purge(client: &ProtonDriveClient, uid: &NodeUid) -> Result<()> {
if client.get_node(uid).await?.is_none() {
return Ok(());
}
for (_, result) in client.trash_nodes(std::slice::from_ref(uid)).await? {
result?;
}
for (_, result) in client.delete_nodes(std::slice::from_ref(uid)).await? {
result?;
}
Ok(())
}
/// `<volume>~<link>`, the form [`NodeUid`] prints. Anything else is a row this
/// build did not write and is treated as untracked rather than trusted.
pub(crate) fn parse_uid(text: &str) -> Option<NodeUid> {
+22 -1
View File
@@ -174,8 +174,12 @@ pub async fn run(
root: &NodeUid,
summary: &mut Summary,
) -> Result<()> {
// A sealed file has no place in a view meant to be opened by name from
// anywhere: it is left out, so it leaves the view when it is sealed.
let tree: Vec<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",
)
.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 };
// What is gone, or is no longer the same bytes, goes first: it frees the
@@ -229,7 +241,16 @@ pub async fn run(
.collect();
for id in stale {
if let Some(gone) = tracked.remove(&id) {
// 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?;
}
}
+12
View File
@@ -16,6 +16,8 @@ pub struct Config {
pub session_days: i64,
pub max_upload_bytes: usize,
pub secure_cookie: bool,
/// How long the vault stays open with nothing using it.
pub vault_idle: std::time::Duration,
pub backup: BackupConfig,
pub mail: MailConfig,
pub sftp: SftpConfig,
@@ -198,6 +200,15 @@ impl Config {
* 1024
* 1024;
let vault_idle = std::time::Duration::from_secs(
var("VAULT_IDLE_MIN")
.map(|v| v.parse::<u64>())
.transpose()
.context("WORKSPACE_VAULT_IDLE_MIN must be a number")?
.unwrap_or(15)
* 60,
);
let secure_cookie = var("SECURE_COOKIE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
@@ -211,6 +222,7 @@ impl Config {
session_days,
max_upload_bytes,
secure_cookie,
vault_idle,
backup: BackupConfig::from_env()?,
mail: MailConfig::from_env()?,
sftp: SftpConfig::from_env()?,
+6
View File
@@ -12,6 +12,8 @@ pub enum AppError {
BadRequest(String),
/// The caller must wait this many seconds before trying again.
TooManyRequests(i64),
/// The file is sealed and the vault is closed.
Locked,
Internal(anyhow::Error),
}
@@ -23,6 +25,10 @@ impl IntoResponse for AppError {
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".to_string()),
AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
AppError::Locked => (
StatusCode::LOCKED,
"the vault is locked — :unlock opens it".to_string(),
),
AppError::TooManyRequests(wait) => (
StatusCode::TOO_MANY_REQUESTS,
format!("too many attempts — wait {wait}s"),
+3
View File
@@ -10,6 +10,7 @@ mod sftp;
mod state;
mod static_files;
mod throttle;
mod vault;
use std::sync::Arc;
@@ -110,9 +111,11 @@ async fn main() -> Result<()> {
notify::spawn(Arc::clone(&notify));
let state = AppState {
vault: vault::Vault::new(db.clone(), config.vault_idle),
db,
config: Arc::clone(&config),
login_throttle: Arc::default(),
vault_throttle: Arc::default(),
backup,
notify,
};
+34 -7
View File
@@ -13,7 +13,11 @@ use sqlx::Row;
use tokio_util::io::ReaderStream;
use zip::{write::SimpleFileOptions, CompressionMethod, ZipWriter};
use crate::{auth::Session, error::AppResult, state::AppState};
use crate::{
auth::Session,
error::{AppError, AppResult},
state::AppState,
};
/// One node, flattened out of the database ahead of the blocking zip work.
struct ExportNode {
@@ -159,12 +163,27 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
})
.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)
.await?
.into_iter()
.await?;
let media: Vec<(String, String)> = rows
.iter()
.map(|row| (row.get("id"), row.get("filename")))
.collect();
let sealed: std::collections::HashSet<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();
@@ -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())));
for (id, entry) in entries {
// 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 {
continue;
let path = uploads.join(id);
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)?;
std::io::copy(&mut source, &mut zip)?;
std::io::copy(&mut { source }, &mut zip)?;
}
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
// dotted name no media row can carry, and goes once it is unpacked.
let id = format!(".import-{}", Uuid::new_v4());
let size = media::store_field(state, &mut field, &id, &mut budget).await?;
let size = media::store_field(state, &mut field, &id, &mut budget, None).await?;
if size == 0 {
skipped.push(name);
continue;
@@ -309,7 +309,7 @@ async fn collect(
let mime = media::mime_of(&path, field.content_type());
let id = Uuid::new_v4().to_string();
let size = media::store_field(state, &mut field, &id, &mut budget).await?;
let size = media::store_field(state, &mut field, &id, &mut budget, None).await?;
if size == 0 {
skipped.push(path);
continue;
+92 -13
View File
@@ -2,13 +2,13 @@ use std::{collections::BTreeSet, sync::LazyLock};
use axum::{
body::Body,
extract::{multipart::Field, Multipart, Path, State},
extract::{multipart::Field, Multipart, Path, Query, State},
http::header,
response::{IntoResponse, Response},
Json,
};
use regex::Regex;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use sqlx::{Row, SqlitePool};
use tokio::io::AsyncWriteExt;
use tokio_util::io::ReaderStream;
@@ -19,6 +19,7 @@ use crate::{
error::{AppError, AppResult},
routes::nodes,
state::AppState,
vault,
};
/// Media referenced from a document body always looks like `/api/media/<uuid>`,
@@ -69,6 +70,8 @@ pub struct Media {
pub size: i64,
pub created_at: i64,
pub url: String,
/// Sealed in the vault: its bytes on disk are ciphertext.
pub encrypted: bool,
/// Every document that embeds this file, which may be none or several.
pub node_ids: Vec<i64>,
}
@@ -91,6 +94,7 @@ fn row_to_media(row: &sqlx::sqlite::SqliteRow) -> Media {
mime: row.get("mime"),
size: row.get("size"),
created_at: row.get("created_at"),
encrypted: row.try_get("encrypted").unwrap_or(false),
node_ids,
}
}
@@ -118,17 +122,29 @@ pub(crate) fn mime_of(filename: &str, declared: Option<&str>) -> String {
/// field has arrived whole, so a dropped connection never leaves a truncated
/// file under a name the database might come to know. Returns the size; an
/// empty field writes nothing and returns 0.
///
/// With `seal`, the bytes are encrypted as they pass, so the plaintext never
/// touches the disk at all; the size returned is still the plaintext's.
pub(crate) async fn store_field(
state: &AppState,
field: &mut Field<'_>,
id: &str,
budget: &mut u64,
seal: Option<&vault::Key>,
) -> AppResult<u64> {
let path = state.config.uploads_dir().join(id);
let partial = path.with_extension("part");
let result = async {
let mut file = tokio::fs::File::create(&partial).await?;
let mut sealer = match seal {
Some(key) => {
let (sealer, header) = vault::Sealer::new(key, id)?;
file.write_all(&header).await?;
Some(sealer)
}
None => None,
};
let mut size = 0u64;
while let Some(chunk) = field
.chunk()
@@ -139,7 +155,13 @@ pub(crate) async fn store_field(
if size > *budget {
return Err(too_large(state));
}
file.write_all(&chunk).await?;
match sealer.as_mut() {
Some(sealer) => file.write_all(&sealer.push(&chunk)?).await?,
None => file.write_all(&chunk).await?,
}
}
if let Some(sealer) = sealer {
file.write_all(&sealer.finish()?).await?;
}
file.flush().await?;
Ok(size)
@@ -186,7 +208,7 @@ pub async fn upload(
let mime = mime_of(&filename, field.content_type());
let id = Uuid::new_v4().to_string();
let size = store_field(&state, &mut field, &id, &mut budget).await?;
let size = store_field(&state, &mut field, &id, &mut budget, None).await?;
if size == 0 {
continue;
}
@@ -214,15 +236,29 @@ pub async fn upload(
Ok(Json(uploaded))
}
#[derive(Deserialize)]
pub struct UploadQuery {
/// Seal the files into the vault as they arrive.
#[serde(default)]
pub encrypt: bool,
}
/// `POST /api/nodes/{id}/files` — every file in the body becomes a file node
/// in that space or folder, under the name it was uploaded with.
/// `?encrypt=true` seals them on the way in, which needs the vault open.
pub async fn upload_into(
_: Session,
State(state): State<AppState>,
Path(parent): Path<i64>,
Query(query): Query<UploadQuery>,
mut multipart: Multipart,
) -> AppResult<Json<Vec<nodes::Node>>> {
nodes::container(&state.db, parent).await?;
let key = if query.encrypt {
Some(state.vault.key().ok_or(AppError::Locked)?)
} else {
None
};
let mut created = Vec::new();
let mut budget = state.config.max_upload_bytes as u64;
@@ -242,12 +278,12 @@ pub async fn upload_into(
let mime = mime_of(&filename, field.content_type());
let id = Uuid::new_v4().to_string();
let size = store_field(&state, &mut field, &id, &mut budget).await?;
let size = store_field(&state, &mut field, &id, &mut budget, key.as_deref()).await?;
if size == 0 {
continue;
}
match insert_file(&state.db, parent, &id, &filename, &mime, size).await {
match insert_file(&state.db, parent, &id, &filename, &mime, size, key.is_some()).await {
Ok(node) => created.push(node),
Err(e) => {
let _ = tokio::fs::remove_file(state.config.uploads_dir().join(&id)).await;
@@ -277,19 +313,22 @@ async fn insert_file(
filename: &str,
mime: &str,
size: u64,
encrypted: bool,
) -> AppResult<i64> {
let slug = nodes::unique_slug(db, Some(parent), &nodes::slugify(filename), None).await?;
let now = crate::now();
let mut tx = db.begin().await?;
sqlx::query(
"INSERT INTO media (id, filename, mime, size, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
"INSERT INTO media (id, filename, mime, size, created_at, encrypted)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
)
.bind(media_id)
.bind(filename)
.bind(mime)
.bind(size as i64)
.bind(now)
.bind(encrypted)
.execute(&mut *tx)
.await?;
@@ -319,7 +358,7 @@ async fn insert_file(
pub async fn list(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<Media>>> {
let rows = sqlx::query(
"SELECT m.id, m.filename, m.mime, m.size, m.created_at,
"SELECT m.id, m.filename, m.mime, m.size, m.created_at, m.encrypted,
(SELECT group_concat(nm.node_id)
FROM node_media nm WHERE nm.media_id = m.id) AS node_ids
FROM media m ORDER BY m.created_at DESC LIMIT 500",
@@ -334,7 +373,7 @@ pub async fn serve(
State(state): State<AppState>,
Path(id): Path<String>,
) -> AppResult<Response> {
stream_media(&state, &id).await
stream_media(&state, &id, true).await
}
pub async fn remove(
@@ -347,9 +386,11 @@ pub async fn remove(
Ok(Json(serde_json::json!({ "ok": true })))
}
/// Shared by the private and the public-share media handlers.
pub async fn stream_media(state: &AppState, id: &str) -> AppResult<Response> {
let row = sqlx::query("SELECT mime, filename, size FROM media WHERE id = ?1")
/// Shared by the private and the public-share media handlers. A sealed file
/// is only ever served to the owner, and only while the vault is open — a
/// share link is a door for strangers, and the vault is not behind it.
pub async fn stream_media(state: &AppState, id: &str, private: bool) -> AppResult<Response> {
let row = sqlx::query("SELECT mime, filename, size, encrypted FROM media WHERE id = ?1")
.bind(id)
.fetch_optional(&state.db)
.await?
@@ -357,14 +398,25 @@ pub async fn stream_media(state: &AppState, id: &str) -> AppResult<Response> {
let filename: String = row.get("filename");
let size: i64 = row.get("size");
let encrypted: bool = row.get("encrypted");
// Re-checked on the way out, not just on the way in, so rows written before
// the allowlist existed cannot be served as something executable either.
let mime = sanitize_mime(&row.get::<String, _>("mime"));
let disposition = if is_inline(&mime) { "inline" } else { "attachment" };
let key = match (encrypted, private) {
(false, _) => None,
(true, false) => return Err(AppError::NotFound),
(true, true) => Some(state.vault.key().ok_or(AppError::Locked)?),
};
let file = tokio::fs::File::open(state.config.uploads_dir().join(id))
.await
.map_err(|_| AppError::NotFound)?;
let body = match key {
None => Body::from_stream(ReaderStream::new(file)),
Some(key) => Body::from_stream(open_stream(file, &key, id).await?),
};
Ok((
[
@@ -386,11 +438,38 @@ pub async fn stream_media(state: &AppState, id: &str) -> AppResult<Response> {
"sandbox; default-src 'none'".to_string(),
),
],
Body::from_stream(ReaderStream::new(file)),
body,
)
.into_response())
}
/// A sealed file as a stream of its plaintext, a record at a time. The
/// header is checked before the first byte goes out, so a file that will not
/// open is an error response rather than a download that breaks off.
async fn open_stream(
mut file: tokio::fs::File,
key: &vault::Key,
id: &str,
) -> AppResult<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<()> {
sqlx::query("DELETE FROM media WHERE id = ?1")
.bind(id)
+7
View File
@@ -4,9 +4,11 @@ pub mod nodes;
pub mod export;
pub mod import;
pub mod media;
pub mod seal;
pub mod session;
pub mod share;
pub mod ssh_keys;
pub mod vault;
use axum::{
routing::{delete, get, post, put},
@@ -39,6 +41,11 @@ pub fn api_router() -> Router<AppState> {
.route("/nodes/{id}/move", post(nodes::move_node))
.route("/nodes/{id}/import", post(import::import))
.route("/nodes/{id}/files", post(media::upload_into))
.route("/nodes/{id}/encrypt", post(seal::encrypt))
.route("/nodes/{id}/decrypt", post(seal::decrypt))
.route("/vault", get(vault::status).post(vault::setup).put(vault::change_password))
.route("/vault/unlock", post(vault::unlock))
.route("/vault/lock", post(vault::lock))
.route("/nodes/{id}/share", post(share::enable))
.route("/nodes/{id}/share", delete(share::disable))
.route("/media", post(media::upload).get(media::list))
+5 -1
View File
@@ -35,6 +35,8 @@ pub struct FileInfo {
pub mime: String,
pub size: i64,
pub url: String,
/// Sealed in the vault: it opens only while the vault does.
pub encrypted: bool,
}
#[derive(Serialize)]
@@ -158,7 +160,8 @@ const SUMMARY_COLUMNS: &str = "n.id, n.parent_id, n.kind, n.name, n.slug, n.posi
n.created_at, n.updated_at, n.share_token, n.media_id,
(SELECT COUNT(*) FROM nodes c WHERE c.parent_id = n.id) AS child_count,
(SELECT m.mime FROM media m WHERE m.id = n.media_id) AS file_mime,
(SELECT m.size FROM media m WHERE m.id = n.media_id) AS file_size";
(SELECT m.size FROM media m WHERE m.id = n.media_id) AS file_size,
(SELECT m.encrypted FROM media m WHERE m.id = n.media_id) AS file_encrypted";
/// A short, plain-ish preview of the body for the list pane. `head` is only the
/// leading `HEAD_CHARS` of the document, so truncation is inferred from its
@@ -286,6 +289,7 @@ fn row_to_summary(row: &sqlx::sqlite::SqliteRow, head: &str) -> NodeSummary {
media_id,
mime: row.get::<Option<String>, _>("file_mime").unwrap_or_default(),
size: row.get::<Option<i64>, _>("file_size").unwrap_or_default(),
encrypted: row.get::<Option<bool>, _>("file_encrypted").unwrap_or_default(),
});
NodeSummary {
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 {
// Leaving is the moment the vault should not outlive.
state.vault.lock();
(
StatusCode::OK,
[(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) {
return Err(AppError::NotFound);
}
media::stream_media(&state, &id).await
media::stream_media(&state, &id, false).await
}
#[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 crate::{backup::Backup, config::Config, notify::Notifier, throttle::LoginThrottle};
use crate::{backup::Backup, config::Config, notify::Notifier, throttle::LoginThrottle, vault::Vault};
#[derive(Clone)]
pub struct AppState {
pub db: SqlitePool,
pub config: Arc<Config>,
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.
pub backup: Arc<Backup>,
/// 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 MediaBrowser from './lib/MediaBrowser.svelte'
import Prompt from './lib/Prompt.svelte'
import SecretPrompt from './lib/SecretPrompt.svelte'
import Rail from './lib/Rail.svelte'
import Shared from './lib/Shared.svelte'
import Sidebar from './lib/Sidebar.svelte'
@@ -31,6 +32,8 @@
let cmdGlobal = $state(false)
let fileInput = $state<HTMLInputElement>()
let treeInput = $state<HTMLInputElement>()
/** Whether the files being picked go into the vault — `:upload!`. */
let sealUpload = false
let folderInput = $state<HTMLInputElement>()
let importInput = $state<HTMLInputElement>()
let shell = $state<HTMLDivElement>()
@@ -45,7 +48,10 @@
$effect(() => {
hooks.pickFiles = () => fileInput?.click()
hooks.pickTreeFiles = () => treeInput?.click()
hooks.pickTreeFiles = (encrypt) => {
sealUpload = encrypt
treeInput?.click()
}
hooks.pickFolder = () => folderInput?.click()
hooks.pickArchive = () => importInput?.click()
hooks.focusList = () => shell?.focus()
@@ -92,7 +98,10 @@
$effect(() => {
if (!workspace.user) return
const refresh = () => {
if (document.visibilityState === 'visible') void workspace.loadAgenda().then(() => workspace.openAgendaIfDue())
if (document.visibilityState !== 'visible') return
void workspace.loadAgenda().then(() => workspace.openAgendaIfDue())
// The vault locks itself when left alone; the bar should say so.
void workspace.loadVault()
}
const timer = setInterval(refresh, 5 * 60 * 1000)
document.addEventListener('visibilitychange', refresh)
@@ -102,6 +111,15 @@
}
})
// The vault closes itself after a quiet spell; look again just after it
// would have, so the bar does not go on saying it is open.
$effect(() => {
const vault = workspace.vault
if (!vault?.unlocked || vault.locks_in === null) return
const timer = setTimeout(() => void workspace.loadVault(), (vault.locks_in + 2) * 1000)
return () => clearTimeout(timer)
})
// Guard against losing a half-written entry to a stray refresh.
$effect(() => {
if (!workspace.dirty) return
@@ -126,7 +144,7 @@
const files = Array.from(input.files ?? [])
input.value = ''
if (files.length === 0) return
await workspace.guard(() => workspace.putFiles(files))
await workspace.guard(() => workspace.putFiles(files, sealUpload))
}
/*
@@ -687,6 +705,7 @@
{/if}
<Prompt />
<SecretPrompt />
{#if workspace.overlay !== 'none'}
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
+24 -3
View File
@@ -6,6 +6,8 @@
const node = $derived(workspace.open!)
const file = $derived(node.file!)
/* A sealed file cannot be fetched while the vault is shut. */
const locked = $derived(file.encrypted && !workspace.vault?.unlocked)
const kind = $derived(file.mime.split('/')[0])
/* What the browser can show by itself; everything else is a download. */
const viewable = $derived(file.mime !== 'application/octet-stream')
@@ -18,7 +20,7 @@
let text = $state<string | null>(null)
$effect(() => {
text = null
if (file.mime !== 'text/plain') return
if (file.mime !== 'text/plain' || locked) return
const url = file.url
void head(url, TEXT_PREVIEW).then((body) => {
if (body !== null && url === file.url) text = body
@@ -77,13 +79,24 @@
<dd>{file.mime}</dd>
<dt>uploaded</dt>
<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>
<div class="actions">
{#if locked}
<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}
<button onclick={() => runCommand(file.encrypted ? 'decrypt' : 'encrypt')}>
{file.encrypted ? 'decrypt' : 'encrypt'}
</button>
<button class="danger" onclick={() => runCommand('d')}>delete</button>
<button class="close" onclick={() => runCommand('q')}>close</button>
</div>
@@ -92,8 +105,13 @@
<div class="body" bind:this={reader}>
<h1 class="name">{node.name}</h1>
{#key file.url}
{#if kind === 'image'}
{#key `${file.url}:${locked}`}
{#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} />
{:else if kind === 'video'}
<!-- svelte-ignore a11y_media_has_caption -->
@@ -197,6 +215,9 @@
overflow-wrap: anywhere;
}
.light { color: var(--light); }
.sealed { display: flex; align-items: center; gap: 8px; color: var(--subtle); }
.back { display: none; }
@media (max-width: 767px) {
+1
View File
@@ -59,6 +59,7 @@
documents: 'documents',
editing: 'writing',
sharing: 'sharing & export',
vault: 'vault',
view: 'session',
}
+1
View File
@@ -18,6 +18,7 @@
shrink: ['M9 4v5H4', 'M15 4v5h5', 'M9 20v-5H4', 'M15 20v-5h5'],
search: ['M11 4a7 7 0 1 0 0 14a7 7 0 1 0 0-14z', 'M20 20l-4-4'],
prompt: ['M5 7l5 5-5 5', 'M12 18h7'],
lock: ['M6 11h12a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1z', 'M8 11V8a4 4 0 0 1 8 0v3'],
help: [
'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18z',
'M9.5 9.5a2.5 2.5 0 1 1 3.5 2.3c-.6.3-1 .9-1 1.6v.6',
+3 -1
View File
@@ -29,7 +29,9 @@
{#each workspace.media as file (file.id)}
<li>
<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" />
{:else}
<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">
import { isContainer, type NodeSummary } from './api'
import Icon from './Icon.svelte'
import { workspace } from './store.svelte'
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>
{#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.file?.encrypted}<span class="sealed" title="in the vault"><Icon name="lock" size={12} /></span>{/if}
<span class="aside">{aside(node)}</span>
</button>
</li>
@@ -215,6 +217,8 @@
border: 1.5px solid currentColor;
border-radius: 999px;
}
.sealed { flex: none; display: grid; color: var(--muted); }
.row.active .sealed { color: var(--ink); }
.empty { padding: 12px 10px; font-size: 12px; }
/* 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>
{#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}
<span class="hint">
{isTouchDevice ? 'tap : for commands' : '? help · : command · / search'}
+32 -2
View File
@@ -10,6 +10,8 @@ export interface FileInfo {
mime: string
size: number
url: string
/** Sealed in the vault: it opens only while the vault is unlocked. */
encrypted: boolean
}
export interface NodeSummary {
@@ -125,10 +127,19 @@ export interface MediaFile {
size: number
created_at: number
url: string
encrypted: boolean
/** Every document that embeds this file — a file may be used by several. */
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. */
export interface BackupStatus {
configured: boolean
@@ -285,12 +296,12 @@ export const api = {
* 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.
*/
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) => {
const form = new FormData()
for (const file of files) form.append('file', file)
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.upload.onprogress = (event) => {
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' }),
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'),
backupNow: () => request<BackupStatus>('/backup', { method: 'POST' }),
+107 -3
View File
@@ -8,7 +8,7 @@ export type { ScrollAmount }
export const hooks = {
pickFiles: () => {},
/** Files for the folder being browsed, rather than for the document. */
pickTreeFiles: () => {},
pickTreeFiles: (_encrypt: boolean) => {},
pickFolder: () => {},
pickArchive: () => {},
focusEditor: () => {},
@@ -24,7 +24,7 @@ export const hooks = {
}
/** 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 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. */
const ago = (at: number | null) => {
if (!at) return 'never'
@@ -622,7 +645,14 @@ export const commands: CommandSpec[] = [
aliases: ['up'],
group: 'editing',
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',
@@ -753,6 +783,80 @@ export const commands: CommandSpec[] = [
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',
aliases: ['h'],
+62 -3
View File
@@ -11,6 +11,7 @@ import {
type MediaFile,
type NodeSummary,
type SpaceOverview,
type VaultStatus,
} from './api'
import { formatBytes } from './util'
@@ -120,6 +121,10 @@ class Workspace {
* keyboard without a modal the rest of the page cannot see past.
*/
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[]>([])
overview = $state<SpaceOverview[]>([])
overviewCursor = $state(0)
@@ -198,6 +203,53 @@ class Workspace {
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> {
this.prompt?.resolve(false)
return new Promise((resolve) => {
@@ -324,6 +376,7 @@ class Workspace {
this.user = user
await this.loadSpaces(Workspace.addressedPath())
await this.loadAgenda()
await this.loadVault()
await this.openAgendaIfDue()
} catch {
this.user = null
@@ -337,12 +390,14 @@ class Workspace {
this.user = user
await this.loadSpaces(Workspace.addressedPath())
await this.loadAgenda()
await this.loadVault()
await this.openAgendaIfDue()
}
async logout() {
await api.logout()
this.user = null
this.vault = null
this.spaces = []
this.nodes = []
this.open = null
@@ -1097,14 +1152,14 @@ class Workspace {
* 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.
*/
async putFiles(files: File[]) {
async putFiles(files: File[], encrypt = false) {
const parent = this.parentId
if (parent === null) return
const total = files.reduce((sum, file) => sum + file.size, 0)
const what = files.length === 1 ? files[0].name : `${files.length} files`
this.say(`uploading ${what} (${formatBytes(total)}) …`)
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.query = ''
@@ -1141,6 +1196,10 @@ class Workspace {
if (error instanceof ApiError && error.status === 401) {
this.user = null
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 {
this.say(error instanceof Error ? error.message : String(error), 'error')
}