mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
364ba61963
commit
a641d9b6ba
33 files changed
+1569
-50
No files matched your search
@@ -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
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()?,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -10,6 +10,7 @@ mod sftp;
|
||||
mod state;
|
||||
mod static_files;
|
||||
mod throttle;
|
||||
mod vault;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -110,9 +111,11 @@ async fn main() -> Result<()> {
|
||||
notify::spawn(Arc::clone(¬ify));
|
||||
|
||||
let state = AppState {
|
||||
vault: vault::Vault::new(db.clone(), config.vault_idle),
|
||||
db,
|
||||
config: Arc::clone(&config),
|
||||
login_throttle: Arc::default(),
|
||||
vault_throttle: Arc::default(),
|
||||
backup,
|
||||
notify,
|
||||
};
|
||||
|
||||
+34
-7
@@ -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()?;
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 -->
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -59,6 +59,7 @@
|
||||
documents: 'documents',
|
||||
editing: 'writing',
|
||||
sharing: 'sharing & export',
|
||||
vault: 'vault',
|
||||
view: 'session',
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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
@@ -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'],
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user