Skip to main content

ssh_cli/
secrets.rs

1//! Cifragem at-rest de segredos no `config.toml` (GAP-009 / R-SECRETS-DEFAULT).
2//!
3//! Ordem de resolução da chave mestra (32 bytes):
4//! 1. `SSH_CLI_SECRETS_KEY` — 64 hex chars
5//! 2. `SSH_CLI_SECRETS_KEY_FILE` — arquivo com 64 hex chars
6//! 3. OS keyring (`service=ssh-cli`, `user=secrets-master-key`) se `SSH_CLI_USE_KEYRING=1`
7//! 4. Arquivo XDG `secrets.key` (ao lado do `config.toml`), criado automaticamente na 1ª gravação
8//!
9//! Opt-out (testes/debug): `SSH_CLI_ALLOW_PLAINTEXT_SECRETS=1` — não auto-cria chave;
10//! serialização permanece em texto se nenhuma fonte 1–3 estiver definida.
11//!
12//! Com chave: serialização grava `sshcli-enc:v1:<base64(nonce||ciphertext)>`.
13//!
14//! **Nunca** logar ou retornar a chave ou o plaintext em erros públicos.
15
16use crate::erros::{ErroSshCli, ResultadoSshCli};
17use chacha20poly1305::aead::{Aead, KeyInit};
18use chacha20poly1305::{ChaCha20Poly1305, Nonce};
19use std::path::{Path, PathBuf};
20use std::sync::Mutex;
21use zeroize::Zeroize;
22
23/// Prefixo de blobs cifrados no TOML.
24pub const PREFIXO_ENC: &str = "sshcli-enc:v1:";
25
26/// Nome do arquivo de chave mestra no diretório de config.
27pub const NOME_ARQUIVO_CHAVE: &str = "secrets.key";
28
29/// Override de diretório de config (ex.: `--config-dir`), para alinhar `secrets.key`.
30static DIR_CONFIG_OVERRIDE: Mutex<Option<PathBuf>> = Mutex::new(None);
31
32/// Define o diretório de config para resolver `secrets.key` (one-shot; chamado no `executar`).
33pub fn definir_diretorio_config(dir: Option<PathBuf>) {
34    if let Ok(mut g) = DIR_CONFIG_OVERRIDE.lock() {
35        *g = dir;
36    }
37}
38
39/// Fonte da chave mestra (sem expor material).
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum FonteChave {
42    /// Ausente — at-rest em texto (só com opt-out ou antes da 1ª gravação).
43    Nenhuma,
44    /// Variável `SSH_CLI_SECRETS_KEY`.
45    Env,
46    /// Arquivo `SSH_CLI_SECRETS_KEY_FILE`.
47    Arquivo,
48    /// OS keyring.
49    Keyring,
50    /// Arquivo XDG / config-dir `secrets.key`.
51    XdgArquivo,
52}
53
54impl FonteChave {
55    /// Nome estável para JSON/doctor.
56    #[must_use]
57    pub const fn as_str(self) -> &'static str {
58        match self {
59            Self::Nenhuma => "none",
60            Self::Env => "env",
61            Self::Arquivo => "file",
62            Self::Keyring => "keyring",
63            Self::XdgArquivo => "xdg_file",
64        }
65    }
66}
67
68/// Relatório de modo de segredos (sem material sensível).
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct StatusSegredos {
71    /// Fonte da chave mestra.
72    pub fonte: FonteChave,
73    /// Se true, serialização cifra secrets.
74    pub cifragem_ativa: bool,
75    /// Path do `secrets.key` (pode não existir ainda).
76    pub key_file_path: PathBuf,
77    /// Se true, opt-out de plaintext está ativo.
78    pub plaintext_opt_out: bool,
79}
80
81/// True se `SSH_CLI_ALLOW_PLAINTEXT_SECRETS` pede plaintext.
82#[must_use]
83pub fn plaintext_permitido() -> bool {
84    std::env::var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS")
85        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
86        .unwrap_or(false)
87}
88
89/// Diretório de config usado para `secrets.key` (override > SSH_CLI_HOME > XDG).
90pub fn diretorio_config_segredos() -> ResultadoSshCli<PathBuf> {
91    if let Ok(g) = DIR_CONFIG_OVERRIDE.lock() {
92        if let Some(ref d) = *g {
93            return Ok(d.clone());
94        }
95    }
96    if let Ok(home) = std::env::var("SSH_CLI_HOME") {
97        if home.contains("..") {
98            return Err(ErroSshCli::ArgumentoInvalido(
99                "SSH_CLI_HOME não pode conter '..'".to_string(),
100            ));
101        }
102        return Ok(PathBuf::from(home));
103    }
104    let dirs = directories::ProjectDirs::from("", "", "ssh-cli").ok_or_else(|| {
105        ErroSshCli::Generico("não foi possível resolver diretório de config".to_string())
106    })?;
107    Ok(dirs.config_dir().to_path_buf())
108}
109
110/// Path canônico do arquivo de chave mestra local.
111pub fn caminho_secrets_key() -> ResultadoSshCli<PathBuf> {
112    Ok(diretorio_config_segredos()?.join(NOME_ARQUIVO_CHAVE))
113}
114
115/// Resolve chave mestra e origem (não auto-cria).
116pub fn carregar_chave_mestra() -> ResultadoSshCli<(Option<[u8; 32]>, FonteChave)> {
117    if let Ok(hex) = std::env::var("SSH_CLI_SECRETS_KEY") {
118        let chave = parse_hex_chave(hex.trim()).map_err(|e| {
119            ErroSshCli::ArgumentoInvalido(format!("SSH_CLI_SECRETS_KEY inválida: {e}"))
120        })?;
121        return Ok((Some(chave), FonteChave::Env));
122    }
123
124    if let Ok(path) = std::env::var("SSH_CLI_SECRETS_KEY_FILE") {
125        let texto = std::fs::read_to_string(&path).map_err(|e| {
126            ErroSshCli::ArgumentoInvalido(format!("falha lendo SSH_CLI_SECRETS_KEY_FILE: {e}"))
127        })?;
128        let chave = parse_hex_chave(texto.trim()).map_err(|e| {
129            ErroSshCli::ArgumentoInvalido(format!("SSH_CLI_SECRETS_KEY_FILE inválida: {e}"))
130        })?;
131        return Ok((Some(chave), FonteChave::Arquivo));
132    }
133
134    if std::env::var("SSH_CLI_USE_KEYRING")
135        .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
136        .unwrap_or(false)
137    {
138        match ler_keyring() {
139            Ok(Some(chave)) => return Ok((Some(chave), FonteChave::Keyring)),
140            Ok(None) => {}
141            Err(e) => {
142                tracing::warn!(erro = %e, "keyring indisponível; tentando secrets.key");
143            }
144        }
145    }
146
147    let path = caminho_secrets_key()?;
148    if path.is_file() {
149        let texto = std::fs::read_to_string(&path)
150            .map_err(|e| ErroSshCli::Generico(format!("falha lendo {}: {e}", path.display())))?;
151        let chave = parse_hex_chave(texto.trim())
152            .map_err(|e| ErroSshCli::ArgumentoInvalido(format!("secrets.key inválida: {e}")))?;
153        return Ok((Some(chave), FonteChave::XdgArquivo));
154    }
155
156    Ok((None, FonteChave::Nenhuma))
157}
158
159/// Garante chave para **escrita**: carrega existente ou auto-cria `secrets.key`
160/// (salvo opt-out plaintext).
161pub fn garantir_chave_para_escrita() -> ResultadoSshCli<(Option<[u8; 32]>, FonteChave)> {
162    let (existente, fonte) = carregar_chave_mestra()?;
163    if existente.is_some() {
164        return Ok((existente, fonte));
165    }
166    if plaintext_permitido() {
167        return Ok((None, FonteChave::Nenhuma));
168    }
169    let path = caminho_secrets_key()?;
170    let hex = gerar_hex_chave()?;
171    gravar_chave_arquivo(&path, &hex, false)?;
172    let chave = parse_hex_chave(&hex)
173        .map_err(|e| ErroSshCli::Generico(format!("chave gerada inválida: {e}")))?;
174    Ok((Some(chave), FonteChave::XdgArquivo))
175}
176
177/// Status atual (sem carregar material em logs).
178pub fn status_segredos() -> ResultadoSshCli<StatusSegredos> {
179    let key_file_path = caminho_secrets_key()?;
180    let (chave, fonte) = carregar_chave_mestra()?;
181    let cifragem_ativa = chave.is_some();
182    if let Some(mut k) = chave {
183        k.zeroize();
184    }
185    Ok(StatusSegredos {
186        fonte,
187        cifragem_ativa,
188        key_file_path,
189        plaintext_opt_out: plaintext_permitido(),
190    })
191}
192
193/// True se a string já é blob cifrado.
194#[must_use]
195pub fn eh_blob_cifrado(valor: &str) -> bool {
196    valor.starts_with(PREFIXO_ENC)
197}
198
199/// Serializa um segredo para TOML: cifra se houver (ou auto-criar) chave; senão plaintext.
200///
201/// Segredo **vazio** nunca vira blob `sshcli-enc` (GAP-SSH-EXP-001): export redacted zera
202/// senhas e deve gravar `""` legível, não ciphertext de string vazia (que engana import
203/// em outra máquina sem a master-key e finge "secret present").
204pub fn serializar_segredo(plaintext: &str) -> ResultadoSshCli<String> {
205    if plaintext.is_empty() {
206        return Ok(String::new());
207    }
208    let (chave, _) = garantir_chave_para_escrita()?;
209    match chave {
210        None => Ok(plaintext.to_string()),
211        Some(mut key) => {
212            let out = cifrar(&key, plaintext)?;
213            key.zeroize();
214            Ok(out)
215        }
216    }
217}
218
219/// Desserializa de TOML: decifra blobs `sshcli-enc:v1:`; senão devolve como está.
220pub fn deserializar_segredo(armazenado: &str) -> ResultadoSshCli<String> {
221    if !eh_blob_cifrado(armazenado) {
222        return Ok(armazenado.to_string());
223    }
224    let (chave, _) = carregar_chave_mestra()?;
225    let mut key = chave.ok_or_else(|| {
226        ErroSshCli::ArgumentoInvalido(
227            "config contém secrets cifrados; defina SSH_CLI_SECRETS_KEY, SSH_CLI_SECRETS_KEY_FILE, SSH_CLI_USE_KEYRING=1 ou secrets.key (ssh-cli secrets init)"
228                .to_string(),
229        )
230    })?;
231    let plain = decifrar(&key, armazenado)?;
232    key.zeroize();
233    Ok(plain)
234}
235
236/// Gera 32 bytes aleatórios como hex 64.
237pub fn gerar_hex_chave() -> ResultadoSshCli<String> {
238    let mut bytes = [0u8; 32];
239    getrandom::getrandom(&mut bytes)
240        .map_err(|e| ErroSshCli::Generico(format!("RNG falhou: {e}")))?;
241    let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
242    bytes.zeroize();
243    Ok(hex)
244}
245
246/// Grava chave hex em arquivo com 0o600 (quando suportado).
247pub fn gravar_chave_arquivo(path: &Path, hex64: &str, force: bool) -> ResultadoSshCli<()> {
248    let _ = parse_hex_chave(hex64)
249        .map_err(|e| ErroSshCli::ArgumentoInvalido(format!("chave inválida: {e}")))?;
250    if path.exists() && !force {
251        return Err(ErroSshCli::ArgumentoInvalido(format!(
252            "{} já existe; use --force para sobrescrever",
253            path.display()
254        )));
255    }
256    if let Some(pai) = path.parent() {
257        std::fs::create_dir_all(pai)?;
258    }
259    let pai = path.parent().unwrap_or_else(|| Path::new("."));
260    let mut tmp = tempfile::NamedTempFile::new_in(pai)
261        .map_err(|e| ErroSshCli::Generico(format!("tempfile secrets.key: {e}")))?;
262    use std::io::Write;
263    tmp.write_all(hex64.trim().as_bytes())
264        .map_err(|e| ErroSshCli::Generico(format!("write secrets.key: {e}")))?;
265    tmp.write_all(b"\n")
266        .map_err(|e| ErroSshCli::Generico(format!("write secrets.key: {e}")))?;
267    tmp.as_file()
268        .sync_all()
269        .map_err(|e| ErroSshCli::Generico(format!("fsync secrets.key: {e}")))?;
270    #[cfg(unix)]
271    {
272        use std::os::unix::fs::PermissionsExt;
273        let perms = std::fs::Permissions::from_mode(0o600);
274        tmp.as_file()
275            .set_permissions(perms)
276            .map_err(|e| ErroSshCli::Generico(format!("chmod secrets.key: {e}")))?;
277    }
278    tmp.persist(path)
279        .map_err(|e| ErroSshCli::Generico(format!("persist secrets.key: {e}")))?;
280    #[cfg(unix)]
281    {
282        use std::os::unix::fs::PermissionsExt;
283        let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
284    }
285    Ok(())
286}
287
288/// Inicializa master-key em arquivo XDG ou keyring. **Nunca** imprime a chave.
289pub fn init_master_key(use_keyring: bool, force: bool) -> ResultadoSshCli<StatusSegredos> {
290    let hex = gerar_hex_chave()?;
291    if use_keyring {
292        if !force {
293            match ler_keyring() {
294                Ok(Some(_)) => {
295                    return Err(ErroSshCli::ArgumentoInvalido(
296                        "keyring já contém master-key; use --force".to_string(),
297                    ));
298                }
299                Ok(None) => {}
300                Err(e) => return Err(e),
301            }
302        }
303        gravar_chave_no_keyring(&hex)?;
304        drop(hex);
305        return status_segredos();
306    }
307    let path = caminho_secrets_key()?;
308    gravar_chave_arquivo(&path, &hex, force)?;
309    drop(hex);
310    status_segredos()
311}
312
313/// Grava chave mestra (hex) no OS keyring. Não imprime a chave.
314pub fn gravar_chave_no_keyring(hex64: &str) -> ResultadoSshCli<()> {
315    let _ = parse_hex_chave(hex64)
316        .map_err(|e| ErroSshCli::ArgumentoInvalido(format!("chave inválida: {e}")))?;
317    let entry = keyring::Entry::new("ssh-cli", "secrets-master-key")
318        .map_err(|e| ErroSshCli::Generico(format!("keyring Entry::new falhou: {e}")))?;
319    entry
320        .set_password(hex64.trim())
321        .map_err(|e| ErroSshCli::Generico(format!("keyring set falhou: {e}")))?;
322    Ok(())
323}
324
325fn parse_hex_chave(hex: &str) -> Result<[u8; 32], String> {
326    let h = hex.trim();
327    if h.len() != 64 {
328        return Err("espere 64 caracteres hex (32 bytes)".to_string());
329    }
330    let mut out = [0u8; 32];
331    for i in 0..32 {
332        let byte =
333            u8::from_str_radix(&h[i * 2..i * 2 + 2], 16).map_err(|_| "hex inválido".to_string())?;
334        out[i] = byte;
335    }
336    Ok(out)
337}
338
339fn cifrar(key: &[u8; 32], plaintext: &str) -> ResultadoSshCli<String> {
340    let cipher = ChaCha20Poly1305::new_from_slice(key)
341        .map_err(|_| ErroSshCli::Generico("chave AEAD inválida".to_string()))?;
342    let mut nonce_bytes = [0u8; 12];
343    getrandom::getrandom(&mut nonce_bytes)
344        .map_err(|e| ErroSshCli::Generico(format!("RNG falhou: {e}")))?;
345    let nonce = Nonce::from_slice(&nonce_bytes);
346    let ciphertext = cipher
347        .encrypt(nonce, plaintext.as_bytes())
348        .map_err(|_| ErroSshCli::Generico("falha ao cifrar segredo".to_string()))?;
349    let mut packed = Vec::with_capacity(12 + ciphertext.len());
350    packed.extend_from_slice(&nonce_bytes);
351    packed.extend_from_slice(&ciphertext);
352    Ok(format!(
353        "{PREFIXO_ENC}{}",
354        base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &packed)
355    ))
356}
357
358fn decifrar(key: &[u8; 32], blob: &str) -> ResultadoSshCli<String> {
359    let b64 = blob
360        .strip_prefix(PREFIXO_ENC)
361        .ok_or_else(|| ErroSshCli::Generico("blob cifrado malformado".to_string()))?;
362    let packed = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64)
363        .map_err(|_| ErroSshCli::Generico("blob cifrado base64 inválido".to_string()))?;
364    if packed.len() < 12 + 16 {
365        return Err(ErroSshCli::Generico(
366            "blob cifrado demasiado curto".to_string(),
367        ));
368    }
369    let (nonce_bytes, ct) = packed.split_at(12);
370    let cipher = ChaCha20Poly1305::new_from_slice(key)
371        .map_err(|_| ErroSshCli::Generico("chave AEAD inválida".to_string()))?;
372    let nonce = Nonce::from_slice(nonce_bytes);
373    let plain = cipher.decrypt(nonce, ct).map_err(|_| {
374        ErroSshCli::Generico("falha ao decifrar segredo (chave errada?)".to_string())
375    })?;
376    String::from_utf8(plain)
377        .map_err(|_| ErroSshCli::Generico("segredo decifrado não é UTF-8".to_string()))
378}
379
380fn ler_keyring() -> ResultadoSshCli<Option<[u8; 32]>> {
381    let entry = keyring::Entry::new("ssh-cli", "secrets-master-key")
382        .map_err(|e| ErroSshCli::Generico(format!("keyring Entry::new falhou: {e}")))?;
383    match entry.get_password() {
384        Ok(hex) => {
385            let chave = parse_hex_chave(&hex).map_err(|e| {
386                ErroSshCli::ArgumentoInvalido(format!("keyring master-key inválida: {e}"))
387            })?;
388            Ok(Some(chave))
389        }
390        Err(keyring::Error::NoEntry) => Ok(None),
391        Err(e) => Err(ErroSshCli::Generico(format!("keyring get falhou: {e}"))),
392    }
393}
394
395#[cfg(test)]
396mod testes {
397    use super::*;
398    use serial_test::serial;
399    use tempfile::TempDir;
400
401    fn limpar_env_chave() {
402        std::env::remove_var("SSH_CLI_SECRETS_KEY");
403        std::env::remove_var("SSH_CLI_SECRETS_KEY_FILE");
404        std::env::remove_var("SSH_CLI_USE_KEYRING");
405        std::env::remove_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS");
406        std::env::remove_var("SSH_CLI_HOME");
407        definir_diretorio_config(None);
408    }
409
410    /// Isola testes do XDG real (nunca poluir config do usuário).
411    fn sandbox() -> TempDir {
412        limpar_env_chave();
413        let tmp = TempDir::new().unwrap();
414        definir_diretorio_config(Some(tmp.path().to_path_buf()));
415        tmp
416    }
417
418    #[test]
419    #[serial]
420    fn roundtrip_com_chave_env() {
421        let _tmp = sandbox();
422        let hex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
423        std::env::set_var("SSH_CLI_SECRETS_KEY", hex);
424        let plain = "fake-test-password-not-real";
425        let enc = serializar_segredo(plain).unwrap();
426        assert!(eh_blob_cifrado(&enc));
427        assert!(!enc.contains(plain));
428        let back = deserializar_segredo(&enc).unwrap();
429        assert_eq!(back, plain);
430        limpar_env_chave();
431    }
432
433    #[test]
434    #[serial]
435    fn opt_out_mantem_plaintext() {
436        let _tmp = sandbox();
437        std::env::set_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS", "1");
438        let plain = "fake-plaintext-only-for-unit-test";
439        let out = serializar_segredo(plain).unwrap();
440        assert_eq!(out, plain);
441        assert!(!eh_blob_cifrado(&out));
442        limpar_env_chave();
443    }
444
445    #[test]
446    #[serial]
447    fn default_auto_cria_secrets_key() {
448        let tmp = sandbox();
449        let plain = "fake-auto-enc-password";
450        let enc = serializar_segredo(plain).unwrap();
451        assert!(eh_blob_cifrado(&enc));
452        assert!(!enc.contains(plain));
453        assert!(tmp.path().join(NOME_ARQUIVO_CHAVE).is_file());
454        let back = deserializar_segredo(&enc).unwrap();
455        assert_eq!(back, plain);
456        limpar_env_chave();
457    }
458
459    #[test]
460    #[serial]
461    fn blob_sem_chave_falha() {
462        let tmp = sandbox();
463        let hex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
464        std::env::set_var("SSH_CLI_SECRETS_KEY", hex);
465        let enc = serializar_segredo("fake-secret").unwrap();
466        // Remove env e qualquer secrets.key do sandbox
467        limpar_env_chave();
468        definir_diretorio_config(Some(tmp.path().to_path_buf()));
469        let _ = std::fs::remove_file(tmp.path().join(NOME_ARQUIVO_CHAVE));
470        std::env::set_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS", "1");
471        let err = deserializar_segredo(&enc).unwrap_err();
472        let msg = err.to_string();
473        assert!(
474            msg.contains("cifrados") || msg.contains("SSH_CLI") || msg.contains("secrets"),
475            "msg={msg}"
476        );
477        limpar_env_chave();
478    }
479
480    #[test]
481    #[serial]
482    fn empty_secret_never_encrypted_blob() {
483        // GAP-SSH-EXP-001
484        let _tmp = sandbox();
485        let hex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
486        std::env::set_var("SSH_CLI_SECRETS_KEY", hex);
487        let out = serializar_segredo("").unwrap();
488        assert_eq!(out, "");
489        assert!(!eh_blob_cifrado(&out));
490        limpar_env_chave();
491    }
492
493    #[test]
494    fn parse_hex_tamanho() {
495        assert!(parse_hex_chave("aa").is_err());
496        assert!(parse_hex_chave(
497            "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
498        )
499        .is_ok());
500    }
501
502    #[test]
503    #[serial]
504    fn init_cria_arquivo() {
505        limpar_env_chave();
506        let tmp = TempDir::new().unwrap();
507        definir_diretorio_config(Some(tmp.path().to_path_buf()));
508        let st = init_master_key(false, false).unwrap();
509        assert!(st.cifragem_ativa);
510        assert_eq!(st.fonte, FonteChave::XdgArquivo);
511        assert!(st.key_file_path.is_file());
512        limpar_env_chave();
513    }
514}