WASM X25519 key agreement (RFC 7748) — Tier-2 fallback for environments where
crypto.subtlelacks X25519 or is unavailable.
Module wasmX25519 | Source packages/front/fw/src/crypto/wasm/x25519.js | Deps wasmRuntime | Worker-safe yes
WASM-backed Diffie-Hellman key agreement over Curve25519: keygen + scalar multiplication (shared secret derivation). The binary frames the libsodium curve25519 ref10 implementation (impl-struct-direct, no runtime dispatcher, no SHA-512 or RNG seam), compiled to a freestanding WASM32 reactor with zero imports.
This is the Tier-2 fallback for environments where crypto.subtle is unavailable (non-secure-context, locked-down workers). In secure contexts prefer ../webcrypto/x25519.md, which is hardware-accelerated. The pure-JS ../pkc/x25519.md remains the universal default.
The binary ships scalar-only (x25519.scalar.wasm; simd: false in targets.json — ref10 is a scalar implementation, no simd128 lane). The { variant: 'scalar' } option is passed explicitly to wasmRuntime.load because selectVariant() defaults to simd and has no automatic fallback.
Resolve
const wasmX25519 = runtime.resolve('wasmX25519');
// Returns: { isAvailable, keygen, deriveBits }
API
| Method | Signature | Returns |
|---|---|---|
isAvailable |
() => boolean |
true when WebAssembly is present |
keygen |
(secretKey?: Uint8Array) => Promise<{publicKey: Uint8Array, secretKey: Uint8Array}|false> |
Fresh X25519 key pair |
deriveBits |
(secretKey: Uint8Array, publicKey: Uint8Array) => Promise<Uint8Array|false> |
32-byte shared secret, or false |
Key encodings
| Item | Encoding | Length |
|---|---|---|
secretKey |
raw scalar, RFC 7748 §5 wire format (little-endian) | 32 bytes |
publicKey |
raw u-coordinate on Curve25519 (Montgomery x, little-endian) | 32 bytes |
deriveBits output |
raw shared secret (u-coordinate of sk·pk) |
32 bytes |
All methods resolve false when:
- a key has the wrong byte length or is not a
Uint8Array deriveBitsproduces an all-zero shared secret (small-subgroup / low-order public key)WebAssemblyis unavailable, or the binary fails to load (fetch error, ABI mismatch)- the WASM entry returns a non-zero status
None of them ever reject.
Scalar clamping
The binary applies the RFC 7748 §5 clamping internally (clear low 3 bits, clear high bit, set bit 254). The caller-supplied secretKey is passed through as-is; keygen returns the original unclamped scalar as secretKey.
all-zero rejection
When the public key is a low-order / small-subgroup point (e.g. the all-zeros u-coordinate), the shared secret is all-zero. The libsodium ref10 implementation detects this and returns a non-zero status; the wrapper maps it to false. This matches the RFC 7748 §6.1 recommendation.
Examples
const wasmX25519 = runtime.resolve('wasmX25519');
if (!wasmX25519.isAvailable()) {
// Fall back to webcrypto/x25519 or pure-JS pkc/x25519.
}
// Generate a key pair.
const A = await wasmX25519.keygen();
const B = await wasmX25519.keygen();
// A and B: { publicKey: Uint8Array(32), secretKey: Uint8Array(32) }
// Derive the shared secret (both sides produce the same 32 bytes).
const sharedAB = await wasmX25519.deriveBits(A.secretKey, B.publicKey);
const sharedBA = await wasmX25519.deriveBits(B.secretKey, A.publicKey);
// sharedAB and sharedBA are byte-identical
// Derive the public key from an existing scalar.
const sk = crypto.getRandomValues(new Uint8Array(32));
const pair = await wasmX25519.keygen(sk);
// pair.secretKey === sk (same 32 bytes)
// pair.publicKey === scalar·basepoint (computed by the binary)
Worker Usage
const worker = fw.createWorker(
function ({ libs }) {
// wasmRuntime fetches the colocated .wasm by name inside the worker —
// no main-thread closure is serialized.
libs.wasmX25519.keygen().then((pair) => {
self.postMessage(pair !== false);
});
},
{ dependencies: ['wasmX25519'] }
);
Notes
- Prefer WebCrypto in secure contexts:
webcrypto/x25519is hardware-accelerated where available. UsewasmX25519only whencrypto.subtleis unavailable or lacks theX25519named curve (older browsers). - Scalar-only:
x25519.simd.wasmis not shipped. The{ variant: 'scalar' }pin is mandatory; a default load would fail on the missing SIMD binary. - No clamping on input: the wrapper stores and returns the raw scalar. The binary applies RFC 7748 §5 clamping on every call. If you compare
keygen().secretKeybyte-for-byte toscalarMultBase(sk)in the pure-JS tier, results match because both apply the same clamping. - all-zero rejection:
deriveBitsreturnsfalsewhen the shared secret is all-zero (detected by the binary). This guards against small-subgroup attacks; see RFC 7748 §6.1. - No KDF included: the 32-byte shared secret is raw. Run it through an HKDF (
crypto/hash/hkdforwebcrypto/hkdf) before using it as keying material. - No-throw contract: all methods resolve to a value or
false; they never reject. - Output is always a fresh copy:
readBytescopies out of WASM linear memory into a newUint8Array. The caller owns the returned buffer.
See also
- webcrypto/x25519 — hardware-backed X25519 in secure contexts (prefer this)
- pkc/x25519 — pure-JS X25519 (universal default)
- wasmEcc — WASM ECDSA + ECDH on NIST P-256/384/521