-
-
Notifications
You must be signed in to change notification settings - Fork 1
wasm
A short introduction to WebAssembly concepts as they apply to the leviathan-crypto library. If you already understand WASM, skip to Project-Specific Concepts.
WebAssembly (WASM) is a binary instruction format that runs in browsers and
server-side runtimes alongside JavaScript. Rather than a programming language
one writes by hand, it serves as a compilation target. Code is written in a higher-level
language, compiled to .wasm, and then executed by the browser.
Consider it a small, fast virtual machine built into every modern browser.
JavaScript can load a .wasm binary, call its exported functions, and read
its results. The WASM code runs in its own sandboxed memory space, and thus cannot
touch the DOM, access JavaScript variables, or reach the network. It computes
and returns values, and that is its sole function.
When a browser encounters a .wasm binary, it performs two steps:
-
Compilation: The binary is validated and compiled into native machine code. This is fast because WASM is already a low-level format, requiring less work for the compiler compared to parsing and optimizing JavaScript.
-
Instantiation: The compiled module is paired with its imports, such as a memory object, to create a live instance. The instance's exported functions are then callable from JavaScript.
Once instantiated, calling a WASM function is similar to calling any JavaScript
function: you pass arguments, it runs, and it returns a result. The key difference
lies in how it runs. WASM still goes through the runtime's WASM JIT (V8 uses
Liftoff and TurboFan, SpiderMonkey uses Baseline and Cranelift, JavaScriptCore
uses BBQ and OMG; there is no ahead-of-time path in mainstream engines today).
What makes the lowering more predictable than equivalent JavaScript is the
structure of the input. Typed bytecode has no hidden classes, structured control
flow has no eval or computed gotos, and there is no polymorphism-driven
specialization or deoptimization tied to runtime type guards. The JS-level
timing oracles that motivate constant-time-coding discipline do not exist for
WASM.
Leviathan performs all cryptographic computations in WASM because JavaScript engines offer no formal constant-time guarantees for arbitrary code. The JS-level JIT can introduce timing variations through speculative type specialization, hidden-class transitions, and deoptimizations that leak information about secret-derived state. WASM removes that surface: typed bytecode and structured control flow give the JIT no opening to speculate against. The hardware below is still a real CPU with caches and speculative execution, so constant-time-at-the-algorithm-level discipline is what actually closes the timing surface; WASM is the deployment vehicle that lets that discipline survive into the browser.
For architectural details and security rationale, see architecture.md.
TLDR: TypeScript handles the API, and WASM handles the math.
A WebAssembly.Module is a compiled .wasm binary and a stateless
template for creating instances. You can compile a module once and create
multiple instances from it. For example, SealStreamPool
uses one compiled module to create many worker instances.
A WebAssembly.Instance is a live, runnable copy of a module, complete with
its own memory and state. When you call init({ serpent: serpentWasm }), the
library compiles the Serpent WASM binary and creates a single instance. All
Serpent classes (Serpent, SerpentCtr, SerpentCbc) share this instance.
A WebAssembly.Memory is a contiguous block of bytes, essentially a
Uint8Array that WASM functions can read and write, also known as linear
memory. Each of our WASM modules gets its own memory: most use 3 pages
(192 KB); aes and mldsa use 4 pages (256 KB); cte uses 1 page (64 KB).
The TypeScript layer communicates with WASM by writing inputs to specific offsets in this memory, calling a WASM function, and then reading the outputs from other offsets. There is no other communication channel, no function arguments for large data, and no return values beyond a single number. Memory is the data bus.
A WASM instance exposes exports: functions and memory that JavaScript can access. In leviathan-crypto, every WASM module exports:
- Getter functions like getKeyOffset() and getChunkPtOffset(): these return the memory offsets where the TypeScript layer should write inputs or read outputs.
- Operation functions like chachaEncryptChunk() and sha256Final(): these perform the actual cryptographic computation on data already in memory.
- wipeBuffers(): this zeros all sensitive regions of memory and is called by every class's dispose() method.
- memory: the linear memory object itself, which allows the TypeScript layer to create Uint8Array views over it.
When instantiating a module, you can pass imports: objects the WASM code
needs from the host. All leviathan-crypto modules export their own
WebAssembly.Memory and import nothing. The JS side provides inputs to a
module by writing into its exported memory at known offsets, calling the
relevant export, and (where the inputs were secret) zeroing the written
region afterward.
The WASM binaries in this project are written in AssemblyScript:
a TypeScript-like language that compiles to WebAssembly. It resembles
TypeScript but targets WASM instead of JavaScript. The source code
resides in src/asm/ and compiles into .wasm binaries in build/.
AssemblyScript was selected because its syntax is familiar to TypeScript developers. It produces small binaries and grants low-level control over memory layout without requiring C, C++, or Rust.
In this project, a thunk is a gzip-compressed, base64-encoded WASM binary embedded directly
within a TypeScript file. The WASM thunk files in src/ts/embedded/
(such as chacha20.ts and serpent.ts) each export a single constant:
export const WASM_GZ_BASE64 = 'H4sIAAAAAAAAA...'This represents the entire compiled .wasm binary, encoded as a base64 string. When
you call init({ chacha20: chacha20Wasm }) with the embedded blob, the library
decodes this string back into bytes and compiles it into a
WebAssembly.Module.
Embedding the binary as a string enables the library to function with zero
configuration. You do not need to serve .wasm files from a CDN, configure MIME
types, or establish a build plugin to manage binary imports. Simply npm install and
import. Gzip compression significantly reduces the embedded footprint, typically
to around 20-25% of the uncompressed WASM binary size. The tradeoff is a
decompression step at init time using DecompressionStream. For production deployments where bundle size is
critical, the library also accepts URL, ArrayBuffer, Response, and pre-compiled
WebAssembly.Module sources. See loader.md for details.
TLDR: Thunks are build artifacts generated by scripts/embed-wasm.ts.
Pool-worker IIFE bundles in the same directory are generated by
scripts/embed-workers.ts. Both are gitignored and regenerated during each
build. Avoid manual edits.
Each WASM module divides its linear memory into fixed regions at known offsets.
For example, the ChaCha20 module has a region for the key, a region for the
nonce, a region for plaintext input, a region for ciphertext output, and so on.
These offsets are defined in src/asm/*/buffers.ts and never change at runtime.
The TypeScript layer calls getter functions (like getKeyOffset()) to
determine where each region starts, then reads and writes Uint8Array slices at those
positions. This is the only way data moves between TypeScript and
WASM. There is no serialization, no copying to intermediate buffers, and no function call
overhead for large data. Data is transferred via direct byte writes to shared memory.
The buffer layouts for each module are documented in architecture.md.
WASM modules must be compiled and instantiated before use. Because compilation
returns a Promise, this is an asynchronous operation. Rather than hiding this
behind lazy auto-initialization, which would make every cryptographic call
implicitly asynchronous and create race conditions, the library requires an explicit
init() call up front. If you forget, every class immediately throws an error message
indicating which init() call is missing. This is deliberate.
See init.md for the full API.
| Document | Description |
|---|---|
| index | Project Documentation index |
| architecture | Repository structure, build and CI, WASM modules, public API, test suite, and security posture |
| init |
init() API and WasmSource types |
| loader | how WASM binaries are loaded and instantiated |
| authenticated encryption |
Seal, SealStream, OpenStream: cipher-agnostic AEAD APIs using a CipherSuite such as SerpentCipher, XChaCha20Cipher, or AESGCMSIVCipher
|
| signing |
Sign, SignStream, VerifyStream: scheme-agnostic signing layer |
| signaturesuite |
SignatureSuite interface and the shipped suite catalog (ML-DSA, SLH-DSA, Ed25519, ECDSA-P256, hybrids) |
- Sign Tools
-
SignatureSuite
- format-byte catalog, hybrid composite encodings, custom suite contract
- Serpent-256 TypeScript | WASM
-
Serpent,SerpentCtr,SerpentCbc,SerpentGenerator
-
- ChaCha20 TypeScript | WASM
-
ChaCha20,Poly1305,ChaCha20Poly1305,XChaCha20Poly1305,ChaCha20Generator
-
- AES TypeScript | WASM
-
AES,AESCbc,AESCtr,AESGCM,AESGCMSIV,AESGenerator
-
- ML-DSA TypeScript | WASM
- pure (FIPS 204):
MlDsa44,MlDsa65,MlDsa87 - pure-mode suites:
MlDsa44Suite,MlDsa65Suite,MlDsa87Suite - prehash suites:
MlDsa44PreHashSuite,MlDsa65PreHashSuite,MlDsa87PreHashSuite
- pure (FIPS 204):
- SLH-DSA TypeScript | WASM
- pure (FIPS 205):
SlhDsa128f,SlhDsa192f,SlhDsa256f - pure-mode suites:
SlhDsa128fSuite,SlhDsa192fSuite,SlhDsa256fSuite - prehash suites:
SlhDsa128fPreHashSuite,SlhDsa192fPreHashSuite,SlhDsa256fPreHashSuite
- pure (FIPS 205):
- Ed25519 TypeScript | WASM
-
Ed25519(pure + Ed25519ph),Ed25519Suite,Ed25519PreHashSuite
-
- ECDSA-P256 TypeScript | WASM
-
EcdsaP256(hedged + RFC 6979),EcdsaP256Suite - DER codec:
ecdsaSignatureToDer,ecdsaSignatureFromDer,encodeEcPrivateKey,decodeEcPrivateKey,pointDecompress
-
- Hybrid composites PQ-only | Classical+PQ
- PQ-only:
MlDsa44SlhDsa128fSuite,MlDsa65SlhDsa192fSuite,MlDsa87SlhDsa256fSuite - Classical+PQ:
MlDsa44Ed25519Suite,MlDsa65Ed25519Suite,MlDsa44EcdsaP256Suite,MlDsa65EcdsaP256Suite
- PQ-only:
- X25519 TypeScript | WASM
-
X25519,KeyAgreementError(RFC 7748)
-
- ML-KEM TypeScript | WASM
-
MlKem512,MlKem768,MlKem1024
-
-
Ratchet (SPQR)
-
KDFChain,ratchetInit,kemRatchetEncap,kemRatchetDecap,RatchetKeypair,SkippedKeyStore
-
- Hashing overview
- SHA-2 TypeScript | WASM
-
SHA256,SHA384,SHA512,SHA224,SHA512_224,SHA512_256 -
HMAC_SHA256,HMAC_SHA384,HMAC_SHA512,HKDF_SHA256,HKDF_SHA512
-
- SHA-3 TypeScript | WASM
-
SHA3_224,SHA3_256,SHA3_384,SHA3_512,SHAKE128,SHAKE256
-
- BLAKE3 TypeScript | WASM
-
BLAKE3,BLAKE3Stream,BLAKE3KeyedHash,BLAKE3KeyedHashStream -
BLAKE3DeriveKey,BLAKE3DeriveKeyStream,BLAKE3OutputReader,BLAKE3Hash
-
-
KMAC
-
CSHAKE128,CSHAKE256,KMAC128,KMAC256,KMACXOF128,KMACXOF256
-
-
Merkle
-
MerkleVerifier,MerkleLog -
SignedLog,Sha256Tree,Blake3Tree,MemoryStorage
-
-
Fortuna CSPRNG
-
Fortuna,SerpentGenerator,ChaCha20Generator,AESGenerator,SHA256Hash,SHA3_256Hash,BLAKE3Hash
-
- Utils TypeScript | WASM
-
constantTimeEqual,randomBytes,wipe, encoding helpers
-
-
TypeScript interfaces
-
Hash,KeyedHash,Blockcipher,Streamcipher,AEAD,Generator,HashFn
-