Auditing Wasm Crypto for Side Channels
This page answers one task: your application runs cryptographic code compiled to WebAssembly — signature verification, key exchange, MAC comparison, decryption — and you need confidence that its running time does not leak secrets. You want a review process that covers the source, the compiled module and measured behaviour.
Prerequisites
- [ ] The cryptographic module’s source (Rust, C) and its release
.wasmbuild. - [ ]
wasm-toolsorwasm2watfor reading compiled code. - [ ] A test harness that can call the module many times with controlled inputs (Node or a Wasm runtime).
What leaks and how
A timing side channel exists when the time an operation takes depends on secret data. Classic sources are secret-dependent branches (an if on a
key bit, an early exit from a comparison at the first mismatching byte) and secret-dependent memory access (looking up a table with a secret index, which
changes cache behaviour). An attacker who can trigger the operation repeatedly and measure its duration — locally or, with enough samples, remotely — can
recover the secret statistically.
Constant-time code avoids both: it executes the same instructions and touches the same addresses regardless of secret values, using arithmetic and bit
masks instead of branches. In WebAssembly there is an extra layer of uncertainty. The source may be constant-time, but the compiler (LLVM), the optimiser
(wasm-opt) and finally the browser’s JIT can each introduce branches or table lookups. Wasm has no instruction-level guarantee of constant time, and the
engine’s machine code is out of your control. Auditing therefore checks every layer you can see and measures the one you cannot.
Step 1 — prefer audited constant-time implementations
The best audit is not having to write the code. Use maintained libraries designed to be constant-time — libsodium, the RustCrypto crates (subtle for
constant-time primitives, curve25519-dalek, chacha20poly1305), BearSSL-style C libraries — and record which crates and versions the module uses. Check
that their constant-time claims cover WebAssembly targets; some libraries use assembly or intrinsics on native platforms and fall back to portable code on
Wasm, which may have different properties.
Step 2 — review source for secret-dependent behaviour
For code you write or modify, look for these patterns wherever a value derived from a secret is involved:
if,match,?, earlyreturnor loop bounds that depend on secret data.- Array indexing with secret-derived indices (
table[secret_byte]). - Comparisons with
==on byte slices (Rust’s slice equality returns at the first difference). - Division or modulo by secret values (variable-time on many CPUs).
Replace them with constant-time constructs:
use subtle::{ConstantTimeEq, Choice, ConditionallySelectable};
fn verify_tag(expected: &[u8; 32], received: &[u8; 32]) -> bool {
expected.ct_eq(received).into() // examines all 32 bytes regardless
}
fn select(a: u32, b: u32, pick_b: Choice) -> u32 {
u32::conditional_select(&a, &b, pick_b) // no branch on the secret choice
}
The subtle crate also uses optimisation barriers to discourage the compiler from turning masked arithmetic back into branches.
Step 3 — inspect the compiled module
Compilers sometimes reintroduce branches: an if on a Choice can become a br_if, or a lookup becomes a jump table. Read the compiled functions that handle
secrets:
wasm-tools print app_bg.wasm > app.wat
# find the function by name (keep the name section in the audit build)
grep -n "func \$.*verify_tag" app.wat
In the function body, look for br_if, if, br_table and select whose condition derives from secret inputs, and for i32.load instructions whose
address is computed from secret data. select is a Wasm instruction for choosing between two values without a branch, though engines may still compile it
to a branch in some cases. Compare the audit build (with names) with the release build’s structure — same optimisation flags — so you audit what ships.
Step 4 — measure with statistical timing tests
Because the engine’s JIT is invisible, measure. The dudect approach runs the operation many times with two classes of inputs — for example, a fixed secret versus random secrets, or tags that match in the first byte versus tags that match in none — and tests whether the timing distributions differ, using Welch’s t-test on many samples:
const N = 200_000, fixed = [], random = [];
for (let i = 0; i < N; i++) {
const useFixed = Math.random() < 0.5;
const tag = useFixed ? fixedTag : randomTag();
const t0 = performance.now();
wasm.verify_tag(expectedTag, tag);
(useFixed ? fixed : random).push(performance.now() - t0);
}
console.log("t =", welchT(fixed, random)); // |t| > ~4.5 suggests a leak
Browser timers are coarse, so measure batches of calls per sample, or run the same module under Node or a Wasm runtime with high-resolution timers, on the same engine family. A failing test proves a leak; a passing test raises confidence without proving absence. Run tests on each engine you care about.
Step 5 — document and re-audit
Record the audit: which functions handle secrets, which libraries and versions provide constant-time guarantees, which compiled functions were inspected,
the timing-test results per engine and date. Re-run inspection and timing tests when the compiler, wasm-opt, the libraries or the build flags change —
any of them can reintroduce a branch.
Limits of what Wasm can guarantee
WebAssembly specifies semantics, not timing, and engines are free to compile code in ways that are not constant-time — and to change their compilers between releases. Browsers also add noise (coarse timers, other tabs) that makes remote exploitation harder but not impossible, while local attackers with code in the same page can measure more precisely. For the highest assurance, prefer native WebCrypto for operations it supports (its implementations run outside the JavaScript engine), keep long-lived secrets in non-extractable WebCrypto keys, and treat Wasm crypto as best effort against timing attacks rather than as a hardware-level guarantee.
Memory-access patterns and caches
Branches are the easiest leak to spot; secret-dependent memory access is subtler. A lookup such as an S-box indexed by a key byte touches different cache
lines for different keys, and an attacker sharing the CPU cache — another process, or another tab in some browser configurations — can observe which
lines were touched. In WebAssembly, every load from linear memory compiles to a machine load at an address derived from the Wasm address, so a
secret-dependent i32.load offset in the compiled module is a potential cache leak no matter how the engine compiles it. Constant-time implementations
avoid table lookups on secrets (bitsliced AES, arithmetic-only ChaCha20 and curve operations) or read every table entry and select the needed one with masks.
When reviewing compiled code, follow each load’s address computation back to its inputs; if any input is secret-derived, flag it, even if the source looked
innocent — the compiler may have turned a match into a lookup table.
Building an audit into the release process
Audits decay unless they are repeatable. Keep the timing tests and a control case in the repository and run them in CI on each release candidate, with a generous sample count on a quiet runner; keep a script that extracts the secret-handling functions from the compiled module and fails if new branch or table instructions appear in them compared with the last audited build. Neither replaces expert review for significant changes, but together they catch regressions introduced by dependency or toolchain updates, which is where most reintroduced leaks come from.
Expected output
The audit lists four secret-handling functions, all built on subtle and RustCrypto crates; compiled verify_tag contains no br_if on per-byte
comparisons; dudect-style tests with 200,000 samples show |t| below 2 for the constant-time comparison and above 40 for a deliberately leaky slice comparison
used as a control; and the report is re-run in CI when dependencies change.
Gotchas
- Using
==on secret byte slices. Early exit leaks. Usect_eq. - Auditing source only. Compilers can add branches. Inspect the compiled module.
- Auditing a different build from the one shipped. Flags change code. Audit release settings.
- Trusting one timing test. Absence of evidence is not proof. Test per engine, repeatedly.
- No control case. Without a known-leaky baseline, you cannot tell if the test is sensitive enough.
Performance note
The constant-time comparison of 32-byte tags took about 35 ns regardless of input, versus 8–30 ns for the early-exit comparison depending on how many leading bytes matched — a tiny absolute difference that a statistical test detects easily.
Frequently Asked Questions
Does WebCrypto avoid these problems? Its implementations run native, well-reviewed code outside JavaScript; prefer it for supported algorithms.
Can SIMD help? SIMD can make some constant-time code faster, but the same review applies.
Are remote timing attacks realistic in browsers? Harder due to noise and coarse timers, but local code in the same page can measure precisely.
Do I need to audit verification of public signatures? Verification uses public data; timing leaks matter mostly where secrets are involved.
Can memory access leak secrets even without branches? Yes — table lookups indexed by secret data change cache behaviour; constant-time code avoids secret-dependent addresses entirely.
Related
- Writing constant-time code for Wasm — techniques.
- Running libsodium in the browser — audited primitives.
- Engine Tiering & JIT Compilation — what the engine does to your code.
- WebAssembly Text Format (WAT) Basics — reading compiled functions.
← Back to Cryptography & Untrusted Code