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
+363
View File
@@ -0,0 +1,363 @@
//! Envelope encryption for the per-user databases (blueprint §4 / §5.1).
//!
//! A user's database is encrypted by SQLCipher under a random 256-bit **DEK**.
//! The DEK is never stored in the clear: `users.database_password` holds it
//! sealed with AES-256-GCM under a **KEK** derived from the password with
//! Argon2id.
//!
//! That seal *is* the password verifier. Opening it either yields the DEK — in
//! which case the password was right — or fails the AEAD tag, which means a
//! wrong password, cleanly distinct from a corrupt file. Storing a second hash
//! of the same password beside the seal would only hand an offline attacker an
//! easier target than the seal itself, so encrypted users have no
//! `password_hash` at all.
//!
//! Cleartext users have no database key to bind a verifier to, so they store the
//! Argon2id output directly and it is compared in constant time. Its
//! crackability is harmless: their database is readable by the box owner by
//! design.
//!
//! Nothing here invents a construction — it composes Argon2id, AES-256-GCM and a
//! CSPRNG. Changing the password re-seals the same DEK; the database itself is
//! never re-encrypted.
use std::fmt::{self, Write as _};
use std::sync::LazyLock;
use aes_gcm::aead::Aead;
use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
use anyhow::{Context, Result, anyhow, bail};
use argon2::{Algorithm, Argon2, Params, Version};
use rand::Rng as _;
use serde::{Deserialize, Serialize};
use subtle::ConstantTimeEq;
use tokio::sync::Semaphore;
use zeroize::{Zeroize, ZeroizeOnDrop};
/// 256-bit keys throughout: AES-256-GCM for the seal, raw SQLCipher key for the
/// database.
const KEY_LEN: usize = 32;
const NONCE_LEN: usize = 12;
const TAG_LEN: usize = 16;
/// Sealed layout: `nonce ‖ ciphertext ‖ tag`.
const SEALED_LEN: usize = NONCE_LEN + KEY_LEN + TAG_LEN;
pub const SALT_LEN: usize = 16;
/// The only algorithm we accept. Stored per row so it can change later without a
/// migration, but a row asking for anything else is a bug, not a fallback.
const ALGO: &str = "argon2id";
/// Argon2id at 256 MiB is a memory bomb if it runs unbounded: every concurrent
/// login would allocate its own arena. Two at a time keeps a login responsive
/// while capping the peak at ~2× `KdfParams::m`.
///
/// Deliberately a module-level invariant rather than a caller's responsibility —
/// a forgotten permit is a memory incident, not a style problem.
static KDF_GATE: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::new(2));
// ── KDF parameters ────────────────────────────────────────────────────────────
/// Serialized into `users.kdf_params`. Not secret: calibrated on the box when the
/// user is created, and kept per row so raising the cost later needs no migration.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct KdfParams {
pub algo: String,
/// Memory cost, in KiB.
pub m: u32,
/// Time cost (passes).
pub t: u32,
/// Parallelism (lanes).
pub p: u32,
}
impl Default for KdfParams {
/// 256 MiB, ~1s on the weakest box we target.
///
/// Between the OWASP baseline (64 MiB) and the 512 MiB1 GiB of §5.1: four
/// times the cost for an attacker with a GPU, while two concurrent logins
/// peak at 512 MiB rather than 2 GiB and never reach swap.
fn default() -> Self {
Self { algo: ALGO.to_string(), m: 262_144, t: 3, p: 1 }
}
}
impl KdfParams {
pub fn to_json(&self) -> Result<String> {
serde_json::to_string(self).context("serializing kdf_params")
}
pub fn from_json(s: &str) -> Result<Self> {
let p: Self = serde_json::from_str(s).context("parsing kdf_params")?;
if p.algo != ALGO {
bail!("unsupported kdf algorithm: {}", p.algo);
}
Ok(p)
}
/// Cheap parameters, tests only: the real ones cost ~1s and 256 MiB per
/// derivation, which a test suite pays on every register and every login.
#[cfg(test)]
pub fn fast() -> Self {
Self { algo: ALGO.to_string(), m: 64, t: 1, p: 1 }
}
}
// ── Keys ──────────────────────────────────────────────────────────────────────
/// Data Encryption Key: the raw SQLCipher key for one user's database. Random,
/// never derived from the password, so changing the password does not re-encrypt
/// anything.
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
pub struct Dek([u8; KEY_LEN]);
/// Key Encryption Key: `Argon2id(password, salt)`. Seals the [`Dek`], and for a
/// cleartext user its raw bytes *are* the stored verifier.
#[derive(Clone, Zeroize, ZeroizeOnDrop)]
pub struct Kek([u8; KEY_LEN]);
impl Dek {
pub fn random() -> Self {
let mut k = [0u8; KEY_LEN];
rand::rng().fill_bytes(&mut k);
Self(k)
}
/// The `PRAGMA key` value for a raw 256-bit key.
///
/// The double quotes are **part of the value**: sqlx pastes it verbatim into
/// `PRAGMA key = {value};`, and SQLCipher only treats the argument as a raw
/// key when it parses as the blob literal `x'…'`. Given anything else it
/// runs its own KDF over the bytes instead, silently deriving a *different*
/// key from our hex digits — and the database would open, just not the one
/// we meant.
///
/// The returned string is key material. It must never be logged, and it
/// outlives this call inside the pool's connect options.
pub fn to_pragma(&self) -> String {
// `"x'` + 64 hex digits + `'"`
let mut s = String::with_capacity(5 + 2 * KEY_LEN);
s.push_str("\"x'");
for b in self.0 {
let _ = write!(s, "{b:02x}");
}
s.push_str("'\"");
s
}
}
impl Kek {
/// The verifier stored in `users.password_hash` for a cleartext user.
pub fn as_verifier(&self) -> &[u8] {
&self.0
}
}
// Hand-written so a stray `{:?}` — a tracing span, an error context, a panic —
// cannot print key material. Same reasoning as `db::users::Credentials`.
impl fmt::Debug for Dek {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Dek(<redacted>)")
}
}
impl fmt::Debug for Kek {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Kek(<redacted>)")
}
}
pub fn random_salt() -> Vec<u8> {
let mut s = vec![0u8; SALT_LEN];
rand::rng().fill_bytes(&mut s);
s
}
// ── Derivation ────────────────────────────────────────────────────────────────
/// `Argon2id(password, salt)` → 256-bit key.
///
/// Runs on a blocking thread — it burns a core and 256 MiB for about a second,
/// which would stall a tokio worker — and behind [`KDF_GATE`].
pub async fn derive_kek(password: &str, salt: &[u8], params: &KdfParams) -> Result<Kek> {
let _permit = KDF_GATE.acquire().await.context("kdf gate closed")?;
let mut password = password.to_owned();
let salt = salt.to_vec();
let params = params.clone();
let out = tokio::task::spawn_blocking(move || {
let result = derive_blocking(password.as_bytes(), &salt, &params);
password.zeroize();
result
})
.await
.context("argon2 task panicked")?;
out
}
fn derive_blocking(password: &[u8], salt: &[u8], params: &KdfParams) -> Result<Kek> {
if params.algo != ALGO {
bail!("unsupported kdf algorithm: {}", params.algo);
}
let p = Params::new(params.m, params.t, params.p, Some(KEY_LEN))
.map_err(|e| anyhow!("invalid argon2 params: {e}"))?;
let mut key = [0u8; KEY_LEN];
Argon2::new(Algorithm::Argon2id, Version::V0x13, p)
.hash_password_into(password, salt, &mut key)
.map_err(|e| anyhow!("argon2 derivation failed: {e}"))?;
Ok(Kek(key))
}
/// Constant-time comparison of a freshly derived key against a stored verifier.
/// `subtle` returns "not equal" for a length mismatch rather than short-circuiting.
pub fn verify(derived: &Kek, stored: &[u8]) -> bool {
derived.0.ct_eq(stored).into()
}
// ── Envelope ──────────────────────────────────────────────────────────────────
/// Why a seal did not open. The distinction is the whole point: a failed tag is
/// an authentication answer, a malformed blob is a broken row.
#[derive(Debug, PartialEq, Eq)]
pub enum KeyError {
/// The AEAD tag did not verify. This *is* the wrong-password signal.
WrongPassword,
Malformed(&'static str),
}
impl fmt::Display for KeyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
KeyError::WrongPassword => f.write_str("wrong password"),
KeyError::Malformed(w) => write!(f, "malformed wrapped key: {w}"),
}
}
}
impl std::error::Error for KeyError {}
/// Seals `dek` under `kek`. Output is `nonce ‖ ciphertext ‖ tag`, stored as-is in
/// `users.database_password`.
pub fn wrap_dek(kek: &Kek, dek: &Dek) -> Result<Vec<u8>> {
let cipher = Aes256Gcm::new_from_slice(&kek.0).map_err(|e| anyhow!("bad kek length: {e}"))?;
let mut nonce = [0u8; NONCE_LEN];
rand::rng().fill_bytes(&mut nonce);
let sealed = cipher
.encrypt(Nonce::from_slice(&nonce), dek.0.as_slice())
.map_err(|_| anyhow!("AEAD seal failed"))?;
let mut out = Vec::with_capacity(SEALED_LEN);
out.extend_from_slice(&nonce);
out.extend_from_slice(&sealed);
Ok(out)
}
/// Opens a seal produced by [`wrap_dek`]. A failed tag returns
/// [`KeyError::WrongPassword`] — one Argon2id pass answers "is the password
/// right" *and* hands back the key.
pub fn unwrap_dek(kek: &Kek, sealed: &[u8]) -> Result<Dek, KeyError> {
if sealed.len() != SEALED_LEN {
return Err(KeyError::Malformed("unexpected length"));
}
let (nonce, body) = sealed.split_at(NONCE_LEN);
let cipher =
Aes256Gcm::new_from_slice(&kek.0).map_err(|_| KeyError::Malformed("bad kek length"))?;
let mut plain = cipher
.decrypt(Nonce::from_slice(nonce), body)
.map_err(|_| KeyError::WrongPassword)?;
if plain.len() != KEY_LEN {
plain.zeroize();
return Err(KeyError::Malformed("unexpected plaintext length"));
}
let mut dek = [0u8; KEY_LEN];
dek.copy_from_slice(&plain);
plain.zeroize();
Ok(Dek(dek))
}
#[cfg(test)]
mod tests {
use super::*;
async fn kek(password: &str, salt: &[u8]) -> Kek {
derive_kek(password, salt, &KdfParams::fast()).await.unwrap()
}
#[tokio::test]
async fn seal_round_trips_and_the_tag_is_the_password_check() {
let salt = random_salt();
let dek = Dek::random();
let sealed = wrap_dek(&kek("correct horse", &salt).await, &dek).unwrap();
assert_eq!(sealed.len(), SEALED_LEN);
let opened = unwrap_dek(&kek("correct horse", &salt).await, &sealed).unwrap();
assert_eq!(opened.0, dek.0, "the same DEK must come back out");
// The whole auth story: a wrong password is a failed AEAD tag, and it is
// reported as such — not as a corrupt blob.
let err = unwrap_dek(&kek("wrong horse", &salt).await, &sealed).unwrap_err();
assert_eq!(err, KeyError::WrongPassword);
}
#[tokio::test]
async fn a_truncated_seal_is_malformed_not_a_wrong_password() {
let salt = random_salt();
let sealed = wrap_dek(&kek("pw", &salt).await, &Dek::random()).unwrap();
let err = unwrap_dek(&kek("pw", &salt).await, &sealed[..SEALED_LEN - 1]).unwrap_err();
assert!(matches!(err, KeyError::Malformed(_)), "got {err:?}");
}
#[tokio::test]
async fn the_salt_separates_identical_passwords() {
let a = kek("same", &random_salt()).await;
let b = kek("same", &random_salt()).await;
assert_ne!(a.0, b.0, "a per-user salt must defuse rainbow tables");
}
#[tokio::test]
async fn verify_accepts_the_right_password_and_rejects_a_short_verifier() {
let salt = random_salt();
let stored = kek("pw", &salt).await;
assert!(verify(&kek("pw", &salt).await, stored.as_verifier()));
assert!(!verify(&kek("nope", &salt).await, stored.as_verifier()));
// A length mismatch must not panic, and must not compare equal.
assert!(!verify(&kek("pw", &salt).await, &stored.as_verifier()[..16]));
}
#[test]
fn pragma_is_a_quoted_blob_literal() {
let dek = Dek([0xAB; KEY_LEN]);
let p = dek.to_pragma();
assert!(p.starts_with("\"x'") && p.ends_with("'\""), "got {p}");
assert_eq!(p.len(), 5 + 2 * KEY_LEN, "\"x' + 64 hex digits + '\"");
assert!(p.contains("abab"), "lowercase hex");
}
#[test]
fn debug_never_prints_key_material() {
let dek = Dek([0xDE; KEY_LEN]);
let kek = Kek([0xAD; KEY_LEN]);
assert_eq!(format!("{dek:?}"), "Dek(<redacted>)");
assert_eq!(format!("{kek:?}"), "Kek(<redacted>)");
assert!(!format!("{dek:?}").contains("dede"), "no raw key bytes");
assert!(!format!("{kek:?}").contains("adad"), "no raw key bytes");
}
#[test]
fn kdf_params_round_trip_and_reject_foreign_algorithms() {
let p = KdfParams::default();
assert_eq!(p.m, 262_144);
assert_eq!(KdfParams::from_json(&p.to_json().unwrap()).unwrap(), p);
let pbkdf2 = r#"{"algo":"pbkdf2","m":1,"t":1,"p":1}"#;
assert!(KdfParams::from_json(pbkdf2).is_err(), "only argon2id is accepted");
}
}