Skip to main content

ssh_cli/
secrets.rs

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