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::{password_hash::SaltString, Argon2, PasswordHasher};
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
50    // Combine machine ID and username as the password
51    let password = format!("{}:{}", machine_id, username);
52
53    // Use a fixed salt derived from machine ID for deterministic key generation
54    // This allows the same key to be derived across runs
55    let salt_string = SaltString::encode_b64(machine_id.as_bytes())
56        .map_err(|e| anyhow!("Failed to encode salt: {}", e))?;
57
58    let argon2 = Argon2::default();
59
60    // Hash the password to get a 32-byte key
61    let hash = argon2
62        .hash_password(password.as_bytes(), &salt_string)
63        .map_err(|e| anyhow!("Failed to hash password: {}", e))?;
64
65    // Extract the 32-byte hash
66    let hash_bytes = hash.hash.ok_or_else(|| anyhow!("Hash output is missing"))?;
67
68    let mut key = [0u8; 32];
69    key.copy_from_slice(&hash_bytes.as_bytes()[..32]);
70
71    Ok(key)
72}
73
74/// Encrypt plaintext using AES-256-GCM
75pub fn encrypt(plaintext: &str, key: &[u8; 32]) -> Result<(String, String)> {
76    let cipher = Aes256Gcm::new(key.into());
77
78    // Generate a random nonce. `Generate` draws from the OS RNG (aes-gcm's
79    // `getrandom` feature, on by default) and replaces the `aead::OsRng` that
80    // aead 0.6 removed. `try_generate` rather than `generate` because the latter
81    // panics if the system RNG fails. Still 12 bytes: the stored format is unchanged.
82    let nonce = Nonce::<Aes256Gcm>::try_generate()
83        .map_err(|e| anyhow!("Failed to generate nonce from system RNG: {}", e))?;
84
85    // Encrypt the plaintext
86    let ciphertext = cipher
87        .encrypt(&nonce, plaintext.as_bytes())
88        .map_err(|e| anyhow!("Encryption failed: {}", e))?;
89
90    // Encode as base64 for storage
91    let nonce_b64 = BASE64.encode(nonce);
92    let ciphertext_b64 = BASE64.encode(ciphertext);
93
94    Ok((nonce_b64, ciphertext_b64))
95}
96
97/// Decrypt ciphertext using AES-256-GCM
98pub fn decrypt(ciphertext_b64: &str, nonce_b64: &str, key: &[u8; 32]) -> Result<String> {
99    let cipher = Aes256Gcm::new(key.into());
100
101    // Decode from base64
102    let nonce_bytes = BASE64
103        .decode(nonce_b64)
104        .context("Failed to decode nonce from base64")?;
105    let ciphertext = BASE64
106        .decode(ciphertext_b64)
107        .context("Failed to decode ciphertext from base64")?;
108
109    if nonce_bytes.len() != NONCE_SIZE {
110        return Err(anyhow!(
111            "Invalid nonce size: expected {}, got {}",
112            NONCE_SIZE,
113            nonce_bytes.len()
114        ));
115    }
116
117    // `Array::from_slice` is deprecated in hybrid-array; the length is already
118    // checked above, so TryFrom cannot fail here.
119    let nonce = Nonce::<Aes256Gcm>::try_from(nonce_bytes.as_slice())
120        .map_err(|_| anyhow!("Invalid nonce size: expected {}", NONCE_SIZE))?;
121
122    // Decrypt
123    let plaintext = cipher
124        .decrypt(&nonce, ciphertext.as_ref())
125        .map_err(|e| anyhow!("Decryption failed: {}", e))?;
126
127    String::from_utf8(plaintext).context("Decrypted data is not valid UTF-8")
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    /// Cross-version known-answer test. This nonce/ciphertext pair was produced by
135    /// the aes-gcm 0.10 build (the version that wrote users' existing
136    /// `~/.atlassian-cli/credentials.enc` files). Decrypting it here proves the
137    /// aes-gcm 0.11 upgrade did not change the on-disk format and that stored
138    /// credentials still open. Every other test in this module is a same-process
139    /// round-trip and would pass even if the format silently changed.
140    #[test]
141    fn decrypts_ciphertext_written_by_aes_gcm_0_10() {
142        let key = [7u8; 32];
143        let nonce_b64 = "Rm9oYDGUcR47yGPD";
144        let ciphertext_b64 = "iwXkjpxTMOS8N/vvR/y0Yvt3G7fE0GW4sl7KvlyPIJ3rW50U/81e";
145
146        let plaintext = decrypt(ciphertext_b64, nonce_b64, &key)
147            .expect("aes-gcm 0.11 must decrypt ciphertext written by 0.10");
148        assert_eq!(plaintext, "hunter2-atlassian-token");
149    }
150
151    #[test]
152    fn nonce_is_12_bytes() {
153        // The stored format depends on this; a change would orphan existing files.
154        let (nonce_b64, _) = encrypt("x", &[0u8; 32]).unwrap();
155        assert_eq!(BASE64.decode(nonce_b64).unwrap().len(), NONCE_SIZE);
156        assert_eq!(NONCE_SIZE, 12);
157    }
158
159    #[test]
160    fn test_derive_key_deterministic() {
161        // Key derivation should be deterministic for the same machine/user
162        let key1 = derive_key().expect("Failed to derive key");
163        let key2 = derive_key().expect("Failed to derive key");
164        assert_eq!(key1, key2, "Key derivation should be deterministic");
165    }
166
167    #[test]
168    fn test_encrypt_decrypt_roundtrip() {
169        let key = derive_key().expect("Failed to derive key");
170        let plaintext = "my-secret-token-12345";
171
172        let (nonce, ciphertext) = encrypt(plaintext, &key).expect("Encryption failed");
173
174        // Verify encrypted data is different from plaintext
175        assert_ne!(ciphertext, plaintext);
176        assert!(!ciphertext.contains("secret"));
177
178        let decrypted = decrypt(&ciphertext, &nonce, &key).expect("Decryption failed");
179        assert_eq!(decrypted, plaintext, "Decrypted text should match original");
180    }
181
182    #[test]
183    fn test_encrypt_produces_different_ciphertext() {
184        let key = derive_key().expect("Failed to derive key");
185        let plaintext = "same-plaintext";
186
187        // Encrypt the same plaintext twice
188        let (nonce1, ciphertext1) = encrypt(plaintext, &key).expect("Encryption failed");
189        let (nonce2, ciphertext2) = encrypt(plaintext, &key).expect("Encryption failed");
190
191        // Nonces should be different (random)
192        assert_ne!(nonce1, nonce2, "Nonces should be randomly generated");
193
194        // Ciphertexts should be different (because nonces are different)
195        assert_ne!(
196            ciphertext1, ciphertext2,
197            "Ciphertexts should differ with different nonces"
198        );
199
200        // Both should decrypt to the same plaintext
201        assert_eq!(decrypt(&ciphertext1, &nonce1, &key).unwrap(), plaintext);
202        assert_eq!(decrypt(&ciphertext2, &nonce2, &key).unwrap(), plaintext);
203    }
204
205    #[test]
206    fn test_decrypt_with_wrong_key_fails() {
207        let key1 = derive_key().expect("Failed to derive key");
208        let mut key2 = key1;
209        key2[0] ^= 0xFF; // Flip bits to create a different key
210
211        let plaintext = "secret-data";
212        let (nonce, ciphertext) = encrypt(plaintext, &key1).expect("Encryption failed");
213
214        // Decryption with wrong key should fail
215        let result = decrypt(&ciphertext, &nonce, &key2);
216        assert!(result.is_err(), "Decryption with wrong key should fail");
217    }
218
219    #[test]
220    fn test_decrypt_with_wrong_nonce_fails() {
221        let key = derive_key().expect("Failed to derive key");
222        let plaintext = "secret-data";
223
224        let (_, ciphertext) = encrypt(plaintext, &key).expect("Encryption failed");
225        let (wrong_nonce, _) = encrypt("other", &key).expect("Encryption failed");
226
227        // Decryption with wrong nonce should fail
228        let result = decrypt(&ciphertext, &wrong_nonce, &key);
229        assert!(result.is_err(), "Decryption with wrong nonce should fail");
230    }
231
232    #[test]
233    fn test_encrypted_credentials_serialization() {
234        let mut creds = EncryptedCredentials {
235            salt: "test-salt".to_string(),
236            ..Default::default()
237        };
238        creds.credentials.insert(
239            "account1".to_string(),
240            EncryptedToken {
241                nonce: "nonce-b64".to_string(),
242                ciphertext: "cipher-b64".to_string(),
243            },
244        );
245
246        let json = serde_json::to_string(&creds).expect("Serialization failed");
247        let deserialized: EncryptedCredentials =
248            serde_json::from_str(&json).expect("Deserialization failed");
249
250        assert_eq!(deserialized.version, 1);
251        assert_eq!(deserialized.salt, "test-salt");
252        assert_eq!(deserialized.credentials.len(), 1);
253    }
254}