Skip to main content

difflore_core/infra/
crypto.rs

1use 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
14/// Retrieve or create a random master key stored in the OS credential store.
15/// Falls back to a path-derived local key if keyring is unavailable outside CI.
16fn get_or_create_master_key() -> Result<[u8; 32], String> {
17    MASTER_KEY.get_or_init(|| {
18        // Env override — primarily for testing on platforms where the OS
19        // keyring is broken (Windows Credential Manager rejecting the
20        // Generic credential scope, CI sandboxes without a keyring, etc).
21        // Accepts 64-char hex (32 bytes).
22        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                // On CI (no user keyring, ephemeral FS), the path-derived
38                // fallback key is unsafe: secrets encrypted with it are
39                // unrecoverable on the next run (different HOME, different
40                // hostname).  Force the user to supply DIFFLORE_MASTER_KEY
41                // explicitly so they know state won't persist.
42                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
59/// Common CI env vars. Used to refuse the silent fallback key path
60/// on hosts where the encrypted state wouldn't survive.
61fn 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
110/// Local fallback key derivation for machines without an OS keyring.
111fn 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/// SHA-256 a byte slice and return it as the prefixed digest string
133/// `"sha256:<lowercase-hex>"`. Used by `difflore-cli`'s MCP install manifest
134/// (item ⑤b) to hash the *exact* DiffLore config block we render, so a later
135/// `agents update` can tell "unchanged since DiffLore wrote it" (safe to
136/// upgrade) from "the human edited it" (must not clobber). Keeping the hashing
137/// here lets the CLI reuse the workspace `sha2` dep instead of pulling in a
138/// second hashing crate, and pins the algorithm choice to one place. The helper
139/// is pure — it never reads files, the keyring, or any repo-scoped state.
140#[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/// Which key successfully decrypted a stored secret.
188#[derive(Debug, Clone, Copy, PartialEq, Eq)]
189pub enum DecryptOrigin {
190    /// Decrypted with the current master key.
191    CurrentKey,
192}
193
194/// Decrypt a stored secret, also reporting which key generation
195/// succeeded.
196pub 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
211/// Decrypt a stored secret. Thin wrapper over
212/// [`decrypt_secret_with_origin`] that discards the key-generation
213/// signal.
214pub 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        // Round-trip every byte value, asserting both the encoding shape
225        // (lowercase pairs) and decoder tolerance for mixed case.
226        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        // Targeted spot checks for empty / short / mixed-case inputs.
236        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        // Reject odd-length / non-hex input.
241        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        // Known-answer vector: SHA-256("") — anchors the prefix + canonical hex.
250        assert_eq!(
251            sha256_block_hex(b""),
252            "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
253        );
254        // Deterministic for identical input (the whole point: same render →
255        // same hash → "unchanged since DiffLore wrote it").
256        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        // A single-byte edit must change the digest (no clobber-on-edit).
261        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        // Odd-length hex fails inside from_hex, which runs before any keyring access.
270        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        // 4 hex chars → 2 bytes, below the fast-path sanity floor (12-byte
277        // nonce + at least one ciphertext byte = 13). Real AES-GCM payloads
278        // also carry a 16-byte tag, so anything genuinely decryptable is
279        // ≥ 28 bytes — but we let aes_gcm reject those itself; the early
280        // 13-byte gate exists only to fail before the keyring is touched.
281        let err = decrypt_secret("abcd").unwrap_err();
282        assert!(err.contains("too short"), "unexpected error: {err}");
283    }
284}