p47h_engine/
vault.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2025 P47H Team <https://p47h.com>
3
4//! Vault cryptographic operations.
5//!
6//! This module provides encryption/decryption for the P47H Identity Vault
7//! using XChaCha20Poly1305 with Argon2id key derivation.
8
9use argon2::{Algorithm, Argon2, Params, Version};
10use chacha20poly1305::{
11    aead::{Aead, KeyInit},
12    XChaCha20Poly1305, XNonce,
13};
14use wasm_bindgen::prelude::*;
15
16/// Magic bytes identifying a valid P47H vault blob
17pub const MAGIC_BYTES: &[u8] = b"P47H_VAULT_V2"; // V2 for WASM vault
18/// Salt length in bytes
19pub const SALT_LEN: usize = 16;
20/// Nonce length in bytes (XChaCha20 uses 24-byte nonces)
21pub const NONCE_LEN: usize = 24;
22/// Minimum valid vault blob length
23pub const MIN_VAULT_LEN: usize = MAGIC_BYTES.len() + SALT_LEN + NONCE_LEN;
24
25// ============================================================================
26// Pure Rust Error Type (for native fuzzing and testing)
27// ============================================================================
28
29/// Vault operation error (pure Rust, no WASM dependencies)
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum VaultError {
32    /// Random number generation failed
33    RngError(String),
34    /// Key derivation failed  
35    KeyDerivationError(String),
36    /// Encryption failed
37    EncryptionError(String),
38    /// Vault blob is too short to be valid
39    TooShort { actual: usize, minimum: usize },
40    /// Magic bytes don't match
41    InvalidMagic,
42    /// Decryption failed (wrong password or corrupted data)
43    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
71// ============================================================================
72// Pure Rust Core Functions (fuzzable without WASM)
73// ============================================================================
74
75/// Derives a key from password and salt using Argon2id.
76/// 
77/// This is the pure Rust version without JsValue dependencies.
78pub 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
98/// Encrypts data with a provided salt and nonce (for testing/fuzzing).
99/// 
100/// In production, use `encrypt_vault_inner` which generates random salt/nonce.
101pub 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
124/// Decrypts a vault blob (pure Rust, fuzzable).
125///
126/// # Arguments
127/// * `blob` - The encrypted vault blob: `[MAGIC][SALT][NONCE][CIPHERTEXT]`
128/// * `password` - User password
129///
130/// # Returns
131/// Decrypted plaintext on success, or VaultError on failure.
132pub fn decrypt_vault_inner(blob: &[u8], password: &str) -> Result<Vec<u8>, VaultError> {
133    // Check minimum length
134    if blob.len() < MIN_VAULT_LEN {
135        return Err(VaultError::TooShort {
136            actual: blob.len(),
137            minimum: MIN_VAULT_LEN,
138        });
139    }
140
141    // Verify magic bytes
142    if &blob[0..MAGIC_BYTES.len()] != MAGIC_BYTES {
143        return Err(VaultError::InvalidMagic);
144    }
145
146    // Parse header
147    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    // Derive key and decrypt
156    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// ============================================================================
168// WASM Bindings (thin wrappers around pure Rust functions)
169// ============================================================================
170
171/// Crypto utilities for the Identity Vault
172#[wasm_bindgen]
173pub struct VaultCrypto;
174
175#[wasm_bindgen]
176impl VaultCrypto {
177    /// Encrypts data using XChaCha20Poly1305 with a key derived from Argon2id
178    /// Output format: [MAGIC_BYTES (13)] [SALT (16)] [NONCE (24)] [CIPHERTEXT]
179    #[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    /// Decrypts a vault blob
193    #[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    /// Derives a session key from password and salt
199    #[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// ============================================================================
207// Tests
208// ============================================================================
209
210#[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// ============================================================================
257// Kani Formal Verification Proofs
258// ============================================================================
259
260/// Formal verification proofs for vault constants and validation logic.
261/// Run with: `cargo kani --package p47h-engine`
262#[cfg(kani)]
263mod kani_proofs {
264    use super::*;
265
266    /// Verify that MIN_VAULT_LEN is correctly computed from components.
267    #[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    /// Verify XChaCha20 nonce size is 24 bytes.
275    #[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    /// Verify salt length is at least 16 bytes (Argon2 minimum).
282    #[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    /// Verify that a blob shorter than MIN_VAULT_LEN would be rejected.
289    #[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        // The validation check in decrypt_vault_inner
295        let is_too_short = short_len < MIN_VAULT_LEN;
296        kani::assert(is_too_short, "Short blobs must fail validation");
297    }
298}
299