vault: files sealed under a password that is never stored

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

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

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

No files matched your search

+1
View File
@@ -112,6 +112,7 @@ mod tests {
session_days,
max_upload_bytes: 1,
secure_cookie: false,
vault_idle: std::time::Duration::from_secs(60),
backup: BackupConfig {
device_name: "narl-diary".into(),
interval: None,
+66 -9
View File
@@ -54,6 +54,11 @@ consistent and carries no write-ahead log: it can be opened directly.
Each file above keeps its history — Proton Drive holds the previous revisions
of diary.db, so an accident that was mirrored can still be undone.
Files sealed in the workspace's vault are in uploads/ as ciphertext. A
restore brings them back exactly as they were; opening them takes the vault
password, which is in nobody's keeping but its owner's — not in diary.db, not
here.
The files/ folder next to data/ holds the same uploaded files again, laid out
by space, folder and name the way the workspace shows them, so they can be
opened from Proton Drive directly. It is a view kept in step with the
@@ -90,6 +95,7 @@ pub async fn run(
mirror.database(config, data_folder).await?;
mirror.media(config, uploads_folder).await?;
mirror.retire().await?;
if config.backup.prune {
mirror.prune().await?;
}
@@ -149,21 +155,23 @@ impl Mirror<'_> {
.map(|row| (row.get("id"), row.get("size")))
.collect();
for (id, size) in media {
for (id, _) in media {
let key = format!("uploads/{id}");
if let Some(tracked) = self.tracked(&key).await? {
if tracked.size == size {
self.summary.skipped += 1;
continue;
}
}
let path = config.uploads_dir().join(&id);
if !path.exists() {
// The size on disk, not the one in the row: a sealed file is larger
// than what was uploaded, and comparing against that would re-hash
// it on every run.
let Ok(metadata) = tokio::fs::metadata(&path).await else {
// A row whose file has gone missing is a local inconsistency,
// not a reason to abandon the rest of the backup.
tracing::warn!(media = %id, "media row has no file on disk; skipping");
continue;
};
if let Some(tracked) = self.tracked(&key).await? {
if tracked.size == metadata.len() as i64 {
self.summary.skipped += 1;
continue;
}
}
self.file(uploads_folder, &key, &id, "application/octet-stream", &path)
@@ -173,6 +181,40 @@ impl Mirror<'_> {
Ok(())
}
/// Take the plaintext of files that have since been sealed off Drive for
/// good: into the trash and straight out of it, whatever
/// `WORKSPACE_BACKUP_PRUNE` says. A sealed file whose old bytes stay on
/// Drive was never sealed for anyone who can read the mirror.
async fn retire(&mut self) -> Result<()> {
let retired: Vec<(String, Option<String>)> = sqlx::query(
"SELECT r.path, f.node_uid FROM backup_retired r
LEFT JOIN backup_files f ON f.path = r.path",
)
.fetch_all(self.db)
.await?
.into_iter()
.map(|row| (row.get("path"), row.get("node_uid")))
.collect();
for (path, uid) in retired {
if let Some(uid) = uid.as_deref().and_then(parse_uid) {
purge(self.client, &uid)
.await
.with_context(|| format!("could not remove {path} from Proton Drive"))?;
self.summary.pruned += 1;
}
sqlx::query("DELETE FROM backup_files WHERE path = ?")
.bind(&path)
.execute(self.db)
.await?;
sqlx::query("DELETE FROM backup_retired WHERE path = ?")
.bind(&path)
.execute(self.db)
.await?;
}
Ok(())
}
/// Trash remote files whose local rows are gone. Off unless asked for.
async fn prune(&mut self) -> Result<()> {
let orphans: Vec<(String, String)> = sqlx::query(
@@ -363,6 +405,21 @@ struct Tracked {
size: i64,
}
/// Trash a node and then delete it from the trash, so nothing of it is left
/// to restore. A node already gone counts as done.
pub(crate) async fn purge(client: &ProtonDriveClient, uid: &NodeUid) -> Result<()> {
if client.get_node(uid).await?.is_none() {
return Ok(());
}
for (_, result) in client.trash_nodes(std::slice::from_ref(uid)).await? {
result?;
}
for (_, result) in client.delete_nodes(std::slice::from_ref(uid)).await? {
result?;
}
Ok(())
}
/// `<volume>~<link>`, the form [`NodeUid`] prints. Anything else is a row this
/// build did not write and is treated as untracked rather than trusted.
pub(crate) fn parse_uid(text: &str) -> Option<NodeUid> {
+23 -2
View File
@@ -174,8 +174,12 @@ pub async fn run(
root: &NodeUid,
summary: &mut Summary,
) -> Result<()> {
// A sealed file has no place in a view meant to be opened by name from
// anywhere: it is left out, so it leaves the view when it is sealed.
let tree: Vec<TreeNode> = sqlx::query(
"SELECT n.id, n.parent_id, n.kind, n.name, n.slug, n.media_id, COALESCE(m.mime, '') AS mime
"SELECT n.id, n.parent_id, n.kind, n.name, n.slug,
CASE WHEN m.encrypted = 0 THEN n.media_id END AS media_id,
COALESCE(m.mime, '') AS mime
FROM nodes n LEFT JOIN media m ON m.id = n.media_id",
)
.fetch_all(db)
@@ -217,6 +221,14 @@ pub async fn run(
}
}
let sealed: HashSet<i64> = sqlx::query_scalar(
"SELECT n.id FROM nodes n JOIN media m ON m.id = n.media_id WHERE m.encrypted <> 0",
)
.fetch_all(db)
.await?
.into_iter()
.collect();
let mut view = View { db, config, client, root, dirs, summary };
// What is gone, or is no longer the same bytes, goes first: it frees the
@@ -229,7 +241,16 @@ pub async fn run(
.collect();
for id in stale {
if let Some(gone) = tracked.remove(&id) {
view.trash(&gone.uid).await;
// What was sealed since leaves no copy in Drive's trash either.
if sealed.contains(&id) {
if let Err(e) = super::sync::purge(client, &gone.uid).await {
tracing::warn!(file = %gone.path, error = %format!("{e:#}"), "could not purge a sealed file from files/");
continue;
}
view.summary.pruned += 1;
} else {
view.trash(&gone.uid).await;
}
forget(db, id).await?;
}
}
+12
View File
@@ -16,6 +16,8 @@ pub struct Config {
pub session_days: i64,
pub max_upload_bytes: usize,
pub secure_cookie: bool,
/// How long the vault stays open with nothing using it.
pub vault_idle: std::time::Duration,
pub backup: BackupConfig,
pub mail: MailConfig,
pub sftp: SftpConfig,
@@ -198,6 +200,15 @@ impl Config {
* 1024
* 1024;
let vault_idle = std::time::Duration::from_secs(
var("VAULT_IDLE_MIN")
.map(|v| v.parse::<u64>())
.transpose()
.context("WORKSPACE_VAULT_IDLE_MIN must be a number")?
.unwrap_or(15)
* 60,
);
let secure_cookie = var("SECURE_COOKIE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
@@ -211,6 +222,7 @@ impl Config {
session_days,
max_upload_bytes,
secure_cookie,
vault_idle,
backup: BackupConfig::from_env()?,
mail: MailConfig::from_env()?,
sftp: SftpConfig::from_env()?,
+6
View File
@@ -12,6 +12,8 @@ pub enum AppError {
BadRequest(String),
/// The caller must wait this many seconds before trying again.
TooManyRequests(i64),
/// The file is sealed and the vault is closed.
Locked,
Internal(anyhow::Error),
}
@@ -23,6 +25,10 @@ impl IntoResponse for AppError {
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".to_string()),
AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m),
AppError::Locked => (
StatusCode::LOCKED,
"the vault is locked — :unlock opens it".to_string(),
),
AppError::TooManyRequests(wait) => (
StatusCode::TOO_MANY_REQUESTS,
format!("too many attempts — wait {wait}s"),
+3
View File
@@ -10,6 +10,7 @@ mod sftp;
mod state;
mod static_files;
mod throttle;
mod vault;
use std::sync::Arc;
@@ -110,9 +111,11 @@ async fn main() -> Result<()> {
notify::spawn(Arc::clone(&notify));
let state = AppState {
vault: vault::Vault::new(db.clone(), config.vault_idle),
db,
config: Arc::clone(&config),
login_throttle: Arc::default(),
vault_throttle: Arc::default(),
backup,
notify,
};
+34 -7
View File
@@ -13,7 +13,11 @@ use sqlx::Row;
use tokio_util::io::ReaderStream;
use zip::{write::SimpleFileOptions, CompressionMethod, ZipWriter};
use crate::{auth::Session, error::AppResult, state::AppState};
use crate::{
auth::Session,
error::{AppError, AppResult},
state::AppState,
};
/// One node, flattened out of the database ahead of the blocking zip work.
struct ExportNode {
@@ -159,12 +163,27 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
})
.collect();
let media: Vec<(String, String)> = sqlx::query("SELECT id, filename FROM media")
let rows = sqlx::query("SELECT id, filename, encrypted FROM media")
.fetch_all(&state.db)
.await?
.into_iter()
.await?;
let media: Vec<(String, String)> = rows
.iter()
.map(|row| (row.get("id"), row.get("filename")))
.collect();
let sealed: std::collections::HashSet<String> = rows
.iter()
.filter(|row| row.get::<bool, _>("encrypted"))
.map(|row| row.get("id"))
.collect();
// The archive is meant to be read without this application, so sealed
// files go in opened — which needs the vault open. Leaving them out
// instead would make an export that silently is not complete.
let key = if sealed.is_empty() {
None
} else {
Some(state.vault.key().ok_or(AppError::Locked)?)
};
let uploads = state.config.uploads_dir();
@@ -243,11 +262,19 @@ pub async fn export(_: Session, State(state): State<AppState>) -> AppResult<Resp
.chain(in_tree.iter().map(|(id, path)| (id, path.clone())));
for (id, entry) in entries {
// A row whose file has gone missing must not fail the whole backup.
let Ok(mut source) = std::fs::File::open(uploads.join(id)) else {
continue;
let path = uploads.join(id);
let source: Box<dyn std::io::Read> = match (&key, sealed.contains(id)) {
(Some(key), true) => match crate::vault::Reader::open(key, id, &path) {
Ok(reader) => Box::new(reader),
Err(_) => continue,
},
_ => match std::fs::File::open(&path) {
Ok(file) => Box::new(file),
Err(_) => continue,
},
};
zip.start_file(entry, blob)?;
std::io::copy(&mut source, &mut zip)?;
std::io::copy(&mut { source }, &mut zip)?;
}
let mut file = zip.finish()?;
+2 -2
View File
@@ -252,7 +252,7 @@ async fn collect(
// The archive itself is only passing through: it lands under a
// dotted name no media row can carry, and goes once it is unpacked.
let id = format!(".import-{}", Uuid::new_v4());
let size = media::store_field(state, &mut field, &id, &mut budget).await?;
let size = media::store_field(state, &mut field, &id, &mut budget, None).await?;
if size == 0 {
skipped.push(name);
continue;
@@ -309,7 +309,7 @@ async fn collect(
let mime = media::mime_of(&path, field.content_type());
let id = Uuid::new_v4().to_string();
let size = media::store_field(state, &mut field, &id, &mut budget).await?;
let size = media::store_field(state, &mut field, &id, &mut budget, None).await?;
if size == 0 {
skipped.push(path);
continue;
+92 -13
View File
@@ -2,13 +2,13 @@ use std::{collections::BTreeSet, sync::LazyLock};
use axum::{
body::Body,
extract::{multipart::Field, Multipart, Path, State},
extract::{multipart::Field, Multipart, Path, Query, State},
http::header,
response::{IntoResponse, Response},
Json,
};
use regex::Regex;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use sqlx::{Row, SqlitePool};
use tokio::io::AsyncWriteExt;
use tokio_util::io::ReaderStream;
@@ -19,6 +19,7 @@ use crate::{
error::{AppError, AppResult},
routes::nodes,
state::AppState,
vault,
};
/// Media referenced from a document body always looks like `/api/media/<uuid>`,
@@ -69,6 +70,8 @@ pub struct Media {
pub size: i64,
pub created_at: i64,
pub url: String,
/// Sealed in the vault: its bytes on disk are ciphertext.
pub encrypted: bool,
/// Every document that embeds this file, which may be none or several.
pub node_ids: Vec<i64>,
}
@@ -91,6 +94,7 @@ fn row_to_media(row: &sqlx::sqlite::SqliteRow) -> Media {
mime: row.get("mime"),
size: row.get("size"),
created_at: row.get("created_at"),
encrypted: row.try_get("encrypted").unwrap_or(false),
node_ids,
}
}
@@ -118,17 +122,29 @@ pub(crate) fn mime_of(filename: &str, declared: Option<&str>) -> String {
/// field has arrived whole, so a dropped connection never leaves a truncated
/// file under a name the database might come to know. Returns the size; an
/// empty field writes nothing and returns 0.
///
/// With `seal`, the bytes are encrypted as they pass, so the plaintext never
/// touches the disk at all; the size returned is still the plaintext's.
pub(crate) async fn store_field(
state: &AppState,
field: &mut Field<'_>,
id: &str,
budget: &mut u64,
seal: Option<&vault::Key>,
) -> AppResult<u64> {
let path = state.config.uploads_dir().join(id);
let partial = path.with_extension("part");
let result = async {
let mut file = tokio::fs::File::create(&partial).await?;
let mut sealer = match seal {
Some(key) => {
let (sealer, header) = vault::Sealer::new(key, id)?;
file.write_all(&header).await?;
Some(sealer)
}
None => None,
};
let mut size = 0u64;
while let Some(chunk) = field
.chunk()
@@ -139,7 +155,13 @@ pub(crate) async fn store_field(
if size > *budget {
return Err(too_large(state));
}
file.write_all(&chunk).await?;
match sealer.as_mut() {
Some(sealer) => file.write_all(&sealer.push(&chunk)?).await?,
None => file.write_all(&chunk).await?,
}
}
if let Some(sealer) = sealer {
file.write_all(&sealer.finish()?).await?;
}
file.flush().await?;
Ok(size)
@@ -186,7 +208,7 @@ pub async fn upload(
let mime = mime_of(&filename, field.content_type());
let id = Uuid::new_v4().to_string();
let size = store_field(&state, &mut field, &id, &mut budget).await?;
let size = store_field(&state, &mut field, &id, &mut budget, None).await?;
if size == 0 {
continue;
}
@@ -214,15 +236,29 @@ pub async fn upload(
Ok(Json(uploaded))
}
#[derive(Deserialize)]
pub struct UploadQuery {
/// Seal the files into the vault as they arrive.
#[serde(default)]
pub encrypt: bool,
}
/// `POST /api/nodes/{id}/files` — every file in the body becomes a file node
/// in that space or folder, under the name it was uploaded with.
/// `?encrypt=true` seals them on the way in, which needs the vault open.
pub async fn upload_into(
_: Session,
State(state): State<AppState>,
Path(parent): Path<i64>,
Query(query): Query<UploadQuery>,
mut multipart: Multipart,
) -> AppResult<Json<Vec<nodes::Node>>> {
nodes::container(&state.db, parent).await?;
let key = if query.encrypt {
Some(state.vault.key().ok_or(AppError::Locked)?)
} else {
None
};
let mut created = Vec::new();
let mut budget = state.config.max_upload_bytes as u64;
@@ -242,12 +278,12 @@ pub async fn upload_into(
let mime = mime_of(&filename, field.content_type());
let id = Uuid::new_v4().to_string();
let size = store_field(&state, &mut field, &id, &mut budget).await?;
let size = store_field(&state, &mut field, &id, &mut budget, key.as_deref()).await?;
if size == 0 {
continue;
}
match insert_file(&state.db, parent, &id, &filename, &mime, size).await {
match insert_file(&state.db, parent, &id, &filename, &mime, size, key.is_some()).await {
Ok(node) => created.push(node),
Err(e) => {
let _ = tokio::fs::remove_file(state.config.uploads_dir().join(&id)).await;
@@ -277,19 +313,22 @@ async fn insert_file(
filename: &str,
mime: &str,
size: u64,
encrypted: bool,
) -> AppResult<i64> {
let slug = nodes::unique_slug(db, Some(parent), &nodes::slugify(filename), None).await?;
let now = crate::now();
let mut tx = db.begin().await?;
sqlx::query(
"INSERT INTO media (id, filename, mime, size, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
"INSERT INTO media (id, filename, mime, size, created_at, encrypted)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
)
.bind(media_id)
.bind(filename)
.bind(mime)
.bind(size as i64)
.bind(now)
.bind(encrypted)
.execute(&mut *tx)
.await?;
@@ -319,7 +358,7 @@ async fn insert_file(
pub async fn list(_: Session, State(state): State<AppState>) -> AppResult<Json<Vec<Media>>> {
let rows = sqlx::query(
"SELECT m.id, m.filename, m.mime, m.size, m.created_at,
"SELECT m.id, m.filename, m.mime, m.size, m.created_at, m.encrypted,
(SELECT group_concat(nm.node_id)
FROM node_media nm WHERE nm.media_id = m.id) AS node_ids
FROM media m ORDER BY m.created_at DESC LIMIT 500",
@@ -334,7 +373,7 @@ pub async fn serve(
State(state): State<AppState>,
Path(id): Path<String>,
) -> AppResult<Response> {
stream_media(&state, &id).await
stream_media(&state, &id, true).await
}
pub async fn remove(
@@ -347,9 +386,11 @@ pub async fn remove(
Ok(Json(serde_json::json!({ "ok": true })))
}
/// Shared by the private and the public-share media handlers.
pub async fn stream_media(state: &AppState, id: &str) -> AppResult<Response> {
let row = sqlx::query("SELECT mime, filename, size FROM media WHERE id = ?1")
/// Shared by the private and the public-share media handlers. A sealed file
/// is only ever served to the owner, and only while the vault is open — a
/// share link is a door for strangers, and the vault is not behind it.
pub async fn stream_media(state: &AppState, id: &str, private: bool) -> AppResult<Response> {
let row = sqlx::query("SELECT mime, filename, size, encrypted FROM media WHERE id = ?1")
.bind(id)
.fetch_optional(&state.db)
.await?
@@ -357,14 +398,25 @@ pub async fn stream_media(state: &AppState, id: &str) -> AppResult<Response> {
let filename: String = row.get("filename");
let size: i64 = row.get("size");
let encrypted: bool = row.get("encrypted");
// Re-checked on the way out, not just on the way in, so rows written before
// the allowlist existed cannot be served as something executable either.
let mime = sanitize_mime(&row.get::<String, _>("mime"));
let disposition = if is_inline(&mime) { "inline" } else { "attachment" };
let key = match (encrypted, private) {
(false, _) => None,
(true, false) => return Err(AppError::NotFound),
(true, true) => Some(state.vault.key().ok_or(AppError::Locked)?),
};
let file = tokio::fs::File::open(state.config.uploads_dir().join(id))
.await
.map_err(|_| AppError::NotFound)?;
let body = match key {
None => Body::from_stream(ReaderStream::new(file)),
Some(key) => Body::from_stream(open_stream(file, &key, id).await?),
};
Ok((
[
@@ -386,11 +438,38 @@ pub async fn stream_media(state: &AppState, id: &str) -> AppResult<Response> {
"sandbox; default-src 'none'".to_string(),
),
],
Body::from_stream(ReaderStream::new(file)),
body,
)
.into_response())
}
/// A sealed file as a stream of its plaintext, a record at a time. The
/// header is checked before the first byte goes out, so a file that will not
/// open is an error response rather than a download that breaks off.
async fn open_stream(
mut file: tokio::fs::File,
key: &vault::Key,
id: &str,
) -> AppResult<impl futures_util::Stream<Item = std::io::Result<axum::body::Bytes>>> {
use tokio::io::AsyncReadExt;
let len = file.metadata().await?.len();
let mut header = [0u8; vault::HEADER];
file.read_exact(&mut header).await?;
let opener = vault::Opener::new(key, id, &header, len)?;
Ok(futures_util::stream::try_unfold((file, opener), |(mut file, mut opener)| async move {
let len = opener.next_len();
if len == 0 {
return Ok(None);
}
let mut record = vec![0u8; len];
file.read_exact(&mut record).await?;
let plain = opener.open(&record).map_err(std::io::Error::other)?;
Ok(Some((axum::body::Bytes::from(plain), (file, opener))))
}))
}
pub async fn delete_media(state: &AppState, id: &str) -> AppResult<()> {
sqlx::query("DELETE FROM media WHERE id = ?1")
.bind(id)
+7
View File
@@ -4,9 +4,11 @@ pub mod nodes;
pub mod export;
pub mod import;
pub mod media;
pub mod seal;
pub mod session;
pub mod share;
pub mod ssh_keys;
pub mod vault;
use axum::{
routing::{delete, get, post, put},
@@ -39,6 +41,11 @@ pub fn api_router() -> Router<AppState> {
.route("/nodes/{id}/move", post(nodes::move_node))
.route("/nodes/{id}/import", post(import::import))
.route("/nodes/{id}/files", post(media::upload_into))
.route("/nodes/{id}/encrypt", post(seal::encrypt))
.route("/nodes/{id}/decrypt", post(seal::decrypt))
.route("/vault", get(vault::status).post(vault::setup).put(vault::change_password))
.route("/vault/unlock", post(vault::unlock))
.route("/vault/lock", post(vault::lock))
.route("/nodes/{id}/share", post(share::enable))
.route("/nodes/{id}/share", delete(share::disable))
.route("/media", post(media::upload).get(media::list))
+5 -1
View File
@@ -35,6 +35,8 @@ pub struct FileInfo {
pub mime: String,
pub size: i64,
pub url: String,
/// Sealed in the vault: it opens only while the vault does.
pub encrypted: bool,
}
#[derive(Serialize)]
@@ -158,7 +160,8 @@ const SUMMARY_COLUMNS: &str = "n.id, n.parent_id, n.kind, n.name, n.slug, n.posi
n.created_at, n.updated_at, n.share_token, n.media_id,
(SELECT COUNT(*) FROM nodes c WHERE c.parent_id = n.id) AS child_count,
(SELECT m.mime FROM media m WHERE m.id = n.media_id) AS file_mime,
(SELECT m.size FROM media m WHERE m.id = n.media_id) AS file_size";
(SELECT m.size FROM media m WHERE m.id = n.media_id) AS file_size,
(SELECT m.encrypted FROM media m WHERE m.id = n.media_id) AS file_encrypted";
/// A short, plain-ish preview of the body for the list pane. `head` is only the
/// leading `HEAD_CHARS` of the document, so truncation is inferred from its
@@ -286,6 +289,7 @@ fn row_to_summary(row: &sqlx::sqlite::SqliteRow, head: &str) -> NodeSummary {
media_id,
mime: row.get::<Option<String>, _>("file_mime").unwrap_or_default(),
size: row.get::<Option<i64>, _>("file_size").unwrap_or_default(),
encrypted: row.get::<Option<bool>, _>("file_encrypted").unwrap_or_default(),
});
NodeSummary {
id: row.get("id"),
+190
View File
@@ -0,0 +1,190 @@
//! Moving files that already exist into the vault, and back out of it.
//!
//! `POST /api/nodes/{id}/encrypt` seals a file node, or every file node under
//! a folder or space; `/decrypt` does the opposite. Either needs the vault open.
//!
//! A file is re-written under a **new** media id rather than in place. The
//! mirror treats `uploads/<uuid>` as immutable, so rewriting one would leave
//! the old bytes on Drive as the previous revision of the same file — the
//! plaintext, in the case that matters. Under a new id the sealed copy is a new
//! file, and the old id is put on `backup_retired`, which the mirror takes off
//! Drive for good whatever `WORKSPACE_BACKUP_PRUNE` says. Documents that embed
//! the file have their links moved to the new id in the same transaction.
use axum::{
extract::{Path, State},
Json,
};
use serde::Serialize;
use sqlx::Row;
use uuid::Uuid;
use crate::{
auth::Session,
error::{AppError, AppResult},
routes::nodes,
state::AppState,
vault,
};
#[derive(Serialize)]
pub struct Resealed {
/// How many files changed.
pub files: usize,
/// Of what was asked about, how many were already that way.
pub unchanged: usize,
}
pub async fn encrypt(
_: Session,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Resealed>> {
reseal(&state, id, true).await.map(Json)
}
pub async fn decrypt(
_: Session,
State(state): State<AppState>,
Path(id): Path<i64>,
) -> AppResult<Json<Resealed>> {
reseal(&state, id, false).await.map(Json)
}
struct Target {
node_id: i64,
media_id: String,
encrypted: bool,
}
async fn reseal(state: &AppState, id: i64, seal: bool) -> AppResult<Resealed> {
let key = state.vault.key().ok_or(AppError::Locked)?;
let subtree = nodes::descendants(&state.db, id).await?;
if subtree.is_empty() {
return Err(AppError::NotFound);
}
let list = subtree.iter().map(i64::to_string).collect::<Vec<_>>().join(",");
let targets: Vec<Target> = sqlx::query(&format!(
"SELECT n.id, n.media_id, m.encrypted
FROM nodes n JOIN media m ON m.id = n.media_id
WHERE n.kind = 'file' AND n.id IN ({list})"
))
.fetch_all(&state.db)
.await?
.iter()
.map(|row| Target {
node_id: row.get("id"),
media_id: row.get("media_id"),
encrypted: row.get("encrypted"),
})
.collect();
if targets.is_empty() {
return Err(AppError::BadRequest("there are no files there".into()));
}
let mut files = 0;
let mut unchanged = 0;
for target in targets {
if target.encrypted == seal {
unchanged += 1;
continue;
}
rewrite(state, &key, &target, seal).await?;
files += 1;
}
if files > 0 {
state.backup.signal();
}
Ok(Resealed { files, unchanged })
}
/// Re-write one file sealed or opened under a new id, then point everything
/// at the new id and retire the old one.
async fn rewrite(state: &AppState, key: &vault::Key, target: &Target, seal: bool) -> AppResult<()> {
let uploads = state.config.uploads_dir();
let old = target.media_id.clone();
let new = Uuid::new_v4().to_string();
let source = uploads.join(&old);
let partial = uploads.join(format!("{new}.part"));
let written = {
let (key, old, new, source, partial) =
(key.clone(), old.clone(), new.clone(), source.clone(), partial.clone());
tokio::task::spawn_blocking(move || -> anyhow::Result<()> {
if seal {
let mut plain = std::fs::File::open(&source)?;
vault::seal_file(&key, &new, &mut plain, &partial)?;
} else {
let mut opened = vault::Reader::open(&key, &old, &source)?;
let mut out = std::fs::File::create(&partial)?;
std::io::copy(&mut opened, &mut out)?;
out.sync_all()?;
}
Ok(())
})
.await
.map_err(anyhow::Error::from)
.and_then(|done| done)
};
if let Err(e) = written {
let _ = tokio::fs::remove_file(&partial).await;
return Err(e.into());
}
tokio::fs::rename(&partial, uploads.join(&new)).await?;
let committed = async {
let mut tx = state.db.begin().await?;
sqlx::query(
"INSERT INTO media (id, filename, mime, size, created_at, encrypted)
SELECT ?1, filename, mime, size, created_at, ?2 FROM media WHERE id = ?3",
)
.bind(&new)
.bind(seal)
.bind(&old)
.execute(&mut *tx)
.await?;
sqlx::query("UPDATE nodes SET media_id = ?1 WHERE media_id = ?2")
.bind(&new)
.bind(&old)
.execute(&mut *tx)
.await?;
// Every document that embeds the file keeps showing it.
sqlx::query(
"UPDATE nodes SET body = replace(body, '/api/media/' || ?2, '/api/media/' || ?1)
WHERE kind = 'document' AND id IN (SELECT node_id FROM node_media WHERE media_id = ?2)",
)
.bind(&new)
.bind(&old)
.execute(&mut *tx)
.await?;
sqlx::query("UPDATE node_media SET media_id = ?1 WHERE media_id = ?2")
.bind(&new)
.bind(&old)
.execute(&mut *tx)
.await?;
sqlx::query("INSERT OR IGNORE INTO backup_retired (path) VALUES ('uploads/' || ?1)")
.bind(&old)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM media WHERE id = ?1")
.bind(&old)
.execute(&mut *tx)
.await?;
tx.commit().await?;
AppResult::Ok(())
}
.await;
if let Err(e) = committed {
let _ = tokio::fs::remove_file(uploads.join(&new)).await;
return Err(e);
}
// The database no longer knows the old bytes; they go from the disk too.
let _ = tokio::fs::remove_file(&source).await;
tracing::info!(node = target.node_id, from = %old, to = %new, sealed = seal, "file re-written");
Ok(())
}
+2
View File
@@ -58,6 +58,8 @@ pub async fn login(
}
pub async fn logout(State(state): State<AppState>) -> impl IntoResponse {
// Leaving is the moment the vault should not outlive.
state.vault.lock();
(
StatusCode::OK,
[(header::SET_COOKIE, auth::clear_cookie(&state.config))],
+1 -1
View File
@@ -160,7 +160,7 @@ pub async fn serve_media(
if !key_matches(stored.as_deref(), &key) {
return Err(AppError::NotFound);
}
media::stream_media(&state, &id).await
media::stream_media(&state, &id, false).await
}
#[cfg(test)]
+105
View File
@@ -0,0 +1,105 @@
//! `/api/vault`: set the vault up, open it, close it, change its password.
use axum::{extract::State, Json};
use serde::Deserialize;
use crate::{
auth::Session,
error::{AppError, AppResult},
state::AppState,
vault::{Status, Unlock, MIN_PASSWORD},
};
#[derive(Deserialize)]
pub struct PasswordInput {
pub password: String,
}
#[derive(Deserialize)]
pub struct ChangeInput {
pub current: String,
pub password: String,
}
fn strong_enough(password: &str) -> AppResult<()> {
if password.chars().count() < MIN_PASSWORD {
return Err(AppError::BadRequest(format!(
"a vault password needs at least {MIN_PASSWORD} characters"
)));
}
Ok(())
}
pub async fn status(_: Session, State(state): State<AppState>) -> AppResult<Json<Status>> {
Ok(Json(state.vault.status().await?))
}
/// `POST /api/vault` — make the vault. Only once.
pub async fn setup(
_: Session,
State(state): State<AppState>,
Json(input): Json<PasswordInput>,
) -> AppResult<Json<Status>> {
strong_enough(&input.password)?;
if !state.vault.setup(input.password).await? {
return Err(AppError::BadRequest(
"there is a vault already — :passwd changes its password".into(),
));
}
tracing::info!("vault set up");
state.backup.signal();
Ok(Json(state.vault.status().await?))
}
/// Wrong guesses are slowed down exactly like logins. Argon2 already makes
/// each one cost a fraction of a second; this makes a run of them cost minutes.
fn throttle(state: &AppState) -> AppResult<()> {
state.vault_throttle.check().map_err(|wait| {
tracing::warn!("vault unlock refused, {wait}s of backoff remaining");
AppError::TooManyRequests(wait)
})
}
fn answer(state: &AppState, outcome: Unlock) -> AppResult<()> {
match outcome {
Unlock::Opened => {
state.vault_throttle.record_success();
Ok(())
}
Unlock::WrongPassword => {
state.vault_throttle.record_failure();
Err(AppError::BadRequest("wrong vault password".into()))
}
Unlock::NotConfigured => Err(AppError::BadRequest(
"there is no vault yet — :vault sets one up".into(),
)),
}
}
pub async fn unlock(
_: Session,
State(state): State<AppState>,
Json(input): Json<PasswordInput>,
) -> AppResult<Json<Status>> {
throttle(&state)?;
answer(&state, state.vault.unlock(input.password).await?)?;
Ok(Json(state.vault.status().await?))
}
pub async fn lock(_: Session, State(state): State<AppState>) -> AppResult<Json<Status>> {
state.vault.lock();
Ok(Json(state.vault.status().await?))
}
pub async fn change_password(
_: Session,
State(state): State<AppState>,
Json(input): Json<ChangeInput>,
) -> AppResult<Json<Status>> {
throttle(&state)?;
strong_enough(&input.password)?;
answer(&state, state.vault.change_password(input.current, input.password).await?)?;
tracing::info!("vault password changed");
state.backup.signal();
Ok(Json(state.vault.status().await?))
}
+6 -1
View File
@@ -2,13 +2,18 @@ use std::sync::Arc;
use sqlx::SqlitePool;
use crate::{backup::Backup, config::Config, notify::Notifier, throttle::LoginThrottle};
use crate::{backup::Backup, config::Config, notify::Notifier, throttle::LoginThrottle, vault::Vault};
#[derive(Clone)]
pub struct AppState {
pub db: SqlitePool,
pub config: Arc<Config>,
pub login_throttle: Arc<LoginThrottle>,
/// The vault password gets the same slowing-down as the login, kept apart
/// so guessing at one does not slow the other.
pub vault_throttle: Arc<LoginThrottle>,
/// Sealed files. Always present; holds no key until it is unlocked.
pub vault: Arc<Vault>,
/// The Proton Drive mirror. Always present; dormant until it has a session.
pub backup: Arc<Backup>,
/// Notification mail. Always present; dormant until WORKSPACE_SMTP_URL is set.
+585
View File
@@ -0,0 +1,585 @@
//! The vault: files encrypted at rest under a key that only a password unlocks.
//!
//! Everything else in the workspace is protected by the login and by where the
//! data directory lives. A file in the vault is protected even from someone who
//! has both — a copy of the volume, the Proton account the mirror writes to, a
//! stolen session — because its bytes on disk are ciphertext and the key is
//! held in memory only while the vault is open.
//!
//! Keys:
//!
//! - a random 256-bit **master key**, made once at setup and never stored in
//! the clear: the `vault` row holds it sealed under a key derived from the
//! password with Argon2id. Changing the password re-seals the master key and
//! touches no file;
//! - a random 256-bit **file key** per file, sealed under the master key in
//! the file's own header, so no two files share a key and nonces never need
//! counting across files.
//!
//! A sealed file is
//!
//! ```text
//! "NARLVLT1" 8 bytes, format and version
//! wrap nonce 12 bytes
//! file key, sealed 48 bytes: AES-256-GCM under the master key,
//! the media id as associated data
//! stream nonce 7 bytes
//! records AES-256-GCM STREAM (BE32): 64 KiB of plaintext
//! plus a 16-byte tag each, the last one shorter
//! and marked last, so a truncated file is caught
//! ```
//!
//! Binding the media id means a sealed file renamed to another id refuses to
//! open rather than showing up as somebody else. The password is never stored
//! and cannot be recovered: forgetting it loses every file in the vault, which
//! is the point.
use std::{
io::Read,
sync::{Arc, Mutex},
time::{Duration, Instant},
};
use aes_gcm::{
aead::{
stream::{DecryptorBE32, EncryptorBE32},
Aead, KeyInit, Payload,
},
Aes256Gcm, Nonce,
};
use anyhow::{anyhow, Context, Result};
use rand::RngCore;
use sqlx::{Row, SqlitePool};
use zeroize::Zeroizing;
pub const MAGIC: &[u8; 8] = b"NARLVLT1";
/// Plaintext per record. Large enough that the tags cost nothing to speak of,
/// small enough to hold one in memory per open download.
pub const CHUNK: usize = 64 * 1024;
const TAG: usize = 16;
const RECORD: usize = CHUNK + TAG;
const WRAP_NONCE: usize = 12;
const STREAM_NONCE: usize = 7;
pub const HEADER: usize = MAGIC.len() + WRAP_NONCE + 32 + TAG + STREAM_NONCE;
/// What the master key is sealed with, besides the password-derived key.
const MASTER_AAD: &[u8] = b"narl-vault-master-key";
/// The shortest password the vault takes. It guards everything in it, and
/// unlike the login it cannot be reset by editing a config file.
pub const MIN_PASSWORD: usize = 10;
/// Argon2id costs for new vaults: 64 MiB, three passes, one lane. A fraction
/// of a second on a small server, which is the budget for an unlock. Kept per
/// vault, so raising them later does not lock anybody out.
const M_COST: u32 = 64 * 1024;
const T_COST: u32 = 3;
const P_COST: u32 = 1;
pub type Key = Zeroizing<[u8; 32]>;
fn random<const N: usize>() -> [u8; N] {
let mut out = [0u8; N];
rand::thread_rng().fill_bytes(&mut out);
out
}
/// The key the password stands for, at the costs the vault was made with.
fn derive(password: &str, salt: &[u8], m: u32, t: u32, p: u32) -> Result<Key> {
let params = argon2::Params::new(m, t, p, Some(32)).map_err(|e| anyhow!("argon2 params: {e}"))?;
let argon = argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
let mut key = Zeroizing::new([0u8; 32]);
argon
.hash_password_into(password.as_bytes(), salt, key.as_mut())
.map_err(|e| anyhow!("argon2: {e}"))?;
Ok(key)
}
fn seal_master(kek: &Key, master: &Key) -> Result<([u8; WRAP_NONCE], Vec<u8>)> {
let nonce = random::<WRAP_NONCE>();
let sealed = Aes256Gcm::new(kek.as_ref().into())
.encrypt(Nonce::from_slice(&nonce), Payload { msg: master.as_ref(), aad: MASTER_AAD })
.map_err(|_| anyhow!("could not seal the master key"))?;
Ok((nonce, sealed))
}
/// `None` is a wrong password: the tag does not verify.
fn open_master(kek: &Key, nonce: &[u8], sealed: &[u8]) -> Option<Key> {
let plain = Zeroizing::new(
Aes256Gcm::new(kek.as_ref().into())
.decrypt(Nonce::from_slice(nonce), Payload { msg: sealed, aad: MASTER_AAD })
.ok()?,
);
let mut key = Zeroizing::new([0u8; 32]);
key.copy_from_slice(plain.get(..32)?);
Some(key)
}
/// Encrypts one file as it streams past: the header first, then a record for
/// every [`CHUNK`] that has arrived, and the short last one at [`finish`](Self::finish).
pub struct Sealer {
stream: EncryptorBE32<Aes256Gcm>,
pending: Vec<u8>,
}
impl Sealer {
/// A sealer for `media_id`, and the header that has to be written first.
pub fn new(master: &Key, media_id: &str) -> Result<(Self, Vec<u8>)> {
let file_key: Key = Zeroizing::new(random::<32>());
let wrap_nonce = random::<WRAP_NONCE>();
let stream_nonce = random::<STREAM_NONCE>();
let sealed_key = Aes256Gcm::new(master.as_ref().into())
.encrypt(
Nonce::from_slice(&wrap_nonce),
Payload { msg: file_key.as_ref(), aad: &file_aad(media_id) },
)
.map_err(|_| anyhow!("could not seal a file key"))?;
let mut header = Vec::with_capacity(HEADER);
header.extend_from_slice(MAGIC);
header.extend_from_slice(&wrap_nonce);
header.extend_from_slice(&sealed_key);
header.extend_from_slice(&stream_nonce);
let stream = EncryptorBE32::from_aead(
Aes256Gcm::new(file_key.as_ref().into()),
stream_nonce.as_slice().into(),
);
Ok((Self { stream, pending: Vec::with_capacity(CHUNK) }, header))
}
/// Take more plaintext; returns whatever whole records it completes. A
/// record is only sealed once more data follows it, because only the last
/// one may be marked last and the last is not known until `finish`.
pub fn push(&mut self, mut data: &[u8]) -> Result<Vec<u8>> {
let mut out = Vec::new();
while !data.is_empty() {
if self.pending.len() == CHUNK {
let record = self
.stream
.encrypt_next(self.pending.as_slice())
.map_err(|_| anyhow!("could not seal a record"))?;
out.extend_from_slice(&record);
self.pending.clear();
}
let take = (CHUNK - self.pending.len()).min(data.len());
self.pending.extend_from_slice(&data[..take]);
data = &data[take..];
}
Ok(out)
}
/// The last record. Always present, empty for an empty file, so even a
/// file cut off exactly at a record boundary is caught.
pub fn finish(self) -> Result<Vec<u8>> {
self.stream
.encrypt_last(self.pending.as_slice())
.map_err(|_| anyhow!("could not seal the last record"))
}
}
fn file_aad(media_id: &str) -> Vec<u8> {
let mut aad = MAGIC.to_vec();
aad.extend_from_slice(media_id.as_bytes());
aad
}
/// Decrypts one sealed file, a record at a time.
pub struct Opener {
stream: Option<DecryptorBE32<Aes256Gcm>>,
/// Records still to come, the last of them included.
remaining: u64,
/// The size of the last record, which is the only short one.
last: usize,
}
impl Opener {
/// Check the header and unseal the file key. `len` is the size of the
/// whole sealed file, which is what tells the reader where the last record
/// starts.
pub fn new(master: &Key, media_id: &str, header: &[u8], len: u64) -> Result<Self> {
if header.len() != HEADER || &header[..MAGIC.len()] != MAGIC {
return Err(anyhow!("not a sealed file"));
}
let body = len
.checked_sub(HEADER as u64)
.filter(|body| *body >= TAG as u64)
.ok_or_else(|| anyhow!("sealed file is truncated"))?;
let remaining = body.div_ceil(RECORD as u64);
let last = (body - (remaining - 1) * RECORD as u64) as usize;
if last < TAG {
return Err(anyhow!("sealed file is truncated"));
}
let (wrap_nonce, rest) = header[MAGIC.len()..].split_at(WRAP_NONCE);
let (sealed_key, stream_nonce) = rest.split_at(32 + TAG);
let file_key = Zeroizing::new(
Aes256Gcm::new(master.as_ref().into())
.decrypt(
Nonce::from_slice(wrap_nonce),
Payload { msg: sealed_key, aad: &file_aad(media_id) },
)
.map_err(|_| anyhow!("this file was not sealed under this vault, or not as {media_id}"))?,
);
let stream = DecryptorBE32::from_aead(
Aes256Gcm::new(file_key.as_slice().into()),
stream_nonce.into(),
);
Ok(Self { stream: Some(stream), remaining, last })
}
/// How many bytes the next record takes on disk; 0 once all are read.
pub fn next_len(&self) -> usize {
match self.remaining {
0 => 0,
1 => self.last,
_ => RECORD,
}
}
/// Decrypt the record `next_len` asked for.
pub fn open(&mut self, record: &[u8]) -> Result<Vec<u8>> {
let stream = self.stream.as_mut().ok_or_else(|| anyhow!("sealed file already read"))?;
let plain = if self.remaining == 1 {
self.stream
.take()
.expect("present: checked above")
.decrypt_last(record)
} else {
stream.decrypt_next(record)
}
.map_err(|_| anyhow!("sealed file is damaged or was tampered with"))?;
self.remaining -= 1;
Ok(plain)
}
}
/// A sealed file read as its plaintext, for code that wants `std::io::Read`:
/// the export's zip writer, re-sealing.
pub struct Reader<R> {
inner: R,
opener: Opener,
buffer: Vec<u8>,
at: usize,
}
impl Reader<std::fs::File> {
pub fn open(master: &Key, media_id: &str, path: &std::path::Path) -> Result<Self> {
let mut file = std::fs::File::open(path).with_context(|| format!("could not read {}", path.display()))?;
let len = file.metadata()?.len();
let mut header = [0u8; HEADER];
file.read_exact(&mut header).context("sealed file is truncated")?;
let opener = Opener::new(master, media_id, &header, len)?;
Ok(Self { inner: file, opener, buffer: Vec::new(), at: 0 })
}
}
impl<R: Read> Read for Reader<R> {
fn read(&mut self, out: &mut [u8]) -> std::io::Result<usize> {
while self.at == self.buffer.len() {
let len = self.opener.next_len();
if len == 0 {
return Ok(0);
}
let mut record = vec![0u8; len];
self.inner.read_exact(&mut record)?;
self.buffer = self.opener.open(&record).map_err(std::io::Error::other)?;
self.at = 0;
}
let n = out.len().min(self.buffer.len() - self.at);
out[..n].copy_from_slice(&self.buffer[self.at..self.at + n]);
self.at += n;
Ok(n)
}
}
/// Seal `source` into `target` under `media_id`, on the calling thread.
/// Returns the plaintext size.
pub fn seal_file(master: &Key, media_id: &str, source: &mut impl Read, target: &std::path::Path) -> Result<u64> {
use std::io::Write;
let mut out = std::io::BufWriter::new(std::fs::File::create(target)?);
let (mut sealer, header) = Sealer::new(master, media_id)?;
out.write_all(&header)?;
let mut buffer = vec![0u8; CHUNK];
let mut size = 0u64;
loop {
let n = source.read(&mut buffer)?;
if n == 0 {
break;
}
size += n as u64;
out.write_all(&sealer.push(&buffer[..n])?)?;
}
out.write_all(&sealer.finish()?)?;
out.into_inner().map_err(|e| e.into_error())?.sync_all()?;
Ok(size)
}
/// The vault's state: whether it exists, and its key while it is open.
pub struct Vault {
db: SqlitePool,
idle: Duration,
open: Mutex<Option<Unlocked>>,
}
struct Unlocked {
key: Arc<Key>,
touched: Instant,
}
#[derive(serde::Serialize)]
pub struct Status {
/// Whether a vault password has been set at all.
pub configured: bool,
pub unlocked: bool,
/// Seconds until it locks itself if nothing uses it.
pub locks_in: Option<u64>,
}
/// What a password check came to.
pub enum Unlock {
Opened,
WrongPassword,
NotConfigured,
}
impl Vault {
pub fn new(db: SqlitePool, idle: Duration) -> Arc<Self> {
Arc::new(Self { db, idle, open: Mutex::new(None) })
}
fn slot(&self) -> std::sync::MutexGuard<'_, Option<Unlocked>> {
self.open.lock().unwrap_or_else(|e| e.into_inner())
}
/// The master key, if the vault is open — and keep it open a while longer.
/// A vault left alone past the idle time closes on the next look.
pub fn key(&self) -> Option<Arc<Key>> {
let mut slot = self.slot();
match slot.as_mut() {
Some(open) if open.touched.elapsed() < self.idle => {
open.touched = Instant::now();
Some(open.key.clone())
}
Some(_) => {
*slot = None;
None
}
None => None,
}
}
pub fn lock(&self) {
*self.slot() = None;
}
pub async fn status(&self) -> Result<Status> {
let configured = self.row().await?.is_some();
let slot = self.slot();
let remaining = slot
.as_ref()
.map(|open| self.idle.saturating_sub(open.touched.elapsed()))
.filter(|left| !left.is_zero());
Ok(Status {
configured,
unlocked: remaining.is_some(),
locks_in: remaining.map(|left| left.as_secs()),
})
}
async fn row(&self) -> Result<Option<sqlx::sqlite::SqliteRow>> {
Ok(sqlx::query("SELECT salt, m_cost, t_cost, p_cost, nonce, sealed FROM vault WHERE id = 1")
.fetch_optional(&self.db)
.await?)
}
/// Make the vault, and leave it open. Refuses when there already is one:
/// a second setup would orphan every file sealed under the first.
pub async fn setup(&self, password: String) -> Result<bool> {
if self.row().await?.is_some() {
return Ok(false);
}
let salt = random::<16>();
let master: Key = Zeroizing::new(random::<32>());
let (nonce, sealed, master) = tokio::task::spawn_blocking(move || -> Result<_> {
let kek = derive(&password, &salt, M_COST, T_COST, P_COST)?;
let (nonce, sealed) = seal_master(&kek, &master)?;
Ok((nonce, sealed, master))
})
.await??;
let inserted = sqlx::query(
"INSERT OR IGNORE INTO vault (id, salt, m_cost, t_cost, p_cost, nonce, sealed, created_at)
VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7)",
)
.bind(salt.as_slice())
.bind(M_COST as i64)
.bind(T_COST as i64)
.bind(P_COST as i64)
.bind(nonce.as_slice())
.bind(sealed)
.bind(crate::now())
.execute(&self.db)
.await?
.rows_affected();
if inserted == 0 {
return Ok(false);
}
*self.slot() = Some(Unlocked { key: Arc::new(master), touched: Instant::now() });
Ok(true)
}
/// The master key the password stands for, if it is the right one.
async fn check(&self, password: String) -> Result<Option<Option<Key>>> {
let Some(row) = self.row().await? else {
return Ok(None);
};
let salt: Vec<u8> = row.get("salt");
let (m, t, p): (i64, i64, i64) = (row.get("m_cost"), row.get("t_cost"), row.get("p_cost"));
let nonce: Vec<u8> = row.get("nonce");
let sealed: Vec<u8> = row.get("sealed");
let key = tokio::task::spawn_blocking(move || -> Result<Option<Key>> {
let kek = derive(&password, &salt, m as u32, t as u32, p as u32)?;
Ok(open_master(&kek, &nonce, &sealed))
})
.await??;
Ok(Some(key))
}
pub async fn unlock(&self, password: String) -> Result<Unlock> {
Ok(match self.check(password).await? {
None => Unlock::NotConfigured,
Some(None) => Unlock::WrongPassword,
Some(Some(key)) => {
*self.slot() = Some(Unlocked { key: Arc::new(key), touched: Instant::now() });
Unlock::Opened
}
})
}
/// Re-seal the master key under a new password. No file changes: they are
/// sealed under the master key, which stays what it was.
pub async fn change_password(&self, current: String, new: String) -> Result<Unlock> {
let master = match self.check(current).await? {
None => return Ok(Unlock::NotConfigured),
Some(None) => return Ok(Unlock::WrongPassword),
Some(Some(key)) => key,
};
let salt = random::<16>();
let (nonce, sealed, master) = tokio::task::spawn_blocking(move || -> Result<_> {
let kek = derive(&new, &salt, M_COST, T_COST, P_COST)?;
let (nonce, sealed) = seal_master(&kek, &master)?;
Ok((nonce, sealed, master))
})
.await??;
sqlx::query(
"UPDATE vault SET salt = ?1, m_cost = ?2, t_cost = ?3, p_cost = ?4, nonce = ?5, sealed = ?6
WHERE id = 1",
)
.bind(salt.as_slice())
.bind(M_COST as i64)
.bind(T_COST as i64)
.bind(P_COST as i64)
.bind(nonce.as_slice())
.bind(sealed)
.execute(&self.db)
.await?;
*self.slot() = Some(Unlocked { key: Arc::new(master), touched: Instant::now() });
Ok(Unlock::Opened)
}
}
#[cfg(test)]
mod tests {
use std::io::Read;
use zeroize::Zeroizing;
use super::{derive, open_master, seal_master, Opener, Reader, Sealer, CHUNK, HEADER, TAG};
fn key(byte: u8) -> super::Key {
Zeroizing::new([byte; 32])
}
fn seal(master: &super::Key, id: &str, plain: &[u8], step: usize) -> Vec<u8> {
let (mut sealer, mut out) = Sealer::new(master, id).expect("sealer");
for piece in plain.chunks(step.max(1)) {
out.extend(sealer.push(piece).expect("push"));
}
out.extend(sealer.finish().expect("finish"));
out
}
fn open(master: &super::Key, id: &str, sealed: &[u8]) -> anyhow::Result<Vec<u8>> {
let mut opener = Opener::new(master, id, &sealed[..HEADER], sealed.len() as u64)?;
let mut at = HEADER;
let mut out = Vec::new();
loop {
let len = opener.next_len();
if len == 0 {
break;
}
out.extend(opener.open(&sealed[at..at + len])?);
at += len;
}
Ok(out)
}
#[test]
fn files_of_every_awkward_size_round_trip() {
let master = key(7);
for size in [0, 1, CHUNK - 1, CHUNK, CHUNK + 1, 3 * CHUNK, 3 * CHUNK + 17] {
let plain: Vec<u8> = (0..size).map(|i| (i % 251) as u8).collect();
for step in [1000, CHUNK, 7 * CHUNK] {
let sealed = seal(&master, "id", &plain, step);
let records = size.div_ceil(CHUNK).max(1);
assert_eq!(sealed.len(), HEADER + size + records * TAG, "size {size}");
assert_eq!(open(&master, "id", &sealed).expect("opens"), plain, "size {size} step {step}");
}
}
}
#[test]
fn the_std_reader_sees_the_plaintext() {
let master = key(1);
let plain: Vec<u8> = (0..(2 * CHUNK + 5)).map(|i| i as u8).collect();
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("x");
std::fs::write(&path, seal(&master, "x", &plain, CHUNK)).expect("write");
let mut out = Vec::new();
Reader::open(&master, "x", &path).expect("reader").read_to_end(&mut out).expect("read");
assert_eq!(out, plain);
}
#[test]
fn the_wrong_key_another_id_or_a_cut_are_all_refused() {
let master = key(2);
let plain = vec![9u8; CHUNK * 2];
let sealed = seal(&master, "a", &plain, CHUNK);
assert!(open(&key(3), "a", &sealed).is_err(), "another vault");
assert!(open(&master, "b", &sealed).is_err(), "renamed to another id");
// Cut at a record boundary: what is left still parses as records, and
// only the missing "last" mark gives it away.
let cut = &sealed[..HEADER + CHUNK + TAG];
assert!(open(&master, "a", cut).is_err(), "truncated at a record boundary");
let mut flipped = sealed.clone();
flipped[HEADER + 10] ^= 1;
assert!(open(&master, "a", &flipped).is_err(), "a flipped bit");
}
#[test]
fn only_the_right_password_opens_the_master_key() {
// Small costs: this is about the sealing, not about Argon2's speed.
let kek = derive("correct horse", b"saltsaltsaltsalt", 256, 1, 1).expect("derive");
let master = key(5);
let (nonce, sealed) = seal_master(&kek, &master).expect("seal");
assert_eq!(open_master(&kek, &nonce, &sealed).expect("opens").as_ref(), master.as_ref());
let wrong = derive("correct horse!", b"saltsaltsaltsalt", 256, 1, 1).expect("derive");
assert!(open_master(&wrong, &nonce, &sealed).is_none());
}
}