mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-04 17:07:00 +01:00
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.
198 lines
5.8 KiB
Rust
198 lines
5.8 KiB
Rust
use axum::{
|
|
extract::{Path, State},
|
|
response::Response,
|
|
Json,
|
|
};
|
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64, Engine};
|
|
use rand::RngCore;
|
|
use serde::Serialize;
|
|
use serde_json::json;
|
|
use sha2::{Digest, Sha256};
|
|
use sqlx::Row;
|
|
use subtle::ConstantTimeEq;
|
|
|
|
use crate::{
|
|
auth::Session,
|
|
error::{AppError, AppResult},
|
|
routes::media,
|
|
state::AppState,
|
|
};
|
|
|
|
#[derive(Serialize)]
|
|
pub struct SharedDocument {
|
|
pub name: String,
|
|
pub body: String,
|
|
pub created_at: i64,
|
|
pub updated_at: i64,
|
|
pub token: String,
|
|
}
|
|
|
|
fn random_secret() -> String {
|
|
let mut bytes = [0u8; 24];
|
|
rand::thread_rng().fill_bytes(&mut bytes);
|
|
B64.encode(bytes)
|
|
}
|
|
|
|
fn hash_key(key: &str) -> String {
|
|
B64.encode(Sha256::digest(key.as_bytes()))
|
|
}
|
|
|
|
/// Constant-time check of a presented key against the stored hash. A share with
|
|
/// no hash at all is unreadable rather than open: that combination should not
|
|
/// exist, and failing closed is the safe reading of it.
|
|
fn key_matches(stored: Option<&str>, presented: &str) -> bool {
|
|
let Some(stored) = stored else { return false };
|
|
bool::from(hash_key(presented).as_bytes().ct_eq(stored.as_bytes()))
|
|
}
|
|
|
|
/// Publish a document, or mint a fresh key for one that is already published.
|
|
///
|
|
/// The plaintext key is returned exactly once, here. Nothing but its hash is
|
|
/// stored, so a link that the author loses cannot be recovered — only replaced,
|
|
/// which is what calling this again does. Rotating the key kills the old link
|
|
/// while leaving the token, so a revoked reader loses access even if they kept
|
|
/// the path.
|
|
pub async fn enable(
|
|
_: Session,
|
|
State(state): State<AppState>,
|
|
Path(id): Path<i64>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
let row = sqlx::query("SELECT kind, share_token FROM nodes WHERE id = ?1")
|
|
.bind(id)
|
|
.fetch_optional(&state.db)
|
|
.await?
|
|
.ok_or(AppError::NotFound)?;
|
|
|
|
// A share link serves one body. A folder has none, and publishing a whole
|
|
// subtree behind one key is a different feature with different consequences.
|
|
if row.get::<String, _>("kind") != super::nodes::DOCUMENT {
|
|
return Err(AppError::BadRequest("only a document can be shared".into()));
|
|
}
|
|
|
|
let token = match row.get::<Option<String>, _>("share_token") {
|
|
Some(token) => token,
|
|
None => random_secret(),
|
|
};
|
|
let key = random_secret();
|
|
|
|
sqlx::query("UPDATE nodes SET share_token = ?1, share_key_hash = ?2 WHERE id = ?3")
|
|
.bind(&token)
|
|
.bind(hash_key(&key))
|
|
.bind(id)
|
|
.execute(&state.db)
|
|
.await?;
|
|
|
|
state.backup.signal();
|
|
Ok(Json(json!({
|
|
"token": token,
|
|
"key": key,
|
|
"path": format!("/s/{token}#{key}"),
|
|
})))
|
|
}
|
|
|
|
pub async fn disable(
|
|
_: Session,
|
|
State(state): State<AppState>,
|
|
Path(id): Path<i64>,
|
|
) -> AppResult<Json<serde_json::Value>> {
|
|
let affected =
|
|
sqlx::query("UPDATE nodes SET share_token = NULL, share_key_hash = NULL WHERE id = ?1")
|
|
.bind(id)
|
|
.execute(&state.db)
|
|
.await?
|
|
.rows_affected();
|
|
if affected == 0 {
|
|
return Err(AppError::NotFound);
|
|
}
|
|
state.backup.signal();
|
|
Ok(Json(json!({ "ok": true })))
|
|
}
|
|
|
|
/// Public: no session required. The credential is the pair — the token from the
|
|
/// link's path and the key the reader's browser lifted out of its fragment.
|
|
pub async fn read(
|
|
State(state): State<AppState>,
|
|
Path((token, key)): Path<(String, String)>,
|
|
) -> AppResult<Json<SharedDocument>> {
|
|
let row = sqlx::query(
|
|
"SELECT name, body, created_at, updated_at, share_key_hash
|
|
FROM nodes WHERE share_token = ?1",
|
|
)
|
|
.bind(&token)
|
|
.fetch_optional(&state.db)
|
|
.await?
|
|
.ok_or(AppError::NotFound)?;
|
|
|
|
let stored: Option<String> = row.get("share_key_hash");
|
|
if !key_matches(stored.as_deref(), &key) {
|
|
// Deliberately the same answer as an unknown token: a wrong key must not
|
|
// confirm that the token itself is live.
|
|
return Err(AppError::NotFound);
|
|
}
|
|
|
|
Ok(Json(SharedDocument {
|
|
name: row.get("name"),
|
|
body: row.get("body"),
|
|
created_at: row.get("created_at"),
|
|
updated_at: row.get("updated_at"),
|
|
token,
|
|
}))
|
|
}
|
|
|
|
/// Public: serves a media file only while it is embedded in a shared document,
|
|
/// and only to a caller holding that document's key.
|
|
pub async fn serve_media(
|
|
State(state): State<AppState>,
|
|
Path((token, key, id)): Path<(String, String, String)>,
|
|
) -> AppResult<Response> {
|
|
let stored: Option<String> = sqlx::query(
|
|
"SELECT n.share_key_hash
|
|
FROM node_media nm
|
|
JOIN nodes n ON n.id = nm.node_id
|
|
WHERE nm.media_id = ?1 AND n.share_token = ?2",
|
|
)
|
|
.bind(&id)
|
|
.bind(&token)
|
|
.fetch_optional(&state.db)
|
|
.await?
|
|
.and_then(|row| row.get("share_key_hash"));
|
|
|
|
if !key_matches(stored.as_deref(), &key) {
|
|
return Err(AppError::NotFound);
|
|
}
|
|
media::stream_media(&state, &id, false).await
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{hash_key, key_matches, random_secret};
|
|
|
|
#[test]
|
|
fn a_key_matches_only_its_own_hash() {
|
|
let key = random_secret();
|
|
assert!(key_matches(Some(&hash_key(&key)), &key));
|
|
assert!(!key_matches(Some(&hash_key(&key)), &random_secret()));
|
|
assert!(!key_matches(Some(&hash_key(&key)), ""));
|
|
}
|
|
|
|
#[test]
|
|
fn the_stored_hash_is_not_the_key() {
|
|
let key = random_secret();
|
|
let stored = hash_key(&key);
|
|
assert_ne!(stored, key);
|
|
// The hash itself must not open the link either.
|
|
assert!(!key_matches(Some(&stored), &stored));
|
|
}
|
|
|
|
#[test]
|
|
fn a_share_without_a_hash_stays_shut() {
|
|
assert!(!key_matches(None, &random_secret()));
|
|
assert!(!key_matches(None, ""));
|
|
}
|
|
|
|
#[test]
|
|
fn secrets_do_not_repeat() {
|
|
assert_ne!(random_secret(), random_secret());
|
|
}
|
|
}
|