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.
200pub fn serializar_segredo(plaintext: &str) -> ResultadoSshCli<String> {
201    let (chave, _) = garantir_chave_para_escrita()?;
202    match chave {
203        None => Ok(plaintext.to_string()),
204        Some(mut key) => {
205            let out = cifrar(&key, plaintext)?;
206            key.zeroize();
207            Ok(out)
208        }
209    }
210}
211
212/// Desserializa de TOML: decifra blobs `sshcli-enc:v1:`; senão devolve como está.
213pub fn deserializar_segredo(armazenado: &str) -> ResultadoSshCli<String> {
214    if !eh_blob_cifrado(armazenado) {
215        return Ok(armazenado.to_string());
216    }
217    let (chave, _) = carregar_chave_mestra()?;
218    let mut key = chave.ok_or_else(|| {
219        ErroSshCli::ArgumentoInvalido(
220            "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)"
221                .to_string(),
222        )
223    })?;
224    let plain = decifrar(&key, armazenado)?;
225    key.zeroize();
226    Ok(plain)
227}
228
229/// Gera 32 bytes aleatórios como hex 64.
230pub fn gerar_hex_chave() -> ResultadoSshCli<String> {
231    let mut bytes = [0u8; 32];
232    getrandom::getrandom(&mut bytes)
233        .map_err(|e| ErroSshCli::Generico(format!("RNG falhou: {e}")))?;
234    let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
235    bytes.zeroize();
236    Ok(hex)
237}
238
239/// Grava chave hex em arquivo com 0o600 (quando suportado).
240pub fn gravar_chave_arquivo(path: &Path, hex64: &str, force: bool) -> ResultadoSshCli<()> {
241    let _ = parse_hex_chave(hex64)
242        .map_err(|e| ErroSshCli::ArgumentoInvalido(format!("chave inválida: {e}")))?;
243    if path.exists() && !force {
244        return Err(ErroSshCli::ArgumentoInvalido(format!(
245            "{} já existe; use --force para sobrescrever",
246            path.display()
247        )));
248    }
249    if let Some(pai) = path.parent() {
250        std::fs::create_dir_all(pai)?;
251    }
252    let pai = path.parent().unwrap_or_else(|| Path::new("."));
253    let mut tmp = tempfile::NamedTempFile::new_in(pai)
254        .map_err(|e| ErroSshCli::Generico(format!("tempfile secrets.key: {e}")))?;
255    use std::io::Write;
256    tmp.write_all(hex64.trim().as_bytes())
257        .map_err(|e| ErroSshCli::Generico(format!("write secrets.key: {e}")))?;
258    tmp.write_all(b"\n")
259        .map_err(|e| ErroSshCli::Generico(format!("write secrets.key: {e}")))?;
260    tmp.as_file()
261        .sync_all()
262        .map_err(|e| ErroSshCli::Generico(format!("fsync secrets.key: {e}")))?;
263    #[cfg(unix)]
264    {
265        use std::os::unix::fs::PermissionsExt;
266        let perms = std::fs::Permissions::from_mode(0o600);
267        tmp.as_file()
268            .set_permissions(perms)
269            .map_err(|e| ErroSshCli::Generico(format!("chmod secrets.key: {e}")))?;
270    }
271    tmp.persist(path)
272        .map_err(|e| ErroSshCli::Generico(format!("persist secrets.key: {e}")))?;
273    #[cfg(unix)]
274    {
275        use std::os::unix::fs::PermissionsExt;
276        let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
277    }
278    Ok(())
279}
280
281/// Inicializa master-key em arquivo XDG ou keyring. **Nunca** imprime a chave.
282pub fn init_master_key(use_keyring: bool, force: bool) -> ResultadoSshCli<StatusSegredos> {
283    let hex = gerar_hex_chave()?;
284    if use_keyring {
285        if !force {
286            match ler_keyring() {
287                Ok(Some(_)) => {
288                    return Err(ErroSshCli::ArgumentoInvalido(
289                        "keyring já contém master-key; use --force".to_string(),
290                    ));
291                }
292                Ok(None) => {}
293                Err(e) => return Err(e),
294            }
295        }
296        gravar_chave_no_keyring(&hex)?;
297        drop(hex);
298        return status_segredos();
299    }
300    let path = caminho_secrets_key()?;
301    gravar_chave_arquivo(&path, &hex, force)?;
302    drop(hex);
303    status_segredos()
304}
305
306/// Grava chave mestra (hex) no OS keyring. Não imprime a chave.
307pub fn gravar_chave_no_keyring(hex64: &str) -> ResultadoSshCli<()> {
308    let _ = parse_hex_chave(hex64)
309        .map_err(|e| ErroSshCli::ArgumentoInvalido(format!("chave inválida: {e}")))?;
310    let entry = keyring::Entry::new("ssh-cli", "secrets-master-key")
311        .map_err(|e| ErroSshCli::Generico(format!("keyring Entry::new falhou: {e}")))?;
312    entry
313        .set_password(hex64.trim())
314        .map_err(|e| ErroSshCli::Generico(format!("keyring set falhou: {e}")))?;
315    Ok(())
316}
317
318fn parse_hex_chave(hex: &str) -> Result<[u8; 32], String> {
319    let h = hex.trim();
320    if h.len() != 64 {
321        return Err("espere 64 caracteres hex (32 bytes)".to_string());
322    }
323    let mut out = [0u8; 32];
324    for i in 0..32 {
325        let byte =
326            u8::from_str_radix(&h[i * 2..i * 2 + 2], 16).map_err(|_| "hex inválido".to_string())?;
327        out[i] = byte;
328    }
329    Ok(out)
330}
331
332fn cifrar(key: &[u8; 32], plaintext: &str) -> ResultadoSshCli<String> {
333    let cipher = ChaCha20Poly1305::new_from_slice(key)
334        .map_err(|_| ErroSshCli::Generico("chave AEAD inválida".to_string()))?;
335    let mut nonce_bytes = [0u8; 12];
336    getrandom::getrandom(&mut nonce_bytes)
337        .map_err(|e| ErroSshCli::Generico(format!("RNG falhou: {e}")))?;
338    let nonce = Nonce::from_slice(&nonce_bytes);
339    let ciphertext = cipher
340        .encrypt(nonce, plaintext.as_bytes())
341        .map_err(|_| ErroSshCli::Generico("falha ao cifrar segredo".to_string()))?;
342    let mut packed = Vec::with_capacity(12 + ciphertext.len());
343    packed.extend_from_slice(&nonce_bytes);
344    packed.extend_from_slice(&ciphertext);
345    Ok(format!(
346        "{PREFIXO_ENC}{}",
347        base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &packed)
348    ))
349}
350
351fn decifrar(key: &[u8; 32], blob: &str) -> ResultadoSshCli<String> {
352    let b64 = blob
353        .strip_prefix(PREFIXO_ENC)
354        .ok_or_else(|| ErroSshCli::Generico("blob cifrado malformado".to_string()))?;
355    let packed = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64)
356        .map_err(|_| ErroSshCli::Generico("blob cifrado base64 inválido".to_string()))?;
357    if packed.len() < 12 + 16 {
358        return Err(ErroSshCli::Generico(
359            "blob cifrado demasiado curto".to_string(),
360        ));
361    }
362    let (nonce_bytes, ct) = packed.split_at(12);
363    let cipher = ChaCha20Poly1305::new_from_slice(key)
364        .map_err(|_| ErroSshCli::Generico("chave AEAD inválida".to_string()))?;
365    let nonce = Nonce::from_slice(nonce_bytes);
366    let plain = cipher.decrypt(nonce, ct).map_err(|_| {
367        ErroSshCli::Generico("falha ao decifrar segredo (chave errada?)".to_string())
368    })?;
369    String::from_utf8(plain)
370        .map_err(|_| ErroSshCli::Generico("segredo decifrado não é UTF-8".to_string()))
371}
372
373fn ler_keyring() -> ResultadoSshCli<Option<[u8; 32]>> {
374    let entry = keyring::Entry::new("ssh-cli", "secrets-master-key")
375        .map_err(|e| ErroSshCli::Generico(format!("keyring Entry::new falhou: {e}")))?;
376    match entry.get_password() {
377        Ok(hex) => {
378            let chave = parse_hex_chave(&hex).map_err(|e| {
379                ErroSshCli::ArgumentoInvalido(format!("keyring master-key inválida: {e}"))
380            })?;
381            Ok(Some(chave))
382        }
383        Err(keyring::Error::NoEntry) => Ok(None),
384        Err(e) => Err(ErroSshCli::Generico(format!("keyring get falhou: {e}"))),
385    }
386}
387
388#[cfg(test)]
389mod testes {
390    use super::*;
391    use serial_test::serial;
392    use tempfile::TempDir;
393
394    fn limpar_env_chave() {
395        std::env::remove_var("SSH_CLI_SECRETS_KEY");
396        std::env::remove_var("SSH_CLI_SECRETS_KEY_FILE");
397        std::env::remove_var("SSH_CLI_USE_KEYRING");
398        std::env::remove_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS");
399        std::env::remove_var("SSH_CLI_HOME");
400        definir_diretorio_config(None);
401    }
402
403    /// Isola testes do XDG real (nunca poluir config do usuário).
404    fn sandbox() -> TempDir {
405        limpar_env_chave();
406        let tmp = TempDir::new().unwrap();
407        definir_diretorio_config(Some(tmp.path().to_path_buf()));
408        tmp
409    }
410
411    #[test]
412    #[serial]
413    fn roundtrip_com_chave_env() {
414        let _tmp = sandbox();
415        let hex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
416        std::env::set_var("SSH_CLI_SECRETS_KEY", hex);
417        let plain = "fake-test-password-not-real";
418        let enc = serializar_segredo(plain).unwrap();
419        assert!(eh_blob_cifrado(&enc));
420        assert!(!enc.contains(plain));
421        let back = deserializar_segredo(&enc).unwrap();
422        assert_eq!(back, plain);
423        limpar_env_chave();
424    }
425
426    #[test]
427    #[serial]
428    fn opt_out_mantem_plaintext() {
429        let _tmp = sandbox();
430        std::env::set_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS", "1");
431        let plain = "fake-plaintext-only-for-unit-test";
432        let out = serializar_segredo(plain).unwrap();
433        assert_eq!(out, plain);
434        assert!(!eh_blob_cifrado(&out));
435        limpar_env_chave();
436    }
437
438    #[test]
439    #[serial]
440    fn default_auto_cria_secrets_key() {
441        let tmp = sandbox();
442        let plain = "fake-auto-enc-password";
443        let enc = serializar_segredo(plain).unwrap();
444        assert!(eh_blob_cifrado(&enc));
445        assert!(!enc.contains(plain));
446        assert!(tmp.path().join(NOME_ARQUIVO_CHAVE).is_file());
447        let back = deserializar_segredo(&enc).unwrap();
448        assert_eq!(back, plain);
449        limpar_env_chave();
450    }
451
452    #[test]
453    #[serial]
454    fn blob_sem_chave_falha() {
455        let tmp = sandbox();
456        let hex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";
457        std::env::set_var("SSH_CLI_SECRETS_KEY", hex);
458        let enc = serializar_segredo("fake-secret").unwrap();
459        // Remove env e qualquer secrets.key do sandbox
460        limpar_env_chave();
461        definir_diretorio_config(Some(tmp.path().to_path_buf()));
462        let _ = std::fs::remove_file(tmp.path().join(NOME_ARQUIVO_CHAVE));
463        std::env::set_var("SSH_CLI_ALLOW_PLAINTEXT_SECRETS", "1");
464        let err = deserializar_segredo(&enc).unwrap_err();
465        let msg = err.to_string();
466        assert!(
467            msg.contains("cifrados") || msg.contains("SSH_CLI") || msg.contains("secrets"),
468            "msg={msg}"
469        );
470        limpar_env_chave();
471    }
472
473    #[test]
474    fn parse_hex_tamanho() {
475        assert!(parse_hex_chave("aa").is_err());
476        assert!(parse_hex_chave(
477            "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
478        )
479        .is_ok());
480    }
481
482    #[test]
483    #[serial]
484    fn init_cria_arquivo() {
485        limpar_env_chave();
486        let tmp = TempDir::new().unwrap();
487        definir_diretorio_config(Some(tmp.path().to_path_buf()));
488        let st = init_master_key(false, false).unwrap();
489        assert!(st.cifragem_ativa);
490        assert_eq!(st.fonte, FonteChave::XdgArquivo);
491        assert!(st.key_file_path.is_file());
492        limpar_env_chave();
493    }
494}