use aes_gcm::{
Aes256Gcm, Nonce,
aead::{Aead, KeyInit},
};
use rand::RngExt;
use sha2::{Digest, Sha256};
use std::{
fs::{self, OpenOptions},
io::{self, Write},
path::{Path, PathBuf},
sync::OnceLock,
};
static MASTER_KEY: OnceLock<[u8; 32]> = OnceLock::new();
const KEYRING_SERVICE: &str = "difflore";
const KEYRING_USER: &str = "master-key-v2";
#[cfg(debug_assertions)]
const DEBUG_MASTER_KEY_FILE: &str = "master-key";
const KEYSEED_FILE: &str = "keyseed";
const KEYSEED_BYTES: usize = 32;
const LOCAL_FALLBACK_CONTEXT: &[u8] = b"difflore-local-fallback-master-key-v2";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KeyseedStatus {
Present {
path: PathBuf,
permissions_ok: Option<bool>,
},
Missing {
path: PathBuf,
},
Invalid {
path: PathBuf,
error: String,
permissions_ok: Option<bool>,
},
Unreadable {
path: PathBuf,
error: String,
permissions_ok: Option<bool>,
},
Unavailable {
error: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MasterKeyStorageStatus {
EnvOverride,
DebugFileOverride {
path: PathBuf,
},
KeyringReady,
KeyringWillCreate,
KeyringInvalid(String),
LocalFallback {
keyring_error: String,
keyseed: KeyseedStatus,
},
CiRequiresExplicitKey {
keyring_error: String,
},
}
fn get_or_create_master_key() -> crate::Result<[u8; 32]> {
if let Some(key) = MASTER_KEY.get() {
return Ok(*key);
}
let key = if let Some(hex) = crate::infra::env::master_key_hex() {
if let Ok(key) = parse_32_byte_hex(hex.trim()) {
key
} else {
eprintln!(
"warning: DIFFLORE_MASTER_KEY is not a valid 64-character hex key; using the local keyring instead."
);
master_key_from_keyring_result(try_keyring_key())?
}
} else if let Some((key, _path)) = try_debug_master_key_file()? {
key
} else {
master_key_from_keyring_result(try_keyring_key())?
};
let _ = MASTER_KEY.set(key);
Ok(*MASTER_KEY.get().unwrap_or(&key))
}
pub fn probe_master_key_storage() -> MasterKeyStorageStatus {
if let Some(hex) = crate::infra::env::master_key_hex()
&& parse_32_byte_hex(hex.trim()).is_ok()
{
return MasterKeyStorageStatus::EnvOverride;
}
if let Ok(Some((_key, path))) = try_debug_master_key_file() {
return MasterKeyStorageStatus::DebugFileOverride { path };
}
let entry = match keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER) {
Ok(entry) => entry,
Err(e) => {
let keyring_error = format!("keyring entry error: {e}");
return fallback_storage_status(keyring_error);
}
};
match entry.get_password() {
Ok(hex) => match parse_32_byte_hex(&hex) {
Ok(_) => MasterKeyStorageStatus::KeyringReady,
Err(e) => MasterKeyStorageStatus::KeyringInvalid(e.to_string()),
},
Err(keyring::Error::NoEntry) => MasterKeyStorageStatus::KeyringWillCreate,
Err(e) => fallback_storage_status(e.to_string()),
}
}
pub fn local_fallback_keyseed_status() -> KeyseedStatus {
match local_keyseed_path() {
Ok(path) => probe_keyseed_path(&path),
Err(error) => KeyseedStatus::Unavailable {
error: error.to_string(),
},
}
}
fn fallback_storage_status(keyring_error: String) -> MasterKeyStorageStatus {
if is_ci_environment() {
MasterKeyStorageStatus::CiRequiresExplicitKey { keyring_error }
} else {
MasterKeyStorageStatus::LocalFallback {
keyring_error,
keyseed: local_fallback_keyseed_status(),
}
}
}
fn master_key_from_keyring_result(
keyring_result: crate::Result<[u8; 32]>,
) -> crate::Result<[u8; 32]> {
match keyring_result {
Ok(key) => Ok(key),
Err(err) => local_fallback_key_for_keyring_error(&err.to_string()),
}
}
fn local_fallback_key_for_keyring_error(err: &str) -> crate::Result<[u8; 32]> {
if is_ci_environment() {
return Err(crate::CoreError::internal(format!(
"OS keyring unavailable ({err}) and running on CI. \
Set DIFFLORE_MASTER_KEY=<64-char-hex> to persist encrypted state; \
refusing local fallback key derivation because CI storage is often ephemeral."
)));
}
let key = derive_local_fallback_key().map_err(|seed_err| {
crate::CoreError::internal(format!(
"OS keyring unavailable ({err}) and local fallback keyseed unavailable: {seed_err}"
))
})?;
eprintln!(
"warning: OS keyring unavailable; DiffLore will use local fallback encryption backed by ~/.difflore/keyseed for stored secrets."
);
if crate::infra::env::debug_providers() {
eprintln!("[crypto] keyring unavailable: {err}");
}
Ok(key)
}
#[cfg(debug_assertions)]
fn debug_master_key_file_path() -> crate::Result<PathBuf> {
Ok(crate::infra::paths::config_home()?.join(DEBUG_MASTER_KEY_FILE))
}
#[cfg(debug_assertions)]
fn try_debug_master_key_file() -> crate::Result<Option<([u8; 32], PathBuf)>> {
let path = debug_master_key_file_path()?;
match read_master_key_file_at(&path) {
Ok(key) => Ok(Some((key, path))),
Err(e) if path.exists() => {
eprintln!("warning: {e}; using the local keyring instead.");
Ok(None)
}
Err(_) => Ok(None),
}
}
#[cfg(not(debug_assertions))]
fn try_debug_master_key_file() -> crate::Result<Option<([u8; 32], PathBuf)>> {
Ok(None)
}
#[cfg(any(debug_assertions, test))]
fn read_master_key_file_at(path: &Path) -> crate::Result<[u8; 32]> {
crate::infra::db::restrict_to_owner(path, false);
let raw = fs::read_to_string(path).map_err(|e| {
crate::CoreError::internal(format!(
"could not read debug master key file {}: {e}",
path.display()
))
})?;
parse_32_byte_hex(raw.trim()).map_err(|e| {
crate::CoreError::internal(format!("invalid master key file {}: {e}", path.display()))
})
}
fn is_ci_environment() -> bool {
const CI_ENV_FLAGS: &[&str] = &[
"CI",
"GITHUB_ACTIONS",
"GITLAB_CI",
"CIRCLECI",
"BUILDKITE",
"JENKINS_URL",
"TRAVIS",
"TEAMCITY_VERSION",
"CODEBUILD_BUILD_ID",
];
CI_ENV_FLAGS.iter().any(|k| crate::infra::env::truthy(k))
}
fn try_keyring_key() -> crate::Result<[u8; 32]> {
let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER)
.map_err(|e| crate::CoreError::internal(format!("keyring entry error: {e}")))?;
match entry.get_password() {
Ok(hex) => {
if let Ok(key) = parse_32_byte_hex(&hex) {
return Ok(key);
}
if let Ok(bytes) = from_hex(&hex) {
if crate::infra::env::debug_providers() {
eprintln!(
"[crypto] keyring: decoded bytes len={} (expected 32)",
bytes.len()
);
}
} else if crate::infra::env::debug_providers() {
eprintln!("[crypto] keyring: hex decode failed");
}
}
Err(e) => {
if crate::infra::env::debug_providers() {
eprintln!("[crypto] keyring get_password failed: {e}");
}
}
}
let mut key = [0u8; 32];
rand::rng().fill(&mut key);
let hex = to_hex(&key);
entry
.set_password(&hex)
.map_err(|e| crate::CoreError::internal(format!("keyring set error: {e}")))?;
Ok(key)
}
fn derive_local_fallback_key() -> crate::Result<[u8; 32]> {
let seed = load_or_create_local_keyseed()?;
Ok(derive_local_fallback_key_from_seed(
&seed,
&local_fallback_context(),
))
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LocalFallbackContext {
home_anchor: String,
machine_id: Option<String>,
}
fn local_fallback_context() -> LocalFallbackContext {
let anchor = dirs::home_dir().map_or_else(
|| "difflore-fallback".to_owned(),
|p| p.to_string_lossy().to_string(),
);
LocalFallbackContext {
home_anchor: anchor,
machine_id: read_machine_id(),
}
}
fn derive_local_fallback_key_from_seed(
seed: &[u8; KEYSEED_BYTES],
context: &LocalFallbackContext,
) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(LOCAL_FALLBACK_CONTEXT);
hasher.update(seed);
hasher.update(context.home_anchor.as_bytes());
if let Some(machine_id) = context.machine_id.as_deref() {
hasher.update(b"\0machine-id\0");
hasher.update(machine_id.as_bytes());
}
hasher.finalize().into()
}
fn read_machine_id() -> Option<String> {
["/etc/machine-id", "/var/lib/dbus/machine-id"]
.iter()
.filter_map(|path| fs::read_to_string(path).ok())
.map(|raw| raw.trim().to_owned())
.find(|id| !id.is_empty())
}
fn local_keyseed_path() -> crate::Result<PathBuf> {
Ok(crate::infra::paths::config_home()?.join(KEYSEED_FILE))
}
fn load_or_create_local_keyseed() -> crate::Result<[u8; KEYSEED_BYTES]> {
load_or_create_local_keyseed_at(&local_keyseed_path()?)
}
fn load_or_create_local_keyseed_at(path: &Path) -> crate::Result<[u8; KEYSEED_BYTES]> {
match read_existing_keyseed(path) {
Ok(seed) => return Ok(seed),
Err(KeyseedReadError::NotFound) => {}
Err(KeyseedReadError::Invalid(e) | KeyseedReadError::Io(e)) => {
return Err(crate::CoreError::internal(e));
}
}
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
crate::CoreError::internal(format!(
"could not create keyseed directory {}: {e}",
parent.display()
))
})?;
crate::infra::db::restrict_to_owner(parent, true);
}
let mut seed = [0u8; KEYSEED_BYTES];
rand::rng().fill(&mut seed);
match write_new_keyseed(path, &seed) {
Ok(()) => Ok(seed),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
let seed = read_existing_keyseed(path)
.map_err(|e| crate::CoreError::internal(KeyseedReadError::into_message(e)))?;
crate::infra::db::restrict_to_owner(path, false);
Ok(seed)
}
Err(e) => Err(crate::CoreError::internal(format!(
"could not create keyseed {}: {e}",
path.display()
))),
}
}
fn write_new_keyseed(path: &Path, seed: &[u8; KEYSEED_BYTES]) -> io::Result<()> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(path)?;
file.write_all(to_hex(seed).as_bytes())?;
file.sync_all()?;
crate::infra::db::restrict_to_owner(path, false);
Ok(())
}
enum KeyseedReadError {
NotFound,
Invalid(String),
Io(String),
}
impl KeyseedReadError {
fn into_message(self) -> String {
match self {
Self::NotFound => "keyseed not found".to_owned(),
Self::Invalid(e) | Self::Io(e) => e,
}
}
}
fn read_existing_keyseed(path: &Path) -> Result<[u8; KEYSEED_BYTES], KeyseedReadError> {
crate::infra::db::restrict_to_owner(path, false);
match fs::read_to_string(path) {
Ok(raw) => parse_keyseed_hex(&raw).map_err(|e| {
KeyseedReadError::Invalid(format!("invalid keyseed {}: {e}", path.display()))
}),
Err(e) if e.kind() == io::ErrorKind::NotFound => Err(KeyseedReadError::NotFound),
Err(e) => Err(KeyseedReadError::Io(format!(
"could not read keyseed {}: {e}",
path.display()
))),
}
}
fn parse_keyseed_hex(raw: &str) -> crate::Result<[u8; KEYSEED_BYTES]> {
let trimmed = raw.trim();
if trimmed.len() != KEYSEED_BYTES * 2 {
return Err(crate::CoreError::internal(format!(
"expected {} lowercase hex characters, got {}",
KEYSEED_BYTES * 2,
trimmed.len()
)));
}
if !trimmed
.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
{
return Err(crate::CoreError::internal("expected lowercase hex keyseed"));
}
parse_32_byte_hex(trimmed)
}
fn probe_keyseed_path(path: &Path) -> KeyseedStatus {
let permissions_ok = keyseed_permissions_ok(path);
match fs::read_to_string(path) {
Ok(raw) => match parse_keyseed_hex(&raw) {
Ok(_) => KeyseedStatus::Present {
path: path.to_path_buf(),
permissions_ok,
},
Err(error) => KeyseedStatus::Invalid {
path: path.to_path_buf(),
error: error.to_string(),
permissions_ok,
},
},
Err(e) if e.kind() == io::ErrorKind::NotFound => KeyseedStatus::Missing {
path: path.to_path_buf(),
},
Err(e) => KeyseedStatus::Unreadable {
path: path.to_path_buf(),
error: e.to_string(),
permissions_ok,
},
}
}
#[cfg(unix)]
fn keyseed_permissions_ok(path: &Path) -> Option<bool> {
use std::os::unix::fs::PermissionsExt;
fs::metadata(path)
.ok()
.map(|meta| meta.permissions().mode() & 0o777 == 0o600)
}
#[cfg(not(unix))]
const fn keyseed_permissions_ok(_path: &Path) -> Option<bool> {
None
}
fn to_hex(bytes: &[u8]) -> String {
use std::fmt::Write as _;
bytes
.iter()
.fold(String::with_capacity(bytes.len() * 2), |mut acc, b| {
let _ = write!(&mut acc, "{b:02x}");
acc
})
}
#[must_use]
pub fn sha256_block_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
let digest: [u8; 32] = hasher.finalize().into();
format!("sha256:{}", to_hex(&digest))
}
fn from_hex(hex: &str) -> crate::Result<Vec<u8>> {
if !hex.len().is_multiple_of(2) {
return Err(crate::CoreError::internal("odd-length hex string"));
}
(0..hex.len())
.step_by(2)
.map(|i| {
u8::from_str_radix(&hex[i..i + 2], 16)
.map_err(|e| crate::CoreError::Internal(e.to_string()))
})
.collect()
}
fn parse_32_byte_hex(hex: &str) -> crate::Result<[u8; 32]> {
let bytes = from_hex(hex)?;
if bytes.len() != 32 {
return Err(crate::CoreError::internal(format!(
"decoded {} bytes, expected 32",
bytes.len()
)));
}
let mut key = [0u8; 32];
key.copy_from_slice(&bytes);
Ok(key)
}
fn try_decrypt_with_key(
key_bytes: &[u8; 32],
nonce_bytes: &[u8],
ciphertext: &[u8],
) -> crate::Result<Vec<u8>, ()> {
let key = aes_gcm::Key::<Aes256Gcm>::from_slice(key_bytes);
let cipher = Aes256Gcm::new(key);
let nonce = Nonce::from_slice(nonce_bytes);
cipher.decrypt(nonce, ciphertext).map_err(|_| ())
}
pub fn encrypt_secret(plaintext: &str) -> crate::Result<String> {
let key_bytes = get_or_create_master_key()?;
let key = aes_gcm::Key::<Aes256Gcm>::from_slice(&key_bytes);
let cipher = Aes256Gcm::new(key);
let mut nonce_bytes = [0u8; 12];
rand::rng().fill(&mut nonce_bytes);
let nonce = Nonce::from_slice(&nonce_bytes);
let ciphertext = cipher
.encrypt(nonce, plaintext.as_bytes())
.map_err(|e| crate::CoreError::internal(format!("encryption failed: {e}")))?;
let mut combined = nonce_bytes.to_vec();
combined.extend_from_slice(&ciphertext);
Ok(to_hex(&combined))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecryptOrigin {
CurrentKey,
}
pub fn decrypt_secret_with_origin(hex_data: &str) -> crate::Result<(String, DecryptOrigin)> {
let combined = from_hex(hex_data)?;
if combined.len() < 13 {
return Err(crate::CoreError::internal("ciphertext too short"));
}
let (nonce_bytes, ciphertext) = combined.split_at(12);
let master_key = get_or_create_master_key()?;
let plaintext = try_decrypt_with_key(&master_key, nonce_bytes, ciphertext)
.map_err(|()| crate::CoreError::internal("decryption failed with current key"))?;
String::from_utf8(plaintext)
.map(|s| (s, DecryptOrigin::CurrentKey))
.map_err(|e| crate::CoreError::internal(format!("invalid utf8: {e}")))
}
pub fn decrypt_secret(hex_data: &str) -> crate::Result<String> {
decrypt_secret_with_origin(hex_data).map(|(plaintext, _origin)| plaintext)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hex_codec_round_trip_and_invariants() {
let data: Vec<u8> = (0u8..=255).collect();
let hex = to_hex(&data);
assert_eq!(hex.len(), data.len() * 2);
assert!(
hex.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
);
assert_eq!(from_hex(&hex).unwrap(), data);
assert_eq!(to_hex(&[]), "");
assert_eq!(from_hex("").unwrap(), Vec::<u8>::new());
assert_eq!(from_hex("DEADBEEF").unwrap(), vec![0xde, 0xad, 0xbe, 0xef]);
let err = from_hex("abc").unwrap_err().to_string();
assert!(err.contains("odd-length"), "unexpected error: {err}");
assert!(from_hex("zz").is_err());
assert!(from_hex("gh").is_err());
}
#[test]
fn sha256_block_hex_is_prefixed_stable_and_input_sensitive() {
assert_eq!(
sha256_block_hex(b""),
"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
let a = sha256_block_hex(br#"{"command":"difflore","args":["mcp-server"]}"#);
let b = sha256_block_hex(br#"{"command":"difflore","args":["mcp-server"]}"#);
assert_eq!(a, b);
assert!(a.starts_with("sha256:"));
assert_ne!(
a,
sha256_block_hex(br#"{"command":"difflore","args":["mcp-server2"]}"#)
);
}
#[test]
fn decrypt_secret_rejects_odd_length_hex_before_touching_keyring() {
let err = decrypt_secret("abc").unwrap_err().to_string();
assert!(err.contains("odd-length"), "unexpected error: {err}");
}
#[test]
fn decrypt_secret_rejects_too_short_ciphertext() {
let err = decrypt_secret("abcd").unwrap_err().to_string();
assert!(err.contains("too short"), "unexpected error: {err}");
}
#[test]
fn local_fallback_key_changes_with_seed_even_for_same_home() {
let context = LocalFallbackContext {
home_anchor: "/home/alice".to_owned(),
machine_id: Some("machine-a".to_owned()),
};
let mut seed_a = [0u8; KEYSEED_BYTES];
seed_a[0] = 1;
let mut seed_b = [0u8; KEYSEED_BYTES];
seed_b[0] = 2;
assert_ne!(
derive_local_fallback_key_from_seed(&seed_a, &context),
derive_local_fallback_key_from_seed(&seed_b, &context)
);
}
#[test]
fn local_fallback_key_is_stable_for_same_seed_and_context() {
let context = LocalFallbackContext {
home_anchor: "/home/alice".to_owned(),
machine_id: None,
};
let seed = [7u8; KEYSEED_BYTES];
assert_eq!(
derive_local_fallback_key_from_seed(&seed, &context),
derive_local_fallback_key_from_seed(&seed, &context)
);
}
#[test]
fn keyseed_creation_writes_lowercase_hex_and_owner_only_permissions() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(KEYSEED_FILE);
let seed = load_or_create_local_keyseed_at(&path).expect("create keyseed");
let raw = fs::read_to_string(&path).expect("read keyseed");
assert_eq!(raw.len(), KEYSEED_BYTES * 2);
assert!(
raw.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"keyseed must be lowercase hex: {raw}"
);
assert_eq!(parse_keyseed_hex(&raw).unwrap(), seed);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "keyseed should be 0600");
}
}
#[test]
fn existing_keyseed_is_read_stably() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(KEYSEED_FILE);
let seed = [9u8; KEYSEED_BYTES];
fs::write(&path, to_hex(&seed)).unwrap();
assert_eq!(load_or_create_local_keyseed_at(&path).unwrap(), seed);
assert_eq!(load_or_create_local_keyseed_at(&path).unwrap(), seed);
}
#[test]
fn invalid_keyseed_format_is_rejected() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join(KEYSEED_FILE);
fs::write(&path, "abc").unwrap();
let err = load_or_create_local_keyseed_at(&path)
.unwrap_err()
.to_string();
assert!(err.contains("invalid keyseed"), "unexpected error: {err}");
assert!(err.contains("expected 64"), "unexpected error: {err}");
}
#[test]
fn master_key_file_parser_accepts_32_byte_hex() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("master-key");
let key = [11u8; 32];
fs::write(&path, to_hex(&key)).unwrap();
assert_eq!(read_master_key_file_at(&path).unwrap(), key);
}
#[test]
fn master_key_file_parser_rejects_invalid_hex() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("master-key");
fs::write(&path, "abc").unwrap();
let err = read_master_key_file_at(&path).unwrap_err().to_string();
assert!(
err.contains("invalid master key"),
"unexpected error: {err}"
);
}
#[test]
fn keyring_success_returns_key_without_fallback() {
let key = [42u8; 32];
assert_eq!(master_key_from_keyring_result(Ok(key)).unwrap(), key);
}
}