openentropy_wasm/lib.rs
1//! OpenEntropy WebAssembly bindings — browser-based entropy collection.
2//!
3//! Exposes two entropy sources via `wasm-bindgen`:
4//!
5//! 1. **Timing jitter** — `performance.now()` micro-timing variations
6//! 2. **Crypto seed mixer** — `crypto.getRandomValues()` as an OS entropy seed
7//!
8//! Plus a combined SHA-256 conditioned output (`get_random_bytes`) that mixes
9//! both sources. All raw sources produce bytes that can be further conditioned
10//! on the JS side or consumed directly.
11
12use sha2::{Digest, Sha256};
13use wasm_bindgen::prelude::*;
14
15// ---------------------------------------------------------------------------
16// Browser API helpers
17// ---------------------------------------------------------------------------
18
19/// Get `performance.now()` as f64 milliseconds.
20fn performance_now() -> f64 {
21 js_sys::Reflect::get(&js_sys::global(), &JsValue::from_str("performance"))
22 .ok()
23 .and_then(|perf| js_sys::Reflect::get(&perf, &JsValue::from_str("now")).ok())
24 .and_then(|func| {
25 let func: js_sys::Function = func.dyn_into().ok()?;
26 func.call0(&js_sys::global().into()).ok()?.as_f64()
27 })
28 .unwrap_or(0.0)
29}
30
31/// Fill a buffer with `crypto.getRandomValues()`.
32fn crypto_get_random(buf: &mut [u8]) -> bool {
33 let global = js_sys::global();
34 let crypto = js_sys::Reflect::get(&global, &JsValue::from_str("crypto")).ok();
35 let crypto = match crypto {
36 Some(c) if !c.is_undefined() => c,
37 _ => return false,
38 };
39
40 let array = js_sys::Uint8Array::new_with_length(buf.len() as u32);
41 let func = js_sys::Reflect::get(&crypto, &JsValue::from_str("getRandomValues")).ok();
42 let func = match func {
43 Some(f) => match f.dyn_into::<js_sys::Function>() {
44 Ok(f) => f,
45 Err(_) => return false,
46 },
47 None => return false,
48 };
49
50 if func.call1(&crypto, &array).is_err() {
51 return false;
52 }
53
54 array.copy_to(buf);
55 true
56}
57
58// ---------------------------------------------------------------------------
59// XOR-fold helper
60// ---------------------------------------------------------------------------
61
62/// XOR-fold a f64 (8 bytes) into a single byte.
63#[inline]
64fn xor_fold_f64(v: f64) -> u8 {
65 let b = v.to_le_bytes();
66 b[0] ^ b[1] ^ b[2] ^ b[3] ^ b[4] ^ b[5] ^ b[6] ^ b[7]
67}
68
69// ---------------------------------------------------------------------------
70// Timing jitter source
71// ---------------------------------------------------------------------------
72
73/// Collect entropy from `performance.now()` timing jitter.
74///
75/// Performs rapid back-to-back `performance.now()` calls and extracts
76/// entropy from the timing deltas. Browser timer resolution is typically
77/// 5-100 µs (reduced by Spectre mitigations), but the jitter between
78/// consecutive calls still carries entropy from CPU scheduling, cache
79/// state, and GC activity.
80#[wasm_bindgen]
81pub fn collect_timing_jitter(n_bytes: usize) -> Vec<u8> {
82 // Oversample 8x — each timing produces ~1 bit of useful jitter.
83 let raw_count = n_bytes * 8 + 64;
84 let mut timings = Vec::with_capacity(raw_count);
85
86 // Warm up the timer
87 for _ in 0..16 {
88 let _ = performance_now();
89 }
90
91 // Interleave timing measurements with small computational work
92 // to vary cache/pipeline state between measurements.
93 let mut work: u64 = performance_now().to_bits();
94 for _ in 0..raw_count {
95 let t = performance_now();
96 timings.push(t);
97
98 // Small amount of work to perturb microarchitectural state
99 work = work.wrapping_mul(6364136223846793005).wrapping_add(1);
100 std::hint::black_box(work);
101 }
102
103 // Compute deltas
104 let deltas: Vec<f64> = timings.windows(2).map(|w| w[1] - w[0]).collect();
105
106 // XOR consecutive deltas and fold
107 let mut raw = Vec::with_capacity(n_bytes);
108 for pair in deltas.windows(2) {
109 let xored = (pair[0] - pair[1]).to_bits() ^ pair[0].to_bits();
110 raw.push(xor_fold_f64(f64::from_bits(xored)));
111 if raw.len() >= n_bytes {
112 break;
113 }
114 }
115
116 raw.truncate(n_bytes);
117 raw
118}
119
120// ---------------------------------------------------------------------------
121// Crypto seed source
122// ---------------------------------------------------------------------------
123
124/// Collect OS entropy via `crypto.getRandomValues()`.
125///
126/// This uses the browser's built-in CSPRNG, which typically draws from
127/// the OS entropy pool. Useful as a high-quality seed to mix with
128/// timing-based sources.
129#[wasm_bindgen]
130pub fn collect_crypto_random(n_bytes: usize) -> Vec<u8> {
131 let mut buf = vec![0u8; n_bytes];
132 if !crypto_get_random(&mut buf) {
133 // Fallback: fill with timing-based entropy if crypto API unavailable
134 return collect_timing_jitter(n_bytes);
135 }
136 buf
137}
138
139// ---------------------------------------------------------------------------
140// Combined conditioned output
141// ---------------------------------------------------------------------------
142
143/// Collect `n_bytes` of SHA-256 conditioned entropy from all available
144/// browser sources.
145///
146/// Combines timing jitter and crypto.getRandomValues() into a SHA-256
147/// conditioned output stream. This is the recommended entry point for
148/// applications that need high-quality random bytes.
149#[wasm_bindgen]
150pub fn get_random_bytes(n_bytes: usize) -> Vec<u8> {
151 let mut output = Vec::with_capacity(n_bytes);
152 let mut counter: u64 = 0;
153
154 // Collect raw material from both sources
155 let timing = collect_timing_jitter(n_bytes.max(32));
156 let crypto = collect_crypto_random(32);
157
158 // Initial state from crypto source
159 let mut state: [u8; 32] = {
160 let mut h = Sha256::new();
161 h.update(&crypto);
162 h.update(performance_now().to_le_bytes());
163 h.finalize().into()
164 };
165
166 while output.len() < n_bytes {
167 counter += 1;
168 let mut h = Sha256::new();
169 h.update(state);
170 h.update(counter.to_le_bytes());
171
172 // Mix in timing entropy
173 let offset = (counter as usize * 16) % timing.len().max(1);
174 let end = (offset + 16).min(timing.len());
175 if offset < end {
176 h.update(&timing[offset..end]);
177 }
178
179 // Mix in fresh timing sample
180 h.update(performance_now().to_le_bytes());
181
182 let digest: [u8; 32] = h.finalize().into();
183 output.extend_from_slice(&digest);
184
185 // Derive next state separately from output for forward secrecy.
186 // An adversary who observes output cannot reconstruct the state.
187 let mut h2 = Sha256::new();
188 h2.update(digest);
189 h2.update(b"openentropy_state");
190 state = h2.finalize().into();
191 }
192
193 output.truncate(n_bytes);
194 output
195}
196
197/// Return the number of available entropy sources in this WASM environment.
198#[wasm_bindgen]
199pub fn available_source_count() -> u32 {
200 let mut count = 1; // timing jitter is always available
201
202 // Check if crypto.getRandomValues() is available
203 let global = js_sys::global();
204 if let Ok(crypto) = js_sys::Reflect::get(&global, &JsValue::from_str("crypto"))
205 && !crypto.is_undefined()
206 {
207 count += 1;
208 }
209
210 count
211}
212
213#[cfg(test)]
214mod tests {
215 use super::*;
216
217 #[test]
218 fn xor_fold_f64_zero() {
219 assert_eq!(xor_fold_f64(0.0), 0);
220 }
221
222 #[test]
223 fn xor_fold_f64_one() {
224 let v = xor_fold_f64(1.0);
225 // 1.0 as f64 = 0x3FF0000000000000
226 // bytes: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x3F]
227 assert_eq!(v, 0xF0 ^ 0x3F);
228 }
229
230 #[test]
231 fn xor_fold_f64_negative_zero() {
232 // -0.0 as f64 = 0x8000000000000000
233 // bytes: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80]
234 assert_eq!(xor_fold_f64(-0.0), 0x80);
235 }
236
237 #[test]
238 fn xor_fold_f64_nan() {
239 let v = xor_fold_f64(f64::NAN);
240 // NaN has non-zero bits, so fold should be non-trivial
241 // (exact value depends on NaN representation, just check it runs)
242 let _ = v;
243 }
244
245 #[test]
246 fn xor_fold_f64_infinity() {
247 let v = xor_fold_f64(f64::INFINITY);
248 // INFINITY = 0x7FF0000000000000
249 // bytes: [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x7F]
250 assert_eq!(v, 0xF0 ^ 0x7F);
251 }
252}