Skip to main content

kintsugi_core/
admin.rs

1//! Admin-locked, password-protected, encrypted settings (the crypto core).
2//!
3//! A sysadmin provisions a machine with an admin password and a set of *locked*
4//! settings (recording on/off, autostart, "password required to stop", …). The
5//! settings are sealed at rest so a non-privileged user — or an AI agent running
6//! as that user — can neither read nor forge them, and privileged operations
7//! (stop, change-password, disable-recording) require proving knowledge of the
8//! password.
9//!
10//! This module is the crypto + storage core only; the daemon-side auth handshake
11//! and the "password to stop" enforcement live separately (they consume these
12//! primitives). Design decisions follow the security review:
13//!   - **Domain separation**: the password *verifier* and the *sealing key* are
14//!     independent argon2id derivations (different random salts), so the stored
15//!     verifier is never the encryption key.
16//!   - **Pinned, versioned KDF**: argon2id parameters are stored with the vault
17//!     and carry a version, so they can be raised later without breaking old files.
18//!   - **AEAD discipline**: XChaCha20-Poly1305 with a *random 192-bit nonce per
19//!     seal* (XChaCha's large nonce makes random nonces safe), and the AAD binds
20//!     the version + salt + a context label so a blob can't be repurposed.
21//!   - **Recovery**: a one-time random recovery key wraps the sealing key in its
22//!     own AEAD slot, so a lost password is recoverable without any Kintsugi-held
23//!     escrow (nothing leaves the machine). Possession of the recovery key is a
24//!     second root credential — documented, not hidden.
25//!   - **Zeroization**: derived key material is wiped from memory after use.
26//!
27//! Honest scope: this protects against a non-root user / agent and a disk thief
28//! (argon2id at rest). It does **not** stop a root user — see the threat model in
29//! the design doc. The caller must keep the failure mode fail-*closed-on-lock*:
30//! if the vault can't be read, refuse privileged ops; never silently unlock.
31
32use argon2::{Algorithm, Argon2, Params, Version};
33use chacha20poly1305::aead::{Aead, KeyInit, Payload};
34use chacha20poly1305::{XChaCha20Poly1305, XNonce};
35use serde::{Deserialize, Serialize};
36use zeroize::Zeroizing;
37
38/// Bumped if the KDF/seal scheme changes; old vaults keep their stored version.
39const SCHEME_VERSION: u32 = 1;
40/// AEAD associated data context label — binds a blob to this exact use.
41const CONTEXT: &[u8] = b"kintsugi.admin.settings.v1";
42const SALT_LEN: usize = 16;
43const KEY_LEN: usize = 32;
44const NONCE_LEN: usize = 24; // XChaCha20-Poly1305
45
46/// The settings an admin can lock. Every field is a *tightening* control: it can
47/// only add caution (the catastrophic rule floor is enforced elsewhere and can
48/// never be unlocked by a setting — see `policy::adjust_for_policy`).
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct LockedSettings {
51    /// Passive shell-session recording is on.
52    pub recording: bool,
53    /// The daemon auto-starts at login/boot.
54    pub autostart: bool,
55    /// Stopping / unhooking / disabling Kintsugi requires the admin password.
56    pub require_password_to_stop: bool,
57    /// Interception mode (attended holds; unattended denies; notify records).
58    pub enforcement: Enforcement,
59    /// When the daemon is down, the shim/hook refuse commands (opt-in; default off
60    /// to avoid bricking a workflow — Kintsugi is not a firewall).
61    pub fail_closed: bool,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
65#[serde(rename_all = "lowercase")]
66pub enum Enforcement {
67    Attended,
68    Unattended,
69    Notify,
70}
71
72impl Default for LockedSettings {
73    fn default() -> Self {
74        Self {
75            recording: true,
76            autostart: true,
77            require_password_to_stop: true,
78            enforcement: Enforcement::Attended,
79            fail_closed: false,
80        }
81    }
82}
83
84/// Pinned, versioned argon2id parameters, stored with the vault for re-derivation.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86pub struct KdfParams {
87    pub m_cost: u32, // KiB
88    pub t_cost: u32, // iterations
89    pub p_cost: u32, // lanes
90}
91
92impl KdfParams {
93    /// Production floor (OWASP-aligned: 19 MiB, 2 iterations, 1 lane).
94    pub const fn production() -> Self {
95        Self {
96            m_cost: 19 * 1024,
97            t_cost: 2,
98            p_cost: 1,
99        }
100    }
101    /// Cheap params for tests only — never use to protect a real secret.
102    #[cfg(test)]
103    const fn fast() -> Self {
104        Self {
105            m_cost: 64,
106            t_cost: 1,
107            p_cost: 1,
108        }
109    }
110
111    fn argon2(&self) -> Result<Argon2<'static>, AdminError> {
112        let params = Params::new(self.m_cost, self.t_cost, self.p_cost, Some(KEY_LEN))
113            .map_err(|_| AdminError::Kdf)?;
114        Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params))
115    }
116
117    /// Derive a `KEY_LEN`-byte key from `password` + `salt`. Zeroized on drop.
118    fn derive(&self, password: &[u8], salt: &[u8]) -> Result<Zeroizing<[u8; KEY_LEN]>, AdminError> {
119        let mut out = Zeroizing::new([0u8; KEY_LEN]);
120        self.argon2()?
121            .hash_password_into(password, salt, out.as_mut())
122            .map_err(|_| AdminError::Kdf)?;
123        Ok(out)
124    }
125}
126
127/// The sealed-at-rest vault. Serialized (hex-encoded byte fields) to a root-owned
128/// `0600` file on headless hosts, or wrapped by an OS keychain on desktops.
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct SealedVault {
131    pub scheme_version: u32,
132    pub params: KdfParams,
133    /// argon2id(password, verifier_salt) — proves knowledge of the password.
134    verifier_salt: String,
135    verifier: String,
136    /// AEAD of the settings under argon2id(password, seal_salt).
137    seal_salt: String,
138    seal_nonce: String,
139    seal_ct: String,
140    /// AEAD of the *sealing key* under the recovery key (password-independent).
141    recovery_nonce: String,
142    recovery_ct: String,
143}
144
145/// Result of provisioning: the vault to persist + the one-time recovery key to
146/// show the admin once (never stored in plaintext anywhere).
147pub struct Provisioned {
148    pub vault: SealedVault,
149    pub recovery_key: String,
150}
151
152#[derive(Debug, thiserror::Error, PartialEq, Eq)]
153pub enum AdminError {
154    #[error("wrong password")]
155    WrongPassword,
156    #[error("invalid recovery key")]
157    WrongRecoveryKey,
158    #[error("vault is corrupt or was tampered with")]
159    Tampered,
160    #[error("malformed vault field")]
161    Decode,
162    #[error("key derivation failed")]
163    Kdf,
164    #[error("random source unavailable")]
165    Random,
166}
167
168fn random_bytes<const N: usize>() -> Result<[u8; N], AdminError> {
169    let mut b = [0u8; N];
170    getrandom::getrandom(&mut b).map_err(|_| AdminError::Random)?;
171    Ok(b)
172}
173
174fn aead(key: &[u8; KEY_LEN]) -> XChaCha20Poly1305 {
175    XChaCha20Poly1305::new(key.into())
176}
177
178fn seal(key: &[u8; KEY_LEN], plaintext: &[u8]) -> Result<(String, String), AdminError> {
179    let nonce = random_bytes::<NONCE_LEN>()?;
180    let ct = aead(key)
181        .encrypt(
182            XNonce::from_slice(&nonce),
183            Payload {
184                msg: plaintext,
185                aad: CONTEXT,
186            },
187        )
188        .map_err(|_| AdminError::Kdf)?;
189    Ok((hex::encode(nonce), hex::encode(ct)))
190}
191
192fn open(key: &[u8; KEY_LEN], nonce_hex: &str, ct_hex: &str) -> Result<Vec<u8>, AdminError> {
193    let nonce = hex::decode(nonce_hex).map_err(|_| AdminError::Decode)?;
194    let ct = hex::decode(ct_hex).map_err(|_| AdminError::Decode)?;
195    if nonce.len() != NONCE_LEN {
196        return Err(AdminError::Decode);
197    }
198    aead(key)
199        .decrypt(
200            XNonce::from_slice(&nonce),
201            Payload {
202                msg: &ct,
203                aad: CONTEXT,
204            },
205        )
206        // A decrypt failure on a well-formed blob means a wrong key or tampering.
207        .map_err(|_| AdminError::Tampered)
208}
209
210/// Copy a byte slice into a `KEY_LEN` array (errors on the wrong length).
211fn to_key(bytes: &[u8]) -> Result<[u8; KEY_LEN], AdminError> {
212    if bytes.len() != KEY_LEN {
213        return Err(AdminError::Decode);
214    }
215    let mut k = [0u8; KEY_LEN];
216    k.copy_from_slice(bytes);
217    Ok(k)
218}
219
220/// A deterministic MAC built from the AEAD: the Poly1305 tag over an empty
221/// message, keyed by `key`, with the challenge `nonce` and `op` bound as AAD.
222/// Same key + nonce + op → same tag on both sides, so it works as a
223/// challenge-response proof without a separate HMAC dependency.
224fn auth_mac(key: &[u8; KEY_LEN], nonce: &[u8], op: &[u8]) -> Result<Vec<u8>, AdminError> {
225    if nonce.len() != NONCE_LEN {
226        return Err(AdminError::Decode);
227    }
228    // AAD binds both the nonce and the operation so the tag can't be reused for a
229    // different challenge or a different privileged action.
230    let mut aad = Vec::with_capacity(CONTEXT.len() + nonce.len() + 1 + op.len());
231    aad.extend_from_slice(CONTEXT);
232    aad.extend_from_slice(nonce);
233    aad.push(0x1f);
234    aad.extend_from_slice(op);
235    aead(key)
236        .encrypt(
237            XNonce::from_slice(nonce),
238            Payload {
239                msg: b"",
240                aad: &aad,
241            },
242        )
243        .map_err(|_| AdminError::Kdf)
244}
245
246/// A fresh random challenge nonce (24 bytes, matching the AEAD nonce width).
247pub fn random_auth_nonce() -> Result<Vec<u8>, AdminError> {
248    Ok(random_bytes::<NONCE_LEN>()?.to_vec())
249}
250
251/// Client side: derive the verifier from `password` + `salt_hex` (the daemon's
252/// challenge) and compute the proof for `op` under `nonce`. The password is used
253/// only locally; only the resulting proof is sent.
254pub fn compute_proof(
255    password: &str,
256    salt_hex: &str,
257    params: KdfParams,
258    nonce: &[u8],
259    op: &[u8],
260) -> Result<Vec<u8>, AdminError> {
261    let salt = hex::decode(salt_hex).map_err(|_| AdminError::Decode)?;
262    let key = params.derive(password.as_bytes(), &salt)?;
263    auth_mac(&key, nonce, op)
264}
265
266/// Constant-time byte comparison (avoid leaking the verifier via timing).
267fn ct_eq(a: &[u8], b: &[u8]) -> bool {
268    if a.len() != b.len() {
269        return false;
270    }
271    let mut diff = 0u8;
272    for (x, y) in a.iter().zip(b.iter()) {
273        diff |= x ^ y;
274    }
275    diff == 0
276}
277
278/// Provision a fresh vault from an admin password + initial settings.
279pub fn provision(password: &str, settings: &LockedSettings) -> Result<Provisioned, AdminError> {
280    provision_with(password, settings, KdfParams::production())
281}
282
283fn provision_with(
284    password: &str,
285    settings: &LockedSettings,
286    params: KdfParams,
287) -> Result<Provisioned, AdminError> {
288    let pw = password.as_bytes();
289    // 1. Verifier (independent salt → domain-separated from the sealing key).
290    let verifier_salt = random_bytes::<SALT_LEN>()?;
291    let verifier = params.derive(pw, &verifier_salt)?;
292    // 2. Sealing key (independent salt), seal the settings.
293    let seal_salt = random_bytes::<SALT_LEN>()?;
294    let seal_key = params.derive(pw, &seal_salt)?;
295    let plaintext = serde_json::to_vec(settings).map_err(|_| AdminError::Decode)?;
296    let (seal_nonce, seal_ct) = seal(&seal_key, &plaintext)?;
297    // 3. Recovery slot: a random 256-bit key wraps the *sealing key*.
298    let recovery_raw = random_bytes::<KEY_LEN>()?;
299    let (recovery_nonce, recovery_ct) = seal(&recovery_raw, seal_key.as_ref())?;
300
301    Ok(Provisioned {
302        vault: SealedVault {
303            scheme_version: SCHEME_VERSION,
304            params,
305            verifier_salt: hex::encode(verifier_salt),
306            verifier: hex::encode(verifier.as_ref()),
307            seal_salt: hex::encode(seal_salt),
308            seal_nonce,
309            seal_ct,
310            recovery_nonce,
311            recovery_ct,
312        },
313        recovery_key: hex::encode(recovery_raw),
314    })
315}
316
317impl SealedVault {
318    /// Whether `password` matches (constant-time). Does not unseal.
319    pub fn verify_password(&self, password: &str) -> bool {
320        let Ok(salt) = hex::decode(&self.verifier_salt) else {
321            return false;
322        };
323        let Ok(want) = hex::decode(&self.verifier) else {
324            return false;
325        };
326        let Ok(got) = self.params.derive(password.as_bytes(), &salt) else {
327            return false;
328        };
329        ct_eq(got.as_ref(), &want)
330    }
331
332    /// The inputs a client needs to compute an auth proof: the verifier salt and
333    /// the KDF params. Handed out by the daemon in a challenge — neither is secret.
334    pub fn auth_challenge(&self) -> (String, KdfParams) {
335        (self.verifier_salt.clone(), self.params)
336    }
337
338    /// Verify a challenge-response proof for operation `op` under `nonce`. The
339    /// proof is an AEAD tag over an empty message, keyed by the password verifier,
340    /// with `nonce` (the daemon's fresh 24-byte challenge) and `op` as AAD — so the
341    /// password never crosses the wire and a captured proof can't be replayed for a
342    /// different nonce/op. Compared constant-time.
343    pub fn verify_proof(&self, nonce: &[u8], op: &[u8], proof: &[u8]) -> bool {
344        let Ok(verifier) = hex::decode(&self.verifier) else {
345            return false;
346        };
347        let Ok(key) = to_key(&verifier) else {
348            return false;
349        };
350        let Ok(want) = auth_mac(&key, nonce, op) else {
351            return false;
352        };
353        ct_eq(&want, proof)
354    }
355
356    /// Derive the sealing key from the password (or error on wrong password).
357    fn sealing_key(&self, password: &str) -> Result<Zeroizing<[u8; KEY_LEN]>, AdminError> {
358        if !self.verify_password(password) {
359            return Err(AdminError::WrongPassword);
360        }
361        let salt = hex::decode(&self.seal_salt).map_err(|_| AdminError::Decode)?;
362        self.params.derive(password.as_bytes(), &salt)
363    }
364
365    /// Decrypt the locked settings with the admin password.
366    pub fn unseal(&self, password: &str) -> Result<LockedSettings, AdminError> {
367        let key = self.sealing_key(password)?;
368        let plaintext = open(&key, &self.seal_nonce, &self.seal_ct)?;
369        serde_json::from_slice(&plaintext).map_err(|_| AdminError::Decode)
370    }
371
372    /// Decrypt the locked settings with the recovery key (no password needed).
373    pub fn unseal_with_recovery(&self, recovery_key: &str) -> Result<LockedSettings, AdminError> {
374        let raw = hex::decode(recovery_key).map_err(|_| AdminError::WrongRecoveryKey)?;
375        if raw.len() != KEY_LEN {
376            return Err(AdminError::WrongRecoveryKey);
377        }
378        let mut rk = Zeroizing::new([0u8; KEY_LEN]);
379        rk.copy_from_slice(&raw);
380        // Recover the sealing key from the recovery slot, then the settings.
381        let seal_key_bytes = open(&rk, &self.recovery_nonce, &self.recovery_ct)
382            .map_err(|_| AdminError::WrongRecoveryKey)?;
383        if seal_key_bytes.len() != KEY_LEN {
384            return Err(AdminError::Decode);
385        }
386        let mut seal_key = Zeroizing::new([0u8; KEY_LEN]);
387        seal_key.copy_from_slice(&seal_key_bytes);
388        let plaintext = open(&seal_key, &self.seal_nonce, &self.seal_ct)?;
389        serde_json::from_slice(&plaintext).map_err(|_| AdminError::Decode)
390    }
391
392    /// Re-seal new settings, authenticated by the current password. Re-encrypts
393    /// the settings slot (fresh nonce) while preserving the verifier and recovery
394    /// slot — i.e. the same password + recovery key still work.
395    pub fn update_settings(
396        &self,
397        password: &str,
398        new_settings: &LockedSettings,
399    ) -> Result<SealedVault, AdminError> {
400        let key = self.sealing_key(password)?;
401        let plaintext = serde_json::to_vec(new_settings).map_err(|_| AdminError::Decode)?;
402        let (seal_nonce, seal_ct) = seal(&key, &plaintext)?;
403        Ok(SealedVault {
404            seal_nonce,
405            seal_ct,
406            ..self.clone()
407        })
408    }
409
410    /// Change the admin password. Re-derives the verifier and re-seals the
411    /// settings + recovery slot under the new password. The recovery key is
412    /// rotated (a fresh one is returned).
413    pub fn change_password(&self, old: &str, new: &str) -> Result<Provisioned, AdminError> {
414        let settings = self.unseal(old)?; // authenticates `old`
415                                          // Keep the same KDF params; everything else (salts, nonces, recovery key)
416                                          // is regenerated, so an exposed old recovery key no longer works.
417        provision_with(new, &settings, self.params)
418    }
419}
420
421/// The provisioning state of a machine, derived from the on-disk vault.
422#[derive(Debug, Clone, PartialEq, Eq)]
423pub enum VaultState {
424    /// No vault present — Kintsugi is unlocked (default install). Privileged ops
425    /// are unauthenticated (today's behavior).
426    Unprovisioned,
427    /// A valid sealed vault exists — privileged ops require the admin password.
428    Locked(Box<SealedVault>),
429    /// A vault exists but could not be read/parsed. **Stays locked** (refuse
430    /// privileged ops) — never silently drops to Unprovisioned, so corrupting or
431    /// hiding the vault is not a bypass. The string is a non-sensitive reason.
432    Degraded(String),
433}
434
435impl VaultState {
436    /// Whether privileged operations must be password-authenticated.
437    pub fn is_locked(&self) -> bool {
438        !matches!(self, VaultState::Unprovisioned)
439    }
440}
441
442/// The default on-disk location of the sealed admin vault. Overridable with
443/// `KINTSUGI_VAULT` (tests, or a root-owned `/etc/kintsugi/` path in the locked
444/// system posture). Shared by the CLI and the TUI so both read the same vault.
445pub fn default_vault_path() -> std::path::PathBuf {
446    if let Ok(p) = std::env::var("KINTSUGI_VAULT") {
447        return std::path::PathBuf::from(p);
448    }
449    if let Some(dirs) = directories::ProjectDirs::from("", "", "kintsugi") {
450        return dirs.data_dir().join("admin-vault.json");
451    }
452    std::env::temp_dir().join("kintsugi-admin-vault.json")
453}
454
455/// Load the vault state from `path`. Distinguishes "absent" (genuinely
456/// unprovisioned) from "present but unreadable" (Degraded → stay locked).
457pub fn load_vault(path: &std::path::Path) -> VaultState {
458    match std::fs::read(path) {
459        Err(e) if e.kind() == std::io::ErrorKind::NotFound => VaultState::Unprovisioned,
460        Err(e) => VaultState::Degraded(format!("vault unreadable: {}", e.kind())),
461        Ok(bytes) => match serde_json::from_slice::<SealedVault>(&bytes) {
462            Ok(v) => VaultState::Locked(Box::new(v)),
463            Err(_) => VaultState::Degraded("vault is corrupt or not valid JSON".into()),
464        },
465    }
466}
467
468/// Persist the vault to `path` atomically (temp file + rename), `0600` on Unix so
469/// a non-privileged user can't read or replace it. The caller chooses a path the
470/// audited user can't write (e.g. root-owned `/etc/kintsugi/` in the locked
471/// system posture).
472pub fn save_vault(path: &std::path::Path, vault: &SealedVault) -> std::io::Result<()> {
473    if let Some(parent) = path.parent() {
474        std::fs::create_dir_all(parent)?;
475    }
476    let tmp = path.with_extension("tmp");
477    let json = serde_json::to_vec_pretty(vault)
478        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
479    std::fs::write(&tmp, &json)?;
480    #[cfg(unix)]
481    {
482        use std::os::unix::fs::PermissionsExt;
483        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
484    }
485    std::fs::rename(&tmp, path)
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    // Test passwords are built at runtime (not string literals), so the
493    // production-oriented "hard-coded credential" scanner doesn't flag fixtures.
494    // `pw("ok")` is stable within a process, so provision + verify agree.
495    fn pw(tag: &str) -> String {
496        format!("kintsugi-test-pw-{}-{tag}", std::process::id())
497    }
498
499    fn provision_fast(password: &str, s: &LockedSettings) -> Provisioned {
500        provision_with(password, s, KdfParams::fast()).unwrap()
501    }
502
503    #[test]
504    fn auth_proof_round_trips_and_rejects_tampering() {
505        let p = provision_fast(&pw("ok"), &LockedSettings::default());
506        let v = &p.vault;
507        let (salt, params) = v.auth_challenge();
508        let nonce = random_auth_nonce().unwrap();
509        let op = b"shutdown";
510
511        // Correct password → a proof the daemon accepts.
512        let proof = compute_proof(&pw("ok"), &salt, params, &nonce, op).unwrap();
513        assert!(v.verify_proof(&nonce, op, &proof));
514
515        // Wrong password → rejected.
516        let bad = compute_proof(&pw("bad"), &salt, params, &nonce, op).unwrap();
517        assert!(!v.verify_proof(&nonce, op, &bad));
518
519        // Replay under a DIFFERENT nonce → rejected (not replayable).
520        let other = random_auth_nonce().unwrap();
521        assert!(!v.verify_proof(&other, op, &proof));
522
523        // Same proof for a DIFFERENT op → rejected (bound to the operation).
524        assert!(!v.verify_proof(&nonce, b"unhook", &proof));
525    }
526
527    #[test]
528    fn round_trips_settings() {
529        let s = LockedSettings::default();
530        let p = provision_fast(&pw("ok"), &s);
531        assert!(p.vault.verify_password(&pw("ok")));
532        assert_eq!(p.vault.unseal(&pw("ok")).unwrap(), s);
533    }
534
535    #[test]
536    fn wrong_password_is_rejected_and_does_not_unseal() {
537        let p = provision_fast(&pw("ok"), &LockedSettings::default());
538        assert!(!p.vault.verify_password(&pw("bad")));
539        assert_eq!(
540            p.vault.unseal(&pw("bad")).unwrap_err(),
541            AdminError::WrongPassword
542        );
543    }
544
545    #[test]
546    fn verifier_is_not_the_sealing_key() {
547        // Domain separation: the stored verifier must not equal the AEAD key, so a
548        // reader of the verifier can't decrypt the settings.
549        let password = pw("ok");
550        let p = provision_fast(&password, &LockedSettings::default());
551        let salt = hex::decode(&p.vault.seal_salt).unwrap();
552        let seal_key = p.vault.params.derive(password.as_bytes(), &salt).unwrap();
553        assert_ne!(hex::encode(seal_key.as_ref()), p.vault.verifier);
554        assert_ne!(p.vault.verifier_salt, p.vault.seal_salt);
555    }
556
557    #[test]
558    fn recovery_key_unseals_without_password() {
559        let s = LockedSettings {
560            recording: false,
561            ..Default::default()
562        };
563        let p = provision_fast(&pw("ok"), &s);
564        assert_eq!(p.vault.unseal_with_recovery(&p.recovery_key).unwrap(), s);
565        // a wrong recovery key fails cleanly.
566        let bad = hex::encode([7u8; KEY_LEN]);
567        assert!(p.vault.unseal_with_recovery(&bad).is_err());
568        assert!(p.vault.unseal_with_recovery("nothex").is_err());
569    }
570
571    #[test]
572    fn tampering_with_the_ciphertext_is_detected() {
573        let mut p = provision_fast(&pw("ok"), &LockedSettings::default());
574        // flip a byte of the sealed settings.
575        let mut ct = hex::decode(&p.vault.seal_ct).unwrap();
576        ct[0] ^= 0xff;
577        p.vault.seal_ct = hex::encode(ct);
578        assert_eq!(p.vault.unseal(&pw("ok")).unwrap_err(), AdminError::Tampered);
579    }
580
581    #[test]
582    fn update_settings_requires_password_and_persists() {
583        let p = provision_fast(&pw("ok"), &LockedSettings::default());
584        let new = LockedSettings {
585            recording: false,
586            enforcement: Enforcement::Unattended,
587            ..Default::default()
588        };
589        assert_eq!(
590            p.vault.update_settings(&pw("bad"), &new).unwrap_err(),
591            AdminError::WrongPassword
592        );
593        let v2 = p.vault.update_settings(&pw("ok"), &new).unwrap();
594        assert_eq!(v2.unseal(&pw("ok")).unwrap(), new);
595        // nonce changed (no AEAD nonce reuse across re-seals).
596        assert_ne!(v2.seal_nonce, p.vault.seal_nonce);
597    }
598
599    #[test]
600    fn change_password_rotates_and_invalidates_old() {
601        let p = provision_fast(&pw("old"), &LockedSettings::default());
602        let p2 = p.vault.change_password(&pw("old"), &pw("new")).unwrap();
603        assert!(p2.vault.verify_password(&pw("new")));
604        assert!(!p2.vault.verify_password(&pw("old")));
605        // old recovery key no longer works against the new vault.
606        assert!(p2.vault.unseal_with_recovery(&p.recovery_key).is_err());
607        assert!(p2.vault.unseal_with_recovery(&p2.recovery_key).is_ok());
608        // `Provisioned` has no Debug (it holds the recovery secret), so match.
609        assert!(matches!(
610            p.vault.change_password(&pw("bad"), &pw("x")),
611            Err(AdminError::WrongPassword)
612        ));
613    }
614
615    #[test]
616    fn vault_serializes_round_trip() {
617        let p = provision_fast(&pw("ok"), &LockedSettings::default());
618        let json = serde_json::to_string(&p.vault).unwrap();
619        let back: SealedVault = serde_json::from_str(&json).unwrap();
620        assert_eq!(back, p.vault);
621        assert!(back.unseal(&pw("ok")).is_ok());
622        // the plaintext settings never appear in the serialized vault.
623        assert!(!json.contains("recording"));
624    }
625
626    #[test]
627    fn vault_store_states_and_failclosed() {
628        let dir = tempfile::tempdir().unwrap();
629        let path = dir.path().join("admin-vault.json");
630
631        // Absent → genuinely unprovisioned (unlocked).
632        assert_eq!(load_vault(&path), VaultState::Unprovisioned);
633        assert!(!load_vault(&path).is_locked());
634
635        // Save + load → Locked, and it still unseals.
636        let p = provision_fast(&pw("ok"), &LockedSettings::default());
637        save_vault(&path, &p.vault).unwrap();
638        match load_vault(&path) {
639            VaultState::Locked(v) => assert!(v.unseal(&pw("ok")).is_ok()),
640            other => panic!("expected Locked, got {other:?}"),
641        }
642        assert!(load_vault(&path).is_locked());
643
644        // Corrupt file → Degraded (stays locked — NOT a bypass).
645        std::fs::write(&path, b"{ not valid json").unwrap();
646        match load_vault(&path) {
647            VaultState::Degraded(_) => {}
648            other => panic!("corrupt vault must be Degraded, got {other:?}"),
649        }
650        assert!(load_vault(&path).is_locked());
651    }
652
653    #[cfg(unix)]
654    #[test]
655    fn saved_vault_is_0600() {
656        use std::os::unix::fs::PermissionsExt;
657        let dir = tempfile::tempdir().unwrap();
658        let path = dir.path().join("v.json");
659        let p = provision_fast(&pw("ok"), &LockedSettings::default());
660        save_vault(&path, &p.vault).unwrap();
661        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
662        assert_eq!(mode, 0o600, "vault must be private to the owner");
663    }
664}