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 {
40 actual: usize,
42 minimum: usize,
44 },
45 InvalidMagic,
47 DecryptionFailed,
49}
50
51impl std::fmt::Display for VaultError {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 match self {
54 VaultError::RngError(msg) => write!(f, "RNG error: {}", msg),
55 VaultError::KeyDerivationError(msg) => write!(f, "Key derivation failed: {}", msg),
56 VaultError::EncryptionError(msg) => write!(f, "Encryption failed: {}", msg),
57 VaultError::TooShort { actual, minimum } => {
58 write!(
59 f,
60 "Invalid vault: too short ({} bytes, minimum {})",
61 actual, minimum
62 )
63 }
64 VaultError::InvalidMagic => write!(f, "Invalid vault: wrong magic bytes"),
65 VaultError::DecryptionFailed => {
66 write!(f, "Decryption failed: wrong password or corrupted data")
67 }
68 }
69 }
70}
71
72impl std::error::Error for VaultError {}
73
74impl From<VaultError> for JsValue {
75 fn from(err: VaultError) -> JsValue {
76 JsValue::from_str(&err.to_string())
77 }
78}
79
80pub fn derive_key_inner(password: &str, salt: &[u8]) -> Result<chacha20poly1305::Key, VaultError> {
88 let mut output_key = [0u8; 32];
89
90 let params = Params::new(
91 Params::DEFAULT_M_COST,
92 Params::DEFAULT_T_COST,
93 Params::DEFAULT_P_COST,
94 Some(32),
95 )
96 .map_err(|e| VaultError::KeyDerivationError(format!("Argon2 params error: {}", e)))?;
97
98 let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
99
100 argon2
101 .hash_password_into(password.as_bytes(), salt, &mut output_key)
102 .map_err(|e| VaultError::KeyDerivationError(e.to_string()))?;
103
104 Ok(*chacha20poly1305::Key::from_slice(&output_key))
105}
106
107pub fn encrypt_vault_with_params(
111 data: &[u8],
112 password: &str,
113 salt: &[u8; SALT_LEN],
114 nonce: &[u8; NONCE_LEN],
115) -> Result<Vec<u8>, VaultError> {
116 let key = derive_key_inner(password, salt)?;
117 let nonce_obj = XNonce::from_slice(nonce);
118
119 let cipher = XChaCha20Poly1305::new(&key);
120 let ciphertext = cipher
121 .encrypt(nonce_obj, data)
122 .map_err(|e| VaultError::EncryptionError(e.to_string()))?;
123
124 let mut result =
125 Vec::with_capacity(MAGIC_BYTES.len() + SALT_LEN + NONCE_LEN + ciphertext.len());
126 result.extend_from_slice(MAGIC_BYTES);
127 result.extend_from_slice(salt);
128 result.extend_from_slice(nonce);
129 result.extend_from_slice(&ciphertext);
130
131 Ok(result)
132}
133
134pub fn decrypt_vault_inner(blob: &[u8], password: &str) -> Result<Vec<u8>, VaultError> {
143 if blob.len() < MIN_VAULT_LEN {
145 return Err(VaultError::TooShort {
146 actual: blob.len(),
147 minimum: MIN_VAULT_LEN,
148 });
149 }
150
151 if &blob[0..MAGIC_BYTES.len()] != MAGIC_BYTES {
153 return Err(VaultError::InvalidMagic);
154 }
155
156 let offset_salt = MAGIC_BYTES.len();
158 let offset_nonce = offset_salt + SALT_LEN;
159 let offset_cipher = offset_nonce + NONCE_LEN;
160
161 let salt = &blob[offset_salt..offset_nonce];
162 let nonce_bytes = &blob[offset_nonce..offset_cipher];
163 let ciphertext = &blob[offset_cipher..];
164
165 let key = derive_key_inner(password, salt)?;
167 let cipher = XChaCha20Poly1305::new(&key);
168 let nonce = XNonce::from_slice(nonce_bytes);
169
170 let plaintext = cipher
171 .decrypt(nonce, ciphertext)
172 .map_err(|_| VaultError::DecryptionFailed)?;
173
174 Ok(plaintext)
175}
176
177#[wasm_bindgen]
183pub struct VaultCrypto;
184
185#[wasm_bindgen]
186impl VaultCrypto {
187 #[wasm_bindgen]
190 pub fn encrypt_vault(data: &[u8], password: &str) -> Result<Vec<u8>, JsValue> {
191 let mut salt = [0u8; SALT_LEN];
192 getrandom::getrandom(&mut salt).map_err(|e| VaultError::RngError(e.to_string()))?;
193
194 let mut nonce = [0u8; NONCE_LEN];
195 getrandom::getrandom(&mut nonce).map_err(|e| VaultError::RngError(e.to_string()))?;
196
197 encrypt_vault_with_params(data, password, &salt, &nonce).map_err(Into::into)
198 }
199
200 #[wasm_bindgen]
202 pub fn decrypt_vault(blob: &[u8], password: &str) -> Result<Vec<u8>, JsValue> {
203 decrypt_vault_inner(blob, password).map_err(Into::into)
204 }
205
206 #[wasm_bindgen]
208 pub fn derive_session_key(password: &str, salt: &[u8]) -> Result<Vec<u8>, JsValue> {
209 let key = derive_key_inner(password, salt)?;
210 Ok(key.to_vec())
211 }
212}
213
214#[cfg(test)]
219mod tests {
220 use super::*;
221
222 #[test]
223 fn test_roundtrip() {
224 let data = b"secret data";
225 let password = "test_password";
226 let salt = [1u8; SALT_LEN];
227 let nonce = [2u8; NONCE_LEN];
228
229 let encrypted = encrypt_vault_with_params(data, password, &salt, &nonce).unwrap();
230 let decrypted = decrypt_vault_inner(&encrypted, password).unwrap();
231
232 assert_eq!(data.as_slice(), decrypted.as_slice());
233 }
234
235 #[test]
236 fn test_too_short() {
237 let result = decrypt_vault_inner(&[0u8; 10], "password");
238 assert!(matches!(result, Err(VaultError::TooShort { .. })));
239 }
240
241 #[test]
242 fn test_invalid_magic() {
243 let mut blob = vec![0u8; MIN_VAULT_LEN + 16];
244 blob[..5].copy_from_slice(b"WRONG");
245
246 let result = decrypt_vault_inner(&blob, "password");
247 assert!(matches!(result, Err(VaultError::InvalidMagic)));
248 }
249
250 #[test]
251 fn test_wrong_password() {
252 let data = b"secret data";
253 let password = "correct_password";
254 let salt = [1u8; SALT_LEN];
255 let nonce = [2u8; NONCE_LEN];
256
257 let encrypted = encrypt_vault_with_params(data, password, &salt, &nonce).unwrap();
258 let result = decrypt_vault_inner(&encrypted, "wrong_password");
259
260 assert!(matches!(result, Err(VaultError::DecryptionFailed)));
261 }
262}
263
264#[cfg(kani)]
271mod kani_proofs {
272 use super::*;
273
274 #[kani::proof]
276 #[kani::unwind(0)]
277 fn proof_min_vault_len_invariant() {
278 let expected = MAGIC_BYTES.len() + SALT_LEN + NONCE_LEN;
279 kani::assert(
280 MIN_VAULT_LEN == expected,
281 "MIN_VAULT_LEN must equal components sum",
282 );
283 }
284
285 #[kani::proof]
287 #[kani::unwind(0)]
288 fn proof_nonce_len_xchacha() {
289 kani::assert(NONCE_LEN == 24, "XChaCha20 requires 24-byte nonces");
290 }
291
292 #[kani::proof]
294 #[kani::unwind(0)]
295 fn proof_salt_len_argon2() {
296 kani::assert(SALT_LEN >= 16, "Argon2 requires at least 16-byte salt");
297 }
298
299 #[kani::proof]
301 #[kani::unwind(0)]
302 fn proof_short_blob_rejected() {
303 let short_len: usize = kani::any();
304 kani::assume(short_len < MIN_VAULT_LEN);
305 let is_too_short = short_len < MIN_VAULT_LEN;
307 kani::assert(is_too_short, "Short blobs must fail validation");
308 }
309}