WASM X25519 key agreement (RFC 7748) — Tier-2 fallback for environments where crypto.subtle lacks 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
  • deriveBits produces an all-zero shared secret (small-subgroup / low-order public key)
  • WebAssembly is 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/x25519 is hardware-accelerated where available. Use wasmX25519 only when crypto.subtle is unavailable or lacks the X25519 named curve (older browsers).
  • Scalar-only: x25519.simd.wasm is 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().secretKey byte-for-byte to scalarMultBase(sk) in the pure-JS tier, results match because both apply the same clamping.
  • all-zero rejection: deriveBits returns false when 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/hkdf or webcrypto/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: readBytes copies out of WASM linear memory into a new Uint8Array. 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