Skip to main content

ssh_cli/
secrets.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// G-SECDEV-05: pure module — no `unsafe` permitted (crate root allows only OS FFI / test env).
3#![forbid(unsafe_code)]
4//! At-rest encryption of secrets in `config.toml` (GAP-009 / R-SECRETS-DEFAULT).
5//!
6//! Primary-key resolution order (32 bytes), 0.5.1:
7//! 1. CLI flags (`--secrets-key-file`, `--use-keyring`, `--allow-plaintext-secrets`)
8//! 2. OS keyring when enabled (`service=ssh-cli`, `user=secrets-primary-key`; legacy read alias)
9//! 3. XDG `secrets.key` file (next to `config.toml`), auto-created on first write
10//!
11//! **Env-as-store is forbidden (G-ERR-13 / G-UNSAFE):** if `SSH_CLI_SECRETS_KEY` or
12//! `SSH_CLI_SECRETS_KEY_FILE` is present, load **fails closed** with a clear error
13//! pointing to XDG `secrets.key` or `--secrets-key-file`.
14//!
15//! Plaintext at-rest opt-out: **only** CLI `--allow-plaintext-secrets` (no env store).
16//!
17//! With a key: serialization writes `sshcli-enc:v2:<base64(nonce||ciphertext)>`.
18//!
19//! # Blob versions (A7)
20//!
21//! `v1` blobs were sealed **without** associated data, so the AEAD tag only
22//! proved "encrypted by this key" and said nothing about *where* the blob
23//! belongs. Anyone able to edit `config.toml` could move a `password` blob to
24//! another host, or paste it into `su_password`, and decryption would still
25//! succeed — context confusion with no detection.
26//!
27//! `v2` binds the ciphertext to a [`SecretContext`] (host name + field name) via
28//! AEAD associated data, so a relocated blob fails tag verification.
29//!
30//! Compatibility is deliberate and dual-read:
31//! - `v1` is still accepted on read (existing configs must keep working).
32//! - `v2` is always written.
33//! - A `v2` blob sealed under [`SecretContext::unbound`] — the context used by
34//!   call sites not yet passing host/field — is accepted under any context. That
35//!   keeps the migration monotonic: today's writes are no weaker than `v1`, and
36//!   once a call site passes a real context the rewritten blob becomes strictly
37//!   bound and can never be relocated afterwards.
38//!
39//! **Never** log or return the key or plaintext in public errors.
40
41use crate::constants::{
42    APP_NAME, ENV_SECRETS_KEY, ENV_SECRETS_KEY_FILE, PRIMARY_KEY_HEX_LEN, PRIMARY_KEY_LEN_BYTES,
43    SECRETS_KEY_FILE_NAME,
44};
45use crate::errors::{SshCliError, SshCliResult};
46use std::path::{Path, PathBuf};
47use std::sync::atomic::{AtomicBool, Ordering};
48use std::sync::Mutex;
49use zeroize::{Zeroize, Zeroizing};
50
51mod aead;
52mod keyring_store;
53
54pub use aead::SecretContext;
55use aead::{decrypt_secret, encrypt_secret};
56use keyring_store::read_keyring;
57pub use keyring_store::write_key_to_keyring;
58
59/// Prefix for legacy encrypted blobs without associated data (read-only).
60pub const ENC_PREFIX: &str = "sshcli-enc:v1:";
61
62/// Prefix for context-bound encrypted blobs (written by this version).
63pub const ENC_PREFIX_V2: &str = "sshcli-enc:v2:";
64
65/// Primary key material that scrubs itself on drop.
66///
67/// A8: the bare `[u8; 32]` is `Copy`, so every hand-off between
68/// `load_primary_key` → `ensure_key_for_write` → `serialize_secret` left a
69/// residual copy on a stack frame that no `.zeroize()` call could reach —
70/// zeroizing the last binding cleaned one copy and no more. Wrapping the array
71/// makes it move-only in practice: each frame owns exactly one value and drops
72/// it scrubbed.
73pub type PrimaryKey = Zeroizing<[u8; PRIMARY_KEY_LEN_BYTES]>;
74
75/// File name of the primary key in the config directory (XDG sibling of `config.toml`).
76pub const KEY_FILE_NAME: &str = SECRETS_KEY_FILE_NAME;
77
78// Compile-time invariants (const/static rules).
79const _: () = assert!(!ENC_PREFIX.is_empty());
80const _: () = assert!(!ENC_PREFIX_V2.is_empty());
81const _: () = assert!(!KEY_FILE_NAME.is_empty());
82const _: () = assert!(PRIMARY_KEY_LEN_BYTES == 32);
83
84/// Locks a process-global `Mutex`, recovering from poison explicitly.
85///
86/// Poison means a previous holder panicked; the data is still usable for this
87/// one-shot CLI, so we take `into_inner()` rather than silently skipping updates.
88/// Recovery is **logged** (Rules Rust: never silence `PoisonError` without log).
89///
90/// Critical sections using this helper must stay short and **never** hold the
91/// guard across `.await` or blocking I/O (clone/copy under lock, then release).
92fn lock_global<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
93    m.lock().unwrap_or_else(|poisoned| {
94        tracing::warn!(
95            "secrets process-global mutex was poisoned; recovering via into_inner (one-shot CLI)"
96        );
97        poisoned.into_inner()
98    })
99}
100
101/// Config directory override (e.g. `--config-dir`) to align `secrets.key`.
102///
103/// Concurrent access: `std::sync::Mutex` (const ctor) — single composite state
104/// (`Option<PathBuf>`); not split into uncoordinated atomics. Poison recovered
105/// via [`lock_global`]. Never held across await.
106static DIR_CONFIG_OVERRIDE: Mutex<Option<PathBuf>> = Mutex::new(None);
107
108/// CLI runtime overrides (flags). Env remains as deprecated fallback.
109#[derive(Debug, Default, Clone)]
110struct RuntimeSecretsFlags {
111    allow_plaintext: bool,
112    secrets_key_file: Option<PathBuf>,
113    use_keyring: bool,
114}
115
116/// Process-wide secrets CLI flags (set once after parse).
117///
118/// Single `Mutex` keeps the three fields consistent (Rules: do not protect a
119/// multi-field invariant with independent atomics). See [`lock_global`].
120static RUNTIME_FLAGS: Mutex<RuntimeSecretsFlags> = Mutex::new(RuntimeSecretsFlags {
121    allow_plaintext: false,
122    secrets_key_file: None,
123    use_keyring: false,
124});
125
126/// Set when `secrets.key` is auto-created during this process (GAP-AUD-007).
127///
128/// Concurrent access: independent status bit; `Ordering::Relaxed` (no dependent
129/// data fence — isolated flag, not paired with other memory).
130static AUTO_KEY_CREATED: AtomicBool = AtomicBool::new(false);
131
132/// Sets the config directory used to resolve `secrets.key` (one-shot; called from `dispatch`).
133pub fn set_config_dir(dir: Option<PathBuf>) {
134    *lock_global(&DIR_CONFIG_OVERRIDE) = dir;
135}
136
137/// Applies one-shot CLI flags for secrets resolution (GAP-AUD-006).
138pub fn set_runtime_flags(
139    allow_plaintext: bool,
140    secrets_key_file: Option<PathBuf>,
141    use_keyring: bool,
142) {
143    {
144        let mut g = lock_global(&RUNTIME_FLAGS);
145        g.allow_plaintext = allow_plaintext;
146        g.secrets_key_file = secrets_key_file;
147        g.use_keyring = use_keyring;
148    }
149    AUTO_KEY_CREATED.store(false, Ordering::Relaxed);
150}
151
152/// Returns true once if a key was auto-created since the last flag reset (consume).
153#[must_use]
154pub fn take_auto_key_created() -> bool {
155    // RMW on an independent flag — Relaxed is enough (no data publish).
156    AUTO_KEY_CREATED.swap(false, Ordering::Relaxed)
157}
158
159/// Returns true if a key was auto-created (non-consuming).
160#[must_use]
161pub fn auto_key_created() -> bool {
162    AUTO_KEY_CREATED.load(Ordering::Relaxed)
163}
164
165/// Primary-key source (without exposing material).
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum KeySource {
168    /// No key source available (plaintext at-rest with opt-out or before first write).
169    Absent,
170    /// Reserved: env key material is **rejected** (fail-closed); never a success source.
171    Env,
172    /// File from CLI `--secrets-key-file`.
173    ConfigFile,
174    /// OS keyring.
175    Keyring,
176    /// XDG / config-dir `secrets.key` file.
177    XdgFile,
178}
179
180impl KeySource {
181    /// Stable name for JSON/doctor.
182    #[must_use]
183    pub const fn as_str(self) -> &'static str {
184        match self {
185            Self::Absent => "none",
186            Self::Env => "env",
187            Self::ConfigFile => "file",
188            Self::Keyring => "keyring",
189            Self::XdgFile => "xdg_file",
190        }
191    }
192}
193
194/// Secrets mode report (no sensitive material).
195#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct SecretsStatus {
197    /// Primary-key source.
198    pub source: KeySource,
199    /// If true, serialization encrypts secrets.
200    pub encryption_active: bool,
201    /// Path of `secrets.key` (may not exist yet).
202    pub key_file_path: PathBuf,
203    /// If true, plaintext opt-out is active.
204    pub plaintext_opt_out: bool,
205}
206
207/// True if plaintext opt-out is active (CLI flag only — G-ERR-13, no env store).
208#[must_use]
209pub fn plaintext_allowed() -> bool {
210    lock_global(&RUNTIME_FLAGS).allow_plaintext
211}
212
213/// Config directory used for `secrets.key` (CLI/test override > XDG).
214///
215/// # Errors
216/// [`SshCliError::XdgDirectory`] when XDG cannot be resolved and no override is set.
217pub fn secrets_config_dir() -> SshCliResult<PathBuf> {
218    if let Some(d) = lock_global(&DIR_CONFIG_OVERRIDE).clone() {
219        return Ok(d);
220    }
221    crate::paths::xdg_config_dir()
222}
223
224/// Canonical path of the local primary-key file.
225pub fn secrets_key_path() -> SshCliResult<PathBuf> {
226    Ok(secrets_config_dir()?.join(KEY_FILE_NAME))
227}
228
229/// Resolves primary key and source (does not auto-create).
230///
231/// # Errors
232/// Returns an error if a configured key source exists but cannot be read or parsed.
233pub fn load_primary_key() -> SshCliResult<(Option<PrimaryKey>, KeySource)> {
234    // CLI flag: --secrets-key-file
235    let secrets_key_file = lock_global(&RUNTIME_FLAGS).secrets_key_file.clone();
236    if let Some(path) = secrets_key_file {
237        let mut text =
238            crate::paths::read_text_capped(&path, crate::paths::MAX_SECRETS_KEY_FILE_BYTES)
239                .map_err(|e| {
240                    SshCliError::InvalidArgument(format!(
241                        "failed reading --secrets-key-file {}: {e}",
242                        path.display()
243                    ))
244                })?;
245        let key = parse_hex_key(text.trim())
246            .map_err(|e| SshCliError::InvalidArgument(format!("invalid --secrets-key-file: {e}")));
247        text.zeroize();
248        return Ok((Some(key?), KeySource::ConfigFile));
249    }
250
251    // G-ERR-13: env-as-store for key material is forbidden (fail closed).
252    if std::env::var_os(ENV_SECRETS_KEY).is_some()
253        || std::env::var_os(ENV_SECRETS_KEY_FILE).is_some()
254    {
255        return Err(SshCliError::InvalidArgument(format!(
256            "{ENV_SECRETS_KEY} / {ENV_SECRETS_KEY_FILE} are not supported; use XDG `{KEY_FILE_NAME}` \
257             (`{APP_NAME} secrets init`) or --secrets-key-file"
258        )));
259    }
260
261    let use_keyring_flag = lock_global(&RUNTIME_FLAGS).use_keyring;
262    if use_keyring_flag {
263        match read_keyring() {
264            Ok(Some(key)) => return Ok((Some(key), KeySource::Keyring)),
265            Ok(None) => {}
266            Err(e) => {
267                tracing::warn!(err = %e, "keyring unavailable; trying secrets.key");
268            }
269        }
270    }
271
272    let path = secrets_key_path()?;
273    if path.is_file() {
274        // G-ERR-R01: failing to *read* the key file is I/O, not a data error. The file
275        // being unreadable and the file holding garbage are different problems with
276        // different fixes, and they shared exit 65.
277        let mut text =
278            crate::paths::read_text_capped(&path, crate::paths::MAX_SECRETS_KEY_FILE_BYTES)
279                .map_err(|e| {
280                    tracing::debug!(err = %e, path = %path.display(), "failed reading secrets key");
281                    e
282                })?;
283        let key = parse_hex_key(text.trim())
284            .map_err(|e| SshCliError::InvalidArgument(format!("invalid {KEY_FILE_NAME}: {e}")));
285        text.zeroize();
286        return Ok((Some(key?), KeySource::XdgFile));
287    }
288
289    Ok((None, KeySource::Absent))
290}
291
292/// Ensures a key for **write**: loads existing or auto-creates `secrets.key`
293/// (unless plaintext opt-out).
294///
295/// # Errors
296/// Returns an error if auto-creating `secrets.key` fails when encryption is required.
297pub fn ensure_key_for_write() -> SshCliResult<(Option<PrimaryKey>, KeySource)> {
298    let (existing, source) = load_primary_key()?;
299    if existing.is_some() {
300        return Ok((existing, source));
301    }
302    if plaintext_allowed() {
303        return Ok((None, KeySource::Absent));
304    }
305    let path = secrets_key_path()?;
306    let mut hex = generate_hex_key()?;
307    write_key_file(&path, &hex, false)?;
308    AUTO_KEY_CREATED.store(true, Ordering::Relaxed);
309    tracing::info!(
310        path = %path.display(),
311        "secrets.key auto-created (event secrets-key-auto-created)"
312    );
313    // G-ERR-R01: this key was produced by `generate_hex_key` three lines up, so failing
314    // to parse it back is an internal contradiction, not bad user input. Exit 65 told
315    // the caller to fix data they never supplied.
316    let key = parse_hex_key(&hex).map_err(|e| {
317        tracing::error!(err = %e, "generated key failed round-trip parse");
318        SshCliError::software("key_encoding")
319    });
320    hex.zeroize();
321    Ok((Some(key?), KeySource::XdgFile))
322}
323
324/// Current status (without loading material into logs).
325pub fn secrets_status() -> SshCliResult<SecretsStatus> {
326    let key_file_path = secrets_key_path()?;
327    let (key, source) = load_primary_key()?;
328    let encryption_active = key.is_some();
329    // A8: dropping the `Zeroizing` scrubs the material; no manual call needed.
330    drop(key);
331    Ok(SecretsStatus {
332        source,
333        encryption_active,
334        key_file_path,
335        plaintext_opt_out: plaintext_allowed(),
336    })
337}
338
339/// True if the string is already an encrypted blob (any supported version).
340#[must_use]
341pub fn is_encrypted_blob(value: &str) -> bool {
342    value.starts_with(ENC_PREFIX) || value.starts_with(ENC_PREFIX_V2)
343}
344
345/// Serializes a secret for TOML: encrypts if a key exists (or is auto-created); otherwise plaintext.
346///
347/// Empty secret never becomes a blob `sshcli-enc` (GAP-SSH-EXP-001): export redacted zera
348/// passwords and must store readable `""`, not ciphertext of empty string (which fools import
349/// on another machine without the primary-key and fakes "secret present").
350///
351/// # Errors
352/// Returns an error if key resolution, RNG, or AEAD encryption fails.
353pub fn serialize_secret(plaintext: &str) -> SshCliResult<String> {
354    serialize_secret_in_context(SecretContext::unbound(), plaintext)
355}
356
357/// Serializes a secret bound to `ctx` (A7): writes a `v2` blob sealed with AAD.
358///
359/// # Errors
360/// Returns an error if key resolution, RNG, or AEAD encryption fails.
361pub fn serialize_secret_in_context(
362    ctx: SecretContext<'_>,
363    plaintext: &str,
364) -> SshCliResult<String> {
365    if plaintext.is_empty() {
366        return Ok(String::new());
367    }
368    let (key, _) = ensure_key_for_write()?;
369    match key {
370        None => Ok(plaintext.to_string()),
371        // `key` is dropped scrubbed at the end of this arm (A8).
372        Some(key) => encrypt_secret(&key, plaintext, ctx),
373    }
374}
375
376/// Deserializes from TOML: decrypts `sshcli-enc:` blobs; otherwise returns as-is.
377///
378/// Uses [`SecretContext::unbound`], so it accepts `v1` blobs and `v2` blobs that
379/// were themselves sealed unbound, but rejects a `v2` blob bound to a concrete
380/// host/field. Call sites that know the owner must use
381/// [`deserialize_secret_in_context`] to get the relocation check.
382pub fn deserialize_secret(stored: &str) -> SshCliResult<String> {
383    deserialize_secret_in_context(SecretContext::unbound(), stored)
384}
385
386/// Deserializes a secret expected to belong to `ctx`.
387///
388/// Fails when a `v2` blob was sealed for a different host or a different field:
389/// the AEAD tag no longer verifies, which is the whole point of A7.
390///
391/// # Errors
392/// Missing primary key, malformed blob, or AEAD verification failure.
393pub fn deserialize_secret_in_context(ctx: SecretContext<'_>, stored: &str) -> SshCliResult<String> {
394    if !is_encrypted_blob(stored) {
395        return Ok(stored.to_string());
396    }
397    let (key, _) = load_primary_key()?;
398    let key = key.ok_or_else(|| {
399        SshCliError::InvalidArgument(format!(
400            "config contains encrypted secrets; run `{APP_NAME} secrets init` (XDG `{KEY_FILE_NAME}`) or pass `--secrets-key-file PATH` / `--use-keyring` (env key material is not supported)"
401        ))
402    })?;
403    decrypt_secret(&key, stored, ctx)
404}
405
406/// Generates [`PRIMARY_KEY_LEN_BYTES`] random bytes as [`PRIMARY_KEY_HEX_LEN`] hex chars.
407pub fn generate_hex_key() -> SshCliResult<String> {
408    let mut bytes = [0u8; PRIMARY_KEY_LEN_BYTES];
409    // G-ERR-R01: a CSPRNG that cannot produce bytes is a broken host, not malformed
410    // input. Reporting it as exit 65 told an agent to "fix the data" for a condition
411    // no input change can resolve.
412    getrandom::fill(&mut bytes).map_err(|_| SshCliError::software("rng"))?;
413    let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
414    bytes.zeroize();
415    Ok(hex)
416}
417
418/// Writes hex key to file with 0o600 (when supported).
419///
420/// # Errors
421/// Returns an error if the key is invalid, the file exists without force, or I/O fails.
422pub fn write_key_file(path: &Path, hex64: &str, force: bool) -> SshCliResult<()> {
423    let _ = parse_hex_key(hex64)
424        .map_err(|e| SshCliError::InvalidArgument(format!("invalid key: {e}")))?;
425    if path.exists() && !force {
426        return Err(SshCliError::InvalidArgument(format!(
427            "{} already exists; use --force to overwrite",
428            path.display()
429        )));
430    }
431    // GAP-AUD-SEC-001: backup previous key before force-overwrite.
432    if path.exists() && force {
433        let bak = path.with_file_name(format!(
434            "{}.bak",
435            path.file_name()
436                .and_then(|s| s.to_str())
437                .unwrap_or(KEY_FILE_NAME)
438        ));
439        if let Err(e) = std::fs::copy(path, &bak) {
440            tracing::warn!(
441                err = %e,
442                path = %bak.display(),
443                "failed to backup secrets key before --force"
444            );
445        }
446    }
447    if let Some(parent_dir) = path.parent() {
448        std::fs::create_dir_all(parent_dir)?;
449    }
450    let parent_dir = path.parent().unwrap_or_else(|| Path::new("."));
451    // G-ERR-R01: a full disk, a read-only mount or a missing XDG directory is I/O
452    // (exit 74), not malformed data (exit 65). Every step below used to collapse into
453    // `Config`, so `secrets init` on a read-only home reported "configuration error"
454    // and an agent had no way to tell it apart from a corrupt registry file.
455    let mut tmp = tempfile::NamedTempFile::new_in(parent_dir).map_err(SshCliError::Io)?;
456    use std::io::Write;
457    tmp.write_all(hex64.trim().as_bytes())
458        .map_err(SshCliError::Io)?;
459    tmp.write_all(b"\n").map_err(SshCliError::Io)?;
460    tmp.as_file().sync_all().map_err(SshCliError::Io)?;
461    crate::fs_perm::set_secret_file_mode(tmp.path())?;
462    // `PersistError` wraps the underlying `io::Error`; unwrapping it keeps the real
463    // cause instead of flattening "permission denied" into a formatted string.
464    tmp.persist(path).map_err(|e| SshCliError::Io(e.error))?;
465    // Best-effort re-apply after rename (matches prior ignore-on-error chmod).
466    let _ = crate::fs_perm::set_secret_file_mode(path);
467    Ok(())
468}
469
470/// Initializes primary-key in XDG file or keyring. **Never** prints the key.
471///
472/// # Errors
473/// Returns an error if the key already exists without `--force`, RNG fails, or keyring/file I/O fails.
474pub fn init_primary_key(use_keyring: bool, force: bool) -> SshCliResult<SecretsStatus> {
475    let mut hex = generate_hex_key()?;
476    if use_keyring {
477        if !force {
478            match read_keyring() {
479                Ok(Some(_)) => {
480                    hex.zeroize();
481                    return Err(SshCliError::InvalidArgument(
482                        "keyring already has a primary-key; use --force".to_string(),
483                    ));
484                }
485                Ok(None) => {}
486                Err(e) => {
487                    hex.zeroize();
488                    return Err(e);
489                }
490            }
491        }
492        let result = write_key_to_keyring(&hex);
493        hex.zeroize();
494        result?;
495        return secrets_status();
496    }
497    let path = secrets_key_path()?;
498    let result = write_key_file(&path, &hex, force);
499    hex.zeroize();
500    result?;
501    secrets_status()
502}
503
504fn parse_hex_key(hex: &str) -> Result<PrimaryKey, String> {
505    let h = hex.trim();
506    // A6: `len()` counts BYTES while `&h[i*2..i*2+2]` requires a char boundary. A key
507    // file holding multi-byte UTF-8 that happens to total 64 bytes would slice mid-character
508    // and panic instead of returning a typed error. Rejecting non-ASCII first makes byte
509    // offsets and character boundaries the same thing, so the loop below cannot panic.
510    if !h.is_ascii() || h.len() != PRIMARY_KEY_HEX_LEN {
511        return Err(format!(
512            "expected {PRIMARY_KEY_HEX_LEN} hex characters ({PRIMARY_KEY_LEN_BYTES} bytes)"
513        ));
514    }
515    // A8: fill the protected buffer directly so no bare `[u8; 32]` copy is left
516    // behind on this frame.
517    let mut out: PrimaryKey = Zeroizing::new([0u8; PRIMARY_KEY_LEN_BYTES]);
518    for i in 0..PRIMARY_KEY_LEN_BYTES {
519        let byte =
520            u8::from_str_radix(&h[i * 2..i * 2 + 2], 16).map_err(|_| "invalid hex".to_string())?;
521        out[i] = byte;
522    }
523    Ok(out)
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529    // Crypto types live in `secrets::aead` now; the legacy-v1 fixture below still
530    // needs to seal a blob by hand, so it imports them locally instead of forcing
531    // the production module to keep an import it no longer uses.
532    use crate::constants::AEAD_NONCE_LEN_BYTES;
533    use chacha20poly1305::aead::{Aead, KeyInit};
534    use chacha20poly1305::{ChaCha20Poly1305, Nonce};
535    use serial_test::serial;
536    use tempfile::TempDir;
537
538    fn clear_key_env() {
539        // Fail-closed path reads these keys; clear so serial tests start clean.
540        crate::test_util::env::remove_var(ENV_SECRETS_KEY);
541        crate::test_util::env::remove_var(ENV_SECRETS_KEY_FILE);
542        crate::test_util::env::remove_var(crate::constants::ENV_USE_KEYRING);
543        set_runtime_flags(false, None, false);
544        set_config_dir(None);
545    }
546
547    /// Isolates tests from real XDG (never pollute user config).
548    fn sandbox() -> TempDir {
549        clear_key_env();
550        let tmp = TempDir::new().unwrap();
551        set_config_dir(Some(tmp.path().to_path_buf()));
552        tmp
553    }
554
555    #[test]
556    #[serial]
557    fn roundtrip_with_xdg_key() {
558        let _tmp = sandbox();
559        init_primary_key(false, false).expect("init key");
560        let plain = "fake-test-password-not-real";
561        let enc = serialize_secret(plain).unwrap();
562        assert!(is_encrypted_blob(&enc));
563        assert!(!enc.contains(plain));
564        let back = deserialize_secret(&enc).unwrap();
565        assert_eq!(back, plain);
566        clear_key_env();
567    }
568
569    #[test]
570    #[serial]
571    fn opt_out_keeps_plaintext() {
572        let _tmp = sandbox();
573        set_runtime_flags(true, None, false);
574        let plain = "fake-plaintext-only-for-unit-test";
575        let out = serialize_secret(plain).unwrap();
576        assert_eq!(out, plain);
577        assert!(!is_encrypted_blob(&out));
578        clear_key_env();
579    }
580
581    #[test]
582    #[serial]
583    fn default_auto_creates_secrets_key() {
584        let tmp = sandbox();
585        let plain = "fake-auto-enc-password";
586        let enc = serialize_secret(plain).unwrap();
587        assert!(is_encrypted_blob(&enc));
588        assert!(!enc.contains(plain));
589        assert!(tmp.path().join(KEY_FILE_NAME).is_file());
590        let back = deserialize_secret(&enc).unwrap();
591        assert_eq!(back, plain);
592        clear_key_env();
593    }
594
595    #[test]
596    #[serial]
597    fn blob_without_key_fails() {
598        let tmp = sandbox();
599        init_primary_key(false, false).expect("init");
600        let enc = serialize_secret("fake-secret").unwrap();
601        // Drop key material from sandbox; allow plaintext so deserialize path
602        // still requires a key for encrypted blobs.
603        clear_key_env();
604        set_config_dir(Some(tmp.path().to_path_buf()));
605        let _ = std::fs::remove_file(tmp.path().join(KEY_FILE_NAME));
606        set_runtime_flags(true, None, false);
607        let err = deserialize_secret(&enc).unwrap_err();
608        let msg = err.to_string();
609        assert!(
610            msg.contains("encrypted") || msg.contains("secrets") || msg.contains("key"),
611            "msg={msg}"
612        );
613        clear_key_env();
614    }
615
616    #[test]
617    #[serial]
618    fn empty_secret_never_encrypted_blob() {
619        // GAP-SSH-EXP-001
620        let _tmp = sandbox();
621        init_primary_key(false, false).expect("init");
622        let out = serialize_secret("").unwrap();
623        assert_eq!(out, "");
624        assert!(!is_encrypted_blob(&out));
625        clear_key_env();
626    }
627
628    #[test]
629    #[serial]
630    fn v2_blob_rejects_foreign_host_and_field() {
631        // A7: an operator editing config.toml must not be able to move a blob
632        // between hosts or between fields and still have it decrypt.
633        let _tmp = sandbox();
634        init_primary_key(false, false).expect("init key");
635        let plain = "fake-bound-secret";
636        let host_a = SecretContext::new("host-a", "password");
637        let enc = serialize_secret_in_context(host_a, plain).unwrap();
638        assert!(enc.starts_with(ENC_PREFIX_V2), "v2 must be written");
639
640        assert_eq!(deserialize_secret_in_context(host_a, &enc).unwrap(), plain);
641
642        let other_host = SecretContext::new("host-b", "password");
643        assert!(
644            deserialize_secret_in_context(other_host, &enc).is_err(),
645            "blob bound to host-a must not open as host-b"
646        );
647
648        let other_field = SecretContext::new("host-a", "su_password");
649        assert!(
650            deserialize_secret_in_context(other_field, &enc).is_err(),
651            "blob bound to password must not open as su_password"
652        );
653        clear_key_env();
654    }
655
656    #[test]
657    #[serial]
658    fn unbound_blob_stays_readable_under_any_context() {
659        // Migration: call sites not yet passing host/field write unbound blobs;
660        // wiring a context later must not lock the user out of their config.
661        let _tmp = sandbox();
662        init_primary_key(false, false).expect("init key");
663        let plain = "fake-unbound-secret";
664        let enc = serialize_secret(plain).unwrap();
665        assert!(enc.starts_with(ENC_PREFIX_V2));
666        let bound = SecretContext::new("host-a", "password");
667        assert_eq!(deserialize_secret_in_context(bound, &enc).unwrap(), plain);
668        clear_key_env();
669    }
670
671    #[test]
672    #[serial]
673    fn legacy_v1_blob_still_decrypts() {
674        // Existing configs hold v1 blobs sealed without associated data.
675        let _tmp = sandbox();
676        init_primary_key(false, false).expect("init key");
677        let (key, _) = load_primary_key().unwrap();
678        let key = key.expect("key present");
679        let plain = "fake-legacy-v1-secret";
680
681        // Rebuild a v1 blob exactly as the previous version wrote it.
682        let cipher = ChaCha20Poly1305::new_from_slice(key.as_slice()).unwrap();
683        let mut nonce_bytes = [0u8; AEAD_NONCE_LEN_BYTES];
684        getrandom::fill(&mut nonce_bytes).unwrap();
685        let ct = cipher
686            .encrypt(&Nonce::from(nonce_bytes), plain.as_bytes())
687            .unwrap();
688        let mut packed = nonce_bytes.to_vec();
689        packed.extend_from_slice(&ct);
690        let blob = format!(
691            "{ENC_PREFIX}{}",
692            base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &packed)
693        );
694
695        assert!(is_encrypted_blob(&blob));
696        assert_eq!(deserialize_secret(&blob).unwrap(), plain);
697        assert_eq!(
698            deserialize_secret_in_context(SecretContext::new("host-a", "password"), &blob).unwrap(),
699            plain
700        );
701        clear_key_env();
702    }
703
704    #[test]
705    fn aad_encoding_is_unambiguous() {
706        // A host literally named "a:b" must not collide with the pair (a, b).
707        assert_ne!(
708            SecretContext::new("a:b", "password").aad(),
709            SecretContext::new("a", "b:password").aad()
710        );
711        assert!(SecretContext::unbound().is_unbound());
712        assert!(!SecretContext::new("host-a", "password").is_unbound());
713    }
714
715    #[test]
716    fn parse_hex_tamanho() {
717        assert!(parse_hex_key("aa").is_err());
718        assert!(
719            parse_hex_key("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")
720                .is_ok()
721        );
722    }
723
724    #[test]
725    #[serial]
726    fn init_creates_file() {
727        clear_key_env();
728        let tmp = TempDir::new().unwrap();
729        set_config_dir(Some(tmp.path().to_path_buf()));
730        let st = init_primary_key(false, false).unwrap();
731        assert!(st.encryption_active);
732        assert_eq!(st.source, KeySource::XdgFile);
733        assert!(st.key_file_path.is_file());
734        clear_key_env();
735    }
736
737    #[test]
738    fn lock_global_recovers_from_poison_with_usable_data() {
739        let m = Mutex::new(42_u32);
740        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
741            let _g = m.lock().unwrap();
742            panic!("intentional poison for lock_global test");
743        }));
744        assert!(m.is_poisoned());
745        let g = lock_global(&m);
746        assert_eq!(*g, 42);
747    }
748}