1use 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
24pub const ENC_PREFIX: &str = "sshcli-enc:v1:";
26
27
28pub const KEY_FILE_NAME: &str = "secrets.key";
30
31static DIR_CONFIG_OVERRIDE: Mutex<Option<PathBuf>> = Mutex::new(None);
33
34#[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
48static AUTO_KEY_CREATED: AtomicBool = AtomicBool::new(false);
50
51pub fn set_config_dir(dir: Option<PathBuf>) {
53 if let Ok(mut g) = DIR_CONFIG_OVERRIDE.lock() {
54 *g = dir;
55 }
56}
57
58pub 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#[must_use]
74pub fn take_auto_key_created() -> bool {
75 AUTO_KEY_CREATED.swap(false, Ordering::SeqCst)
76}
77
78#[must_use]
80pub fn auto_key_created() -> bool {
81 AUTO_KEY_CREATED.load(Ordering::SeqCst)
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum KeySource {
87 Absent,
89 Env,
91 ConfigFile,
93 Keyring,
95 XdgFile,
97}
98
99impl KeySource {
100 #[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#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct SecretsStatus {
116 pub source: KeySource,
118 pub encryption_active: bool,
120 pub key_file_path: PathBuf,
122 pub plaintext_opt_out: bool,
124}
125
126#[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
140pub 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
161pub fn secrets_key_path() -> SshCliResult<PathBuf> {
163 Ok(secrets_config_dir()?.join(KEY_FILE_NAME))
164}
165
166pub fn load_primary_key() -> SshCliResult<(Option<[u8; 32]>, KeySource)> {
171 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
233pub 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
259pub 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#[must_use]
277pub fn is_encrypted_blob(value: &str) -> bool {
278 value.starts_with(ENC_PREFIX)
279}
280
281pub 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
304pub 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
321pub 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
331pub 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 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
392pub 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
420pub 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 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 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 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 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}