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 .wasm build.
  • [ ] wasm-tools or wasm2wat for 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.

Layers where timing leaks can appear Leaks can originate in the source code's algorithm, be introduced by the compiler's optimisations into the Wasm module, be introduced by wasm-opt, or be introduced by the engine's JIT when it generates machine code. Source review and Wasm inspection cover the first three; only measurement covers the last. source algorithm branches/indices on secrets LLVM codegen may add branches wasm-opt passes may restructure engine JIT machine code you cannot see measurement the only check on all layers

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, ?, early return or 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.

Variable-time versus constant-time comparison in compiled Wasm A slice equality comparison compiles to a loop with an early exit br_if on the first differing byte, so its duration reveals how many leading bytes matched. A constant-time comparison accumulates differences with bitwise OR across all bytes and branches only on the final result. a == b (early exit) loop with br_if per byte stops at first mismatch time reveals matching prefix leaks ct_eq (accumulate) OR of XORs over all bytes same work every time one branch on the final bit constant-time intent

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. Use ct_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.

Measured t-statistic for two tag comparisons Absolute Welch t-statistic from 200,000 timing samples comparing fixed-prefix and random tags, for an early-exit slice comparison and for a constant-time comparison. Values above about 4.5 indicate a timing difference. |t| (higher = leak detected) early-exit comparison 43 constant-time ct_eq 1.6

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.

← Back to Cryptography & Untrusted Code