#![forbid(unsafe_code)]
use crate::constants::{
APP_NAME, ENV_SECRETS_KEY, ENV_SECRETS_KEY_FILE, PRIMARY_KEY_HEX_LEN, PRIMARY_KEY_LEN_BYTES,
SECRETS_KEY_FILE_NAME,
};
use crate::errors::{SshCliError, SshCliResult};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use zeroize::{Zeroize, Zeroizing};
mod aead;
mod keyring_store;
pub use aead::SecretContext;
use aead::{decrypt_secret, encrypt_secret};
use keyring_store::read_keyring;
pub use keyring_store::write_key_to_keyring;
pub const ENC_PREFIX: &str = "sshcli-enc:v1:";
pub const ENC_PREFIX_V2: &str = "sshcli-enc:v2:";
pub type PrimaryKey = Zeroizing<[u8; PRIMARY_KEY_LEN_BYTES]>;
pub const KEY_FILE_NAME: &str = SECRETS_KEY_FILE_NAME;
const _: () = assert!(!ENC_PREFIX.is_empty());
const _: () = assert!(!ENC_PREFIX_V2.is_empty());
const _: () = assert!(!KEY_FILE_NAME.is_empty());
const _: () = assert!(PRIMARY_KEY_LEN_BYTES == 32);
fn lock_global<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|poisoned| {
tracing::warn!(
"secrets process-global mutex was poisoned; recovering via into_inner (one-shot CLI)"
);
poisoned.into_inner()
})
}
static DIR_CONFIG_OVERRIDE: Mutex<Option<PathBuf>> = Mutex::new(None);
#[derive(Debug, Default, Clone)]
struct RuntimeSecretsFlags {
allow_plaintext: bool,
secrets_key_file: Option<PathBuf>,
use_keyring: bool,
}
static RUNTIME_FLAGS: Mutex<RuntimeSecretsFlags> = Mutex::new(RuntimeSecretsFlags {
allow_plaintext: false,
secrets_key_file: None,
use_keyring: false,
});
static AUTO_KEY_CREATED: AtomicBool = AtomicBool::new(false);
pub fn set_config_dir(dir: Option<PathBuf>) {
*lock_global(&DIR_CONFIG_OVERRIDE) = dir;
}
pub fn set_runtime_flags(
allow_plaintext: bool,
secrets_key_file: Option<PathBuf>,
use_keyring: bool,
) {
{
let mut g = lock_global(&RUNTIME_FLAGS);
g.allow_plaintext = allow_plaintext;
g.secrets_key_file = secrets_key_file;
g.use_keyring = use_keyring;
}
AUTO_KEY_CREATED.store(false, Ordering::Relaxed);
}
#[must_use]
pub fn take_auto_key_created() -> bool {
AUTO_KEY_CREATED.swap(false, Ordering::Relaxed)
}
#[must_use]
pub fn auto_key_created() -> bool {
AUTO_KEY_CREATED.load(Ordering::Relaxed)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum KeySource {
Absent,
Env,
ConfigFile,
Keyring,
XdgFile,
}
impl KeySource {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Absent => "none",
Self::Env => "env",
Self::ConfigFile => "file",
Self::Keyring => "keyring",
Self::XdgFile => "xdg_file",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SecretsStatus {
pub source: KeySource,
pub encryption_active: bool,
pub key_file_path: PathBuf,
pub plaintext_opt_out: bool,
}
#[must_use]
pub fn plaintext_allowed() -> bool {
lock_global(&RUNTIME_FLAGS).allow_plaintext
}
pub fn secrets_config_dir() -> SshCliResult<PathBuf> {
if let Some(d) = lock_global(&DIR_CONFIG_OVERRIDE).clone() {
return Ok(d);
}
crate::paths::xdg_config_dir()
}
pub fn secrets_key_path() -> SshCliResult<PathBuf> {
Ok(secrets_config_dir()?.join(KEY_FILE_NAME))
}
pub fn load_primary_key() -> SshCliResult<(Option<PrimaryKey>, KeySource)> {
let secrets_key_file = lock_global(&RUNTIME_FLAGS).secrets_key_file.clone();
if let Some(path) = secrets_key_file {
let mut text =
crate::paths::read_text_capped(&path, crate::paths::MAX_SECRETS_KEY_FILE_BYTES)
.map_err(|e| {
SshCliError::InvalidArgument(format!(
"failed reading --secrets-key-file {}: {e}",
path.display()
))
})?;
let key = parse_hex_key(text.trim())
.map_err(|e| SshCliError::InvalidArgument(format!("invalid --secrets-key-file: {e}")));
text.zeroize();
return Ok((Some(key?), KeySource::ConfigFile));
}
if std::env::var_os(ENV_SECRETS_KEY).is_some()
|| std::env::var_os(ENV_SECRETS_KEY_FILE).is_some()
{
return Err(SshCliError::InvalidArgument(format!(
"{ENV_SECRETS_KEY} / {ENV_SECRETS_KEY_FILE} are not supported; use XDG `{KEY_FILE_NAME}` \
(`{APP_NAME} secrets init`) or --secrets-key-file"
)));
}
let use_keyring_flag = lock_global(&RUNTIME_FLAGS).use_keyring;
if use_keyring_flag {
match read_keyring() {
Ok(Some(key)) => return Ok((Some(key), KeySource::Keyring)),
Ok(None) => {}
Err(e) => {
tracing::warn!(err = %e, "keyring unavailable; trying secrets.key");
}
}
}
let path = secrets_key_path()?;
if path.is_file() {
let mut text =
crate::paths::read_text_capped(&path, crate::paths::MAX_SECRETS_KEY_FILE_BYTES)
.map_err(|e| {
tracing::debug!(err = %e, path = %path.display(), "failed reading secrets key");
e
})?;
let key = parse_hex_key(text.trim())
.map_err(|e| SshCliError::InvalidArgument(format!("invalid {KEY_FILE_NAME}: {e}")));
text.zeroize();
return Ok((Some(key?), KeySource::XdgFile));
}
Ok((None, KeySource::Absent))
}
pub fn ensure_key_for_write() -> SshCliResult<(Option<PrimaryKey>, KeySource)> {
let (existing, source) = load_primary_key()?;
if existing.is_some() {
return Ok((existing, source));
}
if plaintext_allowed() {
return Ok((None, KeySource::Absent));
}
let path = secrets_key_path()?;
let mut hex = generate_hex_key()?;
write_key_file(&path, &hex, false)?;
AUTO_KEY_CREATED.store(true, Ordering::Relaxed);
tracing::info!(
path = %path.display(),
"secrets.key auto-created (event secrets-key-auto-created)"
);
let key = parse_hex_key(&hex).map_err(|e| {
tracing::error!(err = %e, "generated key failed round-trip parse");
SshCliError::software("key_encoding")
});
hex.zeroize();
Ok((Some(key?), KeySource::XdgFile))
}
pub fn secrets_status() -> SshCliResult<SecretsStatus> {
let key_file_path = secrets_key_path()?;
let (key, source) = load_primary_key()?;
let encryption_active = key.is_some();
drop(key);
Ok(SecretsStatus {
source,
encryption_active,
key_file_path,
plaintext_opt_out: plaintext_allowed(),
})
}
#[must_use]
pub fn is_encrypted_blob(value: &str) -> bool {
value.starts_with(ENC_PREFIX) || value.starts_with(ENC_PREFIX_V2)
}
pub fn serialize_secret(plaintext: &str) -> SshCliResult<String> {
serialize_secret_in_context(SecretContext::unbound(), plaintext)
}
pub fn serialize_secret_in_context(
ctx: SecretContext<'_>,
plaintext: &str,
) -> SshCliResult<String> {
if plaintext.is_empty() {
return Ok(String::new());
}
let (key, _) = ensure_key_for_write()?;
match key {
None => Ok(plaintext.to_string()),
Some(key) => encrypt_secret(&key, plaintext, ctx),
}
}
pub fn deserialize_secret(stored: &str) -> SshCliResult<String> {
deserialize_secret_in_context(SecretContext::unbound(), stored)
}
pub fn deserialize_secret_in_context(ctx: SecretContext<'_>, stored: &str) -> SshCliResult<String> {
if !is_encrypted_blob(stored) {
return Ok(stored.to_string());
}
let (key, _) = load_primary_key()?;
let key = key.ok_or_else(|| {
SshCliError::InvalidArgument(format!(
"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)"
))
})?;
decrypt_secret(&key, stored, ctx)
}
pub fn generate_hex_key() -> SshCliResult<String> {
let mut bytes = [0u8; PRIMARY_KEY_LEN_BYTES];
getrandom::fill(&mut bytes).map_err(|_| SshCliError::software("rng"))?;
let hex: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
bytes.zeroize();
Ok(hex)
}
pub fn write_key_file(path: &Path, hex64: &str, force: bool) -> SshCliResult<()> {
let _ = parse_hex_key(hex64)
.map_err(|e| SshCliError::InvalidArgument(format!("invalid key: {e}")))?;
if path.exists() && !force {
return Err(SshCliError::InvalidArgument(format!(
"{} already exists; use --force to overwrite",
path.display()
)));
}
if path.exists() && force {
let bak = path.with_file_name(format!(
"{}.bak",
path.file_name()
.and_then(|s| s.to_str())
.unwrap_or(KEY_FILE_NAME)
));
if let Err(e) = std::fs::copy(path, &bak) {
tracing::warn!(
err = %e,
path = %bak.display(),
"failed to backup secrets key before --force"
);
}
}
if let Some(parent_dir) = path.parent() {
std::fs::create_dir_all(parent_dir)?;
}
let parent_dir = path.parent().unwrap_or_else(|| Path::new("."));
let mut tmp = tempfile::NamedTempFile::new_in(parent_dir).map_err(SshCliError::Io)?;
use std::io::Write;
tmp.write_all(hex64.trim().as_bytes())
.map_err(SshCliError::Io)?;
tmp.write_all(b"\n").map_err(SshCliError::Io)?;
tmp.as_file().sync_all().map_err(SshCliError::Io)?;
crate::fs_perm::set_secret_file_mode(tmp.path())?;
tmp.persist(path).map_err(|e| SshCliError::Io(e.error))?;
let _ = crate::fs_perm::set_secret_file_mode(path);
Ok(())
}
pub fn init_primary_key(use_keyring: bool, force: bool) -> SshCliResult<SecretsStatus> {
let mut hex = generate_hex_key()?;
if use_keyring {
if !force {
match read_keyring() {
Ok(Some(_)) => {
hex.zeroize();
return Err(SshCliError::InvalidArgument(
"keyring already has a primary-key; use --force".to_string(),
));
}
Ok(None) => {}
Err(e) => {
hex.zeroize();
return Err(e);
}
}
}
let result = write_key_to_keyring(&hex);
hex.zeroize();
result?;
return secrets_status();
}
let path = secrets_key_path()?;
let result = write_key_file(&path, &hex, force);
hex.zeroize();
result?;
secrets_status()
}
fn parse_hex_key(hex: &str) -> Result<PrimaryKey, String> {
let h = hex.trim();
if !h.is_ascii() || h.len() != PRIMARY_KEY_HEX_LEN {
return Err(format!(
"expected {PRIMARY_KEY_HEX_LEN} hex characters ({PRIMARY_KEY_LEN_BYTES} bytes)"
));
}
let mut out: PrimaryKey = Zeroizing::new([0u8; PRIMARY_KEY_LEN_BYTES]);
for i in 0..PRIMARY_KEY_LEN_BYTES {
let byte =
u8::from_str_radix(&h[i * 2..i * 2 + 2], 16).map_err(|_| "invalid hex".to_string())?;
out[i] = byte;
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::constants::AEAD_NONCE_LEN_BYTES;
use chacha20poly1305::aead::{Aead, KeyInit};
use chacha20poly1305::{ChaCha20Poly1305, Nonce};
use serial_test::serial;
use tempfile::TempDir;
fn clear_key_env() {
crate::test_util::env::remove_var(ENV_SECRETS_KEY);
crate::test_util::env::remove_var(ENV_SECRETS_KEY_FILE);
crate::test_util::env::remove_var(crate::constants::ENV_USE_KEYRING);
set_runtime_flags(false, None, false);
set_config_dir(None);
}
fn sandbox() -> TempDir {
clear_key_env();
let tmp = TempDir::new().unwrap();
set_config_dir(Some(tmp.path().to_path_buf()));
tmp
}
#[test]
#[serial]
fn roundtrip_with_xdg_key() {
let _tmp = sandbox();
init_primary_key(false, false).expect("init key");
let plain = "fake-test-password-not-real";
let enc = serialize_secret(plain).unwrap();
assert!(is_encrypted_blob(&enc));
assert!(!enc.contains(plain));
let back = deserialize_secret(&enc).unwrap();
assert_eq!(back, plain);
clear_key_env();
}
#[test]
#[serial]
fn opt_out_keeps_plaintext() {
let _tmp = sandbox();
set_runtime_flags(true, None, false);
let plain = "fake-plaintext-only-for-unit-test";
let out = serialize_secret(plain).unwrap();
assert_eq!(out, plain);
assert!(!is_encrypted_blob(&out));
clear_key_env();
}
#[test]
#[serial]
fn default_auto_creates_secrets_key() {
let tmp = sandbox();
let plain = "fake-auto-enc-password";
let enc = serialize_secret(plain).unwrap();
assert!(is_encrypted_blob(&enc));
assert!(!enc.contains(plain));
assert!(tmp.path().join(KEY_FILE_NAME).is_file());
let back = deserialize_secret(&enc).unwrap();
assert_eq!(back, plain);
clear_key_env();
}
#[test]
#[serial]
fn blob_without_key_fails() {
let tmp = sandbox();
init_primary_key(false, false).expect("init");
let enc = serialize_secret("fake-secret").unwrap();
clear_key_env();
set_config_dir(Some(tmp.path().to_path_buf()));
let _ = std::fs::remove_file(tmp.path().join(KEY_FILE_NAME));
set_runtime_flags(true, None, false);
let err = deserialize_secret(&enc).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("encrypted") || msg.contains("secrets") || msg.contains("key"),
"msg={msg}"
);
clear_key_env();
}
#[test]
#[serial]
fn empty_secret_never_encrypted_blob() {
let _tmp = sandbox();
init_primary_key(false, false).expect("init");
let out = serialize_secret("").unwrap();
assert_eq!(out, "");
assert!(!is_encrypted_blob(&out));
clear_key_env();
}
#[test]
#[serial]
fn v2_blob_rejects_foreign_host_and_field() {
let _tmp = sandbox();
init_primary_key(false, false).expect("init key");
let plain = "fake-bound-secret";
let host_a = SecretContext::new("host-a", "password");
let enc = serialize_secret_in_context(host_a, plain).unwrap();
assert!(enc.starts_with(ENC_PREFIX_V2), "v2 must be written");
assert_eq!(deserialize_secret_in_context(host_a, &enc).unwrap(), plain);
let other_host = SecretContext::new("host-b", "password");
assert!(
deserialize_secret_in_context(other_host, &enc).is_err(),
"blob bound to host-a must not open as host-b"
);
let other_field = SecretContext::new("host-a", "su_password");
assert!(
deserialize_secret_in_context(other_field, &enc).is_err(),
"blob bound to password must not open as su_password"
);
clear_key_env();
}
#[test]
#[serial]
fn unbound_blob_stays_readable_under_any_context() {
let _tmp = sandbox();
init_primary_key(false, false).expect("init key");
let plain = "fake-unbound-secret";
let enc = serialize_secret(plain).unwrap();
assert!(enc.starts_with(ENC_PREFIX_V2));
let bound = SecretContext::new("host-a", "password");
assert_eq!(deserialize_secret_in_context(bound, &enc).unwrap(), plain);
clear_key_env();
}
#[test]
#[serial]
fn legacy_v1_blob_still_decrypts() {
let _tmp = sandbox();
init_primary_key(false, false).expect("init key");
let (key, _) = load_primary_key().unwrap();
let key = key.expect("key present");
let plain = "fake-legacy-v1-secret";
let cipher = ChaCha20Poly1305::new_from_slice(key.as_slice()).unwrap();
let mut nonce_bytes = [0u8; AEAD_NONCE_LEN_BYTES];
getrandom::fill(&mut nonce_bytes).unwrap();
let ct = cipher
.encrypt(&Nonce::from(nonce_bytes), plain.as_bytes())
.unwrap();
let mut packed = nonce_bytes.to_vec();
packed.extend_from_slice(&ct);
let blob = format!(
"{ENC_PREFIX}{}",
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &packed)
);
assert!(is_encrypted_blob(&blob));
assert_eq!(deserialize_secret(&blob).unwrap(), plain);
assert_eq!(
deserialize_secret_in_context(SecretContext::new("host-a", "password"), &blob).unwrap(),
plain
);
clear_key_env();
}
#[test]
fn aad_encoding_is_unambiguous() {
assert_ne!(
SecretContext::new("a:b", "password").aad(),
SecretContext::new("a", "b:password").aad()
);
assert!(SecretContext::unbound().is_unbound());
assert!(!SecretContext::new("host-a", "password").is_unbound());
}
#[test]
fn parse_hex_tamanho() {
assert!(parse_hex_key("aa").is_err());
assert!(
parse_hex_key("00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")
.is_ok()
);
}
#[test]
#[serial]
fn init_creates_file() {
clear_key_env();
let tmp = TempDir::new().unwrap();
set_config_dir(Some(tmp.path().to_path_buf()));
let st = init_primary_key(false, false).unwrap();
assert!(st.encryption_active);
assert_eq!(st.source, KeySource::XdgFile);
assert!(st.key_file_path.is_file());
clear_key_env();
}
#[test]
fn lock_global_recovers_from_poison_with_usable_data() {
let m = Mutex::new(42_u32);
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _g = m.lock().unwrap();
panic!("intentional poison for lock_global test");
}));
assert!(m.is_poisoned());
let g = lock_global(&m);
assert_eq!(*g, 42);
}
}