Verifying Ed25519 Signatures in Wasm

This page answers one task: an application must check that data really comes from you — an update manifest, a licence file, a plugin bundle — by verifying an Ed25519 signature on the client, in every browser and runtime, with code small enough to review.

Prerequisites

  • [ ] An Ed25519 key pair; the private key stays on your signing server or in a hardware module.
  • [ ] Rust with the wasm32-unknown-unknown target and wasm-bindgen, or a C toolchain for a library such as Monocypher.
  • [ ] Signed data: the message bytes and a 64-byte signature.

Why verify in Wasm

Ed25519 is a modern signature scheme: 32-byte public keys, 64-byte signatures, fast verification and no tricky parameter choices. Verification needs only the public key, so it is safe to do on clients — the secret never leaves the server. The question is where the verification code comes from.

WebCrypto now supports Ed25519 in current browsers and in Node, and where it is available it should be the first choice: native, constant-time, and maintained by the platform. WebAssembly earns its place where WebCrypto falls short: older browsers and webviews without Ed25519, non-browser hosts with no WebCrypto at all (some plugin hosts, embedded runtimes), and situations where you want identical verification semantics everywhere — for example, strict rejection of non-canonical signatures, which implementations historically handled differently. A Wasm module built from a well-reviewed library gives one implementation, one behaviour, in every environment.

WebCrypto Ed25519 versus a Wasm verifier WebCrypto's Ed25519 is native and maintained by the platform but missing in older browsers and some hosts, with implementation-specific edge cases. A Wasm verifier built from a reviewed library behaves identically everywhere and runs in non-browser hosts, at the cost of shipping and updating the code yourself. WebCrypto Ed25519 native, no extra bytes maintained by the platform absent in older engines first choice where available Wasm verifier same semantics everywhere runs in any Wasm host you ship and update it fallback and non-browser hosts

Step 1 — choose a library and build a minimal module

Use a widely reviewed implementation, never your own. In Rust, ed25519-dalek (with default features off) is the common choice; in C, Monocypher or libsodium. Expose exactly one function:

[dependencies]
wasm-bindgen = "0.2"
ed25519-dalek = { version = "2", default-features = false }
use ed25519_dalek::{Signature, VerifyingKey};
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn verify(public_key: &[u8], message: &[u8], signature: &[u8]) -> bool {
    let Ok(pk) = <[u8; 32]>::try_from(public_key) else { return false };
    let Ok(sig) = <[u8; 64]>::try_from(signature) else { return false };
    let Ok(key) = VerifyingKey::from_bytes(&pk) else { return false };
    key.verify_strict(message, &Signature::from_bytes(&sig)).is_ok()
}

verify_strict rejects weak public keys and non-canonical signatures, which removes a class of malleability issues. Returning a plain bool keeps the API impossible to misuse: there is no “partially valid”.

Step 2 — keep the binary small and auditable

Build in release mode with size optimisations and strip everything not needed:

[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
wasm-pack build --release --target web
wasm-opt -Oz pkg/verify_bg.wasm -o pkg/verify_bg.wasm

The result is typically 40–60 KB uncompressed and about 20 KB with Brotli. Small size matters for more than download time: a reviewer can inspect the module’s imports and exports with wasm-tools print and confirm it imports nothing — no network, no randomness, no clock — which is exactly what a pure verifier should need. Record the module’s hash in your release notes so the deployed binary can be checked against the reviewed build, as in producing reproducible Wasm binaries.

Step 3 — pin the public key and verify before use

import init, { verify } from "./pkg/verify.js";

const PUBLIC_KEY = Uint8Array.from(atob("m3H0…base64…"), (c) => c.charCodeAt(0));   // pinned at build time

async function loadVerifiedManifest() {
  await init();
  const [body, sig] = await Promise.all([
    fetch("/updates/manifest.json").then((r) => r.arrayBuffer()),
    fetch("/updates/manifest.json.sig").then((r) => r.arrayBuffer()),
  ]);
  if (!verify(PUBLIC_KEY, new Uint8Array(body), new Uint8Array(sig))) {
    throw new Error("manifest signature invalid");
  }
  return JSON.parse(new TextDecoder().decode(body));            // parse only after verification
}

Verify the exact bytes that were signed — before parsing, decompressing or normalising them — and only then use them. Parsing first and re-serialising for verification invites mismatches. Pin the public key in the application’s code, not in data fetched alongside the signature; a key delivered with the data proves nothing.

Verifying a signed manifest before trusting it The client downloads the manifest bytes and the detached signature. The Wasm verifier checks the signature against the pinned public key over the exact bytes. Only if verification succeeds are the bytes parsed and used; otherwise the update is rejected. download bytes + .sig untrusted pinned public key compiled into the app verify(pk, bytes, sig) Wasm, strict valid? true / false parse and use only after true

Step 4 — prefer WebCrypto where available, fall back to Wasm

Where WebCrypto supports Ed25519, use it, and load the Wasm module only as a fallback:

async function verifyAny(pkBytes, msg, sig) {
  try {
    const key = await crypto.subtle.importKey("raw", pkBytes, { name: "Ed25519" }, false, ["verify"]);
    return await crypto.subtle.verify({ name: "Ed25519" }, key, sig, msg);
  } catch {
    const { default: init, verify } = await import("./pkg/verify.js");
    await init();
    return verify(pkBytes, msg, sig);
  }
}

If identical strictness everywhere is a requirement, use the Wasm path unconditionally; WebCrypto implementations may differ in how they treat edge-case signatures. The trade-offs between platform and Wasm crypto are discussed in WebCrypto vs Wasm for hashing.

Step 5 — test against official vectors

Run the RFC 8032 test vectors and the Wycheproof Ed25519 vectors against the built module, in the target environments, as part of CI. Include negative cases — flipped bits in the message, the signature and the key; wrong lengths; non-canonical encodings — and assert that each is rejected. A verifier that accepts everything passes every positive test, so the negative tests are the ones that matter.

Signing on the server side

Verification is only half the system, and the signing side deserves equal care. Generate the key pair offline or in a hardware security module, and keep the private key out of CI logs, container images and developer laptops. Sign in a dedicated release step that receives the exact bytes to be published and emits a detached .sig file next to them, so the published bytes and the signed bytes are the same artefact. Sign a manifest that lists hashes of the files in a release, rather than every file separately: clients verify one signature, then check each downloaded file’s SHA-256 against the manifest, which scales to many files and lets large files be verified as they stream. Include a version number and an expiry time in the signed manifest, and have clients reject manifests older than the one they already have — otherwise an attacker can replay an old, validly signed manifest pointing at a vulnerable release. These rules are independent of WebAssembly, but the verifier is only as useful as the signing process behind it.

Key rotation and revocation

A pinned key has to be replaceable. Plan for rotation from the first release: pin a small set of public keys rather than one, sign with the newest, and let the verifier accept any key in the set. To rotate, ship a release that adds the new key, wait until most clients have it, start signing with the new key, then remove the old key in a later release. For revocation — a key that may be compromised — the only reliable mechanism on clients is a new release without that key, which is a strong argument for an update path that is itself verified with a separate, rarely used key kept offline. Some designs sign the key set itself with a long-term root key, so clients can accept new signing keys from a signed key list without an application update. Whatever the scheme, document it before you need it; key rotation designed during an incident tends to be rushed and wrong.

Expected output

verify returns true for the published manifest and its signature, and false for every Wycheproof negative vector; the module is about 22 KB compressed, imports nothing, and the browser falls back to it only where WebCrypto lacks Ed25519.

Gotchas

  • Verifying parsed data instead of raw bytes. Re-serialisation changes bytes. Verify exactly what was signed.
  • Shipping the public key with the data. An attacker can replace both. Pin keys in code.
  • Non-strict verification. Lenient checks accept malleable signatures. Use verify_strict or equivalent.
  • Using the result before checking it. Parse and act only after true.
  • Missing negative tests. An always-true verifier passes positive tests. Test rejections.

Performance note

Verifying one signature over a 4 KB manifest took about 55 µs in the Wasm module and 40 µs with WebCrypto in Chrome. Loading and instantiating the 22 KB module took about 3 ms once. Neither is noticeable next to the download being verified.

Verifying one Ed25519 signature over a 4 KB message Microseconds per verification in Chrome using WebCrypto's native Ed25519 and using the Wasm verifier built from ed25519-dalek. microseconds per verification WebCrypto Ed25519 40 µs Wasm (ed25519-dalek) 55 µs

Frequently Asked Questions

Can I sign in the browser too? You can, but signing needs the private key, which should not be on clients for publisher signatures. Client-side signing suits per-user keys, generated and stored with WebCrypto.

Is constant-time execution a concern for verification? Verification uses only public data, so timing side channels matter far less than for signing.

Why not RSA or ECDSA? They work, but Ed25519 has smaller keys and signatures and fewer pitfalls in implementation and parameter choice.

Does the module need randomness? No. Verification is deterministic; a verifier that imports a random source is suspicious.

Can the verifier run in a Web Worker? Yes, and for large files it should, together with the hashing; the module has no imports and runs anywhere.

← Back to Cryptography & Untrusted Code