Skip to main content

atlassian_cli_auth/
encryption.rs

1use aes_gcm::{
2    aead::{Aead, Generate, KeyInit, Nonce},
3    Aes256Gcm,
4};
5use anyhow::{anyhow, Context, Result};
6use argon2::{Algorithm, Argon2, Params, Version};
7use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11/// Size of AES-256-GCM nonce in bytes (96 bits / 12 bytes is standard)
12const NONCE_SIZE: usize = 12;
13
14/// Encrypted credential storage format
15#[derive(Debug, Serialize, Deserialize)]
16pub struct EncryptedCredentials {
17    /// Format version for future compatibility
18    pub version: u32,
19    /// Base64-encoded salt used for key derivation
20    pub salt: String,
21    /// Map of account name to encrypted token
22    pub credentials: HashMap<String, EncryptedToken>,
23}
24
25/// A single encrypted token with its nonce
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct EncryptedToken {
28    /// Base64-encoded nonce (12 bytes)
29    pub nonce: String,
30    /// Base64-encoded ciphertext
31    pub ciphertext: String,
32}
33
34impl Default for EncryptedCredentials {
35    fn default() -> Self {
36        Self {
37            version: 1,
38            salt: String::new(),
39            credentials: HashMap::new(),
40        }
41    }
42}
43
44/// Derive an encryption key from machine-specific identifiers.
45/// Uses Argon2 for key derivation to resist brute-force attacks.
46pub fn derive_key() -> Result<[u8; 32]> {
47    let machine_id = machine_uid::get().map_err(|e| anyhow!("Failed to get machine ID: {}", e))?;
48    let username = whoami::username().unwrap_or_else(|_| "unknown".to_string());
49    derive_key_from(&machine_id, &username)
50}
51
52/// Argon2 parameters for key derivation, pinned rather than taken from
53/// `Argon2::default()`. They equal argon2's defaults as of 0.5 and 0.6
54/// (Argon2id, version 0x13, 19 MiB, 2 passes, 1 lane, 32-byte output), but
55/// every existing `credentials.enc` depends on them, so a future change of
56/// library default must not be able to change the key silently.
57const KDF_M_COST_KIB: u32 = 19 * 1024;
58const KDF_T_COST: u32 = 2;
59const KDF_P_COST: u32 = 1;
60const KEY_LEN: usize = 32;
61
62/// The key derivation itself, separated from the machine lookups so a
63/// known-answer test can pin it with synthetic inputs.
64///
65/// The salt is the raw machine-id bytes. Up to argon2 0.5 this code went
66/// through `PasswordHasher::hash_password` with
67/// `SaltString::encode_b64(machine_id)`, which base64-decodes the salt again
68/// before hashing, so the effective salt was always the raw bytes. argon2 0.6
69/// removed `SaltString`, and its one-argument `hash_password` draws a random
70/// salt, which would make every stored file undecryptable. Calling
71/// `hash_password_into` with the raw bytes derives the identical key;
72/// `derive_key_known_answer` holds it to the value 0.5.3 produced.
73fn derive_key_from(machine_id: &str, username: &str) -> Result<[u8; 32]> {
74    // Combine machine ID and username as the password
75    let password = format!("{}:{}", machine_id, username);
76
77    let params = Params::new(KDF_M_COST_KIB, KDF_T_COST, KDF_P_COST, Some(KEY_LEN))
78        .map_err(|e| anyhow!("Invalid Argon2 parameters: {}", e))?;
79    let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
80
81    let mut key = [0u8; KEY_LEN];
82    argon2
83        .hash_password_into(password.as_bytes(), machine_id.as_bytes(), &mut key)
84        .map_err(|e| anyhow!("Failed to derive key: {}", e))?;
85
86    Ok(key)
87}
88
89/// Encrypt plaintext using AES-256-GCM
90pub fn encrypt(plaintext: &str, key: &[u8; 32]) -> Result<(String, String)> {
91    let cipher = Aes256Gcm::new(key.into());
92
93    // Generate a random nonce. `Generate` draws from the OS RNG (aes-gcm's
94    // `getrandom` feature, on by default) and replaces the `aead::OsRng` that
95    // aead 0.6 removed. `try_generate` rather than `generate` because the latter
96    // panics if the system RNG fails. Still 12 bytes: the stored format is unchanged.
97    let nonce = Nonce::<Aes256Gcm>::try_generate()
98        .map_err(|e| anyhow!("Failed to generate nonce from system RNG: {}", e))?;
99
100    // Encrypt the plaintext
101    let ciphertext = cipher
102        .encrypt(&nonce, plaintext.as_bytes())
103        .map_err(|e| anyhow!("Encryption failed: {}", e))?;
104
105    // Encode as base64 for storage
106    let nonce_b64 = BASE64.encode(nonce);
107    let ciphertext_b64 = BASE64.encode(ciphertext);
108
109    Ok((nonce_b64, ciphertext_b64))
110}
111
112/// Decrypt ciphertext using AES-256-GCM
113pub fn decrypt(ciphertext_b64: &str, nonce_b64: &str, key: &[u8; 32]) -> Result<String> {
114    let cipher = Aes256Gcm::new(key.into());
115
116    // Decode from base64
117    let nonce_bytes = BASE64
118        .decode(nonce_b64)
119        .context("Failed to decode nonce from base64")?;
120    let ciphertext = BASE64
121        .decode(ciphertext_b64)
122        .context("Failed to decode ciphertext from base64")?;
123
124    if nonce_bytes.len() != NONCE_SIZE {
125        return Err(anyhow!(
126            "Invalid nonce size: expected {}, got {}",
127            NONCE_SIZE,
128            nonce_bytes.len()
129        ));
130    }
131
132    // `Array::from_slice` is deprecated in hybrid-array; the length is already
133    // checked above, so TryFrom cannot fail here.
134    let nonce = Nonce::<Aes256Gcm>::try_from(nonce_bytes.as_slice())
135        .map_err(|_| anyhow!("Invalid nonce size: expected {}", NONCE_SIZE))?;
136
137    // Decrypt
138    let plaintext = cipher
139        .decrypt(&nonce, ciphertext.as_ref())
140        .map_err(|e| anyhow!("Decryption failed: {}", e))?;
141
142    String::from_utf8(plaintext).context("Decrypted data is not valid UTF-8")
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    /// Cross-version known-answer test. This nonce/ciphertext pair was produced by
150    /// the aes-gcm 0.10 build (the version that wrote users' existing
151    /// `credentials.enc` files). Decrypting it here proves the
152    /// aes-gcm 0.11 upgrade did not change the on-disk format and that stored
153    /// credentials still open. Every other test in this module is a same-process
154    /// round-trip and would pass even if the format silently changed.
155    #[test]
156    fn decrypts_ciphertext_written_by_aes_gcm_0_10() {
157        let key = [7u8; 32];
158        let nonce_b64 = "Rm9oYDGUcR47yGPD";
159        let ciphertext_b64 = "iwXkjpxTMOS8N/vvR/y0Yvt3G7fE0GW4sl7KvlyPIJ3rW50U/81e";
160
161        let plaintext = decrypt(ciphertext_b64, nonce_b64, &key)
162            .expect("aes-gcm 0.11 must decrypt ciphertext written by 0.10");
163        assert_eq!(plaintext, "hunter2-atlassian-token");
164    }
165
166    /// Known-answer test for key derivation. Every existing `credentials.enc` is
167    /// encrypted under this derivation, so any change to the output orphans them
168    /// all. `test_derive_key_deterministic` cannot catch that: it compares two
169    /// calls in the same build.
170    ///
171    /// The inputs are synthetic (a UUID-shaped id like macOS's IOPlatformUUID and
172    /// a made-up user name). The expected key was produced by the argon2 0.5.3
173    /// build via `hash_password(password, SaltString::encode_b64(machine_id))`,
174    /// the code that wrote users' files, and cross-checked against the reference
175    /// C implementation (argon2-cffi `hash_secret_raw`, Argon2id v19, m=19456,
176    /// t=2, p=1, 32 bytes, salt = the raw machine-id bytes).
177    #[test]
178    fn derive_key_known_answer() {
179        let key = derive_key_from("00000000-1111-2222-3333-444444444444", "synthetic-user")
180            .expect("key derivation must succeed for a UUID-shaped machine id");
181        let hex: String = key.iter().map(|b| format!("{:02x}", b)).collect();
182        assert_eq!(
183            hex, "add8654d98e33b867373ecac2765c95fda969e03b12af6620399f41395994255",
184            "derive_key output changed: existing credentials.enc files would no longer decrypt"
185        );
186    }
187
188    #[test]
189    fn nonce_is_12_bytes() {
190        // The stored format depends on this; a change would orphan existing files.
191        let (nonce_b64, _) = encrypt("x", &[0u8; 32]).unwrap();
192        assert_eq!(BASE64.decode(nonce_b64).unwrap().len(), NONCE_SIZE);
193        assert_eq!(NONCE_SIZE, 12);
194    }
195
196    #[test]
197    fn test_derive_key_deterministic() {
198        // Key derivation should be deterministic for the same machine/user
199        let key1 = derive_key().expect("Failed to derive key");
200        let key2 = derive_key().expect("Failed to derive key");
201        assert_eq!(key1, key2, "Key derivation should be deterministic");
202    }
203
204    #[test]
205    fn test_encrypt_decrypt_roundtrip() {
206        let key = derive_key().expect("Failed to derive key");
207        let plaintext = "my-secret-token-12345";
208
209        let (nonce, ciphertext) = encrypt(plaintext, &key).expect("Encryption failed");
210
211        // Verify encrypted data is different from plaintext
212        assert_ne!(ciphertext, plaintext);
213        assert!(!ciphertext.contains("secret"));
214
215        let decrypted = decrypt(&ciphertext, &nonce, &key).expect("Decryption failed");
216        assert_eq!(decrypted, plaintext, "Decrypted text should match original");
217    }
218
219    #[test]
220    fn test_encrypt_produces_different_ciphertext() {
221        let key = derive_key().expect("Failed to derive key");
222        let plaintext = "same-plaintext";
223
224        // Encrypt the same plaintext twice
225        let (nonce1, ciphertext1) = encrypt(plaintext, &key).expect("Encryption failed");
226        let (nonce2, ciphertext2) = encrypt(plaintext, &key).expect("Encryption failed");
227
228        // Nonces should be different (random)
229        assert_ne!(nonce1, nonce2, "Nonces should be randomly generated");
230
231        // Ciphertexts should be different (because nonces are different)
232        assert_ne!(
233            ciphertext1, ciphertext2,
234            "Ciphertexts should differ with different nonces"
235        );
236
237        // Both should decrypt to the same plaintext
238        assert_eq!(decrypt(&ciphertext1, &nonce1, &key).unwrap(), plaintext);
239        assert_eq!(decrypt(&ciphertext2, &nonce2, &key).unwrap(), plaintext);
240    }
241
242    #[test]
243    fn test_decrypt_with_wrong_key_fails() {
244        let key1 = derive_key().expect("Failed to derive key");
245        let mut key2 = key1;
246        key2[0] ^= 0xFF; // Flip bits to create a different key
247
248        let plaintext = "secret-data";
249        let (nonce, ciphertext) = encrypt(plaintext, &key1).expect("Encryption failed");
250
251        // Decryption with wrong key should fail
252        let result = decrypt(&ciphertext, &nonce, &key2);
253        assert!(result.is_err(), "Decryption with wrong key should fail");
254    }
255
256    #[test]
257    fn test_decrypt_with_wrong_nonce_fails() {
258        let key = derive_key().expect("Failed to derive key");
259        let plaintext = "secret-data";
260
261        let (_, ciphertext) = encrypt(plaintext, &key).expect("Encryption failed");
262        let (wrong_nonce, _) = encrypt("other", &key).expect("Encryption failed");
263
264        // Decryption with wrong nonce should fail
265        let result = decrypt(&ciphertext, &wrong_nonce, &key);
266        assert!(result.is_err(), "Decryption with wrong nonce should fail");
267    }
268
269    #[test]
270    fn test_encrypted_credentials_serialization() {
271        let mut creds = EncryptedCredentials {
272            salt: "test-salt".to_string(),
273            ..Default::default()
274        };
275        creds.credentials.insert(
276            "account1".to_string(),
277            EncryptedToken {
278                nonce: "nonce-b64".to_string(),
279                ciphertext: "cipher-b64".to_string(),
280            },
281        );
282
283        let json = serde_json::to_string(&creds).expect("Serialization failed");
284        let deserialized: EncryptedCredentials =
285            serde_json::from_str(&json).expect("Deserialization failed");
286
287        assert_eq!(deserialized.version, 1);
288        assert_eq!(deserialized.salt, "test-salt");
289        assert_eq!(deserialized.credentials.len(), 1);
290    }
291}