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-unknowntarget 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.
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.
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_strictor 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.
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.
Related
- Generating secure random numbers in Wasm — when a module does need entropy.
- Writing constant-time code for Wasm — the signing side’s concern.
- Loading untrusted plugins safely — verifying plugin bundles before loading.
- Shrinking Rust Wasm with cargo profiles — keeping the verifier small.
← Back to Cryptography & Untrusted Code