From a601102ce6a1b7f0f6589f3d79be6467ce9b2aa8 Mon Sep 17 00:00:00 2001 From: 27bslash6 <2221076+27Bslash6@users.noreply.github.com> Date: Sun, 15 Mar 2026 12:43:10 +1100 Subject: [PATCH 1/8] chore: add wasm32 conditional timing for Instant compatibility --- src/byte_storage.rs | 19 ++++++++---- src/encryption/core.rs | 19 ++++++++---- tests/wasm32_compat_tests.rs | 59 ++++++++++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 12 deletions(-) create mode 100644 tests/wasm32_compat_tests.rs diff --git a/src/byte_storage.rs b/src/byte_storage.rs index 17f1a92..518c5af 100644 --- a/src/byte_storage.rs +++ b/src/byte_storage.rs @@ -10,6 +10,7 @@ use crate::metrics::OperationMetrics; use lz4_flex; use serde::{Deserialize, Serialize}; use std::sync::{Arc, Mutex}; +#[cfg(not(target_arch = "wasm32"))] use std::time::Instant; use thiserror::Error; #[cfg(feature = "checksum")] @@ -175,14 +176,17 @@ impl ByteStorage { let format = format.unwrap_or_else(|| self.default_format.clone()); - // Time compression operation + // Time compression operation (wasm32: Instant unavailable, use 0) + #[cfg(not(target_arch = "wasm32"))] let compression_start = Instant::now(); let original_size = data.len(); let envelope = StorageEnvelope::new(data.to_vec(), format)?; - let compression_elapsed = compression_start.elapsed(); - let compression_micros = compression_elapsed.as_micros() as u64; + #[cfg(not(target_arch = "wasm32"))] + let compression_micros = compression_start.elapsed().as_micros() as u64; + #[cfg(target_arch = "wasm32")] + let compression_micros = 0u64; let compressed_size = envelope.compressed_data.len(); // Serialize envelope with MessagePack @@ -220,14 +224,17 @@ impl ByteStorage { let envelope: StorageEnvelope = rmp_serde::from_slice(envelope_bytes) .map_err(|e| ByteStorageError::DeserializationFailed(e.to_string()))?; - // Time decompression and checksum operations + // Time decompression and checksum operations (wasm32: Instant unavailable, use 0) + #[cfg(not(target_arch = "wasm32"))] let decompress_start = Instant::now(); // Extract and validate data (all security checks happen inside extract()) let data = envelope.extract()?; - let decompress_elapsed = decompress_start.elapsed(); - let decompress_micros = decompress_elapsed.as_micros() as u64; + #[cfg(not(target_arch = "wasm32"))] + let decompress_micros = decompress_start.elapsed().as_micros() as u64; + #[cfg(target_arch = "wasm32")] + let decompress_micros = 0u64; // Calculate compression ratio from stored metadata let compressed_size = envelope.compressed_data.len(); diff --git a/src/encryption/core.rs b/src/encryption/core.rs index 560817a..f722682 100644 --- a/src/encryption/core.rs +++ b/src/encryption/core.rs @@ -26,6 +26,7 @@ use ring::{ }; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, LazyLock, Mutex}; +#[cfg(not(target_arch = "wasm32"))] use std::time::Instant; use thiserror::Error; @@ -271,7 +272,8 @@ impl ZeroKnowledgeEncryptor { key: &[u8], aad: &[u8], ) -> Result, EncryptionError> { - // Time encryption operation + // Time encryption operation (wasm32: Instant unavailable, use 0) + #[cfg(not(target_arch = "wasm32"))] let encryption_start = Instant::now(); // Validate key length @@ -304,8 +306,10 @@ impl ZeroKnowledgeEncryptor { result.extend_from_slice(&ciphertext); // Update metrics for observability - let encryption_elapsed = encryption_start.elapsed(); - let encryption_micros = encryption_elapsed.as_micros() as u64; + #[cfg(not(target_arch = "wasm32"))] + let encryption_micros = encryption_start.elapsed().as_micros() as u64; + #[cfg(target_arch = "wasm32")] + let encryption_micros = 0u64; if let Ok(mut metrics) = self.last_metrics.lock() { *metrics = OperationMetrics::new() .with_encryption(encryption_micros, self.hardware_acceleration_detected); @@ -329,7 +333,8 @@ impl ZeroKnowledgeEncryptor { key: &[u8], aad: &[u8], ) -> Result, EncryptionError> { - // Time decryption operation + // Time decryption operation (wasm32: Instant unavailable, use 0) + #[cfg(not(target_arch = "wasm32"))] let decryption_start = Instant::now(); // Validate key length @@ -373,8 +378,10 @@ impl ZeroKnowledgeEncryptor { plaintext.truncate(decrypted_len); // Update metrics for observability - let decryption_elapsed = decryption_start.elapsed(); - let decryption_micros = decryption_elapsed.as_micros() as u64; + #[cfg(not(target_arch = "wasm32"))] + let decryption_micros = decryption_start.elapsed().as_micros() as u64; + #[cfg(target_arch = "wasm32")] + let decryption_micros = 0u64; if let Ok(mut metrics) = self.last_metrics.lock() { *metrics = OperationMetrics::new() .with_encryption(decryption_micros, self.hardware_acceleration_detected); diff --git a/tests/wasm32_compat_tests.rs b/tests/wasm32_compat_tests.rs new file mode 100644 index 0000000..07daaa7 --- /dev/null +++ b/tests/wasm32_compat_tests.rs @@ -0,0 +1,59 @@ +//! wasm32 compatibility smoke tests. +//! +//! These tests verify core ByteStorage round-trip functionality works correctly. +//! They run on native targets and serve as a baseline before wasm32 compilation. + +#[cfg(all(feature = "compression", feature = "checksum", feature = "messagepack"))] +mod byte_storage_roundtrip { + use cachekit_core::ByteStorage; + + #[test] + fn test_wasm32_compat_basic_roundtrip() { + let storage = ByteStorage::new(None); + let data = b"Hello wasm32! This is a round-trip test."; + + let stored = storage.store(data, None).expect("store must succeed"); + let (retrieved, format) = storage.retrieve(&stored).expect("retrieve must succeed"); + + assert_eq!(data as &[u8], retrieved.as_slice()); + assert_eq!("msgpack", format); + } + + #[test] + fn test_wasm32_compat_empty_payload() { + let storage = ByteStorage::new(None); + let data: &[u8] = b""; + + let stored = storage.store(data, None).expect("store empty must succeed"); + let (retrieved, _) = storage.retrieve(&stored).expect("retrieve empty must succeed"); + + assert_eq!(data, retrieved.as_slice()); + } + + #[test] + fn test_wasm32_compat_binary_payload() { + let storage = ByteStorage::new(None); + let data: Vec = (0u8..=255u8).collect(); + + let stored = storage.store(&data, None).expect("store binary must succeed"); + let (retrieved, _) = storage.retrieve(&stored).expect("retrieve binary must succeed"); + + assert_eq!(data, retrieved); + } + + #[test] + fn test_wasm32_compat_custom_format() { + let storage = ByteStorage::new(None); + let data = b"custom format test"; + + let stored = storage + .store(data, Some("cbor".to_string())) + .expect("store with custom format must succeed"); + let (retrieved, format) = storage + .retrieve(&stored) + .expect("retrieve with custom format must succeed"); + + assert_eq!(data as &[u8], retrieved.as_slice()); + assert_eq!("cbor", format); + } +} From 087a4fb1f3e00c177c94f1f72e35c909beda997b Mon Sep 17 00:00:00 2001 From: 27bslash6 <2221076+27Bslash6@users.noreply.github.com> Date: Sun, 15 Mar 2026 12:47:30 +1100 Subject: [PATCH 2/8] chore: add wasm32 fallback for AtomicU64 and SystemRandom --- Cargo.toml | 6 +++ src/encryption/core.rs | 103 ++++++++++++++++++++++++++++++++++------- 2 files changed, 91 insertions(+), 18 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 353aae8..6b5bb4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,9 @@ sha2 = { version = "0.10", optional = true } hmac = { version = "0.12", optional = true } generic-array = { version = "0.14", optional = true } +# wasm32 RNG: getrandom with JS feature for wasm32-unknown-unknown targets +getrandom = { version = "0.2", features = ["js"], optional = true } + # Byte utilities bytes = "1.5" byteorder = "1.5" @@ -81,6 +84,9 @@ encryption = [ # C FFI layer (generates include/cachekit.h) ffi = [] +# wasm32 support: getrandom with JS bindings for RNG on wasm32-unknown-unknown +wasm = ["dep:getrandom"] + # Kani formal verification configuration # Provides mathematical proofs of memory safety for unsafe code and FFI boundaries [package.metadata.kani] diff --git a/src/encryption/core.rs b/src/encryption/core.rs index f722682..5421a70 100644 --- a/src/encryption/core.rs +++ b/src/encryption/core.rs @@ -24,13 +24,19 @@ use ring::{ aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM}, rand::{SecureRandom, SystemRandom}, }; +#[cfg(not(target_arch = "wasm32"))] use std::sync::atomic::{AtomicU64, Ordering}; +#[cfg(not(target_arch = "wasm32"))] use std::sync::{Arc, LazyLock, Mutex}; +#[cfg(target_arch = "wasm32")] +use std::sync::{Arc, Mutex}; #[cfg(not(target_arch = "wasm32"))] use std::time::Instant; use thiserror::Error; -/// Global encryptor instance counter for deterministic nonce uniqueness. +// ── Native: LazyLock seeded from ring's SystemRandom ───────────── + +/// Global encryptor instance counter for deterministic nonce uniqueness (native only). /// /// Each encryptor gets a unique 64-bit instance ID from this counter, /// which is used as the first 8 bytes of every nonce. This provides @@ -46,17 +52,56 @@ use thiserror::Error; /// reusing instance IDs from the previous run. By starting with a random /// 32-bit offset, we get ~2^32 cross-process collision resistance while /// maintaining deterministic uniqueness within a single process. +#[cfg(not(target_arch = "wasm32"))] static GLOBAL_INSTANCE_COUNTER: LazyLock = LazyLock::new(|| { // Initialize with random 32-bit value in upper bits for cross-process uniqueness // Lower 32 bits start at 0 for deterministic ordering let rng = SystemRandom::new(); let mut random_seed = [0u8; 4]; - // If RNG fails, fall back to 0 (still unique within process) - let _ = rng.fill(&mut random_seed); + // RNG failure is a hard error — silently falling back to 0 is a security risk + // because multiple restarts would produce the same instance IDs + rng.fill(&mut random_seed) + .expect("SystemRandom::fill failed during GLOBAL_INSTANCE_COUNTER initialization"); let seed = u32::from_be_bytes(random_seed) as u64; AtomicU64::new(seed << 32) }); +// ── wasm32: thread_local Cell seeded from getrandom ──────────────────── + +/// Per-thread encryptor instance counter for wasm32 (no atomics available). +/// +/// wasm32-unknown-unknown lacks threads, so a thread-local Cell is safe. +/// Seeded from `getrandom` (which uses the JS crypto API via WASM). +#[cfg(target_arch = "wasm32")] +thread_local! { + static WASM_INSTANCE_COUNTER: std::cell::Cell = { + let mut seed_bytes = [0u8; 4]; + getrandom::getrandom(&mut seed_bytes) + .expect("getrandom failed during WASM_INSTANCE_COUNTER initialization"); + let seed = u32::from_be_bytes(seed_bytes) as u64; + std::cell::Cell::new(seed << 32) + }; +} + +/// Get the next globally unique instance ID, incrementing the counter. +/// +/// On native: uses `GLOBAL_INSTANCE_COUNTER` (thread-safe `AtomicU64`). +/// On wasm32: uses `WASM_INSTANCE_COUNTER` (thread-local `Cell`). +fn next_instance_id() -> u64 { + #[cfg(not(target_arch = "wasm32"))] + { + GLOBAL_INSTANCE_COUNTER.fetch_add(1, Ordering::SeqCst) + } + #[cfg(target_arch = "wasm32")] + { + WASM_INSTANCE_COUNTER.with(|c| { + let id = c.get(); + c.set(id.wrapping_add(1)); + id + }) + } +} + // CPU feature detection #[cfg(any(target_arch = "x86", target_arch = "x86_64"))] use std::arch::is_x86_feature_detected; @@ -104,7 +149,9 @@ pub enum EncryptionError { /// Zero-knowledge encryptor using AES-256-GCM with hardware acceleration detection pub struct ZeroKnowledgeEncryptor { hardware_acceleration_detected: bool, - /// Atomic counter for provably unique nonces. + /// Nonce counter for provably unique nonces. + /// + /// Native: `AtomicU64` for lock-free thread safety. /// /// # Why AtomicU64 instead of AtomicU32? /// @@ -121,11 +168,14 @@ pub struct ZeroKnowledgeEncryptor { /// - Next call: counter is u32::MAX + 1, check fails again /// - Counter STAYS exhausted, no nonce reuse possible /// - /// This is defense-in-depth: the type prevents wraparound from ever occurring. + /// wasm32: `std::cell::Cell` — no threads on wasm32-unknown-unknown, Cell is safe. + #[cfg(not(target_arch = "wasm32"))] nonce_counter: AtomicU64, + #[cfg(target_arch = "wasm32")] + nonce_counter: std::cell::Cell, /// Globally unique 64-bit instance ID (deterministic, no birthday paradox). /// - /// Assigned from GLOBAL_INSTANCE_COUNTER at construction. Used as the first + /// Assigned from the platform counter at construction. Used as the first /// 8 bytes of every nonce to guarantee cross-instance uniqueness. /// /// # Security Properties @@ -152,13 +202,15 @@ impl ZeroKnowledgeEncryptor { pub fn new() -> Result { let hardware_acceleration_detected = Self::detect_hardware_acceleration(); - // Get a globally unique instance ID (deterministic, no birthday paradox) - // This replaces the previous random IV which had ~2^32 collision bound - let instance_id = GLOBAL_INSTANCE_COUNTER.fetch_add(1, Ordering::SeqCst); + // Get a globally unique instance ID via platform-specific counter + let instance_id = next_instance_id(); Ok(Self { hardware_acceleration_detected, + #[cfg(not(target_arch = "wasm32"))] nonce_counter: AtomicU64::new(0), + #[cfg(target_arch = "wasm32")] + nonce_counter: std::cell::Cell::new(0), instance_id, last_metrics: Arc::new(Mutex::new(OperationMetrics::new())), }) @@ -226,17 +278,22 @@ impl ZeroKnowledgeEncryptor { /// Format: [instance_id(8)][counter(4)] = 12 bytes total /// /// Security properties: - /// - Instance ID is globally unique (from atomic counter, no birthday paradox) + /// - Instance ID is globally unique (from platform counter, no birthday paradox) /// - Counter ensures per-instance uniqueness (up to 2^32 encryptions) /// - Combined: 2^96 total unique nonces possible - /// - Atomic operations ensure thread safety - /// - Overflow detection prevents wraparound + /// - Overflow detection prevents wraparound and nonce reuse fn generate_nonce(&self) -> Result<[u8; 12], EncryptionError> { - // Fetch and increment counter atomically (thread-safe across PyO3 boundary) - // Using 32-bit counter allows ~4 billion operations per encryptor instance + // Increment counter and retrieve previous value + #[cfg(not(target_arch = "wasm32"))] let counter = self.nonce_counter.fetch_add(1, Ordering::SeqCst); + #[cfg(target_arch = "wasm32")] + let counter = { + let c = self.nonce_counter.get(); + self.nonce_counter.set(c.wrapping_add(1)); + c + }; - // Check for overflow (after 2^32 operations on this instance, require new instance) + // Check for exhaustion (after 2^32 operations, require new instance) if counter >= u32::MAX as u64 { return Err(EncryptionError::NonceCounterExhausted); } @@ -254,7 +311,14 @@ impl ZeroKnowledgeEncryptor { /// /// Exposed for operational monitoring and alerting on counter exhaustion. pub fn get_nonce_counter(&self) -> u64 { - self.nonce_counter.load(Ordering::SeqCst) + #[cfg(not(target_arch = "wasm32"))] + { + self.nonce_counter.load(Ordering::SeqCst) + } + #[cfg(target_arch = "wasm32")] + { + self.nonce_counter.get() + } } /// Encrypt data using AES-256-GCM with authenticated additional data @@ -391,7 +455,7 @@ impl ZeroKnowledgeEncryptor { } /// Generate a secure random key for testing purposes - #[cfg(test)] + #[cfg(all(test, not(target_arch = "wasm32")))] pub fn generate_key(&self) -> Result<[u8; 32], EncryptionError> { let rng = SystemRandom::new(); let mut key = [0u8; 32]; @@ -431,7 +495,7 @@ impl ZeroKnowledgeEncryptor { // ZeroKnowledgeEncryptor::new() returns Result for API stability, even though // the current implementation is infallible. -#[cfg(test)] +#[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; @@ -566,6 +630,7 @@ mod tests { // ============================================================================ #[test] + #[cfg(not(target_arch = "wasm32"))] fn test_nonce_exhaustion_at_boundary() { // WHY: Verify nonce counter exhaustion is detected at u32::MAX // This is critical for AES-GCM security - nonce reuse is catastrophic @@ -610,6 +675,7 @@ mod tests { } #[test] + #[cfg(not(target_arch = "wasm32"))] fn test_counter_no_wraparound_after_exhaustion() { // WHY: Verify that after counter exhaustion, subsequent operations // continue to fail (counter doesn't wrap back to 0) @@ -935,6 +1001,7 @@ mod tests { } #[test] + #[cfg(not(target_arch = "wasm32"))] fn test_concurrent_nonce_exhaustion() { // WHY: Verify atomic counter behavior under concurrent access at exhaustion boundary // This ensures no race conditions allow nonce reuse From 0defd5df46e9d09ab646d2c71e773452fc282bdc Mon Sep 17 00:00:00 2001 From: 27bslash6 <2221076+27Bslash6@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:59:08 +1100 Subject: [PATCH 3/8] chore: add RustCrypto aes-gcm backend for wasm32 encryption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ring doesn't compile on wasm32-unknown-unknown (requires clang for C asm). Move ring to a target-conditional dep (native only), add aes-gcm from RustCrypto for wasm32. Both produce identical AES-256-GCM wire format: [nonce(12)][ciphertext][auth_tag(16)] Native targets continue using ring for hardware-accelerated performance. wasm32 targets use aes-gcm (pure Rust) when wasm+encryption features active. key_derivation.rs unchanged — already uses RustCrypto hkdf/sha2, compiles on both targets without modification. --- Cargo.toml | 21 +++++++-- src/encryption/core.rs | 97 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6b5bb4e..7745fb0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,10 +36,9 @@ lz4_flex = { version = "0.11", features = ["frame", "std"], optional = true } # xxHash3-64: ~36 GB/s, sufficient for corruption detection (security via AES-GCM auth tag) xxhash-rust = { version = "0.8", features = ["xxh3"], optional = true } - # Encryption dependencies (all optional, gated by encryption feature) # Uses HKDF-SHA256 for key derivation (NOT Blake2b - that's only for Python cache keys) -ring = { version = "0.17", optional = true } +# ring is native-only (see [target.'cfg(not(target_arch = "wasm32"))'.dependencies]) zeroize = { version = "1.8", features = ["derive"], optional = true } hkdf = { version = "0.12", optional = true } sha2 = { version = "0.10", optional = true } @@ -49,10 +48,20 @@ generic-array = { version = "0.14", optional = true } # wasm32 RNG: getrandom with JS feature for wasm32-unknown-unknown targets getrandom = { version = "0.2", features = ["js"], optional = true } +# RustCrypto: pure-Rust AES-256-GCM for wasm32 targets (ring requires clang + C asm) +aes-gcm = { version = "0.10", optional = true } +aes = { version = "0.8", optional = true } + # Byte utilities bytes = "1.5" byteorder = "1.5" +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +# ring: hardware-accelerated AES-256-GCM for native targets only. +# Does NOT compile on wasm32-unknown-unknown (requires clang for C asm). +# Kept optional so it is only compiled when the `encryption` feature is active. +ring = { version = "0.17", optional = true } + [build-dependencies] # C header generation (required for ffi feature) cbindgen = "0.29" @@ -72,6 +81,10 @@ checksum = ["dep:xxhash-rust"] messagepack = ["dep:rmp-serde"] # Encryption (AES-256-GCM with HKDF-SHA256 key derivation) +# Native: ring provides hardware-accelerated AES-256-GCM (target-conditional dep above) +# wasm32: aes-gcm (pure Rust) is used instead — activated by the `wasm` feature +# Note: `dep:ring` is valid here because ring is declared optional in the target section; +# on wasm32 targets the dep simply doesn't exist, so Cargo ignores it safely. encryption = [ "dep:ring", "dep:zeroize", @@ -84,8 +97,8 @@ encryption = [ # C FFI layer (generates include/cachekit.h) ffi = [] -# wasm32 support: getrandom with JS bindings for RNG on wasm32-unknown-unknown -wasm = ["dep:getrandom"] +# wasm32 support: getrandom with JS bindings for RNG + RustCrypto AES-GCM for encryption +wasm = ["dep:getrandom", "dep:aes-gcm", "dep:aes"] # Kani formal verification configuration # Provides mathematical proofs of memory safety for unsafe code and FFI boundaries diff --git a/src/encryption/core.rs b/src/encryption/core.rs index 5421a70..26db5c4 100644 --- a/src/encryption/core.rs +++ b/src/encryption/core.rs @@ -20,10 +20,20 @@ //! for a total of 2^96 unique nonces - far exceeding any practical usage. use crate::metrics::OperationMetrics; + +// Native: ring for AES-256-GCM (hardware-accelerated, requires clang) +#[cfg(not(target_arch = "wasm32"))] use ring::{ aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM}, rand::{SecureRandom, SystemRandom}, }; + +// wasm32: RustCrypto aes-gcm (pure Rust, compiles on wasm32-unknown-unknown) +#[cfg(target_arch = "wasm32")] +use aes_gcm::{ + aead::{Aead, KeyInit, Payload}, + Aes256Gcm, Nonce as AesGcmNonce, +}; #[cfg(not(target_arch = "wasm32"))] use std::sync::atomic::{AtomicU64, Ordering}; #[cfg(not(target_arch = "wasm32"))] @@ -330,6 +340,7 @@ impl ZeroKnowledgeEncryptor { /// /// # Returns /// Encrypted data in format: `[nonce(12)][ciphertext+auth_tag]` + #[cfg(not(target_arch = "wasm32"))] pub fn encrypt_aes_gcm( &self, plaintext: &[u8], @@ -391,6 +402,7 @@ impl ZeroKnowledgeEncryptor { /// /// # Returns /// Decrypted plaintext data + #[cfg(not(target_arch = "wasm32"))] pub fn decrypt_aes_gcm( &self, ciphertext: &[u8], @@ -489,6 +501,91 @@ impl ZeroKnowledgeEncryptor { .into(), )) } + + /// Encrypt data using AES-256-GCM with authenticated additional data (wasm32) + /// + /// Uses RustCrypto's `aes-gcm` crate (pure Rust, compiles on wasm32-unknown-unknown). + /// Produces identical wire format to the native `ring` path: + /// `[nonce(12)][ciphertext][auth_tag(16)]` + #[cfg(target_arch = "wasm32")] + pub fn encrypt_aes_gcm( + &self, + plaintext: &[u8], + key: &[u8], + aad: &[u8], + ) -> Result, EncryptionError> { + if key.len() != 32 { + return Err(EncryptionError::InvalidKeyLength(key.len())); + } + + let nonce_bytes = self.generate_nonce()?; + + let cipher = Aes256Gcm::new_from_slice(key) + .map_err(|e| EncryptionError::EncryptionFailed(format!("key error: {e}")))?; + + let nonce = AesGcmNonce::from_slice(&nonce_bytes); + + // encrypt() returns ciphertext || tag (no nonce) — same layout as ring's seal_in_place_append_tag + let ciphertext_with_tag = cipher + .encrypt(nonce, Payload { msg: plaintext, aad }) + .map_err(|e| EncryptionError::EncryptionFailed(format!("AES-GCM encrypt failed: {e}")))?; + + // Wire format: nonce(12) || ciphertext || tag(16) — identical to native ring output + let mut result = Vec::with_capacity(12 + ciphertext_with_tag.len()); + result.extend_from_slice(&nonce_bytes); + result.extend_from_slice(&ciphertext_with_tag); + + // Metrics: Instant unavailable on wasm32 + if let Ok(mut metrics) = self.last_metrics.lock() { + *metrics = OperationMetrics::new().with_encryption(0u64, false); + } + + Ok(result) + } + + /// Decrypt data using AES-256-GCM with authenticated additional data (wasm32) + /// + /// Uses RustCrypto's `aes-gcm` crate (pure Rust, compiles on wasm32-unknown-unknown). + /// Expects identical wire format to the native `ring` path: + /// `[nonce(12)][ciphertext][auth_tag(16)]` + #[cfg(target_arch = "wasm32")] + pub fn decrypt_aes_gcm( + &self, + ciphertext: &[u8], + key: &[u8], + aad: &[u8], + ) -> Result, EncryptionError> { + if key.len() != 32 { + return Err(EncryptionError::InvalidKeyLength(key.len())); + } + + // Minimum: nonce(12) + tag(16) = 28 bytes + if ciphertext.len() < 28 { + return Err(EncryptionError::InvalidCiphertext( + "ciphertext too short (minimum 28 bytes: 12 nonce + 16 tag)".into(), + )); + } + + let nonce_bytes = &ciphertext[..12]; + let encrypted_data = &ciphertext[12..]; // ciphertext + tag + + let cipher = Aes256Gcm::new_from_slice(key) + .map_err(|e| EncryptionError::DecryptionFailed(format!("key error: {e}")))?; + + let nonce = AesGcmNonce::from_slice(nonce_bytes); + + // decrypt() verifies auth tag and returns plaintext + let plaintext = cipher + .decrypt(nonce, Payload { msg: encrypted_data, aad }) + .map_err(|_| EncryptionError::AuthenticationFailed)?; + + // Metrics: Instant unavailable on wasm32 + if let Ok(mut metrics) = self.last_metrics.lock() { + *metrics = OperationMetrics::new().with_encryption(0u64, false); + } + + Ok(plaintext) + } } // Note: Default is intentionally NOT implemented. From 59f90dfd196ae45a6239340b0f29a783de97cc99 Mon Sep 17 00:00:00 2001 From: 27bslash6 <2221076+27Bslash6@users.noreply.github.com> Date: Sun, 15 Mar 2026 19:50:38 +1100 Subject: [PATCH 4/8] fix: resolve expert panel findings for wasm32 compat - Fold aes-gcm/getrandom into encryption feature (no separate wasm flag needed) - Enable zeroize on aes/aes-gcm deps (scrub round keys on drop) - Add cross-backend wire format test (ring <-> aes-gcm roundtrip) - Remove dead cfg guards inside native-only functions - Consistency: error messages, min length expression, doc comments --- Cargo.toml | 18 ++++++++------ src/encryption/core.rs | 26 +++++++------------- tests/wasm32_compat_tests.rs | 47 ++++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 24 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 7745fb0..2c6c1bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,8 +49,8 @@ generic-array = { version = "0.14", optional = true } getrandom = { version = "0.2", features = ["js"], optional = true } # RustCrypto: pure-Rust AES-256-GCM for wasm32 targets (ring requires clang + C asm) -aes-gcm = { version = "0.10", optional = true } -aes = { version = "0.8", optional = true } +aes-gcm = { version = "0.10", features = ["zeroize"], optional = true } +aes = { version = "0.8", features = ["zeroize"], optional = true } # Byte utilities bytes = "1.5" @@ -71,6 +71,7 @@ proptest = "1.4" serde_json = "1.0" blake2 = "0.10" hex = "0.4" +aes-gcm = { version = "0.10", features = ["zeroize"] } [features] default = ["compression", "checksum", "messagepack"] @@ -82,9 +83,9 @@ messagepack = ["dep:rmp-serde"] # Encryption (AES-256-GCM with HKDF-SHA256 key derivation) # Native: ring provides hardware-accelerated AES-256-GCM (target-conditional dep above) -# wasm32: aes-gcm (pure Rust) is used instead — activated by the `wasm` feature -# Note: `dep:ring` is valid here because ring is declared optional in the target section; -# on wasm32 targets the dep simply doesn't exist, so Cargo ignores it safely. +# wasm32: aes-gcm (pure Rust) is used instead — automatically selected via cfg(target_arch) +# aes-gcm/aes/getrandom compile on native but are dead code (all usage behind wasm32 cfg); +# the compiler optimizes them out. encryption = [ "dep:ring", "dep:zeroize", @@ -92,13 +93,16 @@ encryption = [ "dep:sha2", "dep:hmac", "dep:generic-array", + "dep:aes-gcm", + "dep:aes", + "dep:getrandom", ] # C FFI layer (generates include/cachekit.h) ffi = [] -# wasm32 support: getrandom with JS bindings for RNG + RustCrypto AES-GCM for encryption -wasm = ["dep:getrandom", "dep:aes-gcm", "dep:aes"] +# wasm32 support: alias for encryption (deps now folded into encryption feature) +wasm = ["encryption"] # Kani formal verification configuration # Provides mathematical proofs of memory safety for unsafe code and FFI boundaries diff --git a/src/encryption/core.rs b/src/encryption/core.rs index 26db5c4..0ae46c5 100644 --- a/src/encryption/core.rs +++ b/src/encryption/core.rs @@ -78,10 +78,10 @@ static GLOBAL_INSTANCE_COUNTER: LazyLock = LazyLock::new(|| { // ── wasm32: thread_local Cell seeded from getrandom ──────────────────── -/// Per-thread encryptor instance counter for wasm32 (no atomics available). -/// -/// wasm32-unknown-unknown lacks threads, so a thread-local Cell is safe. -/// Seeded from `getrandom` (which uses the JS crypto API via WASM). +// Per-thread encryptor instance counter for wasm32 (no atomics available). +// +// wasm32-unknown-unknown lacks threads, so a thread-local Cell is safe. +// Seeded from `getrandom` (which uses the JS crypto API via WASM). #[cfg(target_arch = "wasm32")] thread_local! { static WASM_INSTANCE_COUNTER: std::cell::Cell = { @@ -347,8 +347,7 @@ impl ZeroKnowledgeEncryptor { key: &[u8], aad: &[u8], ) -> Result, EncryptionError> { - // Time encryption operation (wasm32: Instant unavailable, use 0) - #[cfg(not(target_arch = "wasm32"))] + // Time encryption operation let encryption_start = Instant::now(); // Validate key length @@ -381,10 +380,7 @@ impl ZeroKnowledgeEncryptor { result.extend_from_slice(&ciphertext); // Update metrics for observability - #[cfg(not(target_arch = "wasm32"))] let encryption_micros = encryption_start.elapsed().as_micros() as u64; - #[cfg(target_arch = "wasm32")] - let encryption_micros = 0u64; if let Ok(mut metrics) = self.last_metrics.lock() { *metrics = OperationMetrics::new() .with_encryption(encryption_micros, self.hardware_acceleration_detected); @@ -409,8 +405,7 @@ impl ZeroKnowledgeEncryptor { key: &[u8], aad: &[u8], ) -> Result, EncryptionError> { - // Time decryption operation (wasm32: Instant unavailable, use 0) - #[cfg(not(target_arch = "wasm32"))] + // Time decryption operation let decryption_start = Instant::now(); // Validate key length @@ -454,10 +449,7 @@ impl ZeroKnowledgeEncryptor { plaintext.truncate(decrypted_len); // Update metrics for observability - #[cfg(not(target_arch = "wasm32"))] let decryption_micros = decryption_start.elapsed().as_micros() as u64; - #[cfg(target_arch = "wasm32")] - let decryption_micros = 0u64; if let Ok(mut metrics) = self.last_metrics.lock() { *metrics = OperationMetrics::new() .with_encryption(decryption_micros, self.hardware_acceleration_detected); @@ -559,10 +551,10 @@ impl ZeroKnowledgeEncryptor { return Err(EncryptionError::InvalidKeyLength(key.len())); } - // Minimum: nonce(12) + tag(16) = 28 bytes - if ciphertext.len() < 28 { + // Minimum: nonce(12) + tag(16) + if ciphertext.len() < 12 + 16 { return Err(EncryptionError::InvalidCiphertext( - "ciphertext too short (minimum 28 bytes: 12 nonce + 16 tag)".into(), + "Ciphertext too short".into(), )); } diff --git a/tests/wasm32_compat_tests.rs b/tests/wasm32_compat_tests.rs index 07daaa7..34a41bb 100644 --- a/tests/wasm32_compat_tests.rs +++ b/tests/wasm32_compat_tests.rs @@ -57,3 +57,50 @@ mod byte_storage_roundtrip { assert_eq!("cbor", format); } } + +/// Verify that aes-gcm (wasm32 backend) produces wire-format-compatible +/// output that ring (native backend) can decrypt, and vice versa. +#[cfg(feature = "encryption")] +#[test] +fn cross_backend_wire_format_compatibility() { + use aes_gcm::{ + aead::{Aead, KeyInit, Payload}, + Aes256Gcm, Nonce as AesGcmNonce, + }; + use cachekit_core::ZeroKnowledgeEncryptor; + + let key = [0x42u8; 32]; + let nonce_bytes = [1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; + let plaintext = b"cross-backend wire format test"; + let aad = b"test_aad_domain"; + + // Encrypt with aes-gcm (simulating wasm32 path) + let cipher = Aes256Gcm::new_from_slice(&key).unwrap(); + let nonce = AesGcmNonce::from_slice(&nonce_bytes); + let ct = cipher + .encrypt(nonce, Payload { msg: &plaintext[..], aad: &aad[..] }) + .unwrap(); + + // Build wire format: nonce(12) || ciphertext || tag(16) + let mut wire = Vec::new(); + wire.extend_from_slice(&nonce_bytes); + wire.extend_from_slice(&ct); + + // Decrypt with ring (native path) via ZeroKnowledgeEncryptor + let encryptor = ZeroKnowledgeEncryptor::new().unwrap(); + let decrypted = encryptor.decrypt_aes_gcm(&wire, &key, aad).unwrap(); + assert_eq!(decrypted, plaintext); + + // Also test the reverse: ring encrypts, aes-gcm decrypts + let ring_ciphertext = encryptor.encrypt_aes_gcm(plaintext, &key, aad).unwrap(); + + // Extract nonce and ciphertext+tag from ring output + let ring_nonce = &ring_ciphertext[..12]; + let ring_ct_tag = &ring_ciphertext[12..]; + + let nonce2 = AesGcmNonce::from_slice(ring_nonce); + let decrypted2 = cipher + .decrypt(nonce2, Payload { msg: ring_ct_tag, aad: &aad[..] }) + .unwrap(); + assert_eq!(decrypted2, plaintext); +} From f77592e6997f5eb12db264a03df74db0e5d83544 Mon Sep 17 00:00:00 2001 From: 27bslash6 <2221076+27Bslash6@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:26:13 +1100 Subject: [PATCH 5/8] chore: apply cargo fmt --- src/encryption/core.rs | 20 +++++++++++++++++--- tests/wasm32_compat_tests.rs | 28 +++++++++++++++++++++++----- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/src/encryption/core.rs b/src/encryption/core.rs index 0ae46c5..1b68e70 100644 --- a/src/encryption/core.rs +++ b/src/encryption/core.rs @@ -519,8 +519,16 @@ impl ZeroKnowledgeEncryptor { // encrypt() returns ciphertext || tag (no nonce) — same layout as ring's seal_in_place_append_tag let ciphertext_with_tag = cipher - .encrypt(nonce, Payload { msg: plaintext, aad }) - .map_err(|e| EncryptionError::EncryptionFailed(format!("AES-GCM encrypt failed: {e}")))?; + .encrypt( + nonce, + Payload { + msg: plaintext, + aad, + }, + ) + .map_err(|e| { + EncryptionError::EncryptionFailed(format!("AES-GCM encrypt failed: {e}")) + })?; // Wire format: nonce(12) || ciphertext || tag(16) — identical to native ring output let mut result = Vec::with_capacity(12 + ciphertext_with_tag.len()); @@ -568,7 +576,13 @@ impl ZeroKnowledgeEncryptor { // decrypt() verifies auth tag and returns plaintext let plaintext = cipher - .decrypt(nonce, Payload { msg: encrypted_data, aad }) + .decrypt( + nonce, + Payload { + msg: encrypted_data, + aad, + }, + ) .map_err(|_| EncryptionError::AuthenticationFailed)?; // Metrics: Instant unavailable on wasm32 diff --git a/tests/wasm32_compat_tests.rs b/tests/wasm32_compat_tests.rs index 34a41bb..62f393a 100644 --- a/tests/wasm32_compat_tests.rs +++ b/tests/wasm32_compat_tests.rs @@ -25,7 +25,9 @@ mod byte_storage_roundtrip { let data: &[u8] = b""; let stored = storage.store(data, None).expect("store empty must succeed"); - let (retrieved, _) = storage.retrieve(&stored).expect("retrieve empty must succeed"); + let (retrieved, _) = storage + .retrieve(&stored) + .expect("retrieve empty must succeed"); assert_eq!(data, retrieved.as_slice()); } @@ -35,8 +37,12 @@ mod byte_storage_roundtrip { let storage = ByteStorage::new(None); let data: Vec = (0u8..=255u8).collect(); - let stored = storage.store(&data, None).expect("store binary must succeed"); - let (retrieved, _) = storage.retrieve(&stored).expect("retrieve binary must succeed"); + let stored = storage + .store(&data, None) + .expect("store binary must succeed"); + let (retrieved, _) = storage + .retrieve(&stored) + .expect("retrieve binary must succeed"); assert_eq!(data, retrieved); } @@ -78,7 +84,13 @@ fn cross_backend_wire_format_compatibility() { let cipher = Aes256Gcm::new_from_slice(&key).unwrap(); let nonce = AesGcmNonce::from_slice(&nonce_bytes); let ct = cipher - .encrypt(nonce, Payload { msg: &plaintext[..], aad: &aad[..] }) + .encrypt( + nonce, + Payload { + msg: &plaintext[..], + aad: &aad[..], + }, + ) .unwrap(); // Build wire format: nonce(12) || ciphertext || tag(16) @@ -100,7 +112,13 @@ fn cross_backend_wire_format_compatibility() { let nonce2 = AesGcmNonce::from_slice(ring_nonce); let decrypted2 = cipher - .decrypt(nonce2, Payload { msg: ring_ct_tag, aad: &aad[..] }) + .decrypt( + nonce2, + Payload { + msg: ring_ct_tag, + aad: &aad[..], + }, + ) .unwrap(); assert_eq!(decrypted2, plaintext); } From b98a201a0780e941660ca129f4d832521b93913d Mon Sep 17 00:00:00 2001 From: 27bslash6 <2221076+27Bslash6@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:35:20 +1100 Subject: [PATCH 6/8] chore: bump MSRV from 1.82 to 1.85 cbindgen 0.29.2 pulls clap 4.6.0 which requires edition2024 (stabilized in Rust 1.85, Feb 2025). Pinning transitive deps is fragile; bumping MSRV is the correct fix. --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 2c6c1bf..f288751 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.1" edition = "2021" authors = ["cachekit Contributors"] description = "LZ4 compression, xxHash3 integrity, AES-256-GCM encryption for byte payloads" -rust-version = "1.82" +rust-version = "1.85" license = "MIT" repository = "https://github.com/cachekit-io/cachekit-core" homepage = "https://github.com/cachekit-io/cachekit-core" From 375fbf88cb29d70f1515253b657fdbd30345e49a Mon Sep 17 00:00:00 2001 From: 27bslash6 <2221076+27Bslash6@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:36:40 +1100 Subject: [PATCH 7/8] ci: update MSRV job from 1.82 to 1.85 --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8afc1b..e116e71 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: # Full OS matrix for stable only; MSRV and beta on ubuntu only include: # MSRV - ensures we don't use newer Rust features - - rust: "1.82" + - rust: "1.85" os: ubuntu-latest # Stable - primary target, all platforms - rust: stable @@ -51,7 +51,7 @@ jobs: run: cargo fmt --check - name: Run clippy - if: matrix.rust != '1.82' + if: matrix.rust != '1.85' run: cargo clippy --all-features -- -D warnings - name: Run tests (all features) From 274fd197e30fa1636a1e5552a58ba12d5f07e5e7 Mon Sep 17 00:00:00 2001 From: 27bslash6 <2221076+27Bslash6@users.noreply.github.com> Date: Sun, 15 Mar 2026 21:57:03 +1100 Subject: [PATCH 8/8] chore: update cargo-deny skips for getrandom version changes aes-gcm brings in getrandom 0.2.x (via crypto-common), proptest uses 0.3.x, and tempfile/cbindgen uses 0.4.x. Updated skip entries to use semver ranges instead of exact versions. --- deny.toml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/deny.toml b/deny.toml index af5777c..17ea1fd 100644 --- a/deny.toml +++ b/deny.toml @@ -70,8 +70,16 @@ deny = [] # Skip specific dependencies from multiple-version checks # These are transitive dependencies where version duplication is unavoidable skip = [ - # getrandom 0.2.x via ring, getrandom 0.3.x via proptest/tempfile - { crate = "getrandom@0.2.16", reason = "Transitive via ring crypto lib" }, + # getrandom has 3 major versions in the dep tree: + # 0.2.x via aes-gcm/crypto-common (encryption) + # 0.3.x via proptest (dev-dependency) + # 0.4.x via tempfile/cbindgen (build-dependency) + { crate = "getrandom@0.2", reason = "Transitive via aes-gcm crypto chain" }, + { crate = "getrandom@0.3", reason = "Transitive via proptest (dev-dependency)" }, + # rand_core duplication from aes-gcm (0.6.x) vs proptest (0.9.x) + { crate = "rand_core@0.6", reason = "Transitive via aes-gcm crypto chain" }, + # libc duplication unavoidable (getrandom versions pull different libc) + { crate = "libc@0.2", reason = "Transitive via multiple getrandom versions" }, ] # Skip crate trees entirely (e.g., frequently-updated foundational crates)