1use argon2::{Algorithm, Argon2, Params, Version};
10use chacha20poly1305::{
11 aead::{Aead, KeyInit},
12 XChaCha20Poly1305, XNonce,
13};
14use wasm_bindgen::prelude::*;
15
16pub const MAGIC_BYTES: &[u8] = b"P47H_VAULT_V2"; pub const SALT_LEN: usize = 16;
20pub const NONCE_LEN: usize = 24;
22pub const MIN_VAULT_LEN: usize = MAGIC_BYTES.len() + SALT_LEN + NONCE_LEN;
24
25#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum VaultError {
32 RngError(String),
34 KeyDerivationError(String),
36 EncryptionError(String),
38 TooShort { actual: usize, minimum: usize },
40 InvalidMagic,
42 DecryptionFailed,
44}
45
46impl std::fmt::Display for VaultError {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 match self {
49 VaultError::RngError(msg) => write!(f, "RNG error: {}", msg),
50 VaultError::KeyDerivationError(msg) => write!(f, "Key derivation failed: {}", msg),
51 VaultError::EncryptionError(msg) => write!(f, "Encryption failed: {}", msg),
52 VaultError::TooShort { actual, minimum } => {
53 write!(f, "Invalid vault: too short ({} bytes, minimum {})", actual, minimum)
54 }
55 VaultError::InvalidMagic => write!(f, "Invalid vault: wrong magic bytes"),
56 VaultError::DecryptionFailed => {
57 write!(f, "Decryption failed: wrong password or corrupted data")
58 }
59 }
60 }
61}
62
63impl std::error::Error for VaultError {}
64
65impl From<VaultError> for JsValue {
66 fn from(err: VaultError) -> JsValue {
67 JsValue::from_str(&err.to_string())
68 }
69}
70
71pub fn derive_key_inner(password: &str, salt: &[u8]) -> Result<chacha20poly1305::Key, VaultError> {
79 let mut output_key = [0u8; 32];
80
81 let params = Params::new(
82 Params::DEFAULT_M_COST,
83 Params::DEFAULT_T_COST,
84 Params::DEFAULT_P_COST,
85 Some(32),
86 )
87 .map_err(|e| VaultError::KeyDerivationError(format!("Argon2 params error: {}", e)))?;
88
89 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
90
91 argon2
92 .hash_password_into(password.as_bytes(), salt, &mut output_key)
93 .map_err(|e| VaultError::KeyDerivationError(e.to_string()))?;
94
95 Ok(*chacha20poly1305::Key::from_slice(&output_key))
96}
97
98pub fn encrypt_vault_with_params(
102 data: &[u8],
103 password: &str,
104 salt: &[u8; SALT_LEN],
105 nonce: &[u8; NONCE_LEN],
106) -> Result<Vec<u8>, VaultError> {
107 let key = derive_key_inner(password, salt)?;
108 let nonce_obj = XNonce::from_slice(nonce);
109
110 let cipher = XChaCha20Poly1305::new(&key);
111 let ciphertext = cipher
112 .encrypt(nonce_obj, data)
113 .map_err(|e| VaultError::EncryptionError(e.to_string()))?;
114
115 let mut result = Vec::with_capacity(MAGIC_BYTES.len() + SALT_LEN + NONCE_LEN + ciphertext.len());
116 result.extend_from_slice(MAGIC_BYTES);
117 result.extend_from_slice(salt);
118 result.extend_from_slice(nonce);
119 result.extend_from_slice(&ciphertext);
120
121 Ok(result)
122}
123
124pub fn decrypt_vault_inner(blob: &[u8], password: &str) -> Result<Vec<u8>, VaultError> {
133 if blob.len() < MIN_VAULT_LEN {
135 return Err(VaultError::TooShort {
136 actual: blob.len(),
137 minimum: MIN_VAULT_LEN,
138 });
139 }
140
141 if &blob[0..MAGIC_BYTES.len()] != MAGIC_BYTES {
143 return Err(VaultError::InvalidMagic);
144 }
145
146 let offset_salt = MAGIC_BYTES.len();
148 let offset_nonce = offset_salt + SALT_LEN;
149 let offset_cipher = offset_nonce + NONCE_LEN;
150
151 let salt = &blob[offset_salt..offset_nonce];
152 let nonce_bytes = &blob[offset_nonce..offset_cipher];
153 let ciphertext = &blob[offset_cipher..];
154
155 let key = derive_key_inner(password, salt)?;
157 let cipher = XChaCha20Poly1305::new(&key);
158 let nonce = XNonce::from_slice(nonce_bytes);
159
160 let plaintext = cipher
161 .decrypt(nonce, ciphertext)
162 .map_err(|_| VaultError::DecryptionFailed)?;
163
164 Ok(plaintext)
165}
166
167#[wasm_bindgen]
173pub struct VaultCrypto;
174
175#[wasm_bindgen]
176impl VaultCrypto {
177 #[wasm_bindgen]
180 pub fn encrypt_vault(data: &[u8], password: &str) -> Result<Vec<u8>, JsValue> {
181 let mut salt = [0u8; SALT_LEN];
182 getrandom::getrandom(&mut salt)
183 .map_err(|e| VaultError::RngError(e.to_string()))?;
184
185 let mut nonce = [0u8; NONCE_LEN];
186 getrandom::getrandom(&mut nonce)
187 .map_err(|e| VaultError::RngError(e.to_string()))?;
188
189 encrypt_vault_with_params(data, password, &salt, &nonce).map_err(Into::into)
190 }
191
192 #[wasm_bindgen]
194 pub fn decrypt_vault(blob: &[u8], password: &str) -> Result<Vec<u8>, JsValue> {
195 decrypt_vault_inner(blob, password).map_err(Into::into)
196 }
197
198 #[wasm_bindgen]
200 pub fn derive_session_key(password: &str, salt: &[u8]) -> Result<Vec<u8>, JsValue> {
201 let key = derive_key_inner(password, salt)?;
202 Ok(key.to_vec())
203 }
204}
205
206#[cfg(test)]
211mod tests {
212 use super::*;
213
214 #[test]
215 fn test_roundtrip() {
216 let data = b"secret data";
217 let password = "test_password";
218 let salt = [1u8; SALT_LEN];
219 let nonce = [2u8; NONCE_LEN];
220
221 let encrypted = encrypt_vault_with_params(data, password, &salt, &nonce).unwrap();
222 let decrypted = decrypt_vault_inner(&encrypted, password).unwrap();
223
224 assert_eq!(data.as_slice(), decrypted.as_slice());
225 }
226
227 #[test]
228 fn test_too_short() {
229 let result = decrypt_vault_inner(&[0u8; 10], "password");
230 assert!(matches!(result, Err(VaultError::TooShort { .. })));
231 }
232
233 #[test]
234 fn test_invalid_magic() {
235 let mut blob = vec![0u8; MIN_VAULT_LEN + 16];
236 blob[..5].copy_from_slice(b"WRONG");
237
238 let result = decrypt_vault_inner(&blob, "password");
239 assert!(matches!(result, Err(VaultError::InvalidMagic)));
240 }
241
242 #[test]
243 fn test_wrong_password() {
244 let data = b"secret data";
245 let password = "correct_password";
246 let salt = [1u8; SALT_LEN];
247 let nonce = [2u8; NONCE_LEN];
248
249 let encrypted = encrypt_vault_with_params(data, password, &salt, &nonce).unwrap();
250 let result = decrypt_vault_inner(&encrypted, "wrong_password");
251
252 assert!(matches!(result, Err(VaultError::DecryptionFailed)));
253 }
254}
255
256#[cfg(kani)]
263mod kani_proofs {
264 use super::*;
265
266 #[kani::proof]
268 #[kani::unwind(0)]
269 fn proof_min_vault_len_invariant() {
270 let expected = MAGIC_BYTES.len() + SALT_LEN + NONCE_LEN;
271 kani::assert(MIN_VAULT_LEN == expected, "MIN_VAULT_LEN must equal components sum");
272 }
273
274 #[kani::proof]
276 #[kani::unwind(0)]
277 fn proof_nonce_len_xchacha() {
278 kani::assert(NONCE_LEN == 24, "XChaCha20 requires 24-byte nonces");
279 }
280
281 #[kani::proof]
283 #[kani::unwind(0)]
284 fn proof_salt_len_argon2() {
285 kani::assert(SALT_LEN >= 16, "Argon2 requires at least 16-byte salt");
286 }
287
288 #[kani::proof]
290 #[kani::unwind(0)]
291 fn proof_short_blob_rejected() {
292 let short_len: usize = kani::any();
293 kani::assume(short_len < MIN_VAULT_LEN);
294 let is_too_short = short_len < MIN_VAULT_LEN;
296 kani::assert(is_too_short, "Short blobs must fail validation");
297 }
298}
299