1#![forbid(unsafe_code)]
4use crate::constants::{
22 AEAD_NONCE_LEN_BYTES, AEAD_TAG_LEN_BYTES, APP_NAME, ENV_SECRETS_KEY, ENV_SECRETS_KEY_FILE,
23 KEYRING_SERVICE, KEYRING_USER_LEGACY, KEYRING_USER_PRIMARY, PRIMARY_KEY_HEX_LEN,
24 PRIMARY_KEY_LEN_BYTES, SECRETS_KEY_FILE_NAME,
25};
26use crate::errors::{SshCliError, SshCliResult};
27use chacha20poly1305::aead::{Aead, KeyInit};
28use chacha20poly1305::{ChaCha20Poly1305, Nonce};
29use std::path::{Path, PathBuf};
30use std::sync::atomic::{AtomicBool, Ordering};
31use std::sync::Mutex;
32use zeroize::Zeroize;
33
34pub const ENC_PREFIX: &str = "sshcli-enc:v1:";
36
37pub const KEY_FILE_NAME: &str = SECRETS_KEY_FILE_NAME;
39
40const _: () = assert!(!ENC_PREFIX.is_empty());
42const _: () = assert!(!KEY_FILE_NAME.is_empty());
43const _: () = assert!(PRIMARY_KEY_LEN_BYTES == 32);
44
45fn lock_global<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
54 m.lock().unwrap_or_else(|poisoned| {
55 tracing::warn!(
56 "secrets process-global mutex was poisoned; recovering via into_inner (one-shot CLI)"
57 );
58 poisoned.into_inner()
59 })
60}
61
62static DIR_CONFIG_OVERRIDE: Mutex<Option<PathBuf>> = Mutex::new(None);
68
69#[derive(Debug, Default, Clone)]
71struct RuntimeSecretsFlags {
72 allow_plaintext: bool,
73 secrets_key_file: Option<PathBuf>,
74 use_keyring: bool,
75}
76
77static RUNTIME_FLAGS: Mutex<RuntimeSecretsFlags> = Mutex::new(RuntimeSecretsFlags {
82 allow_plaintext: false,
83 secrets_key_file: None,
84 use_keyring: false,
85});
86
87static AUTO_KEY_CREATED: AtomicBool = AtomicBool::new(false);
92
93pub fn set_config_dir(dir: Option<PathBuf>) {
95 *lock_global(&DIR_CONFIG_OVERRIDE) = dir;
96}
97
98pub fn set_runtime_flags(
100 allow_plaintext: bool,
101 secrets_key_file: Option<PathBuf>,
102 use_keyring: bool,
103) {
104 {
105 let mut g = lock_global(&RUNTIME_FLAGS);
106 g.allow_plaintext = allow_plaintext;
107 g.secrets_key_file = secrets_key_file;
108 g.use_keyring = use_keyring;
109 }
110 AUTO_KEY_CREATED.store(false, Ordering::Relaxed);
111}
112
113#[must_use]
115pub fn take_auto_key_created() -> bool {
116 AUTO_KEY_CREATED.swap(false, Ordering::Relaxed)
118}
119
120#[must_use]
122pub fn auto_key_created() -> bool {
123 AUTO_KEY_CREATED.load(Ordering::Relaxed)
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum KeySource {
129 Absent,
131 Env,
133 ConfigFile,
135 Keyring,
137 XdgFile,
139}
140
141impl KeySource {
142 #[must_use]
144 pub const fn as_str(self) -> &'static str {
145 match self {
146 Self::Absent => "none",
147 Self::Env => "env",
148 Self::ConfigFile => "file",
149 Self::Keyring => "keyring",
150 Self::XdgFile => "xdg_file",
151 }
152 }
153}
154
155#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct SecretsStatus {
158 pub source: KeySource,
160 pub encryption_active: bool,
162 pub key_file_path: PathBuf,
164 pub plaintext_opt_out: bool,
166}
167
168#[must_use]
170pub fn plaintext_allowed() -> bool {
171 lock_global(&RUNTIME_FLAGS).allow_plaintext
172}
173
174pub fn secrets_config_dir() -> SshCliResult<PathBuf> {
179 if let Some(d) = lock_global(&DIR_CONFIG_OVERRIDE).clone() {
180 return Ok(d);
181 }
182 crate::paths::xdg_config_dir()
183}
184
185pub fn secrets_key_path() -> SshCliResult<PathBuf> {
187 Ok(secrets_config_dir()?.join(KEY_FILE_NAME))
188}
189
190pub fn load_primary_key() -> SshCliResult<(Option<[u8; PRIMARY_KEY_LEN_BYTES]>, KeySource)> {
195 let secrets_key_file = lock_global(&RUNTIME_FLAGS).secrets_key_file.clone();
197 if let Some(path) = secrets_key_file {
198 let mut text =
199 crate::paths::read_text_capped(&path, crate::paths::MAX_SECRETS_KEY_FILE_BYTES)
200 .map_err(|e| {
201 SshCliError::InvalidArgument(format!(
202 "failed reading --secrets-key-file {}: {e}",
203 path.display()
204 ))
205 })?;
206 let key = parse_hex_key(text.trim())
207 .map_err(|e| SshCliError::InvalidArgument(format!("invalid --secrets-key-file: {e}")));
208 text.zeroize();
209 return Ok((Some(key?), KeySource::ConfigFile));
210 }
211
212 if std::env::var_os(ENV_SECRETS_KEY).is_some()
214 || std::env::var_os(ENV_SECRETS_KEY_FILE).is_some()
215 {
216 return Err(SshCliError::InvalidArgument(format!(
217 "{ENV_SECRETS_KEY} / {ENV_SECRETS_KEY_FILE} are not supported; use XDG `{KEY_FILE_NAME}` \
218 (`{APP_NAME} secrets init`) or --secrets-key-file"
219 )));
220 }
221
222 let use_keyring_flag = lock_global(&RUNTIME_FLAGS).use_keyring;
223 if use_keyring_flag {
224 match read_keyring() {
225 Ok(Some(key)) => return Ok((Some(key), KeySource::Keyring)),
226 Ok(None) => {}
227 Err(e) => {
228 tracing::warn!(err = %e, "keyring unavailable; trying secrets.key");
229 }
230 }
231 }
232
233 let path = secrets_key_path()?;
234 if path.is_file() {
235 let mut text =
236 crate::paths::read_text_capped(&path, crate::paths::MAX_SECRETS_KEY_FILE_BYTES)
237 .map_err(|e| {
238 SshCliError::Config(format!("failed reading {}: {e}", path.display()))
239 })?;
240 let key = parse_hex_key(text.trim())
241 .map_err(|e| SshCliError::InvalidArgument(format!("invalid {KEY_FILE_NAME}: {e}")));
242 text.zeroize();
243 return Ok((Some(key?), KeySource::XdgFile));
244 }
245
246 Ok((None, KeySource::Absent))
247}
248
249pub fn ensure_key_for_write() -> SshCliResult<(Option<[u8; PRIMARY_KEY_LEN_BYTES]>, KeySource)> {
255 let (existing, source) = load_primary_key()?;
256 if existing.is_some() {
257 return Ok((existing, source));
258 }
259 if plaintext_allowed() {
260 return Ok((None, KeySource::Absent));
261 }
262 let path = secrets_key_path()?;
263 let mut hex = generate_hex_key()?;
264 write_key_file(&path, &hex, false)?;
265 AUTO_KEY_CREATED.store(true, Ordering::Relaxed);
266 tracing::info!(
267 path = %path.display(),
268 "secrets.key auto-created (event secrets-key-auto-created)"
269 );
270 let key =
271 parse_hex_key(&hex).map_err(|e| SshCliError::Config(format!("invalid generated key: {e}")));
272 hex.zeroize();
273 Ok((Some(key?), KeySource::XdgFile))
274}
275
276pub fn secrets_status() -> SshCliResult<SecretsStatus> {
278 let key_file_path = secrets_key_path()?;
279 let (key, source) = load_primary_key()?;
280 let encryption_active = key.is_some();
281 if let Some(mut k) = key {
282 k.zeroize();
283 }
284 Ok(SecretsStatus {
285 source,
286 encryption_active,
287 key_file_path,
288 plaintext_opt_out: plaintext_allowed(),
289 })
290}
291
292#[must_use]
294pub fn is_encrypted_blob(value: &str) -> bool {
295 value.starts_with(ENC_PREFIX)
296}
297
298pub fn serialize_secret(plaintext: &str) -> SshCliResult<String> {
307 if plaintext.is_empty() {
308 return Ok(String::new());
309 }
310 let (key, _) = ensure_key_for_write()?;
311 match key {
312 None => Ok(plaintext.to_string()),
313 Some(mut key) => {
314 let out = encrypt_secret(&key, plaintext)?;
315 key.zeroize();
316 Ok(out)
317 }
318 }
319}
320
321pub fn deserialize_secret(stored: &str) -> SshCliResult<String> {
323 if !is_encrypted_blob(stored) {
324 return Ok(stored.to_string());
325 }
326 let (key, _) = load_primary_key()?;
327 let mut key = key.ok_or_else(|| {
328 SshCliError::InvalidArgument(format!(
329 "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)"
330 ))
331 })?;
332 let plain = decrypt_secret(&key, stored)?;
333 key.zeroize();
334 Ok(plain)
335}
336
337pub fn generate_hex_key() -> SshCliResult<String> {
339 let mut bytes = [0u8; PRIMARY_KEY_LEN_BYTES];
340 getrandom::getrandom(&mut bytes)
341 .map_err(|e| SshCliError::Config(format!("RNG failed: {e}")))?;
342 let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
343 bytes.zeroize();
344 Ok(hex)
345}
346
347pub fn write_key_file(path: &Path, hex64: &str, force: bool) -> SshCliResult<()> {
352 let _ = parse_hex_key(hex64)
353 .map_err(|e| SshCliError::InvalidArgument(format!("invalid key: {e}")))?;
354 if path.exists() && !force {
355 return Err(SshCliError::InvalidArgument(format!(
356 "{} already exists; use --force to overwrite",
357 path.display()
358 )));
359 }
360 if path.exists() && force {
362 let bak = path.with_file_name(format!(
363 "{}.bak",
364 path.file_name()
365 .and_then(|s| s.to_str())
366 .unwrap_or(KEY_FILE_NAME)
367 ));
368 if let Err(e) = std::fs::copy(path, &bak) {
369 tracing::warn!(
370 err = %e,
371 path = %bak.display(),
372 "failed to backup secrets key before --force"
373 );
374 }
375 }
376 if let Some(parent_dir) = path.parent() {
377 std::fs::create_dir_all(parent_dir)?;
378 }
379 let parent_dir = path.parent().unwrap_or_else(|| Path::new("."));
380 let mut tmp = tempfile::NamedTempFile::new_in(parent_dir)
381 .map_err(|e| SshCliError::Config(format!("tempfile secrets.key: {e}")))?;
382 use std::io::Write;
383 tmp.write_all(hex64.trim().as_bytes())
384 .map_err(|e| SshCliError::Config(format!("write secrets.key: {e}")))?;
385 tmp.write_all(b"\n")
386 .map_err(|e| SshCliError::Config(format!("write secrets.key: {e}")))?;
387 tmp.as_file()
388 .sync_all()
389 .map_err(|e| SshCliError::Config(format!("fsync secrets.key: {e}")))?;
390 crate::fs_perm::set_secret_file_mode(tmp.path())
391 .map_err(|e| SshCliError::Config(format!("chmod secrets.key: {e}")))?;
392 tmp.persist(path)
393 .map_err(|e| SshCliError::Config(format!("persist secrets.key: {e}")))?;
394 let _ = crate::fs_perm::set_secret_file_mode(path);
396 Ok(())
397}
398
399pub fn init_primary_key(use_keyring: bool, force: bool) -> SshCliResult<SecretsStatus> {
404 let mut hex = generate_hex_key()?;
405 if use_keyring {
406 if !force {
407 match read_keyring() {
408 Ok(Some(_)) => {
409 hex.zeroize();
410 return Err(SshCliError::InvalidArgument(
411 "keyring already has a primary-key; use --force".to_string(),
412 ));
413 }
414 Ok(None) => {}
415 Err(e) => {
416 hex.zeroize();
417 return Err(e);
418 }
419 }
420 }
421 let result = write_key_to_keyring(&hex);
422 hex.zeroize();
423 result?;
424 return secrets_status();
425 }
426 let path = secrets_key_path()?;
427 let result = write_key_file(&path, &hex, force);
428 hex.zeroize();
429 result?;
430 secrets_status()
431}
432
433pub fn write_key_to_keyring(hex64: &str) -> SshCliResult<()> {
435 let _ = parse_hex_key(hex64)
436 .map_err(|e| SshCliError::InvalidArgument(format!("invalid key: {e}")))?;
437 let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER_PRIMARY)
438 .map_err(|e| SshCliError::Config(format!("keyring Entry::new failed: {e}")))?;
439 entry
440 .set_password(hex64.trim())
441 .map_err(|e| SshCliError::Config(format!("keyring set failed: {e}")))?;
442 Ok(())
443}
444
445fn parse_hex_key(hex: &str) -> Result<[u8; PRIMARY_KEY_LEN_BYTES], String> {
446 let h = hex.trim();
447 if h.len() != PRIMARY_KEY_HEX_LEN {
448 return Err(format!(
449 "expected {PRIMARY_KEY_HEX_LEN} hex characters ({PRIMARY_KEY_LEN_BYTES} bytes)"
450 ));
451 }
452 let mut out = [0u8; PRIMARY_KEY_LEN_BYTES];
453 for i in 0..PRIMARY_KEY_LEN_BYTES {
454 let byte =
455 u8::from_str_radix(&h[i * 2..i * 2 + 2], 16).map_err(|_| "invalid hex".to_string())?;
456 out[i] = byte;
457 }
458 Ok(out)
459}
460
461fn encrypt_secret(key: &[u8; PRIMARY_KEY_LEN_BYTES], plaintext: &str) -> SshCliResult<String> {
462 let cipher =
463 ChaCha20Poly1305::new_from_slice(key).map_err(|_| SshCliError::crypto("aead_key"))?;
464 let mut nonce_bytes = [0u8; AEAD_NONCE_LEN_BYTES];
465 getrandom::getrandom(&mut nonce_bytes)
466 .map_err(|e| SshCliError::Config(format!("RNG failed: {e}")))?;
467 let nonce = Nonce::from_slice(&nonce_bytes);
468 let ciphertext = cipher
469 .encrypt(nonce, plaintext.as_bytes())
470 .map_err(|_| SshCliError::crypto("encrypt"))?;
471 let mut packed = Vec::with_capacity(AEAD_NONCE_LEN_BYTES + ciphertext.len());
472 packed.extend_from_slice(&nonce_bytes);
473 packed.extend_from_slice(&ciphertext);
474 Ok(format!(
475 "{ENC_PREFIX}{}",
476 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &packed)
477 ))
478}
479
480fn decrypt_secret(key: &[u8; PRIMARY_KEY_LEN_BYTES], blob: &str) -> SshCliResult<String> {
481 let b64 = blob
482 .strip_prefix(ENC_PREFIX)
483 .ok_or_else(|| SshCliError::crypto("blob_parse"))?;
484 let packed = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, b64)
485 .map_err(|_| SshCliError::crypto("blob_b64"))?;
486 if packed.len() < AEAD_NONCE_LEN_BYTES + AEAD_TAG_LEN_BYTES {
487 return Err(SshCliError::Config("encrypted blob too short".to_string()));
488 }
489 let (nonce_bytes, ct) = packed.split_at(AEAD_NONCE_LEN_BYTES);
490 let cipher =
491 ChaCha20Poly1305::new_from_slice(key).map_err(|_| SshCliError::crypto("aead_key"))?;
492 let nonce = Nonce::from_slice(nonce_bytes);
493 let plain = cipher
494 .decrypt(nonce, ct)
495 .map_err(|_| SshCliError::crypto("decrypt"))?;
496 match String::from_utf8(plain) {
497 Ok(s) => Ok(s),
498 Err(e) => {
499 let mut bad = e.into_bytes();
501 bad.zeroize();
502 Err(SshCliError::Config(
503 "decrypted secret is not valid UTF-8".to_string(),
504 ))
505 }
506 }
507}
508
509fn read_keyring() -> SshCliResult<Option<[u8; PRIMARY_KEY_LEN_BYTES]>> {
510 for user in [KEYRING_USER_PRIMARY, KEYRING_USER_LEGACY] {
512 let entry = match keyring::Entry::new(KEYRING_SERVICE, user) {
513 Ok(e) => e,
514 Err(e) => {
515 if user == "secrets-master-key" {
516 return Err(SshCliError::Config(format!(
517 "keyring Entry::new failed: {e}"
518 )));
519 }
520 continue;
521 }
522 };
523 match entry.get_password() {
524 Ok(mut s) => {
525 let key = parse_hex_key(&s).map_err(|e| {
526 SshCliError::InvalidArgument(format!("invalid keyring primary-key: {e}"))
527 });
528 s.zeroize();
529 return Ok(Some(key?));
530 }
531 Err(keyring::Error::NoEntry) => continue,
532 Err(e) => {
533 if user == "secrets-master-key" {
534 return Err(SshCliError::Config(format!("keyring get failed: {e}")));
535 }
536 continue;
537 }
538 }
539 }
540 Ok(None)
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546 use serial_test::serial;
547 use tempfile::TempDir;
548
549 fn clear_key_env() {
550 crate::test_util::env::remove_var(ENV_SECRETS_KEY);
552 crate::test_util::env::remove_var(ENV_SECRETS_KEY_FILE);
553 crate::test_util::env::remove_var(crate::constants::ENV_USE_KEYRING);
554 set_runtime_flags(false, None, false);
555 set_config_dir(None);
556 }
557
558 fn sandbox() -> TempDir {
560 clear_key_env();
561 let tmp = TempDir::new().unwrap();
562 set_config_dir(Some(tmp.path().to_path_buf()));
563 tmp
564 }
565
566 #[test]
567 #[serial]
568 fn roundtrip_with_xdg_key() {
569 let _tmp = sandbox();
570 init_primary_key(false, false).expect("init key");
571 let plain = "fake-test-password-not-real";
572 let enc = serialize_secret(plain).unwrap();
573 assert!(is_encrypted_blob(&enc));
574 assert!(!enc.contains(plain));
575 let back = deserialize_secret(&enc).unwrap();
576 assert_eq!(back, plain);
577 clear_key_env();
578 }
579
580 #[test]
581 #[serial]
582 fn opt_out_keeps_plaintext() {
583 let _tmp = sandbox();
584 set_runtime_flags(true, None, false);
585 let plain = "fake-plaintext-only-for-unit-test";
586 let out = serialize_secret(plain).unwrap();
587 assert_eq!(out, plain);
588 assert!(!is_encrypted_blob(&out));
589 clear_key_env();
590 }
591
592 #[test]
593 #[serial]
594 fn default_auto_creates_secrets_key() {
595 let tmp = sandbox();
596 let plain = "fake-auto-enc-password";
597 let enc = serialize_secret(plain).unwrap();
598 assert!(is_encrypted_blob(&enc));
599 assert!(!enc.contains(plain));
600 assert!(tmp.path().join(KEY_FILE_NAME).is_file());
601 let back = deserialize_secret(&enc).unwrap();
602 assert_eq!(back, plain);
603 clear_key_env();
604 }
605
606 #[test]
607 #[serial]
608 fn blob_without_key_fails() {
609 let tmp = sandbox();
610 init_primary_key(false, false).expect("init");
611 let enc = serialize_secret("fake-secret").unwrap();
612 clear_key_env();
615 set_config_dir(Some(tmp.path().to_path_buf()));
616 let _ = std::fs::remove_file(tmp.path().join(KEY_FILE_NAME));
617 set_runtime_flags(true, None, false);
618 let err = deserialize_secret(&enc).unwrap_err();
619 let msg = err.to_string();
620 assert!(
621 msg.contains("encrypted") || msg.contains("secrets") || msg.contains("key"),
622 "msg={msg}"
623 );
624 clear_key_env();
625 }
626
627 #[test]
628 #[serial]
629 fn empty_secret_never_encrypted_blob() {
630 let _tmp = sandbox();
632 init_primary_key(false, false).expect("init");
633 let out = serialize_secret("").unwrap();
634 assert_eq!(out, "");
635 assert!(!is_encrypted_blob(&out));
636 clear_key_env();
637 }
638
639 #[test]
640 fn parse_hex_tamanho() {
641 assert!(parse_hex_key("aa").is_err());
642 assert!(
643 parse_hex_key("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")
644 .is_ok()
645 );
646 }
647
648 #[test]
649 #[serial]
650 fn init_creates_file() {
651 clear_key_env();
652 let tmp = TempDir::new().unwrap();
653 set_config_dir(Some(tmp.path().to_path_buf()));
654 let st = init_primary_key(false, false).unwrap();
655 assert!(st.encryption_active);
656 assert_eq!(st.source, KeySource::XdgFile);
657 assert!(st.key_file_path.is_file());
658 clear_key_env();
659 }
660
661 #[test]
662 fn lock_global_recovers_from_poison_with_usable_data() {
663 let m = Mutex::new(42_u32);
664 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
665 let _g = m.lock().unwrap();
666 panic!("intentional poison for lock_global test");
667 }));
668 assert!(m.is_poisoned());
669 let g = lock_global(&m);
670 assert_eq!(*g, 42);
671 }
672}