Skip to main content

atlassian_cli_auth/
encryption.rs

1use aes_gcm::{
2    aead::{Aead, KeyInit, OsRng},
3    Aes256Gcm, Nonce,
4};
5use anyhow::{anyhow, Context, Result};
6use argon2::{
7    password_hash::{rand_core::RngCore, SaltString},
8    Argon2, PasswordHasher,
9};
10use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13
14/// Size of AES-256-GCM nonce in bytes (96 bits / 12 bytes is standard)
15const NONCE_SIZE: usize = 12;
16
17/// Encrypted credential storage format
18#[derive(Debug, Serialize, Deserialize)]
19pub struct EncryptedCredentials {
20    /// Format version for future compatibility
21    pub version: u32,
22    /// Base64-encoded salt used for key derivation
23    pub salt: String,
24    /// Map of account name to encrypted token
25    pub credentials: HashMap<String, EncryptedToken>,
26}
27
28/// A single encrypted token with its nonce
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct EncryptedToken {
31    /// Base64-encoded nonce (12 bytes)
32    pub nonce: String,
33    /// Base64-encoded ciphertext
34    pub ciphertext: String,
35}
36
37impl Default for EncryptedCredentials {
38    fn default() -> Self {
39        Self {
40            version: 1,
41            salt: String::new(),
42            credentials: HashMap::new(),
43        }
44    }
45}
46
47/// Derive an encryption key from machine-specific identifiers.
48/// Uses Argon2 for key derivation to resist brute-force attacks.
49pub fn derive_key() -> Result<[u8; 32]> {
50    let machine_id = machine_uid::get().map_err(|e| anyhow!("Failed to get machine ID: {}", e))?;
51    let username = whoami::username().unwrap_or_else(|_| "unknown".to_string());
52
53    // Combine machine ID and username as the password
54    let password = format!("{}:{}", machine_id, username);
55
56    // Use a fixed salt derived from machine ID for deterministic key generation
57    // This allows the same key to be derived across runs
58    let salt_string = SaltString::encode_b64(machine_id.as_bytes())
59        .map_err(|e| anyhow!("Failed to encode salt: {}", e))?;
60
61    let argon2 = Argon2::default();
62
63    // Hash the password to get a 32-byte key
64    let hash = argon2
65        .hash_password(password.as_bytes(), &salt_string)
66        .map_err(|e| anyhow!("Failed to hash password: {}", e))?;
67
68    // Extract the 32-byte hash
69    let hash_bytes = hash.hash.ok_or_else(|| anyhow!("Hash output is missing"))?;
70
71    let mut key = [0u8; 32];
72    key.copy_from_slice(&hash_bytes.as_bytes()[..32]);
73
74    Ok(key)
75}
76
77/// Encrypt plaintext using AES-256-GCM
78pub fn encrypt(plaintext: &str, key: &[u8; 32]) -> Result<(String, String)> {
79    let cipher = Aes256Gcm::new(key.into());
80
81    // Generate a random nonce
82    let mut nonce_bytes = [0u8; NONCE_SIZE];
83    OsRng.fill_bytes(&mut nonce_bytes);
84    let nonce = Nonce::from_slice(&nonce_bytes);
85
86    // Encrypt the plaintext
87    let ciphertext = cipher
88        .encrypt(nonce, plaintext.as_bytes())
89        .map_err(|e| anyhow!("Encryption failed: {}", e))?;
90
91    // Encode as base64 for storage
92    let nonce_b64 = BASE64.encode(nonce_bytes);
93    let ciphertext_b64 = BASE64.encode(ciphertext);
94
95    Ok((nonce_b64, ciphertext_b64))
96}
97
98/// Decrypt ciphertext using AES-256-GCM
99pub fn decrypt(ciphertext_b64: &str, nonce_b64: &str, key: &[u8; 32]) -> Result<String> {
100    let cipher = Aes256Gcm::new(key.into());
101
102    // Decode from base64
103    let nonce_bytes = BASE64
104        .decode(nonce_b64)
105        .context("Failed to decode nonce from base64")?;
106    let ciphertext = BASE64
107        .decode(ciphertext_b64)
108        .context("Failed to decode ciphertext from base64")?;
109
110    if nonce_bytes.len() != NONCE_SIZE {
111        return Err(anyhow!(
112            "Invalid nonce size: expected {}, got {}",
113            NONCE_SIZE,
114            nonce_bytes.len()
115        ));
116    }
117
118    let nonce = Nonce::from_slice(&nonce_bytes);
119
120    // Decrypt
121    let plaintext = cipher
122        .decrypt(nonce, ciphertext.as_ref())
123        .map_err(|e| anyhow!("Decryption failed: {}", e))?;
124
125    String::from_utf8(plaintext).context("Decrypted data is not valid UTF-8")
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    #[test]
133    fn test_derive_key_deterministic() {
134        // Key derivation should be deterministic for the same machine/user
135        let key1 = derive_key().expect("Failed to derive key");
136        let key2 = derive_key().expect("Failed to derive key");
137        assert_eq!(key1, key2, "Key derivation should be deterministic");
138    }
139
140    #[test]
141    fn test_encrypt_decrypt_roundtrip() {
142        let key = derive_key().expect("Failed to derive key");
143        let plaintext = "my-secret-token-12345";
144
145        let (nonce, ciphertext) = encrypt(plaintext, &key).expect("Encryption failed");
146
147        // Verify encrypted data is different from plaintext
148        assert_ne!(ciphertext, plaintext);
149        assert!(!ciphertext.contains("secret"));
150
151        let decrypted = decrypt(&ciphertext, &nonce, &key).expect("Decryption failed");
152        assert_eq!(decrypted, plaintext, "Decrypted text should match original");
153    }
154
155    #[test]
156    fn test_encrypt_produces_different_ciphertext() {
157        let key = derive_key().expect("Failed to derive key");
158        let plaintext = "same-plaintext";
159
160        // Encrypt the same plaintext twice
161        let (nonce1, ciphertext1) = encrypt(plaintext, &key).expect("Encryption failed");
162        let (nonce2, ciphertext2) = encrypt(plaintext, &key).expect("Encryption failed");
163
164        // Nonces should be different (random)
165        assert_ne!(nonce1, nonce2, "Nonces should be randomly generated");
166
167        // Ciphertexts should be different (because nonces are different)
168        assert_ne!(
169            ciphertext1, ciphertext2,
170            "Ciphertexts should differ with different nonces"
171        );
172
173        // Both should decrypt to the same plaintext
174        assert_eq!(decrypt(&ciphertext1, &nonce1, &key).unwrap(), plaintext);
175        assert_eq!(decrypt(&ciphertext2, &nonce2, &key).unwrap(), plaintext);
176    }
177
178    #[test]
179    fn test_decrypt_with_wrong_key_fails() {
180        let key1 = derive_key().expect("Failed to derive key");
181        let mut key2 = key1;
182        key2[0] ^= 0xFF; // Flip bits to create a different key
183
184        let plaintext = "secret-data";
185        let (nonce, ciphertext) = encrypt(plaintext, &key1).expect("Encryption failed");
186
187        // Decryption with wrong key should fail
188        let result = decrypt(&ciphertext, &nonce, &key2);
189        assert!(result.is_err(), "Decryption with wrong key should fail");
190    }
191
192    #[test]
193    fn test_decrypt_with_wrong_nonce_fails() {
194        let key = derive_key().expect("Failed to derive key");
195        let plaintext = "secret-data";
196
197        let (_, ciphertext) = encrypt(plaintext, &key).expect("Encryption failed");
198        let (wrong_nonce, _) = encrypt("other", &key).expect("Encryption failed");
199
200        // Decryption with wrong nonce should fail
201        let result = decrypt(&ciphertext, &wrong_nonce, &key);
202        assert!(result.is_err(), "Decryption with wrong nonce should fail");
203    }
204
205    #[test]
206    fn test_encrypted_credentials_serialization() {
207        let mut creds = EncryptedCredentials {
208            salt: "test-salt".to_string(),
209            ..Default::default()
210        };
211        creds.credentials.insert(
212            "account1".to_string(),
213            EncryptedToken {
214                nonce: "nonce-b64".to_string(),
215                ciphertext: "cipher-b64".to_string(),
216            },
217        );
218
219        let json = serde_json::to_string(&creds).expect("Serialization failed");
220        let deserialized: EncryptedCredentials =
221            serde_json::from_str(&json).expect("Deserialization failed");
222
223        assert_eq!(deserialized.version, 1);
224        assert_eq!(deserialized.salt, "test-salt");
225        assert_eq!(deserialized.credentials.len(), 1);
226    }
227}