While exploring a browser authentication project, I needed to generate the same RSA key pair from the same seed. Web Crypto provides key generation, but it does not accept a caller-supplied seed. Rust compiled to WebAssembly makes it possible to experiment with that missing capability.
Correction, September 2026: the earlier version used RSA-OAEP with signing operations and described the surrounding design as zero-knowledge authentication. The API combination was invalid, and deterministic key generation alone does not justify that security claim. This article now focuses on a tested key-generation experiment.
First, a working Web Crypto example #
RSA-PSS supports signing and verification. RSA-OAEP supports encryption and decryption. Requesting sign and verify when generating an RSA-OAEP key causes an error.
This example creates a random signing key pair, signs a message, and verifies the signature. It works in a browser module on HTTPS or localhost, or in Node.js 24 and newer. The private key is not exportable.
// Run in a browser module or in Node.js 24+.
const keyPair = await crypto.subtle.generateKey(
{
name: "RSA-PSS",
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: "SHA-256",
},
false,
["sign", "verify"],
);
const message = new TextEncoder().encode("Key-generation example");
const signature = await crypto.subtle.sign(
{ name: "RSA-PSS", saltLength: 32 }, keyPair.privateKey, message,
);
const valid = await crypto.subtle.verify(
{ name: "RSA-PSS", saltLength: 32 }, keyPair.publicKey, signature, message,
);
if (!valid) throw new Error("Signature verification failed");
console.log("Signature verified:", valid);
export { keyPair, message, signature };
See the Web Crypto key-generation documentation
. There is no seed parameter in generateKey(), so this API cannot reproduce a key from a supplied seed.
Seeded generation in Rust #
For a reproducibility experiment, use a named random-number generator and lock the dependencies. ChaCha20Rng gives us an explicit algorithm; StdRng does not promise the same algorithm across library versions.
The example accepts exactly 32 seed bytes and returns a 2048-bit RSA private key in PKCS#8 DER format. The seed is secret material: anyone who has it can regenerate the private key. The fixed seeds used in the tests are public test fixtures and must never be used for real keys.
Create this Cargo.toml:
[package]
name = "ecostack-wasm-keygen"
version = "0.1.0"
edition = "2021"
publish = false
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
rand_chacha = { version = "=0.3.1", default-features = false }
rand_core = "=0.6.4"
rsa = { version = "=0.9.8", default-features = false, features = ["u64_digit"] }
wasm-bindgen = "=0.2.100"
[profile.test]
opt-level = 2
Then create src/lib.rs:
use rand_chacha::ChaCha20Rng;
use rand_core::SeedableRng;
use rsa::{pkcs8::EncodePrivateKey, RsaPrivateKey};
use wasm_bindgen::prelude::*;
// Reproducibility demonstration only; not a password authentication protocol.
fn derive_pkcs8(seed: &[u8]) -> Result<Vec<u8>, String> {
let seed: [u8; 32] = seed.try_into().map_err(|_| "Expected a 32-byte seed")?;
let mut rng = ChaCha20Rng::from_seed(seed);
let key = RsaPrivateKey::new(&mut rng, 2048).map_err(|e| e.to_string())?;
let der = key.to_pkcs8_der().map_err(|e| e.to_string())?;
Ok(der.as_bytes().to_vec())
}
#[wasm_bindgen]
pub fn generate_keypair(seed: &[u8]) -> Result<Vec<u8>, JsValue> {
derive_pkcs8(seed).map_err(|message| JsValue::from_str(&message))
}
#[cfg(test)]
mod tests {
use super::*;
use rsa::pkcs8::DecodePrivateKey;
#[test]
fn reproducible_valid_key() {
let first = derive_pkcs8(&[7; 32]).unwrap();
let second = derive_pkcs8(&[7; 32]).unwrap();
assert_eq!(first, second);
RsaPrivateKey::from_pkcs8_der(&first).unwrap().validate().unwrap();
assert_ne!(first, derive_pkcs8(&[8; 32]).unwrap());
}
#[test]
fn rejects_wrong_seed_lengths() {
for length in [0, 31, 33] {
assert!(derive_pkcs8(&vec![0; length]).is_err());
}
}
}
The encoding method is to_pkcs8_der(), supplied by the EncodePrivateKey trait. The exported function returns a JavaScript error for an invalid seed instead of panicking.
This demonstrates reproducibility with the checked-in lockfile. Changes to the random-number generator or RSA key-generation implementation may change the resulting key, even for the same seed. Do not use this as a key-recovery format without a versioned specification and compatibility tests.
Compile and call the WebAssembly module #
The code blocks above are loaded directly from the source files tested alongside this article. You can download Cargo.toml, src/lib.rs, webcrypto.mjs, and the pinned Cargo.lock. Save them in a project directory, keeping lib.rs inside src.
From that directory, with Rust and wasm-pack installed:
cargo test --locked
rustup target add wasm32-unknown-unknown
wasm-pack build --target web --dev
Serve the generated pkg directory from your local web server and call the module from a browser script:
import init, { generate_keypair } from "./pkg/ecostack_wasm_keygen.js";
await init();
const seed = crypto.getRandomValues(new Uint8Array(32));
const privateKeyDer = generate_keypair(seed);
// Import the generated bytes without logging or transmitting the private key.
const privateKey = await crypto.subtle.importKey(
"pkcs8",
privateKeyDer,
{ name: "RSA-PSS", hash: "SHA-256" },
false,
["sign"],
);
RSA key generation is synchronous inside this module and can block the main thread. For an interactive application, evaluate moving the computation into a Web Worker and measure its impact on the interface.
What this does and does not establish #
The tests check repeatability, different-seed behavior, invalid inputs, and whether the exported bytes describe a valid RSA private key. The JavaScript tests also check signature verification and rejection of a changed message. These checks establish that the examples run; they do not establish a secure login protocol.
In particular, deriving a key directly from a guessable password can let someone check password guesses against the corresponding public key. Keeping the password off the network is not, by itself, a zero-knowledge proof or protection against offline guessing.
The pinned Rust rsa release also has a documented timing-attack advisory
. This example uses it for an isolated key-generation demonstration, not as a recommendation for production RSA signing, decryption, or password authentication. A production authentication design needs a reviewed protocol and suitable maintained implementations.
The useful lesson is narrower: WebAssembly can expose functionality that a browser API does not provide, but that flexibility does not automatically add security. For another practical look at the tradeoffs, see the WebAssembly sorting comparison , whose results are specific to its recorded test environment.