Skip to main content

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 {
40        /// Actual size in bytes
41        actual: usize,
42        /// Minimum required size in bytes
43        minimum: usize,
44    },
45    /// Magic bytes don't match
46    InvalidMagic,
47    /// Decryption failed (wrong password or corrupted data)
48    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
80// ============================================================================
81// Pure Rust Core Functions (fuzzable without WASM)
82// ============================================================================
83
84/// Derives a key from password and salt using Argon2id.
85///
86/// This is the pure Rust version without JsValue dependencies.
87pub 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
107/// Encrypts data with a provided salt and nonce (for testing/fuzzing).
108///
109/// In production, use `encrypt_vault_inner` which generates random salt/nonce.
110pub 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
134/// Decrypts a vault blob (pure Rust, fuzzable).
135///
136/// # Arguments
137/// * `blob` - The encrypted vault blob: `[MAGIC][SALT][NONCE][CIPHERTEXT]`
138/// * `password` - User password
139///
140/// # Returns
141/// Decrypted plaintext on success, or VaultError on failure.
142pub fn decrypt_vault_inner(blob: &[u8], password: &str) -> Result<Vec<u8>, VaultError> {
143    // Check minimum length
144    if blob.len() < MIN_VAULT_LEN {
145        return Err(VaultError::TooShort {
146            actual: blob.len(),
147            minimum: MIN_VAULT_LEN,
148        });
149    }
150
151    // Verify magic bytes
152    if &blob[0..MAGIC_BYTES.len()] != MAGIC_BYTES {
153        return Err(VaultError::InvalidMagic);
154    }
155
156    // Parse header
157    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    // Derive key and decrypt
166    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// ============================================================================
178// WASM Bindings (thin wrappers around pure Rust functions)
179// ============================================================================
180
181/// Crypto utilities for the Identity Vault
182#[wasm_bindgen]
183pub struct VaultCrypto;
184
185#[wasm_bindgen]
186impl VaultCrypto {
187    /// Encrypts data using XChaCha20Poly1305 with a key derived from Argon2id
188    /// Output format: [MAGIC_BYTES (13)] [SALT (16)] [NONCE (24)] [CIPHERTEXT]
189    #[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    /// Decrypts a vault blob
201    #[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    /// Derives a session key from password and salt
207    #[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// ============================================================================
215// Tests
216// ============================================================================
217
218#[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// ============================================================================
265// Kani Formal Verification Proofs
266// ============================================================================
267
268/// Formal verification proofs for vault constants and validation logic.
269/// Run with: `cargo kani --package p47h-engine`
270#[cfg(kani)]
271mod kani_proofs {
272    use super::*;
273
274    /// Verify that MIN_VAULT_LEN is correctly computed from components.
275    #[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    /// Verify XChaCha20 nonce size is 24 bytes.
286    #[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    /// Verify salt length is at least 16 bytes (Argon2 minimum).
293    #[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    /// Verify that a blob shorter than MIN_VAULT_LEN would be rejected.
300    #[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        // The validation check in decrypt_vault_inner
306        let is_too_short = short_len < MIN_VAULT_LEN;
307        kani::assert(is_too_short, "Short blobs must fail validation");
308    }
309}