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) diff --git a/Cargo.toml b/Cargo.toml index 353aae8..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" @@ -36,20 +36,32 @@ 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 } 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 } + +# RustCrypto: pure-Rust AES-256-GCM for wasm32 targets (ring requires clang + C asm) +aes-gcm = { version = "0.10", features = ["zeroize"], optional = true } +aes = { version = "0.8", features = ["zeroize"], 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" @@ -59,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"] @@ -69,6 +82,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 — 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", @@ -76,11 +93,17 @@ 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: 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 [package.metadata.kani] 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) 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..1b68e70 100644 --- a/src/encryption/core.rs +++ b/src/encryption/core.rs @@ -20,16 +20,33 @@ //! 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"))] 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 @@ -45,17 +62,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; @@ -103,7 +159,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? /// @@ -120,11 +178,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 @@ -151,13 +212,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())), }) @@ -225,17 +288,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); - - // Check for overflow (after 2^32 operations on this instance, require new instance) + #[cfg(target_arch = "wasm32")] + let counter = { + let c = self.nonce_counter.get(); + self.nonce_counter.set(c.wrapping_add(1)); + c + }; + + // Check for exhaustion (after 2^32 operations, require new instance) if counter >= u32::MAX as u64 { return Err(EncryptionError::NonceCounterExhausted); } @@ -253,7 +321,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 @@ -265,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], @@ -304,8 +380,7 @@ 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; + let encryption_micros = encryption_start.elapsed().as_micros() as u64; if let Ok(mut metrics) = self.last_metrics.lock() { *metrics = OperationMetrics::new() .with_encryption(encryption_micros, self.hardware_acceleration_detected); @@ -323,6 +398,7 @@ impl ZeroKnowledgeEncryptor { /// /// # Returns /// Decrypted plaintext data + #[cfg(not(target_arch = "wasm32"))] pub fn decrypt_aes_gcm( &self, ciphertext: &[u8], @@ -373,8 +449,7 @@ 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; + let decryption_micros = decryption_start.elapsed().as_micros() as u64; if let Ok(mut metrics) = self.last_metrics.lock() { *metrics = OperationMetrics::new() .with_encryption(decryption_micros, self.hardware_acceleration_detected); @@ -384,7 +459,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]; @@ -418,13 +493,112 @@ 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) + if ciphertext.len() < 12 + 16 { + return Err(EncryptionError::InvalidCiphertext( + "Ciphertext too short".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. // 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::*; @@ -559,6 +733,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 @@ -603,6 +778,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) @@ -928,6 +1104,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 diff --git a/tests/wasm32_compat_tests.rs b/tests/wasm32_compat_tests.rs new file mode 100644 index 0000000..62f393a --- /dev/null +++ b/tests/wasm32_compat_tests.rs @@ -0,0 +1,124 @@ +//! 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); + } +} + +/// 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); +}