Skip to main content

licenz_core/
encrypted_store.rs

1//! Encrypted key storage for secure backups
2//!
3//! This module provides AES-256-GCM encryption for private key storage with
4//! **Argon2id** key derivation. The on-disk format uses [`ENCRYPTED_STORE_VERSION`] **1**.
5
6use crate::error::{LicenseError, Result};
7use serde::{Deserialize, Serialize};
8use std::path::Path;
9use zeroize::Zeroizing;
10
11/// Minimum passphrase length required for encryption
12pub const MIN_PASSPHRASE_LENGTH: usize = 12;
13
14/// On-disk encryption format version for [`EncryptedKeyStore`].
15pub const ENCRYPTED_STORE_VERSION: u8 = 1;
16
17/// Encrypted private key storage
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct EncryptedKeyStore {
20    /// Encrypted key data
21    encrypted_data: Vec<u8>,
22    /// Nonce used for encryption
23    nonce: [u8; 12],
24    /// Salt used for key derivation
25    salt: [u8; 32],
26    /// Version of the encryption format
27    version: u8,
28}
29
30impl EncryptedKeyStore {
31    /// Encrypt a private key PEM with a passphrase
32    ///
33    /// Uses Argon2id for key derivation and AES-256-GCM for encryption.
34    pub fn encrypt(private_key_pem: &str, passphrase: &str) -> Result<Self> {
35        // Validate passphrase strength
36        if passphrase.len() < MIN_PASSPHRASE_LENGTH {
37            return Err(LicenseError::InvalidKeyFormat(format!(
38                "Passphrase must be at least {} characters",
39                MIN_PASSPHRASE_LENGTH
40            )));
41        }
42
43        // Generate random salt and nonce
44        let salt: [u8; 32] = rand::random();
45        let nonce: [u8; 12] = rand::random();
46
47        let derived_key = derive_key(passphrase.as_bytes(), &salt)?;
48
49        // Encrypt the private key
50        let encrypted_data = encrypt_aes_gcm(private_key_pem.as_bytes(), &derived_key, &nonce)?;
51
52        Ok(Self {
53            encrypted_data,
54            nonce,
55            salt,
56            version: ENCRYPTED_STORE_VERSION,
57        })
58    }
59
60    /// Decrypt the private key using the passphrase
61    pub fn decrypt(&self, passphrase: &str) -> Result<String> {
62        if self.version != ENCRYPTED_STORE_VERSION {
63            return Err(LicenseError::InvalidKeyFormat(format!(
64                "Unsupported encrypted key store version: {} (expected {})",
65                self.version, ENCRYPTED_STORE_VERSION
66            )));
67        }
68
69        let derived_key = derive_key(passphrase.as_bytes(), &self.salt)?;
70
71        // Decrypt
72        let decrypted =
73            decrypt_aes_gcm(&self.encrypted_data, &derived_key, &self.nonce).map_err(|_| {
74                LicenseError::InvalidKeyFormat(
75                    "Decryption failed - incorrect passphrase or corrupted data".into(),
76                )
77            })?;
78
79        String::from_utf8(decrypted).map_err(|e| LicenseError::InvalidKeyFormat(e.to_string()))
80    }
81
82    /// Save the encrypted key store to a file
83    pub fn save(&self, path: &Path) -> Result<()> {
84        let data = bincode::serde::encode_to_vec(self, bincode::config::standard())
85            .map_err(|e| LicenseError::SerializationError(e.to_string()))?;
86
87        std::fs::write(path, data)?;
88
89        // Set restrictive permissions on Unix
90        #[cfg(unix)]
91        {
92            use std::os::unix::fs::PermissionsExt;
93            let perms = std::fs::Permissions::from_mode(0o600);
94            std::fs::set_permissions(path, perms)?;
95        }
96
97        Ok(())
98    }
99
100    /// Load an encrypted key store from a file
101    pub fn load(path: &Path) -> Result<Self> {
102        let data = std::fs::read(path)?;
103        let (store, _len) = bincode::serde::decode_from_slice(&data, bincode::config::standard())
104            .map_err(|e| LicenseError::InvalidKeyFormat(e.to_string()))?;
105        Ok(store)
106    }
107
108    /// Create an encrypted backup of a private key file
109    pub fn backup_key_file(
110        private_key_path: &Path,
111        backup_path: &Path,
112        passphrase: &str,
113    ) -> Result<()> {
114        let pem = std::fs::read_to_string(private_key_path)?;
115        let store = Self::encrypt(&pem, passphrase)?;
116        store.save(backup_path)?;
117        Ok(())
118    }
119
120    /// Restore a private key from an encrypted backup
121    pub fn restore_key_file(
122        backup_path: &Path,
123        private_key_path: &Path,
124        passphrase: &str,
125    ) -> Result<()> {
126        let store = Self::load(backup_path)?;
127        let pem = store.decrypt(passphrase)?;
128
129        std::fs::write(private_key_path, &pem)?;
130
131        // Set restrictive permissions on Unix
132        #[cfg(unix)]
133        {
134            use std::os::unix::fs::PermissionsExt;
135            let perms = std::fs::Permissions::from_mode(0o600);
136            std::fs::set_permissions(private_key_path, perms)?;
137        }
138
139        Ok(())
140    }
141}
142
143/// Argon2id per OWASP-style parameters (~19 MiB, t=2, p=1).
144fn derive_key(passphrase: &[u8], salt: &[u8; 32]) -> Result<Zeroizing<[u8; 32]>> {
145    use argon2::{Algorithm, Argon2, Params, Version};
146
147    let params = Params::new(19_456, 2, 1, Some(32))
148        .map_err(|e| LicenseError::KeyGenerationFailed(format!("Argon2 params: {}", e)))?;
149    let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
150    let mut key = Zeroizing::new([0u8; 32]);
151    argon2
152        .hash_password_into(passphrase, salt.as_slice(), key.as_mut())
153        .map_err(|e| LicenseError::KeyGenerationFailed(format!("Argon2id: {}", e)))?;
154    Ok(key)
155}
156
157/// Encrypt data with AES-256-GCM
158fn encrypt_aes_gcm(
159    plaintext: &[u8],
160    key: &Zeroizing<[u8; 32]>,
161    nonce: &[u8; 12],
162) -> Result<Vec<u8>> {
163    use aes_gcm::{
164        aead::{Aead, KeyInit},
165        Aes256Gcm, Nonce,
166    };
167
168    let cipher = Aes256Gcm::new_from_slice(key.as_ref())
169        .map_err(|e| LicenseError::KeyGenerationFailed(e.to_string()))?;
170
171    let nonce = Nonce::from_slice(nonce);
172
173    cipher
174        .encrypt(nonce, plaintext)
175        .map_err(|e| LicenseError::KeyGenerationFailed(e.to_string()))
176}
177
178/// Decrypt data with AES-256-GCM
179fn decrypt_aes_gcm(
180    ciphertext: &[u8],
181    key: &Zeroizing<[u8; 32]>,
182    nonce: &[u8; 12],
183) -> std::result::Result<Vec<u8>, ()> {
184    use aes_gcm::{
185        aead::{Aead, KeyInit},
186        Aes256Gcm, Nonce,
187    };
188
189    let cipher = Aes256Gcm::new_from_slice(key.as_ref()).map_err(|_| ())?;
190    let nonce = Nonce::from_slice(nonce);
191
192    cipher.decrypt(nonce, ciphertext).map_err(|_| ())
193}
194
195/// Validate passphrase strength
196pub fn validate_passphrase(passphrase: &str) -> std::result::Result<(), Vec<&'static str>> {
197    let mut errors = Vec::new();
198
199    if passphrase.len() < MIN_PASSPHRASE_LENGTH {
200        errors.push("Passphrase must be at least 12 characters");
201    }
202
203    if !passphrase.chars().any(|c| c.is_uppercase()) {
204        errors.push("Passphrase should contain at least one uppercase letter");
205    }
206
207    if !passphrase.chars().any(|c| c.is_lowercase()) {
208        errors.push("Passphrase should contain at least one lowercase letter");
209    }
210
211    if !passphrase.chars().any(|c| c.is_numeric()) {
212        errors.push("Passphrase should contain at least one number");
213    }
214
215    if errors.is_empty() {
216        Ok(())
217    } else {
218        Err(errors)
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use tempfile::TempDir;
226
227    #[test]
228    fn test_encrypt_decrypt_round_trip() {
229        let original = "-----BEGIN PRIVATE KEY-----\ntest key content\n-----END PRIVATE KEY-----";
230        let passphrase = "SecurePass123!";
231
232        let store = EncryptedKeyStore::encrypt(original, passphrase).unwrap();
233        assert_eq!(store.version, ENCRYPTED_STORE_VERSION);
234        let decrypted = store.decrypt(passphrase).unwrap();
235
236        assert_eq!(original, decrypted);
237    }
238
239    #[test]
240    fn test_wrong_passphrase_fails() {
241        let original = "test key content";
242        let passphrase = "SecurePass123!";
243
244        let store = EncryptedKeyStore::encrypt(original, passphrase).unwrap();
245        let result = store.decrypt("WrongPassword1!");
246
247        assert!(result.is_err());
248    }
249
250    #[test]
251    fn test_passphrase_too_short() {
252        let result = EncryptedKeyStore::encrypt("key", "short");
253        assert!(result.is_err());
254    }
255
256    #[test]
257    fn test_file_round_trip() {
258        let temp_dir = TempDir::new().unwrap();
259        let backup_path = temp_dir.path().join("key.backup");
260
261        let original = "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----";
262        let passphrase = "SecurePass123!";
263
264        let store = EncryptedKeyStore::encrypt(original, passphrase).unwrap();
265        store.save(&backup_path).unwrap();
266
267        let loaded = EncryptedKeyStore::load(&backup_path).unwrap();
268        let decrypted = loaded.decrypt(passphrase).unwrap();
269
270        assert_eq!(original, decrypted);
271    }
272
273    #[test]
274    fn test_validate_passphrase() {
275        assert!(validate_passphrase("SecurePass123!").is_ok());
276        assert!(validate_passphrase("short").is_err());
277        assert!(validate_passphrase("alllowercase123").is_err());
278    }
279}