feat(users): UserManager with per-user SQLCipher, and extract skald-core crate

Two changes developed together in one session; they share the same module
structure (db/mod.rs, the core lib root) and only compile together, so they
land as one commit.

## UserManager + per-user encryption (§9/§11)

New `users::UserManager`: owns the system.db pool plus a map
`userid -> SqlitePool` of unlocked databases. The pool *is* the unlock token —
its connect options carry the DEK as SQLCipher's raw key, so an open pool means
the key is in RAM until restart and dropping it re-locks (§9). Knows nothing
about cookies.

New `crypto` module: envelope encryption. A random 256-bit DEK encrypts
`{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under
`Argon2id(password, salt)`. The AEAD tag is the password verifier — one
derivation both authenticates and yields the key, so encrypted users store no
second hash. Cleartext users store the Argon2id output directly, compared in
constant time. Argon2 runs in spawn_blocking behind a 2-permit semaphore
(256 MiB per derivation).

- SQLCipher via `libsqlite3-sys` `bundled-sqlcipher-vendored-openssl`, pinned
  <0.38 so it unifies with the one sqlx-sqlite links (a newer copy would apply
  the feature to a SQLite sqlx never uses). OpenSSL is vendored and static, so
  the binary stays self-contained.
- Schema split into `create_registry_tables` (instance-wide, no user key) and
  `create_owner_tables` (one owner's content, identical in every file). No FK in
  the owner bucket may reach the registry — enforced by a standalone test.
  Dropped `chat_history.model_db_id` (write-only, and the only registry-crossing
  key); moved `projects`/`project_tickets` into the owner bucket.
- Provisioning invariant: the file is written before the row, deleted after it,
  so a crash leaves an orphan file, never a user without a database. `open_db`
  never creates: a missing file is an error, not a silent empty database.

Not consumed yet: no login, call sites still use the shared system.db pool.

## Extract crates/skald-core

The headless core moves out of `src/` into its own crate; `skald` (server) and
the coming `skald-setup` are shells around it. Two dependencies on the shell
were inverted rather than dragged along, so the core names neither Tauri nor any
concrete plugin:

- `Plugin::tools(self: Arc<Self>)` — plugins contribute tools through this hook
  (sibling of `http_router`), so the core no longer downcasts to
  `MobileConnectorPlugin`.
- `tools::restart::set_restart_handler` — the desktop shell installs its
  teardown-and-respawn; the core defaults to the supervisor exit code. The core
  loses its `desktop` feature.
- `boot`'s stdout formatter moves to the binary (`src/boot_format.rs`); the core
  only emits tracing events.

All 79 core tests pass; the binary boots and serves in a clean directory, and
the mobile-connector tools still register through the new hook.
This commit is contained in:
2026-07-10 16:48:51 +01:00
parent 38494a85a9
commit 178a38357e
173 changed files with 2650 additions and 1106 deletions
+697
View File
@@ -0,0 +1,697 @@
//! `UserManager` — the user directory, authentication, and the registry of
//! unlocked databases (blueprint §9 / §11).
//!
//! It owns the registry pool (`system.db`) and a map `userid → SqlitePool` of the
//! per-user databases that are currently **unlocked**.
//!
//! The pool *is* the unlock token. Its connect options carry the DEK as
//! SQLCipher's raw key, so an open pool means the key is in RAM and a dropped
//! pool means the database is locked again. There is no separate key registry to
//! keep in sync, and §9's lifetime — unlocked from first login until the process
//! restarts — falls out of the map's lifetime.
//!
//! Three responsibilities live here on purpose: directory CRUD, credential
//! checking, and pool lifecycle. The seam for splitting them (`UserDirectory` /
//! `UserVault`) is the `Credentials` boundary, if this ever grows.
//!
//! **Boundary**: this is credential-check plus pool lifecycle. Whatever maps an
//! HTTP session or a token to a user id sits above it. `UserManager` knows
//! nothing about cookies.
use std::collections::HashMap;
use std::collections::hash_map::Entry;
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use anyhow::{Result, anyhow, bail};
use sqlx::SqlitePool;
use tracing::{info, warn};
use crate::crypto::{self, Dek, KdfParams, KeyError};
use crate::db::{self, DATABASE_DIR};
use crate::db::users::{ClearVerifier, Credentials, User, UserSummary};
/// Why a login did not happen. Deliberately not `anyhow`: the caller has to be
/// able to tell "wrong password" from "the file is gone", and answer the two very
/// differently.
#[derive(Debug)]
pub enum AuthError {
UnknownUser,
Inactive,
/// The AEAD tag on the sealed DEK did not verify, or the stored verifier did
/// not match. One answer for both, so nothing distinguishes them from outside.
WrongPassword,
/// The user has credentials but none were supplied.
PasswordRequired,
/// The user has no verifier at all; offering a password is a caller bug.
PasswordNotSet,
/// The row exists and the password was right, but `{userid}.db` is not there.
/// Never recreated: a fresh empty database under the correct password would
/// hide the loss instead of reporting it.
MissingDatabase(PathBuf),
Internal(anyhow::Error),
}
impl fmt::Display for AuthError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AuthError::UnknownUser => f.write_str("no such user"),
AuthError::Inactive => f.write_str("user is not active"),
AuthError::WrongPassword => f.write_str("wrong password"),
AuthError::PasswordRequired => f.write_str("this user requires a password"),
AuthError::PasswordNotSet => f.write_str("this user has no password"),
AuthError::MissingDatabase(p) => {
write!(f, "database file is missing: {}", p.display())
}
AuthError::Internal(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for AuthError {}
impl From<anyhow::Error> for AuthError {
fn from(e: anyhow::Error) -> Self {
AuthError::Internal(e)
}
}
impl From<sqlx::Error> for AuthError {
fn from(e: sqlx::Error) -> Self {
AuthError::Internal(e.into())
}
}
pub struct UserManager {
/// `system.db`: the directory, read before anything else can be opened.
system: Arc<SqlitePool>,
/// The unlocked databases. A `std::sync::RwLock` on purpose — holding it
/// across an `.await` would make the future `!Send` and stop compiling, which
/// is the enforcement we want.
unlocked: RwLock<HashMap<String, SqlitePool>>,
/// Where `{userid}.db` files live, and the KDF cost applied to new users.
/// Both are fields rather than constants so tests need neither the process's
/// working directory nor a real 256 MiB derivation.
db_dir: PathBuf,
kdf: KdfParams,
}
impl UserManager {
pub fn new(system: Arc<SqlitePool>) -> Self {
Self {
system,
unlocked: RwLock::new(HashMap::new()),
db_dir: PathBuf::from(DATABASE_DIR),
kdf: KdfParams::default(),
}
}
fn path_of(&self, id: &str) -> PathBuf {
db::user_db_path(&self.db_dir, id)
}
/// The registry pool. Not a user's database — the directory everything else
/// is looked up in.
pub fn system(&self) -> &SqlitePool {
&self.system
}
// ── Directory reads ───────────────────────────────────────────────────────
pub async fn list(&self) -> Result<Vec<UserSummary>> {
Ok(db::users::list(&self.system).await?.iter().map(User::summary).collect())
}
pub async fn get(&self, id: &str) -> Result<Option<UserSummary>> {
Ok(db::users::get(&self.system, id).await?.map(|u| u.summary()))
}
pub async fn by_username(&self, username: &str) -> Result<Option<UserSummary>> {
Ok(db::users::by_username(&self.system, username).await?.map(|u| u.summary()))
}
pub async fn count(&self) -> Result<i64> {
db::users::count(&self.system).await
}
// ── Unlock registry ───────────────────────────────────────────────────────
/// The user's pool, or `None` when the database is still locked (§9).
/// Returns a clone: `SqlitePool` is an `Arc` internally, so this is cheap.
pub fn pool_of(&self, id: &str) -> Option<SqlitePool> {
self.unlocked.read().ok()?.get(id).cloned()
}
pub fn is_unlocked(&self, id: &str) -> bool {
self.unlocked.read().map(|m| m.contains_key(id)).unwrap_or(false)
}
/// Login and unlock in one operation.
///
/// For an encrypted user a single Argon2id pass answers both questions: the
/// sealed DEK either opens — password right, and here is the key — or its
/// AEAD tag fails, which is the wrong password. There is no second hash to
/// check, and none to steal from `system.db`.
///
/// Idempotent: an already-unlocked user gets the existing pool back without
/// re-deriving anything.
///
/// Note that an unknown user answers immediately while a real one costs a
/// full derivation. Under the threat model (§2) the adversary owns the box
/// and can simply read `users`, so this timing difference buys nothing; a
/// public login endpoint would want to think about it again.
pub async fn open_db(&self, id: &str, password: Option<&str>) -> Result<SqlitePool, AuthError> {
if let Some(pool) = self.pool_of(id) {
return Ok(pool);
}
let user = db::users::get(&self.system, id)
.await
.map_err(AuthError::Internal)?
.ok_or(AuthError::UnknownUser)?;
if !user.active {
return Err(AuthError::Inactive);
}
let key = self.authenticate(&user, password).await?;
// The row exists, so the file must too — `register_user` writes it first.
let path = self.path_of(id);
if !path.exists() {
return Err(AuthError::MissingDatabase(path));
}
let pool = db::open_user_pool(&path, key.as_ref())
.await
.map_err(AuthError::Internal)?;
// Another task may have unlocked the same user while we were deriving.
// Whoever landed first wins; ours is closed below, outside the lock,
// since `close()` is async.
let winner = {
let mut map = self.unlocked.write().expect("unlocked map poisoned");
match map.entry(id.to_string()) {
Entry::Occupied(e) => Some(e.get().clone()),
Entry::Vacant(e) => {
e.insert(pool.clone());
None
}
}
};
match winner {
Some(winner) => {
pool.close().await;
Ok(winner)
}
None => {
info!(user = %id, encrypted = user.is_encrypted(), "user database unlocked");
Ok(pool)
}
}
}
/// Verifies the password and, for an encrypted user, recovers the DEK.
/// `Ok(None)` means the user's database is not encrypted.
async fn authenticate(
&self,
user: &User,
password: Option<&str>,
) -> Result<Option<Dek>, AuthError> {
match &user.credentials {
Credentials::Encrypted { kdf_params, kdf_salt, database_password } => {
let pw = password.ok_or(AuthError::PasswordRequired)?;
let params = KdfParams::from_json(kdf_params)?;
let kek = crypto::derive_kek(pw, kdf_salt, &params).await?;
match crypto::unwrap_dek(&kek, database_password) {
Ok(dek) => Ok(Some(dek)),
Err(KeyError::WrongPassword) => Err(AuthError::WrongPassword),
Err(e) => Err(AuthError::Internal(anyhow!(e))),
}
}
Credentials::Cleartext(Some(v)) => {
let pw = password.ok_or(AuthError::PasswordRequired)?;
let params = KdfParams::from_json(&v.kdf_params)?;
let kek = crypto::derive_kek(pw, &v.kdf_salt, &params).await?;
if !crypto::verify(&kek, &v.password_hash) {
return Err(AuthError::WrongPassword);
}
Ok(None)
}
// A role that has no login of its own. Who is allowed to ask for this
// pool is the caller's problem — see the boundary note on the module.
Credentials::Cleartext(None) => {
if password.is_some() {
return Err(AuthError::PasswordNotSet);
}
Ok(None)
}
}
}
/// Drops the pool, which drops the key: the database is opaque again until
/// the next login.
pub async fn lock(&self, id: &str) {
let pool = self.unlocked.write().expect("unlocked map poisoned").remove(id);
if let Some(p) = pool {
p.close().await;
info!(user = %id, "user database locked");
}
}
/// Shutdown: every key leaves RAM.
pub async fn lock_all(&self) {
let pools: Vec<_> = {
let mut map = self.unlocked.write().expect("unlocked map poisoned");
map.drain().collect()
};
for (id, pool) in pools {
pool.close().await;
info!(user = %id, "user database locked");
}
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
/// Creates a user: opaque id, database file, then the directory row.
///
/// The order is load-bearing. The row lives in `system.db` and the database
/// in its own file, and no transaction spans the two. Writing the file first
/// means a crash in between leaves an orphan file — harmless garbage — rather
/// than a row whose database never existed, which would be a user that can
/// authenticate and then find nothing to open.
///
/// The new pool is closed before returning: creating a user is not logging in
/// as them. The DEK is dropped here and survives only inside the seal.
pub async fn register_user(
&self,
username: &str,
display_name: Option<&str>,
role_id: &str,
password: Option<&str>,
encrypted: bool,
) -> Result<String> {
if username.trim().is_empty() {
bail!("username must not be empty");
}
if db::users::by_username(&self.system, username).await?.is_some() {
bail!("username already taken: {username}");
}
let id = uuid::Uuid::new_v4().to_string();
let (credentials, dek) = self.mint_credentials(password, encrypted).await?;
let path = self.path_of(&id);
let pool = db::create_user_pool(&path, dek.as_ref())
.await
.with_context_path(&path)?;
pool.close().await;
if let Err(e) =
db::users::insert(&self.system, &id, username, display_name, role_id, &credentials).await
{
// The row never landed, so nothing points at this file. Take it back
// out rather than leaving a database nobody can reach.
self.remove_files(&path);
return Err(e);
}
info!(user = %id, %username, encrypted, "user registered");
Ok(id)
}
/// Builds the auth material for a new user, and the DEK when encrypted.
async fn mint_credentials(
&self,
password: Option<&str>,
encrypted: bool,
) -> Result<(Credentials, Option<Dek>)> {
match (encrypted, password) {
(true, None) => bail!("an encrypted user needs a password"),
(true, Some(pw)) => {
let salt = crypto::random_salt();
let kek = crypto::derive_kek(pw, &salt, &self.kdf).await?;
let dek = Dek::random();
let sealed = crypto::wrap_dek(&kek, &dek)?;
let creds = Credentials::Encrypted {
kdf_params: self.kdf.to_json()?,
kdf_salt: salt,
database_password: sealed,
};
Ok((creds, Some(dek)))
}
(false, Some(pw)) => {
let salt = crypto::random_salt();
let kek = crypto::derive_kek(pw, &salt, &self.kdf).await?;
let creds = Credentials::Cleartext(Some(ClearVerifier {
kdf_params: self.kdf.to_json()?,
kdf_salt: salt,
password_hash: kek.as_verifier().to_vec(),
}));
Ok((creds, None))
}
(false, None) => Ok((Credentials::Cleartext(None), None)),
}
}
/// Erases a user: the row first, then the file.
///
/// The mirror of [`Self::register_user`], and for the same reason — a crash
/// after the row is gone leaves an unreachable file, never a row without a
/// database. GDPR erasure is then a matter of deleting files.
pub async fn delete_user(&self, id: &str) -> Result<()> {
self.lock(id).await;
db::users::delete(&self.system, id).await?;
self.remove_files(&self.path_of(id));
info!(user = %id, "user deleted");
Ok(())
}
/// Re-seals the same DEK under a key derived from the new password.
///
/// The database is never re-encrypted and an unlocked pool stays valid: only
/// the seal in `system.db` changes. Failing to open the old seal *is* the
/// check that the old password was right.
///
/// Migrating between encrypted and cleartext is **not** done here — that one
/// needs `sqlcipher_export` to rewrite the file.
pub async fn change_password(
&self,
id: &str,
old: Option<&str>,
new: Option<&str>,
) -> Result<(), AuthError> {
let user = db::users::get(&self.system, id)
.await
.map_err(AuthError::Internal)?
.ok_or(AuthError::UnknownUser)?;
let credentials = match &user.credentials {
Credentials::Encrypted { kdf_params, kdf_salt, database_password } => {
let old = old.ok_or(AuthError::PasswordRequired)?;
let new = new.ok_or_else(|| {
AuthError::Internal(anyhow!("an encrypted user cannot drop its password"))
})?;
let params = KdfParams::from_json(kdf_params)?;
let kek_old = crypto::derive_kek(old, kdf_salt, &params).await?;
let dek = match crypto::unwrap_dek(&kek_old, database_password) {
Ok(dek) => dek,
Err(KeyError::WrongPassword) => return Err(AuthError::WrongPassword),
Err(e) => return Err(AuthError::Internal(anyhow!(e))),
};
let salt = crypto::random_salt();
let kek_new = crypto::derive_kek(new, &salt, &self.kdf).await?;
Credentials::Encrypted {
kdf_params: self.kdf.to_json()?,
kdf_salt: salt,
database_password: crypto::wrap_dek(&kek_new, &dek)?,
}
}
Credentials::Cleartext(existing) => {
match (existing, old) {
(Some(v), Some(old)) => {
let params = KdfParams::from_json(&v.kdf_params)?;
let kek = crypto::derive_kek(old, &v.kdf_salt, &params).await?;
if !crypto::verify(&kek, &v.password_hash) {
return Err(AuthError::WrongPassword);
}
}
(Some(_), None) => return Err(AuthError::PasswordRequired),
(None, Some(_)) => return Err(AuthError::PasswordNotSet),
(None, None) => {}
}
match new {
None => Credentials::Cleartext(None),
Some(new) => {
let salt = crypto::random_salt();
let kek = crypto::derive_kek(new, &salt, &self.kdf).await?;
Credentials::Cleartext(Some(ClearVerifier {
kdf_params: self.kdf.to_json()?,
kdf_salt: salt,
password_hash: kek.as_verifier().to_vec(),
}))
}
}
}
};
db::users::set_credentials(&self.system, id, &credentials)
.await
.map_err(AuthError::Internal)?;
info!(user = %id, "password changed");
Ok(())
}
/// Best-effort: a leftover file is garbage, not a correctness problem, and a
/// failure to remove it must not fail the operation that asked.
fn remove_files(&self, path: &Path) {
for p in std::iter::once(path.to_path_buf()).chain(db::user_db_sidecars(path)) {
if p.exists()
&& let Err(e) = std::fs::remove_file(&p)
{
warn!(path = %p.display(), error = %e, "could not remove database file");
}
}
}
}
/// Adds the file path to a provisioning failure, which otherwise surfaces as a
/// bare sqlx error with no hint of which database it was.
trait ContextPath<T> {
fn with_context_path(self, path: &Path) -> Result<T>;
}
impl<T> ContextPath<T> for Result<T> {
fn with_context_path(self, path: &Path) -> Result<T> {
self.map_err(|e| e.context(format!("provisioning {}", path.display())))
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Fixture {
dir: PathBuf,
users: UserManager,
}
impl Fixture {
async fn new(tag: &str) -> Self {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
let mut dir = std::env::temp_dir();
dir.push(format!("skald-um-{tag}-{}-{nanos}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let system = db::init_system_pool(dir.join("system.db").to_str().unwrap())
.await
.unwrap();
let users = UserManager {
system: Arc::new(system),
unlocked: RwLock::new(HashMap::new()),
db_dir: dir.clone(),
// The real 256 MiB / ~1s derivation, times two per test, is not
// something a test suite should pay.
kdf: KdfParams::fast(),
};
Fixture { dir, users }
}
fn path_of(&self, id: &str) -> PathBuf {
self.users.path_of(id)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.dir);
}
}
async fn write_marker(pool: &SqlitePool, title: &str) {
sqlx::query("INSERT INTO chat_sessions (title) VALUES (?1)")
.bind(title)
.execute(pool)
.await
.unwrap();
}
async fn read_marker(pool: &SqlitePool) -> String {
sqlx::query_scalar::<_, String>("SELECT title FROM chat_sessions LIMIT 1")
.fetch_one(pool)
.await
.unwrap()
}
#[tokio::test]
async fn register_writes_the_file_before_the_row() {
let f = Fixture::new("order").await;
let id = f.users.register_user("ada", Some("Ada"), "admin", Some("pw"), true).await.unwrap();
assert!(f.path_of(&id).exists(), "the database file must exist");
assert_eq!(f.users.count().await.unwrap(), 1);
// Creating a user is not logging in as them.
assert!(!f.users.is_unlocked(&id), "register must not leave the pool unlocked");
}
#[tokio::test]
async fn a_duplicate_username_leaves_no_orphan_file() {
let f = Fixture::new("dup").await;
f.users.register_user("ada", None, "admin", Some("pw"), true).await.unwrap();
let before: Vec<_> = std::fs::read_dir(&f.dir).unwrap().collect();
assert!(f.users.register_user("ada", None, "admin", Some("pw2"), true).await.is_err());
let after: Vec<_> = std::fs::read_dir(&f.dir).unwrap().collect();
assert_eq!(before.len(), after.len(), "the failed registration must clean up its file");
assert_eq!(f.users.count().await.unwrap(), 1);
}
/// The full §9 cycle: unlock at login, key in RAM until the pool is dropped.
#[tokio::test]
async fn encrypted_user_round_trips_through_lock_and_unlock() {
let f = Fixture::new("cycle").await;
let id = f.users.register_user("ada", None, "admin", Some("pw"), true).await.unwrap();
let pool = f.users.open_db(&id, Some("pw")).await.unwrap();
write_marker(&pool, "private").await;
assert!(f.users.is_unlocked(&id));
f.users.lock(&id).await;
assert!(!f.users.is_unlocked(&id));
assert!(f.users.pool_of(&id).is_none(), "locked means no pool");
let pool = f.users.open_db(&id, Some("pw")).await.unwrap();
assert_eq!(read_marker(&pool).await, "private");
// The file on disk is genuinely encrypted, not merely gated by our code.
let raw = std::fs::read(f.path_of(&id)).unwrap();
assert_ne!(&raw[..16], b"SQLite format 3\0");
assert!(!String::from_utf8_lossy(&raw).contains("private"));
}
#[tokio::test]
async fn a_wrong_password_is_rejected_and_unlocks_nothing() {
let f = Fixture::new("wrongpw").await;
let id = f.users.register_user("ada", None, "admin", Some("pw"), true).await.unwrap();
let err = f.users.open_db(&id, Some("nope")).await.unwrap_err();
assert!(matches!(err, AuthError::WrongPassword), "got {err:?}");
assert!(!f.users.is_unlocked(&id));
assert!(matches!(f.users.open_db(&id, None).await.unwrap_err(), AuthError::PasswordRequired));
assert!(matches!(f.users.open_db("ghost", Some("pw")).await.unwrap_err(), AuthError::UnknownUser));
}
/// A row without a file is the one state the ordering rule forbids. If it
/// happens anyway, say so — never silently hand back an empty database.
#[tokio::test]
async fn a_missing_file_is_reported_not_recreated() {
let f = Fixture::new("missing").await;
let id = f.users.register_user("ada", None, "admin", Some("pw"), true).await.unwrap();
std::fs::remove_file(f.path_of(&id)).unwrap();
let err = f.users.open_db(&id, Some("pw")).await.unwrap_err();
assert!(matches!(err, AuthError::MissingDatabase(_)), "got {err:?}");
assert!(!f.path_of(&id).exists(), "a failed login must not create a database");
}
#[tokio::test]
async fn changing_the_password_reseals_the_same_key() {
let f = Fixture::new("rewrap").await;
let id = f.users.register_user("ada", None, "admin", Some("old"), true).await.unwrap();
let pool = f.users.open_db(&id, Some("old")).await.unwrap();
write_marker(&pool, "kept").await;
f.users.change_password(&id, Some("old"), Some("new")).await.unwrap();
// The DEK never changed, so the pool we are holding is still live.
assert_eq!(read_marker(&pool).await, "kept");
f.users.lock(&id).await;
assert!(matches!(
f.users.change_password(&id, Some("old"), Some("x")).await.unwrap_err(),
AuthError::WrongPassword
));
// ...and the data is still there, reachable only with the new password.
assert!(f.users.open_db(&id, Some("old")).await.is_err());
let pool = f.users.open_db(&id, Some("new")).await.unwrap();
assert_eq!(read_marker(&pool).await, "kept");
}
#[tokio::test]
async fn cleartext_users_authenticate_without_encrypting_anything() {
let f = Fixture::new("clear").await;
let pinned = f.users.register_user("kid", None, "children", Some("pin"), false).await.unwrap();
let open = f.users.register_user("kiosk", None, "children", None, false).await.unwrap();
// A verifier is checked; the database itself is a plain SQLite file, which
// is what makes supervision possible at all (§12).
assert!(matches!(f.users.open_db(&pinned, Some("no")).await.unwrap_err(), AuthError::WrongPassword));
let pool = f.users.open_db(&pinned, Some("pin")).await.unwrap();
write_marker(&pool, "homework").await;
assert_eq!(&std::fs::read(f.path_of(&pinned)).unwrap()[..16], b"SQLite format 3\0");
// No verifier: no password to give, and offering one is a caller bug.
assert!(matches!(f.users.open_db(&open, Some("x")).await.unwrap_err(), AuthError::PasswordNotSet));
f.users.open_db(&open, None).await.unwrap();
}
#[tokio::test]
async fn open_db_is_idempotent_and_returns_the_same_pool() {
let f = Fixture::new("idem").await;
let id = f.users.register_user("ada", None, "admin", Some("pw"), true).await.unwrap();
let a = f.users.open_db(&id, Some("pw")).await.unwrap();
// No password needed the second time: the pool *is* the unlock token.
let b = f.users.open_db(&id, None).await.unwrap();
write_marker(&a, "shared").await;
assert_eq!(read_marker(&b).await, "shared");
}
#[tokio::test]
async fn deleting_a_user_evicts_the_pool_and_erases_the_files() {
let f = Fixture::new("erase").await;
let id = f.users.register_user("ada", None, "admin", Some("pw"), true).await.unwrap();
let pool = f.users.open_db(&id, Some("pw")).await.unwrap();
write_marker(&pool, "gone").await;
f.users.delete_user(&id).await.unwrap();
assert!(!f.users.is_unlocked(&id));
assert_eq!(f.users.count().await.unwrap(), 0);
assert!(!f.path_of(&id).exists());
for sidecar in db::user_db_sidecars(&f.path_of(&id)) {
assert!(!sidecar.exists(), "{} survived erasure", sidecar.display());
}
}
#[tokio::test]
async fn lock_all_drops_every_key() {
let f = Fixture::new("lockall").await;
let a = f.users.register_user("ada", None, "admin", Some("pw"), true).await.unwrap();
let b = f.users.register_user("bob", None, "admin", None, false).await.unwrap();
f.users.open_db(&a, Some("pw")).await.unwrap();
f.users.open_db(&b, None).await.unwrap();
f.users.lock_all().await;
assert!(!f.users.is_unlocked(&a) && !f.users.is_unlocked(&b));
}
#[tokio::test]
async fn auth_errors_never_carry_key_material() {
let f = Fixture::new("noleak").await;
let id = f.users.register_user("ada", None, "admin", Some("hunter2"), true).await.unwrap();
let err = f.users.open_db(&id, Some("hunter2!")).await.unwrap_err();
let printed = format!("{err:?} {err}");
assert!(!printed.contains("hunter2"), "the password must not reach an error");
}
}