Files
narl-workspace/src/auth.rs
T
nvrl a641d9b6ba 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.
2026-10-19 16:03:59 +02:00

191 lines
6.4 KiB
Rust

use axum::{
extract::FromRequestParts,
http::{header, request::Parts},
};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD as B64, Engine};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use subtle::ConstantTimeEq;
use crate::{config::Config, error::AppError, state::AppState};
// Kept from before the rename: the name is never seen, and changing it would
// sign every browser out to no purpose.
pub const COOKIE_NAME: &str = "diary_session";
type HmacSha256 = Hmac<Sha256>;
fn sign(secret: &[u8], payload: &str) -> String {
let mut mac = HmacSha256::new_from_slice(secret).expect("hmac accepts any key length");
mac.update(payload.as_bytes());
B64.encode(mac.finalize().into_bytes())
}
/// A session token is `base64(expiry_unix_seconds).base64(hmac)`. There is no
/// server-side session store: rotating `WORKSPACE_SECRET` invalidates every session.
pub fn issue_token(config: &Config) -> String {
let expires = crate::now() + config.session_days * 86_400;
let payload = B64.encode(expires.to_string());
let signature = sign(&config.secret, &payload);
format!("{payload}.{signature}")
}
pub fn verify_token(config: &Config, token: &str) -> bool {
let Some((payload, signature)) = token.split_once('.') else {
return false;
};
let expected = sign(&config.secret, payload);
if !bool::from(expected.as_bytes().ct_eq(signature.as_bytes())) {
return false;
}
let Ok(decoded) = B64.decode(payload) else {
return false;
};
let Ok(expires) = String::from_utf8_lossy(&decoded).parse::<i64>() else {
return false;
};
expires > crate::now()
}
pub fn credentials_match(config: &Config, username: &str, password: &str) -> bool {
let user_ok = username.as_bytes().ct_eq(config.username.as_bytes());
let pass_ok = password.as_bytes().ct_eq(config.password.as_bytes());
bool::from(user_ok & pass_ok)
}
pub fn session_cookie(config: &Config, token: &str) -> String {
let max_age = config.session_days * 86_400;
let secure = if config.secure_cookie { "; Secure" } else { "" };
format!(
"{COOKIE_NAME}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}"
)
}
pub fn clear_cookie(config: &Config) -> String {
let secure = if config.secure_cookie { "; Secure" } else { "" };
format!("{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure}")
}
fn cookie_value<'a>(parts: &'a Parts, name: &str) -> Option<&'a str> {
parts
.headers
.get_all(header::COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(';'))
.filter_map(|pair| pair.split_once('='))
.find(|(k, _)| k.trim() == name)
.map(|(_, v)| v.trim())
}
/// Extractor that rejects the request unless a valid session cookie is present.
pub struct Session;
impl FromRequestParts<AppState> for Session {
type Rejection = AppError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let token = cookie_value(parts, COOKIE_NAME).ok_or(AppError::Unauthorized)?;
if verify_token(&state.config, token) {
Ok(Session)
} else {
Err(AppError::Unauthorized)
}
}
}
#[cfg(test)]
mod tests {
use super::{credentials_match, issue_token, verify_token};
use crate::config::{BackupConfig, Config, MailConfig, SftpConfig};
fn config(session_days: i64) -> Config {
Config {
username: "narl".into(),
password: "hunter2".into(),
secret: b"a-secret-of-at-least-16-bytes".to_vec(),
bind: "127.0.0.1:0".parse().unwrap(),
data_dir: ".".into(),
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,
debounce: std::time::Duration::from_secs(0),
prune: false,
},
mail: MailConfig {
url: None,
from: String::new(),
to: String::new(),
timezone: chrono_tz::UTC,
reminder_at: None,
card_due_at: None,
digest_at: None,
kinds: Vec::new(),
},
sftp: SftpConfig { bind: None },
}
}
#[test]
fn a_fresh_token_verifies() {
let config = config(30);
assert!(verify_token(&config, &issue_token(&config)));
}
#[test]
fn an_expired_token_does_not() {
let config = config(-1);
assert!(!verify_token(&config, &issue_token(&config)));
}
#[test]
fn rotating_the_secret_invalidates_every_session() {
let token = issue_token(&config(30));
let mut rotated = config(30);
rotated.secret = b"a-different-secret-entirely".to_vec();
assert!(!verify_token(&rotated, &token));
}
#[test]
fn a_tampered_token_does_not_verify() {
let config = config(30);
let token = issue_token(&config);
let (payload, signature) = token.split_once('.').unwrap();
// A far-future expiry the holder signed themselves.
let forged = base64::Engine::encode(&super::B64, "99999999999");
assert!(!verify_token(&config, &format!("{forged}.{signature}")));
// The real expiry with the last byte of the signature flipped.
let mut bytes = signature.as_bytes().to_vec();
bytes[0] = if bytes[0] == b'A' { b'B' } else { b'A' };
let flipped = String::from_utf8(bytes).unwrap();
assert!(!verify_token(&config, &format!("{payload}.{flipped}")));
}
#[test]
fn malformed_tokens_are_rejected_rather_than_panicking() {
let config = config(30);
for token in ["", ".", "no-dot", "!!!.!!!", "a.b.c", &".".repeat(100)] {
assert!(!verify_token(&config, token), "{token:?}");
}
}
#[test]
fn credentials_must_match_exactly() {
let config = config(30);
assert!(credentials_match(&config, "narl", "hunter2"));
assert!(!credentials_match(&config, "narl", "hunter"));
assert!(!credentials_match(&config, "narl", "hunter22"));
assert!(!credentials_match(&config, "nar", "hunter2"));
assert!(!credentials_match(&config, "", ""));
}
}