1#![forbid(unsafe_code)]
4use crate::constants::{
42 APP_NAME, ENV_SECRETS_KEY, ENV_SECRETS_KEY_FILE, PRIMARY_KEY_HEX_LEN, PRIMARY_KEY_LEN_BYTES,
43 SECRETS_KEY_FILE_NAME,
44};
45use crate::errors::{SshCliError, SshCliResult};
46use std::path::{Path, PathBuf};
47use std::sync::atomic::{AtomicBool, Ordering};
48use std::sync::Mutex;
49use zeroize::{Zeroize, Zeroizing};
50
51mod aead;
52mod keyring_store;
53
54pub use aead::SecretContext;
55use aead::{decrypt_secret, encrypt_secret};
56use keyring_store::read_keyring;
57pub use keyring_store::write_key_to_keyring;
58
59pub const ENC_PREFIX: &str = "sshcli-enc:v1:";
61
62pub const ENC_PREFIX_V2: &str = "sshcli-enc:v2:";
64
65pub type PrimaryKey = Zeroizing<[u8; PRIMARY_KEY_LEN_BYTES]>;
74
75pub const KEY_FILE_NAME: &str = SECRETS_KEY_FILE_NAME;
77
78const _: () = assert!(!ENC_PREFIX.is_empty());
80const _: () = assert!(!ENC_PREFIX_V2.is_empty());
81const _: () = assert!(!KEY_FILE_NAME.is_empty());
82const _: () = assert!(PRIMARY_KEY_LEN_BYTES == 32);
83
84fn lock_global<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
93 m.lock().unwrap_or_else(|poisoned| {
94 tracing::warn!(
95 "secrets process-global mutex was poisoned; recovering via into_inner (one-shot CLI)"
96 );
97 poisoned.into_inner()
98 })
99}
100
101static DIR_CONFIG_OVERRIDE: Mutex<Option<PathBuf>> = Mutex::new(None);
107
108#[derive(Debug, Default, Clone)]
110struct RuntimeSecretsFlags {
111 allow_plaintext: bool,
112 secrets_key_file: Option<PathBuf>,
113 use_keyring: bool,
114}
115
116static RUNTIME_FLAGS: Mutex<RuntimeSecretsFlags> = Mutex::new(RuntimeSecretsFlags {
121 allow_plaintext: false,
122 secrets_key_file: None,
123 use_keyring: false,
124});
125
126static AUTO_KEY_CREATED: AtomicBool = AtomicBool::new(false);
131
132pub fn set_config_dir(dir: Option<PathBuf>) {
134 *lock_global(&DIR_CONFIG_OVERRIDE) = dir;
135}
136
137pub fn set_runtime_flags(
139 allow_plaintext: bool,
140 secrets_key_file: Option<PathBuf>,
141 use_keyring: bool,
142) {
143 {
144 let mut g = lock_global(&RUNTIME_FLAGS);
145 g.allow_plaintext = allow_plaintext;
146 g.secrets_key_file = secrets_key_file;
147 g.use_keyring = use_keyring;
148 }
149 AUTO_KEY_CREATED.store(false, Ordering::Relaxed);
150}
151
152#[must_use]
154pub fn take_auto_key_created() -> bool {
155 AUTO_KEY_CREATED.swap(false, Ordering::Relaxed)
157}
158
159#[must_use]
161pub fn auto_key_created() -> bool {
162 AUTO_KEY_CREATED.load(Ordering::Relaxed)
163}
164
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum KeySource {
168 Absent,
170 Env,
172 ConfigFile,
174 Keyring,
176 XdgFile,
178}
179
180impl KeySource {
181 #[must_use]
183 pub const fn as_str(self) -> &'static str {
184 match self {
185 Self::Absent => "none",
186 Self::Env => "env",
187 Self::ConfigFile => "file",
188 Self::Keyring => "keyring",
189 Self::XdgFile => "xdg_file",
190 }
191 }
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
196pub struct SecretsStatus {
197 pub source: KeySource,
199 pub encryption_active: bool,
201 pub key_file_path: PathBuf,
203 pub plaintext_opt_out: bool,
205}
206
207#[must_use]
209pub fn plaintext_allowed() -> bool {
210 lock_global(&RUNTIME_FLAGS).allow_plaintext
211}
212
213pub fn secrets_config_dir() -> SshCliResult<PathBuf> {
218 if let Some(d) = lock_global(&DIR_CONFIG_OVERRIDE).clone() {
219 return Ok(d);
220 }
221 crate::paths::xdg_config_dir()
222}
223
224pub fn secrets_key_path() -> SshCliResult<PathBuf> {
226 Ok(secrets_config_dir()?.join(KEY_FILE_NAME))
227}
228
229pub fn load_primary_key() -> SshCliResult<(Option<PrimaryKey>, KeySource)> {
234 let secrets_key_file = lock_global(&RUNTIME_FLAGS).secrets_key_file.clone();
236 if let Some(path) = secrets_key_file {
237 let mut text =
238 crate::paths::read_text_capped(&path, crate::paths::MAX_SECRETS_KEY_FILE_BYTES)
239 .map_err(|e| {
240 SshCliError::InvalidArgument(format!(
241 "failed reading --secrets-key-file {}: {e}",
242 path.display()
243 ))
244 })?;
245 let key = parse_hex_key(text.trim())
246 .map_err(|e| SshCliError::InvalidArgument(format!("invalid --secrets-key-file: {e}")));
247 text.zeroize();
248 return Ok((Some(key?), KeySource::ConfigFile));
249 }
250
251 if std::env::var_os(ENV_SECRETS_KEY).is_some()
253 || std::env::var_os(ENV_SECRETS_KEY_FILE).is_some()
254 {
255 return Err(SshCliError::InvalidArgument(format!(
256 "{ENV_SECRETS_KEY} / {ENV_SECRETS_KEY_FILE} are not supported; use XDG `{KEY_FILE_NAME}` \
257 (`{APP_NAME} secrets init`) or --secrets-key-file"
258 )));
259 }
260
261 let use_keyring_flag = lock_global(&RUNTIME_FLAGS).use_keyring;
262 if use_keyring_flag {
263 match read_keyring() {
264 Ok(Some(key)) => return Ok((Some(key), KeySource::Keyring)),
265 Ok(None) => {}
266 Err(e) => {
267 tracing::warn!(err = %e, "keyring unavailable; trying secrets.key");
268 }
269 }
270 }
271
272 let path = secrets_key_path()?;
273 if path.is_file() {
274 let mut text =
278 crate::paths::read_text_capped(&path, crate::paths::MAX_SECRETS_KEY_FILE_BYTES)
279 .map_err(|e| {
280 tracing::debug!(err = %e, path = %path.display(), "failed reading secrets key");
281 e
282 })?;
283 let key = parse_hex_key(text.trim())
284 .map_err(|e| SshCliError::InvalidArgument(format!("invalid {KEY_FILE_NAME}: {e}")));
285 text.zeroize();
286 return Ok((Some(key?), KeySource::XdgFile));
287 }
288
289 Ok((None, KeySource::Absent))
290}
291
292pub fn ensure_key_for_write() -> SshCliResult<(Option<PrimaryKey>, KeySource)> {
298 let (existing, source) = load_primary_key()?;
299 if existing.is_some() {
300 return Ok((existing, source));
301 }
302 if plaintext_allowed() {
303 return Ok((None, KeySource::Absent));
304 }
305 let path = secrets_key_path()?;
306 let mut hex = generate_hex_key()?;
307 write_key_file(&path, &hex, false)?;
308 AUTO_KEY_CREATED.store(true, Ordering::Relaxed);
309 tracing::info!(
310 path = %path.display(),
311 "secrets.key auto-created (event secrets-key-auto-created)"
312 );
313 let key = parse_hex_key(&hex).map_err(|e| {
317 tracing::error!(err = %e, "generated key failed round-trip parse");
318 SshCliError::software("key_encoding")
319 });
320 hex.zeroize();
321 Ok((Some(key?), KeySource::XdgFile))
322}
323
324pub fn secrets_status() -> SshCliResult<SecretsStatus> {
326 let key_file_path = secrets_key_path()?;
327 let (key, source) = load_primary_key()?;
328 let encryption_active = key.is_some();
329 drop(key);
331 Ok(SecretsStatus {
332 source,
333 encryption_active,
334 key_file_path,
335 plaintext_opt_out: plaintext_allowed(),
336 })
337}
338
339#[must_use]
341pub fn is_encrypted_blob(value: &str) -> bool {
342 value.starts_with(ENC_PREFIX) || value.starts_with(ENC_PREFIX_V2)
343}
344
345pub fn serialize_secret(plaintext: &str) -> SshCliResult<String> {
354 serialize_secret_in_context(SecretContext::unbound(), plaintext)
355}
356
357pub fn serialize_secret_in_context(
362 ctx: SecretContext<'_>,
363 plaintext: &str,
364) -> SshCliResult<String> {
365 if plaintext.is_empty() {
366 return Ok(String::new());
367 }
368 let (key, _) = ensure_key_for_write()?;
369 match key {
370 None => Ok(plaintext.to_string()),
371 Some(key) => encrypt_secret(&key, plaintext, ctx),
373 }
374}
375
376pub fn deserialize_secret(stored: &str) -> SshCliResult<String> {
383 deserialize_secret_in_context(SecretContext::unbound(), stored)
384}
385
386pub fn deserialize_secret_in_context(ctx: SecretContext<'_>, stored: &str) -> SshCliResult<String> {
394 if !is_encrypted_blob(stored) {
395 return Ok(stored.to_string());
396 }
397 let (key, _) = load_primary_key()?;
398 let key = key.ok_or_else(|| {
399 SshCliError::InvalidArgument(format!(
400 "config contains encrypted secrets; run `{APP_NAME} secrets init` (XDG `{KEY_FILE_NAME}`) or pass `--secrets-key-file PATH` / `--use-keyring` (env key material is not supported)"
401 ))
402 })?;
403 decrypt_secret(&key, stored, ctx)
404}
405
406pub fn generate_hex_key() -> SshCliResult<String> {
408 let mut bytes = [0u8; PRIMARY_KEY_LEN_BYTES];
409 getrandom::fill(&mut bytes).map_err(|_| SshCliError::software("rng"))?;
413 let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
414 bytes.zeroize();
415 Ok(hex)
416}
417
418pub fn write_key_file(path: &Path, hex64: &str, force: bool) -> SshCliResult<()> {
423 let _ = parse_hex_key(hex64)
424 .map_err(|e| SshCliError::InvalidArgument(format!("invalid key: {e}")))?;
425 if path.exists() && !force {
426 return Err(SshCliError::InvalidArgument(format!(
427 "{} already exists; use --force to overwrite",
428 path.display()
429 )));
430 }
431 if path.exists() && force {
433 let bak = path.with_file_name(format!(
434 "{}.bak",
435 path.file_name()
436 .and_then(|s| s.to_str())
437 .unwrap_or(KEY_FILE_NAME)
438 ));
439 if let Err(e) = std::fs::copy(path, &bak) {
440 tracing::warn!(
441 err = %e,
442 path = %bak.display(),
443 "failed to backup secrets key before --force"
444 );
445 }
446 }
447 if let Some(parent_dir) = path.parent() {
448 std::fs::create_dir_all(parent_dir)?;
449 }
450 let parent_dir = path.parent().unwrap_or_else(|| Path::new("."));
451 let mut tmp = tempfile::NamedTempFile::new_in(parent_dir).map_err(SshCliError::Io)?;
456 use std::io::Write;
457 tmp.write_all(hex64.trim().as_bytes())
458 .map_err(SshCliError::Io)?;
459 tmp.write_all(b"\n").map_err(SshCliError::Io)?;
460 tmp.as_file().sync_all().map_err(SshCliError::Io)?;
461 crate::fs_perm::set_secret_file_mode(tmp.path())?;
462 tmp.persist(path).map_err(|e| SshCliError::Io(e.error))?;
465 let _ = crate::fs_perm::set_secret_file_mode(path);
467 Ok(())
468}
469
470pub fn init_primary_key(use_keyring: bool, force: bool) -> SshCliResult<SecretsStatus> {
475 let mut hex = generate_hex_key()?;
476 if use_keyring {
477 if !force {
478 match read_keyring() {
479 Ok(Some(_)) => {
480 hex.zeroize();
481 return Err(SshCliError::InvalidArgument(
482 "keyring already has a primary-key; use --force".to_string(),
483 ));
484 }
485 Ok(None) => {}
486 Err(e) => {
487 hex.zeroize();
488 return Err(e);
489 }
490 }
491 }
492 let result = write_key_to_keyring(&hex);
493 hex.zeroize();
494 result?;
495 return secrets_status();
496 }
497 let path = secrets_key_path()?;
498 let result = write_key_file(&path, &hex, force);
499 hex.zeroize();
500 result?;
501 secrets_status()
502}
503
504fn parse_hex_key(hex: &str) -> Result<PrimaryKey, String> {
505 let h = hex.trim();
506 if !h.is_ascii() || h.len() != PRIMARY_KEY_HEX_LEN {
511 return Err(format!(
512 "expected {PRIMARY_KEY_HEX_LEN} hex characters ({PRIMARY_KEY_LEN_BYTES} bytes)"
513 ));
514 }
515 let mut out: PrimaryKey = Zeroizing::new([0u8; PRIMARY_KEY_LEN_BYTES]);
518 for i in 0..PRIMARY_KEY_LEN_BYTES {
519 let byte =
520 u8::from_str_radix(&h[i * 2..i * 2 + 2], 16).map_err(|_| "invalid hex".to_string())?;
521 out[i] = byte;
522 }
523 Ok(out)
524}
525
526#[cfg(test)]
527mod tests {
528 use super::*;
529 use crate::constants::AEAD_NONCE_LEN_BYTES;
533 use chacha20poly1305::aead::{Aead, KeyInit};
534 use chacha20poly1305::{ChaCha20Poly1305, Nonce};
535 use serial_test::serial;
536 use tempfile::TempDir;
537
538 fn clear_key_env() {
539 crate::test_util::env::remove_var(ENV_SECRETS_KEY);
541 crate::test_util::env::remove_var(ENV_SECRETS_KEY_FILE);
542 crate::test_util::env::remove_var(crate::constants::ENV_USE_KEYRING);
543 set_runtime_flags(false, None, false);
544 set_config_dir(None);
545 }
546
547 fn sandbox() -> TempDir {
549 clear_key_env();
550 let tmp = TempDir::new().unwrap();
551 set_config_dir(Some(tmp.path().to_path_buf()));
552 tmp
553 }
554
555 #[test]
556 #[serial]
557 fn roundtrip_with_xdg_key() {
558 let _tmp = sandbox();
559 init_primary_key(false, false).expect("init key");
560 let plain = "fake-test-password-not-real";
561 let enc = serialize_secret(plain).unwrap();
562 assert!(is_encrypted_blob(&enc));
563 assert!(!enc.contains(plain));
564 let back = deserialize_secret(&enc).unwrap();
565 assert_eq!(back, plain);
566 clear_key_env();
567 }
568
569 #[test]
570 #[serial]
571 fn opt_out_keeps_plaintext() {
572 let _tmp = sandbox();
573 set_runtime_flags(true, None, false);
574 let plain = "fake-plaintext-only-for-unit-test";
575 let out = serialize_secret(plain).unwrap();
576 assert_eq!(out, plain);
577 assert!(!is_encrypted_blob(&out));
578 clear_key_env();
579 }
580
581 #[test]
582 #[serial]
583 fn default_auto_creates_secrets_key() {
584 let tmp = sandbox();
585 let plain = "fake-auto-enc-password";
586 let enc = serialize_secret(plain).unwrap();
587 assert!(is_encrypted_blob(&enc));
588 assert!(!enc.contains(plain));
589 assert!(tmp.path().join(KEY_FILE_NAME).is_file());
590 let back = deserialize_secret(&enc).unwrap();
591 assert_eq!(back, plain);
592 clear_key_env();
593 }
594
595 #[test]
596 #[serial]
597 fn blob_without_key_fails() {
598 let tmp = sandbox();
599 init_primary_key(false, false).expect("init");
600 let enc = serialize_secret("fake-secret").unwrap();
601 clear_key_env();
604 set_config_dir(Some(tmp.path().to_path_buf()));
605 let _ = std::fs::remove_file(tmp.path().join(KEY_FILE_NAME));
606 set_runtime_flags(true, None, false);
607 let err = deserialize_secret(&enc).unwrap_err();
608 let msg = err.to_string();
609 assert!(
610 msg.contains("encrypted") || msg.contains("secrets") || msg.contains("key"),
611 "msg={msg}"
612 );
613 clear_key_env();
614 }
615
616 #[test]
617 #[serial]
618 fn empty_secret_never_encrypted_blob() {
619 let _tmp = sandbox();
621 init_primary_key(false, false).expect("init");
622 let out = serialize_secret("").unwrap();
623 assert_eq!(out, "");
624 assert!(!is_encrypted_blob(&out));
625 clear_key_env();
626 }
627
628 #[test]
629 #[serial]
630 fn v2_blob_rejects_foreign_host_and_field() {
631 let _tmp = sandbox();
634 init_primary_key(false, false).expect("init key");
635 let plain = "fake-bound-secret";
636 let host_a = SecretContext::new("host-a", "password");
637 let enc = serialize_secret_in_context(host_a, plain).unwrap();
638 assert!(enc.starts_with(ENC_PREFIX_V2), "v2 must be written");
639
640 assert_eq!(deserialize_secret_in_context(host_a, &enc).unwrap(), plain);
641
642 let other_host = SecretContext::new("host-b", "password");
643 assert!(
644 deserialize_secret_in_context(other_host, &enc).is_err(),
645 "blob bound to host-a must not open as host-b"
646 );
647
648 let other_field = SecretContext::new("host-a", "su_password");
649 assert!(
650 deserialize_secret_in_context(other_field, &enc).is_err(),
651 "blob bound to password must not open as su_password"
652 );
653 clear_key_env();
654 }
655
656 #[test]
657 #[serial]
658 fn unbound_blob_stays_readable_under_any_context() {
659 let _tmp = sandbox();
662 init_primary_key(false, false).expect("init key");
663 let plain = "fake-unbound-secret";
664 let enc = serialize_secret(plain).unwrap();
665 assert!(enc.starts_with(ENC_PREFIX_V2));
666 let bound = SecretContext::new("host-a", "password");
667 assert_eq!(deserialize_secret_in_context(bound, &enc).unwrap(), plain);
668 clear_key_env();
669 }
670
671 #[test]
672 #[serial]
673 fn legacy_v1_blob_still_decrypts() {
674 let _tmp = sandbox();
676 init_primary_key(false, false).expect("init key");
677 let (key, _) = load_primary_key().unwrap();
678 let key = key.expect("key present");
679 let plain = "fake-legacy-v1-secret";
680
681 let cipher = ChaCha20Poly1305::new_from_slice(key.as_slice()).unwrap();
683 let mut nonce_bytes = [0u8; AEAD_NONCE_LEN_BYTES];
684 getrandom::fill(&mut nonce_bytes).unwrap();
685 let ct = cipher
686 .encrypt(&Nonce::from(nonce_bytes), plain.as_bytes())
687 .unwrap();
688 let mut packed = nonce_bytes.to_vec();
689 packed.extend_from_slice(&ct);
690 let blob = format!(
691 "{ENC_PREFIX}{}",
692 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &packed)
693 );
694
695 assert!(is_encrypted_blob(&blob));
696 assert_eq!(deserialize_secret(&blob).unwrap(), plain);
697 assert_eq!(
698 deserialize_secret_in_context(SecretContext::new("host-a", "password"), &blob).unwrap(),
699 plain
700 );
701 clear_key_env();
702 }
703
704 #[test]
705 fn aad_encoding_is_unambiguous() {
706 assert_ne!(
708 SecretContext::new("a:b", "password").aad(),
709 SecretContext::new("a", "b:password").aad()
710 );
711 assert!(SecretContext::unbound().is_unbound());
712 assert!(!SecretContext::new("host-a", "password").is_unbound());
713 }
714
715 #[test]
716 fn parse_hex_tamanho() {
717 assert!(parse_hex_key("aa").is_err());
718 assert!(
719 parse_hex_key("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")
720 .is_ok()
721 );
722 }
723
724 #[test]
725 #[serial]
726 fn init_creates_file() {
727 clear_key_env();
728 let tmp = TempDir::new().unwrap();
729 set_config_dir(Some(tmp.path().to_path_buf()));
730 let st = init_primary_key(false, false).unwrap();
731 assert!(st.encryption_active);
732 assert_eq!(st.source, KeySource::XdgFile);
733 assert!(st.key_file_path.is_file());
734 clear_key_env();
735 }
736
737 #[test]
738 fn lock_global_recovers_from_poison_with_usable_data() {
739 let m = Mutex::new(42_u32);
740 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
741 let _g = m.lock().unwrap();
742 panic!("intentional poison for lock_global test");
743 }));
744 assert!(m.is_poisoned());
745 let g = lock_global(&m);
746 assert_eq!(*g, 42);
747 }
748}