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:v1:<base64(nonce||ciphertext)>`.
18//!
19//! **Never** log or return the key or plaintext in public errors.
20
21use crate::constants::{
22    AEAD_NONCE_LEN_BYTES, AEAD_TAG_LEN_BYTES, APP_NAME, ENV_SECRETS_KEY, ENV_SECRETS_KEY_FILE,
23    KEYRING_SERVICE, KEYRING_USER_LEGACY, KEYRING_USER_PRIMARY, PRIMARY_KEY_HEX_LEN,
24    PRIMARY_KEY_LEN_BYTES, SECRETS_KEY_FILE_NAME,
25};
26use crate::errors::{SshCliError, SshCliResult};
27use chacha20poly1305::aead::{Aead, KeyInit};
28use chacha20poly1305::{ChaCha20Poly1305, Nonce};
29use std::path::{Path, PathBuf};
30use std::sync::atomic::{AtomicBool, Ordering};
31use std::sync::Mutex;
32use zeroize::Zeroize;
33
34/// Prefix for encrypted blobs in TOML.
35pub const ENC_PREFIX: &str = "sshcli-enc:v1:";
36
37/// File name of the primary key in the config directory (XDG sibling of `config.toml`).
38pub const KEY_FILE_NAME: &str = SECRETS_KEY_FILE_NAME;
39
40// Compile-time invariants (const/static rules).
41const _: () = assert!(!ENC_PREFIX.is_empty());
42const _: () = assert!(!KEY_FILE_NAME.is_empty());
43const _: () = assert!(PRIMARY_KEY_LEN_BYTES == 32);
44
45/// Locks a process-global `Mutex`, recovering from poison explicitly.
46///
47/// Poison means a previous holder panicked; the data is still usable for this
48/// one-shot CLI, so we take `into_inner()` rather than silently skipping updates.
49/// Recovery is **logged** (Rules Rust: never silence `PoisonError` without log).
50///
51/// Critical sections using this helper must stay short and **never** hold the
52/// guard across `.await` or blocking I/O (clone/copy under lock, then release).
53fn lock_global<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
54    m.lock().unwrap_or_else(|poisoned| {
55        tracing::warn!(
56            "secrets process-global mutex was poisoned; recovering via into_inner (one-shot CLI)"
57        );
58        poisoned.into_inner()
59    })
60}
61
62/// Config directory override (e.g. `--config-dir`) to align `secrets.key`.
63///
64/// Concurrent access: `std::sync::Mutex` (const ctor) — single composite state
65/// (`Option<PathBuf>`); not split into uncoordinated atomics. Poison recovered
66/// via [`lock_global`]. Never held across await.
67static DIR_CONFIG_OVERRIDE: Mutex<Option<PathBuf>> = Mutex::new(None);
68
69/// CLI runtime overrides (flags). Env remains as deprecated fallback.
70#[derive(Debug, Default, Clone)]
71struct RuntimeSecretsFlags {
72    allow_plaintext: bool,
73    secrets_key_file: Option<PathBuf>,
74    use_keyring: bool,
75}
76
77/// Process-wide secrets CLI flags (set once after parse).
78///
79/// Single `Mutex` keeps the three fields consistent (Rules: do not protect a
80/// multi-field invariant with independent atomics). See [`lock_global`].
81static RUNTIME_FLAGS: Mutex<RuntimeSecretsFlags> = Mutex::new(RuntimeSecretsFlags {
82    allow_plaintext: false,
83    secrets_key_file: None,
84    use_keyring: false,
85});
86
87/// Set when `secrets.key` is auto-created during this process (GAP-AUD-007).
88///
89/// Concurrent access: independent status bit; `Ordering::Relaxed` (no dependent
90/// data fence — isolated flag, not paired with other memory).
91static AUTO_KEY_CREATED: AtomicBool = AtomicBool::new(false);
92
93/// Sets the config directory used to resolve `secrets.key` (one-shot; called from `dispatch`).
94pub fn set_config_dir(dir: Option<PathBuf>) {
95    *lock_global(&DIR_CONFIG_OVERRIDE) = dir;
96}
97
98/// Applies one-shot CLI flags for secrets resolution (GAP-AUD-006).
99pub fn set_runtime_flags(
100    allow_plaintext: bool,
101    secrets_key_file: Option<PathBuf>,
102    use_keyring: bool,
103) {
104    {
105        let mut g = lock_global(&RUNTIME_FLAGS);
106        g.allow_plaintext = allow_plaintext;
107        g.secrets_key_file = secrets_key_file;
108        g.use_keyring = use_keyring;
109    }
110    AUTO_KEY_CREATED.store(false, Ordering::Relaxed);
111}
112
113/// Returns true once if a key was auto-created since the last flag reset (consume).
114#[must_use]
115pub fn take_auto_key_created() -> bool {
116    // RMW on an independent flag — Relaxed is enough (no data publish).
117    AUTO_KEY_CREATED.swap(false, Ordering::Relaxed)
118}
119
120/// Returns true if a key was auto-created (non-consuming).
121#[must_use]
122pub fn auto_key_created() -> bool {
123    AUTO_KEY_CREATED.load(Ordering::Relaxed)
124}
125
126/// Primary-key source (without exposing material).
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum KeySource {
129    /// No key source available (plaintext at-rest with opt-out or before first write).
130    Absent,
131    /// Reserved: env key material is **rejected** (fail-closed); never a success source.
132    Env,
133    /// File from CLI `--secrets-key-file`.
134    ConfigFile,
135    /// OS keyring.
136    Keyring,
137    /// XDG / config-dir `secrets.key` file.
138    XdgFile,
139}
140
141impl KeySource {
142    /// Stable name for JSON/doctor.
143    #[must_use]
144    pub const fn as_str(self) -> &'static str {
145        match self {
146            Self::Absent => "none",
147            Self::Env => "env",
148            Self::ConfigFile => "file",
149            Self::Keyring => "keyring",
150            Self::XdgFile => "xdg_file",
151        }
152    }
153}
154
155/// Secrets mode report (no sensitive material).
156#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct SecretsStatus {
158    /// Primary-key source.
159    pub source: KeySource,
160    /// If true, serialization encrypts secrets.
161    pub encryption_active: bool,
162    /// Path of `secrets.key` (may not exist yet).
163    pub key_file_path: PathBuf,
164    /// If true, plaintext opt-out is active.
165    pub plaintext_opt_out: bool,
166}
167
168/// True if plaintext opt-out is active (CLI flag only — G-ERR-13, no env store).
169#[must_use]
170pub fn plaintext_allowed() -> bool {
171    lock_global(&RUNTIME_FLAGS).allow_plaintext
172}
173
174/// Config directory used for `secrets.key` (CLI/test override > XDG).
175///
176/// # Errors
177/// [`SshCliError::XdgDirectory`] when XDG cannot be resolved and no override is set.
178pub fn secrets_config_dir() -> SshCliResult<PathBuf> {
179    if let Some(d) = lock_global(&DIR_CONFIG_OVERRIDE).clone() {
180        return Ok(d);
181    }
182    crate::paths::xdg_config_dir()
183}
184
185/// Canonical path of the local primary-key file.
186pub fn secrets_key_path() -> SshCliResult<PathBuf> {
187    Ok(secrets_config_dir()?.join(KEY_FILE_NAME))
188}
189
190/// Resolves primary key and source (does not auto-create).
191///
192/// # Errors
193/// Returns an error if a configured key source exists but cannot be read or parsed.
194pub fn load_primary_key() -> SshCliResult<(Option<[u8; PRIMARY_KEY_LEN_BYTES]>, KeySource)> {
195    // CLI flag: --secrets-key-file
196    let secrets_key_file = lock_global(&RUNTIME_FLAGS).secrets_key_file.clone();
197    if let Some(path) = secrets_key_file {
198        let mut text =
199            crate::paths::read_text_capped(&path, crate::paths::MAX_SECRETS_KEY_FILE_BYTES)
200                .map_err(|e| {
201                    SshCliError::InvalidArgument(format!(
202                        "failed reading --secrets-key-file {}: {e}",
203                        path.display()
204                    ))
205                })?;
206        let key = parse_hex_key(text.trim())
207            .map_err(|e| SshCliError::InvalidArgument(format!("invalid --secrets-key-file: {e}")));
208        text.zeroize();
209        return Ok((Some(key?), KeySource::ConfigFile));
210    }
211
212    // G-ERR-13: env-as-store for key material is forbidden (fail closed).
213    if std::env::var_os(ENV_SECRETS_KEY).is_some()
214        || std::env::var_os(ENV_SECRETS_KEY_FILE).is_some()
215    {
216        return Err(SshCliError::InvalidArgument(format!(
217            "{ENV_SECRETS_KEY} / {ENV_SECRETS_KEY_FILE} are not supported; use XDG `{KEY_FILE_NAME}` \
218             (`{APP_NAME} secrets init`) or --secrets-key-file"
219        )));
220    }
221
222    let use_keyring_flag = lock_global(&RUNTIME_FLAGS).use_keyring;
223    if use_keyring_flag {
224        match read_keyring() {
225            Ok(Some(key)) => return Ok((Some(key), KeySource::Keyring)),
226            Ok(None) => {}
227            Err(e) => {
228                tracing::warn!(err = %e, "keyring unavailable; trying secrets.key");
229            }
230        }
231    }
232
233    let path = secrets_key_path()?;
234    if path.is_file() {
235        let mut text =
236            crate::paths::read_text_capped(&path, crate::paths::MAX_SECRETS_KEY_FILE_BYTES)
237                .map_err(|e| {
238                    SshCliError::Config(format!("failed reading {}: {e}", path.display()))
239                })?;
240        let key = parse_hex_key(text.trim())
241            .map_err(|e| SshCliError::InvalidArgument(format!("invalid {KEY_FILE_NAME}: {e}")));
242        text.zeroize();
243        return Ok((Some(key?), KeySource::XdgFile));
244    }
245
246    Ok((None, KeySource::Absent))
247}
248
249/// Ensures a key for **write**: loads existing or auto-creates `secrets.key`
250/// (unless plaintext opt-out).
251///
252/// # Errors
253/// Returns an error if auto-creating `secrets.key` fails when encryption is required.
254pub fn ensure_key_for_write() -> SshCliResult<(Option<[u8; PRIMARY_KEY_LEN_BYTES]>, KeySource)> {
255    let (existing, source) = load_primary_key()?;
256    if existing.is_some() {
257        return Ok((existing, source));
258    }
259    if plaintext_allowed() {
260        return Ok((None, KeySource::Absent));
261    }
262    let path = secrets_key_path()?;
263    let mut hex = generate_hex_key()?;
264    write_key_file(&path, &hex, false)?;
265    AUTO_KEY_CREATED.store(true, Ordering::Relaxed);
266    tracing::info!(
267        path = %path.display(),
268        "secrets.key auto-created (event secrets-key-auto-created)"
269    );
270    let key =
271        parse_hex_key(&hex).map_err(|e| SshCliError::Config(format!("invalid generated key: {e}")));
272    hex.zeroize();
273    Ok((Some(key?), KeySource::XdgFile))
274}
275
276/// Current status (without loading material into logs).
277pub fn secrets_status() -> SshCliResult<SecretsStatus> {
278    let key_file_path = secrets_key_path()?;
279    let (key, source) = load_primary_key()?;
280    let encryption_active = key.is_some();
281    if let Some(mut k) = key {
282        k.zeroize();
283    }
284    Ok(SecretsStatus {
285        source,
286        encryption_active,
287        key_file_path,
288        plaintext_opt_out: plaintext_allowed(),
289    })
290}
291
292/// True if the string is already an encrypted blob.
293#[must_use]
294pub fn is_encrypted_blob(value: &str) -> bool {
295    value.starts_with(ENC_PREFIX)
296}
297
298/// Serializes a secret for TOML: encrypts if a key exists (or is auto-created); otherwise plaintext.
299///
300/// Empty secret never becomes a blob `sshcli-enc` (GAP-SSH-EXP-001): export redacted zera
301/// passwords and must store readable `""`, not ciphertext of empty string (which fools import
302/// on another machine without the primary-key and fakes "secret present").
303///
304/// # Errors
305/// Returns an error if key resolution, RNG, or AEAD encryption fails.
306pub fn serialize_secret(plaintext: &str) -> SshCliResult<String> {
307    if plaintext.is_empty() {
308        return Ok(String::new());
309    }
310    let (key, _) = ensure_key_for_write()?;
311    match key {
312        None => Ok(plaintext.to_string()),
313        Some(mut key) => {
314            let out = encrypt_secret(&key, plaintext)?;
315            key.zeroize();
316            Ok(out)
317        }
318    }
319}
320
321/// Deserializes from TOML: decrypts `sshcli-enc:v1:` blobs; otherwise returns as-is.
322pub fn deserialize_secret(stored: &str) -> SshCliResult<String> {
323    if !is_encrypted_blob(stored) {
324        return Ok(stored.to_string());
325    }
326    let (key, _) = load_primary_key()?;
327    let mut key = key.ok_or_else(|| {
328        SshCliError::InvalidArgument(format!(
329            "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)"
330        ))
331    })?;
332    let plain = decrypt_secret(&key, stored)?;
333    key.zeroize();
334    Ok(plain)
335}
336
337/// Generates [`PRIMARY_KEY_LEN_BYTES`] random bytes as [`PRIMARY_KEY_HEX_LEN`] hex chars.
338pub fn generate_hex_key() -> SshCliResult<String> {
339    let mut bytes = [0u8; PRIMARY_KEY_LEN_BYTES];
340    getrandom::getrandom(&mut bytes)
341        .map_err(|e| SshCliError::Config(format!("RNG failed: {e}")))?;
342    let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
343    bytes.zeroize();
344    Ok(hex)
345}
346
347/// Writes hex key to file with 0o600 (when supported).
348///
349/// # Errors
350/// Returns an error if the key is invalid, the file exists without force, or I/O fails.
351pub fn write_key_file(path: &Path, hex64: &str, force: bool) -> SshCliResult<()> {
352    let _ = parse_hex_key(hex64)
353        .map_err(|e| SshCliError::InvalidArgument(format!("invalid key: {e}")))?;
354    if path.exists() && !force {
355        return Err(SshCliError::InvalidArgument(format!(
356            "{} already exists; use --force to overwrite",
357            path.display()
358        )));
359    }
360    // GAP-AUD-SEC-001: backup previous key before force-overwrite.
361    if path.exists() && force {
362        let bak = path.with_file_name(format!(
363            "{}.bak",
364            path.file_name()
365                .and_then(|s| s.to_str())
366                .unwrap_or(KEY_FILE_NAME)
367        ));
368        if let Err(e) = std::fs::copy(path, &bak) {
369            tracing::warn!(
370                err = %e,
371                path = %bak.display(),
372                "failed to backup secrets key before --force"
373            );
374        }
375    }
376    if let Some(parent_dir) = path.parent() {
377        std::fs::create_dir_all(parent_dir)?;
378    }
379    let parent_dir = path.parent().unwrap_or_else(|| Path::new("."));
380    let mut tmp = tempfile::NamedTempFile::new_in(parent_dir)
381        .map_err(|e| SshCliError::Config(format!("tempfile secrets.key: {e}")))?;
382    use std::io::Write;
383    tmp.write_all(hex64.trim().as_bytes())
384        .map_err(|e| SshCliError::Config(format!("write secrets.key: {e}")))?;
385    tmp.write_all(b"\n")
386        .map_err(|e| SshCliError::Config(format!("write secrets.key: {e}")))?;
387    tmp.as_file()
388        .sync_all()
389        .map_err(|e| SshCliError::Config(format!("fsync secrets.key: {e}")))?;
390    crate::fs_perm::set_secret_file_mode(tmp.path())
391        .map_err(|e| SshCliError::Config(format!("chmod secrets.key: {e}")))?;
392    tmp.persist(path)
393        .map_err(|e| SshCliError::Config(format!("persist secrets.key: {e}")))?;
394    // Best-effort re-apply after rename (matches prior ignore-on-error chmod).
395    let _ = crate::fs_perm::set_secret_file_mode(path);
396    Ok(())
397}
398
399/// Initializes primary-key in XDG file or keyring. **Never** prints the key.
400///
401/// # Errors
402/// Returns an error if the key already exists without `--force`, RNG fails, or keyring/file I/O fails.
403pub fn init_primary_key(use_keyring: bool, force: bool) -> SshCliResult<SecretsStatus> {
404    let mut hex = generate_hex_key()?;
405    if use_keyring {
406        if !force {
407            match read_keyring() {
408                Ok(Some(_)) => {
409                    hex.zeroize();
410                    return Err(SshCliError::InvalidArgument(
411                        "keyring already has a primary-key; use --force".to_string(),
412                    ));
413                }
414                Ok(None) => {}
415                Err(e) => {
416                    hex.zeroize();
417                    return Err(e);
418                }
419            }
420        }
421        let result = write_key_to_keyring(&hex);
422        hex.zeroize();
423        result?;
424        return secrets_status();
425    }
426    let path = secrets_key_path()?;
427    let result = write_key_file(&path, &hex, force);
428    hex.zeroize();
429    result?;
430    secrets_status()
431}
432
433/// Stores primary-key (hex) in the OS keyring. Does not print the key.
434pub fn write_key_to_keyring(hex64: &str) -> SshCliResult<()> {
435    let _ = parse_hex_key(hex64)
436        .map_err(|e| SshCliError::InvalidArgument(format!("invalid key: {e}")))?;
437    let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER_PRIMARY)
438        .map_err(|e| SshCliError::Config(format!("keyring Entry::new failed: {e}")))?;
439    entry
440        .set_password(hex64.trim())
441        .map_err(|e| SshCliError::Config(format!("keyring set failed: {e}")))?;
442    Ok(())
443}
444
445fn parse_hex_key(hex: &str) -> Result<[u8; PRIMARY_KEY_LEN_BYTES], String> {
446    let h = hex.trim();
447    if h.len() != PRIMARY_KEY_HEX_LEN {
448        return Err(format!(
449            "expected {PRIMARY_KEY_HEX_LEN} hex characters ({PRIMARY_KEY_LEN_BYTES} bytes)"
450        ));
451    }
452    let mut out = [0u8; PRIMARY_KEY_LEN_BYTES];
453    for i in 0..PRIMARY_KEY_LEN_BYTES {
454        let byte =
455            u8::from_str_radix(&h[i * 2..i * 2 + 2], 16).map_err(|_| "invalid hex".to_string())?;
456        out[i] = byte;
457    }
458    Ok(out)
459}
460
461fn encrypt_secret(key: &[u8; PRIMARY_KEY_LEN_BYTES], plaintext: &str) -> SshCliResult<String> {
462    let cipher =
463        ChaCha20Poly1305::new_from_slice(key).map_err(|_| SshCliError::crypto("aead_key"))?;
464    let mut nonce_bytes = [0u8; AEAD_NONCE_LEN_BYTES];
465    getrandom::getrandom(&mut nonce_bytes)
466        .map_err(|e| SshCliError::Config(format!("RNG failed: {e}")))?;
467    let nonce = Nonce::from_slice(&nonce_bytes);
468    let ciphertext = cipher
469        .encrypt(nonce, plaintext.as_bytes())
470        .map_err(|_| SshCliError::crypto("encrypt"))?;
471    let mut packed = Vec::with_capacity(AEAD_NONCE_LEN_BYTES + ciphertext.len());
472    packed.extend_from_slice(&nonce_bytes);
473    packed.extend_from_slice(&ciphertext);
474    Ok(format!(
475        "{ENC_PREFIX}{}",
476        base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &packed)
477    ))
478}
479
480fn decrypt_secret(key: &[u8; PRIMARY_KEY_LEN_BYTES], blob: &str) -> SshCliResult<String> {
481    let b64 = blob
482        .strip_prefix(ENC_PREFIX)
483        .ok_or_else(|| SshCliError::crypto("blob_parse"))?;
484    let packed = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64)
485        .map_err(|_| SshCliError::crypto("blob_b64"))?;
486    if packed.len() < AEAD_NONCE_LEN_BYTES + AEAD_TAG_LEN_BYTES {
487        return Err(SshCliError::Config("encrypted blob too short".to_string()));
488    }
489    let (nonce_bytes, ct) = packed.split_at(AEAD_NONCE_LEN_BYTES);
490    let cipher =
491        ChaCha20Poly1305::new_from_slice(key).map_err(|_| SshCliError::crypto("aead_key"))?;
492    let nonce = Nonce::from_slice(nonce_bytes);
493    let plain = cipher
494        .decrypt(nonce, ct)
495        .map_err(|_| SshCliError::crypto("decrypt"))?;
496    match String::from_utf8(plain) {
497        Ok(s) => Ok(s),
498        Err(e) => {
499            // from_utf8 failure keeps bytes in the error — scrub before drop.
500            let mut bad = e.into_bytes();
501            bad.zeroize();
502            Err(SshCliError::Config(
503                "decrypted secret is not valid UTF-8".to_string(),
504            ))
505        }
506    }
507}
508
509fn read_keyring() -> SshCliResult<Option<[u8; PRIMARY_KEY_LEN_BYTES]>> {
510    // Prefer inclusive primary-key id; fall back to legacy master-key user for migration.
511    for user in [KEYRING_USER_PRIMARY, KEYRING_USER_LEGACY] {
512        let entry = match keyring::Entry::new(KEYRING_SERVICE, user) {
513            Ok(e) => e,
514            Err(e) => {
515                if user == "secrets-master-key" {
516                    return Err(SshCliError::Config(format!(
517                        "keyring Entry::new failed: {e}"
518                    )));
519                }
520                continue;
521            }
522        };
523        match entry.get_password() {
524            Ok(mut s) => {
525                let key = parse_hex_key(&s).map_err(|e| {
526                    SshCliError::InvalidArgument(format!("invalid keyring primary-key: {e}"))
527                });
528                s.zeroize();
529                return Ok(Some(key?));
530            }
531            Err(keyring::Error::NoEntry) => continue,
532            Err(e) => {
533                if user == "secrets-master-key" {
534                    return Err(SshCliError::Config(format!("keyring get failed: {e}")));
535                }
536                continue;
537            }
538        }
539    }
540    Ok(None)
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546    use serial_test::serial;
547    use tempfile::TempDir;
548
549    fn clear_key_env() {
550        // Fail-closed path reads these keys; clear so serial tests start clean.
551        crate::test_util::env::remove_var(ENV_SECRETS_KEY);
552        crate::test_util::env::remove_var(ENV_SECRETS_KEY_FILE);
553        crate::test_util::env::remove_var(crate::constants::ENV_USE_KEYRING);
554        set_runtime_flags(false, None, false);
555        set_config_dir(None);
556    }
557
558    /// Isolates tests from real XDG (never pollute user config).
559    fn sandbox() -> TempDir {
560        clear_key_env();
561        let tmp = TempDir::new().unwrap();
562        set_config_dir(Some(tmp.path().to_path_buf()));
563        tmp
564    }
565
566    #[test]
567    #[serial]
568    fn roundtrip_with_xdg_key() {
569        let _tmp = sandbox();
570        init_primary_key(false, false).expect("init key");
571        let plain = "fake-test-password-not-real";
572        let enc = serialize_secret(plain).unwrap();
573        assert!(is_encrypted_blob(&enc));
574        assert!(!enc.contains(plain));
575        let back = deserialize_secret(&enc).unwrap();
576        assert_eq!(back, plain);
577        clear_key_env();
578    }
579
580    #[test]
581    #[serial]
582    fn opt_out_keeps_plaintext() {
583        let _tmp = sandbox();
584        set_runtime_flags(true, None, false);
585        let plain = "fake-plaintext-only-for-unit-test";
586        let out = serialize_secret(plain).unwrap();
587        assert_eq!(out, plain);
588        assert!(!is_encrypted_blob(&out));
589        clear_key_env();
590    }
591
592    #[test]
593    #[serial]
594    fn default_auto_creates_secrets_key() {
595        let tmp = sandbox();
596        let plain = "fake-auto-enc-password";
597        let enc = serialize_secret(plain).unwrap();
598        assert!(is_encrypted_blob(&enc));
599        assert!(!enc.contains(plain));
600        assert!(tmp.path().join(KEY_FILE_NAME).is_file());
601        let back = deserialize_secret(&enc).unwrap();
602        assert_eq!(back, plain);
603        clear_key_env();
604    }
605
606    #[test]
607    #[serial]
608    fn blob_without_key_fails() {
609        let tmp = sandbox();
610        init_primary_key(false, false).expect("init");
611        let enc = serialize_secret("fake-secret").unwrap();
612        // Drop key material from sandbox; allow plaintext so deserialize path
613        // still requires a key for encrypted blobs.
614        clear_key_env();
615        set_config_dir(Some(tmp.path().to_path_buf()));
616        let _ = std::fs::remove_file(tmp.path().join(KEY_FILE_NAME));
617        set_runtime_flags(true, None, false);
618        let err = deserialize_secret(&enc).unwrap_err();
619        let msg = err.to_string();
620        assert!(
621            msg.contains("encrypted") || msg.contains("secrets") || msg.contains("key"),
622            "msg={msg}"
623        );
624        clear_key_env();
625    }
626
627    #[test]
628    #[serial]
629    fn empty_secret_never_encrypted_blob() {
630        // GAP-SSH-EXP-001
631        let _tmp = sandbox();
632        init_primary_key(false, false).expect("init");
633        let out = serialize_secret("").unwrap();
634        assert_eq!(out, "");
635        assert!(!is_encrypted_blob(&out));
636        clear_key_env();
637    }
638
639    #[test]
640    fn parse_hex_tamanho() {
641        assert!(parse_hex_key("aa").is_err());
642        assert!(
643            parse_hex_key("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")
644                .is_ok()
645        );
646    }
647
648    #[test]
649    #[serial]
650    fn init_creates_file() {
651        clear_key_env();
652        let tmp = TempDir::new().unwrap();
653        set_config_dir(Some(tmp.path().to_path_buf()));
654        let st = init_primary_key(false, false).unwrap();
655        assert!(st.encryption_active);
656        assert_eq!(st.source, KeySource::XdgFile);
657        assert!(st.key_file_path.is_file());
658        clear_key_env();
659    }
660
661    #[test]
662    fn lock_global_recovers_from_poison_with_usable_data() {
663        let m = Mutex::new(42_u32);
664        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
665            let _g = m.lock().unwrap();
666            panic!("intentional poison for lock_global test");
667        }));
668        assert!(m.is_poisoned());
669        let g = lock_global(&m);
670        assert_eq!(*g, 42);
671    }
672}