mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-10 17:47:03 +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
+1573
-54
No files matched your search
@@ -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> {
|
||||
|
||||
+23
-2
@@ -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?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user