difflore_core/infra/
crypto.rs1use aes_gcm::{
2 Aes256Gcm, Nonce,
3 aead::{Aead, KeyInit},
4};
5use rand::RngExt;
6use sha2::{Digest, Sha256};
7use std::sync::OnceLock;
8
9static MASTER_KEY: OnceLock<Result<[u8; 32], String>> = OnceLock::new();
10
11const KEYRING_SERVICE: &str = "difflore";
12const KEYRING_USER: &str = "master-key-v2";
13
14fn get_or_create_master_key() -> Result<[u8; 32], String> {
17 MASTER_KEY.get_or_init(|| {
18 if let Some(hex) = crate::env::master_key_hex() {
23 if let Ok(bytes) = from_hex(hex.trim())
24 && bytes.len() == 32 {
25 let mut key = [0u8; 32];
26 key.copy_from_slice(&bytes);
27 return Ok(key);
28 }
29 eprintln!(
30 "[crypto] DIFFLORE_MASTER_KEY set but not 64-char hex; ignoring."
31 );
32 }
33
34 match try_keyring_key() {
35 Ok(key) => Ok(key),
36 Err(err) => {
37 if is_ci_environment() {
43 return Err(format!(
44 "OS keyring unavailable ({err}) and running on CI. \
45 Set DIFFLORE_MASTER_KEY=<64-char-hex> to persist encrypted state; \
46 refusing local fallback key derivation because it produces unrecoverable secrets on CI."
47 ));
48 }
49 eprintln!(
50 "[crypto] WARNING: OS keyring unavailable ({err}), using local fallback key derivation. \
51 Stored secrets are protected with a weaker key."
52 );
53 Ok(derive_local_fallback_key())
54 }
55 }
56 }).clone()
57}
58
59fn is_ci_environment() -> bool {
62 const CI_ENV_FLAGS: &[&str] = &[
63 "CI",
64 "GITHUB_ACTIONS",
65 "GITLAB_CI",
66 "CIRCLECI",
67 "BUILDKITE",
68 "JENKINS_URL",
69 "TRAVIS",
70 "TEAMCITY_VERSION",
71 "CODEBUILD_BUILD_ID",
72 ];
73 CI_ENV_FLAGS.iter().any(|k| crate::env::truthy(k))
74}
75
76fn try_keyring_key() -> Result<[u8; 32], String> {
77 let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER)
78 .map_err(|e| format!("keyring entry error: {e}"))?;
79
80 match entry.get_password() {
81 Ok(hex) => {
82 if let Ok(bytes) = from_hex(&hex) {
83 if bytes.len() == 32 {
84 let mut key = [0u8; 32];
85 key.copy_from_slice(&bytes);
86 return Ok(key);
87 }
88 eprintln!(
89 "[crypto] keyring: decoded bytes len={} (expected 32)",
90 bytes.len()
91 );
92 } else {
93 eprintln!("[crypto] keyring: hex decode failed");
94 }
95 }
96 Err(e) => {
97 eprintln!("[crypto] keyring get_password failed: {e}");
98 }
99 }
100
101 let mut key = [0u8; 32];
102 rand::rng().fill(&mut key);
103 let hex = to_hex(&key);
104 entry
105 .set_password(&hex)
106 .map_err(|e| format!("keyring set error: {e}"))?;
107 Ok(key)
108}
109
110fn derive_local_fallback_key() -> [u8; 32] {
112 let anchor = dirs::home_dir().map_or_else(
113 || "difflore-fallback".to_owned(),
114 |p| p.to_string_lossy().to_string(),
115 );
116 let mut hasher = Sha256::new();
117 hasher.update(anchor.as_bytes());
118 hasher.update(b"difflore-cloud-encryption-key-v1");
119 hasher.finalize().into()
120}
121
122fn to_hex(bytes: &[u8]) -> String {
123 use std::fmt::Write as _;
124 bytes
125 .iter()
126 .fold(String::with_capacity(bytes.len() * 2), |mut acc, b| {
127 let _ = write!(&mut acc, "{b:02x}");
128 acc
129 })
130}
131
132#[must_use]
141pub fn sha256_block_hex(bytes: &[u8]) -> String {
142 let mut hasher = Sha256::new();
143 hasher.update(bytes);
144 let digest: [u8; 32] = hasher.finalize().into();
145 format!("sha256:{}", to_hex(&digest))
146}
147
148fn from_hex(hex: &str) -> Result<Vec<u8>, String> {
149 if !hex.len().is_multiple_of(2) {
150 return Err("odd-length hex string".into());
151 }
152 (0..hex.len())
153 .step_by(2)
154 .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).map_err(|e| e.to_string()))
155 .collect()
156}
157
158fn try_decrypt_with_key(
159 key_bytes: &[u8; 32],
160 nonce_bytes: &[u8],
161 ciphertext: &[u8],
162) -> Result<Vec<u8>, ()> {
163 let key = aes_gcm::Key::<Aes256Gcm>::from_slice(key_bytes);
164 let cipher = Aes256Gcm::new(key);
165 let nonce = Nonce::from_slice(nonce_bytes);
166 cipher.decrypt(nonce, ciphertext).map_err(|_| ())
167}
168
169pub fn encrypt_secret(plaintext: &str) -> Result<String, String> {
170 let key_bytes = get_or_create_master_key()?;
171 let key = aes_gcm::Key::<Aes256Gcm>::from_slice(&key_bytes);
172 let cipher = Aes256Gcm::new(key);
173
174 let mut nonce_bytes = [0u8; 12];
175 rand::rng().fill(&mut nonce_bytes);
176 let nonce = Nonce::from_slice(&nonce_bytes);
177
178 let ciphertext = cipher
179 .encrypt(nonce, plaintext.as_bytes())
180 .map_err(|e| format!("encryption failed: {e}"))?;
181
182 let mut combined = nonce_bytes.to_vec();
183 combined.extend_from_slice(&ciphertext);
184 Ok(to_hex(&combined))
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum DecryptOrigin {
190 CurrentKey,
192}
193
194pub fn decrypt_secret_with_origin(hex_data: &str) -> Result<(String, DecryptOrigin), String> {
197 let combined = from_hex(hex_data)?;
198 if combined.len() < 13 {
199 return Err("ciphertext too short".into());
200 }
201 let (nonce_bytes, ciphertext) = combined.split_at(12);
202 let master_key = get_or_create_master_key()?;
203
204 let plaintext = try_decrypt_with_key(&master_key, nonce_bytes, ciphertext)
205 .map_err(|()| "decryption failed with current key".to_owned())?;
206 String::from_utf8(plaintext)
207 .map(|s| (s, DecryptOrigin::CurrentKey))
208 .map_err(|e| format!("invalid utf8: {e}"))
209}
210
211pub fn decrypt_secret(hex_data: &str) -> Result<String, String> {
215 decrypt_secret_with_origin(hex_data).map(|(plaintext, _origin)| plaintext)
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 #[test]
223 fn hex_codec_round_trip_and_invariants() {
224 let data: Vec<u8> = (0u8..=255).collect();
227 let hex = to_hex(&data);
228 assert_eq!(hex.len(), data.len() * 2);
229 assert!(
230 hex.chars()
231 .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
232 );
233 assert_eq!(from_hex(&hex).unwrap(), data);
234
235 assert_eq!(to_hex(&[]), "");
237 assert_eq!(from_hex("").unwrap(), Vec::<u8>::new());
238 assert_eq!(from_hex("DEADBEEF").unwrap(), vec![0xde, 0xad, 0xbe, 0xef]);
239
240 let err = from_hex("abc").unwrap_err();
242 assert!(err.contains("odd-length"), "unexpected error: {err}");
243 assert!(from_hex("zz").is_err());
244 assert!(from_hex("gh").is_err());
245 }
246
247 #[test]
248 fn sha256_block_hex_is_prefixed_stable_and_input_sensitive() {
249 assert_eq!(
251 sha256_block_hex(b""),
252 "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
253 );
254 let a = sha256_block_hex(br#"{"command":"difflore","args":["mcp-server"]}"#);
257 let b = sha256_block_hex(br#"{"command":"difflore","args":["mcp-server"]}"#);
258 assert_eq!(a, b);
259 assert!(a.starts_with("sha256:"));
260 assert_ne!(
262 a,
263 sha256_block_hex(br#"{"command":"difflore","args":["mcp-server2"]}"#)
264 );
265 }
266
267 #[test]
268 fn decrypt_secret_rejects_odd_length_hex_before_touching_keyring() {
269 let err = decrypt_secret("abc").unwrap_err();
271 assert!(err.contains("odd-length"), "unexpected error: {err}");
272 }
273
274 #[test]
275 fn decrypt_secret_rejects_too_short_ciphertext() {
276 let err = decrypt_secret("abcd").unwrap_err();
282 assert!(err.contains("too short"), "unexpected error: {err}");
283 }
284}