1use 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
23pub const PREFIXO_ENC: &str = "sshcli-enc:v1:";
25
26pub const NOME_ARQUIVO_CHAVE: &str = "secrets.key";
28
29static DIR_CONFIG_OVERRIDE: Mutex<Option<PathBuf>> = Mutex::new(None);
31
32pub fn definir_diretorio_config(dir: Option<PathBuf>) {
34 if let Ok(mut g) = DIR_CONFIG_OVERRIDE.lock() {
35 *g = dir;
36 }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum FonteChave {
42 Nenhuma,
44 Env,
46 Arquivo,
48 Keyring,
50 XdgArquivo,
52}
53
54impl FonteChave {
55 #[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#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct StatusSegredos {
71 pub fonte: FonteChave,
73 pub cifragem_ativa: bool,
75 pub key_file_path: PathBuf,
77 pub plaintext_opt_out: bool,
79}
80
81#[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
89pub 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
110pub fn caminho_secrets_key() -> ResultadoSshCli<PathBuf> {
112 Ok(diretorio_config_segredos()?.join(NOME_ARQUIVO_CHAVE))
113}
114
115pub 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
159pub 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
177pub 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#[must_use]
195pub fn eh_blob_cifrado(valor: &str) -> bool {
196 valor.starts_with(PREFIXO_ENC)
197}
198
199pub 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
219pub 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
236pub 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
246pub 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
288pub 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
313pub 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 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 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 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}