utils - xero/leviathan-crypto GitHub Wiki
Utilities
Pure TypeScript utilities that ship alongside the WASM-backed primitives. No init() call required; all functions work immediately on import.
Table of Contents
The module covers encoding (hex, UTF-8, and base64 conversions between strings
and Uint8Array), security (constant-time comparison and secure memory
wiping), byte manipulation (XOR and concatenation), and random byte generation.
Security Notes
constantTimeEqual runs inside a WASM SIMD module that removes the JS JIT
compiler from the timing picture. Use this function whenever you compare MACs,
hashes, authentication tags, or any secret-derived values. Never use ===,
Buffer.equals, or a manual loop-with-break for security comparisons. Those
leak timing information that attackers can exploit to recover secrets. Inputs
are limited to CT_MAX_BYTES (32768 bytes) per side.
The length check in constantTimeEqual is not constant-time, because array
length is non-secret in all standard protocols. If your use case treats length
as secret, pad to equal length before comparing.
wipe zeroes a typed array in-place. Call it on keys, plaintext buffers,
and any other sensitive data as soon as you are done with them. JavaScript's
garbage collector does not guarantee timely or complete erasure of memory.
randomBytes delegates to crypto.getRandomValues (Web Crypto API), which
is cryptographically secure in all modern browsers and Node.js 19+. It does not
fall back to Math.random or any insecure source.
The encoding functions (hexToBytes, bytesToHex, utf8ToBytes,
bytesToUtf8, base64ToBytes, bytesToBase64) perform no security-sensitive
operations.
API Reference
constantTimeEqual
constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean
Returns true if a and b contain identical bytes. Returns false
immediately if the arrays differ in length (length is non-secret in all
standard protocols).
The comparison runs inside a WASM SIMD module, removing the JS JIT compiler
from the timing picture. Speculative optimisation and branch prediction inside
the engine cannot short-circuit the loop. This function requires WebAssembly
SIMD; on runtimes without SIMD support it throws Error: leviathan-crypto: constantTimeEqual requires WebAssembly SIMD โ this runtime does not support it
at first call. SIMD has been a baseline feature of all major browsers and
Node.js 16.4+ since 2021, and the library's symmetric and post-quantum
primitives require SIMD already; this function tightens that posture
consistently.
The WASM module ORs the two 64-bit lanes of the SIMD accumulator into a scalar,
then folds it to 0/1 via ~((diff | -diff) >> 63) & 1. The only remaining
observable control flow is the outer length check, which operates on non-secret
input.
Zero-copy design. The WASM module has no internal staging buffers. The
wrapper writes a into WASM linear memory at offset 0 and b immediately
after at offset a.length, then passes those two offsets directly to the
compare function. No data is duplicated inside the WASM binary; the
comparison reads in-place from the caller-owned positions. This eliminates an
entire class of residual-data risk: there is no intermediate copy that outlives
the operation.
Guaranteed memory zeroing. After every comparison, the wrapper clears both
input regions via mem.fill(0, 0, a.length * 2) inside a finally block. The
wipe runs whether the comparison succeeds, throws, or is interrupted. Sensitive
material written into WASM linear memory does not persist past the end of the
call.
WASM SIMD JITs do not guarantee cycle-level constant time; the branch-free tail
is best-effort hardening against branch-prediction side channels. For
defence-in-depth against timing attacks on tag comparisons, pair this primitive
with an authenticated construction (SerpentCipher, XChaCha20Cipher) that
terminates on mismatch with a generic error message.
Maximum input size is CT_MAX_BYTES (32768 bytes) per side.
Throws RangeError if either array exceeds this limit.
See asm_ct.md for the full WASM module reference, including the SIMD algorithm, reduction technique, instantiation model, and memory layout.
Use this function when working with lower-level unauthenticated primitives or building custom authenticated protocols on top of the hashing and KDF APIs. Common cases:
- Encrypt-then-MAC with
SerpentCbcorSerpentCtr. If you use thedangerUnauthenticatedprimitive directly and compute your own HMAC-SHA256 tag, compare that tag withconstantTimeEqual. See the example below. - Argon2id key verification. When re-deriving an Argon2id hash to verify a passphrase, the final comparison must be constant-time. See argon2id.md for the full example.
- Custom HMAC protocols. Any protocol where you derive a MAC with
HMAC_SHA256orHMAC_SHA512and compare it against a received value. See examples.md for a complete example.
CT_MAX_BYTES
const CT_MAX_BYTES: 32768
Maximum input size accepted by constantTimeEqual per
side, in bytes. Reflects the physical layout of the WASM comparison module: one
64 KiB page of linear memory split equally between the two input buffers (32
KiB each).
In practice the largest comparison performed anywhere in this library is a 32-byte HMAC-SHA-256 tag. This limit only matters for custom protocols that compare unusually large values. Use this constant to guard your own inputs rather than hardcoding the magic number:
import { constantTimeEqual, CT_MAX_BYTES } from 'leviathan-crypto'
if (a.length > CT_MAX_BYTES || b.length > CT_MAX_BYTES) {
throw new RangeError(`comparison input exceeds CT_MAX_BYTES (${CT_MAX_BYTES})`)
}
const match = constantTimeEqual(a, b)
hexToBytes
hexToBytes(hex: string): Uint8Array
Converts a hex string to a Uint8Array. Accepts lowercase or uppercase
characters. An optional 0x or 0X prefix is stripped automatically. Throws
RangeError on odd-length or non-hex input.
bytesToHex
bytesToHex(bytes: Uint8Array): string
Converts a Uint8Array to a lowercase hex string (no prefix).
utf8ToBytes
utf8ToBytes(str: string): Uint8Array
Encodes a JavaScript string as UTF-8 bytes using the platform TextEncoder.
bytesToUtf8
bytesToUtf8(bytes: Uint8Array): string
Decodes UTF-8 bytes to a JavaScript string using the platform TextDecoder.
base64ToBytes
base64ToBytes(b64: string): Uint8Array
Decodes a base64 or base64url string to a Uint8Array. Handles padded,
unpadded, and legacy %3d padding. Unpadded base64url input is accepted (RFC
4648 ยง5). Throws RangeError if the input is not valid base64 (illegal
characters or rem=1 length).
bytesToBase64
bytesToBase64(bytes: Uint8Array, url?: boolean): string
Encodes a Uint8Array to a base64 string. Pass url = true for base64url (RFC
4648 ยง5), which uses - and _ instead of + and / with no padding
characters. Defaults to standard base64.
wipe
wipe(data: Uint8Array | Uint16Array | Uint32Array): void
Zeroes a typed array in-place by calling fill(0). Use this to clear keys,
plaintext, or any sensitive material when you are done with it.
xor
xor(a: Uint8Array, b: Uint8Array): Uint8Array
Returns a new Uint8Array where each byte is a[i] ^ b[i]. Both arrays must
have the same length. Throws RangeError if they differ.
concat
concat(...arrays: Uint8Array[]): Uint8Array
Concatenates one or more Uint8Arrays into a new array.
randomBytes
randomBytes(n: number): Uint8Array
Returns n cryptographically secure random bytes via the Web Crypto API
(crypto.getRandomValues).
hasSIMD
hasSIMD(): boolean
Returns true if the current runtime supports WebAssembly SIMD (the v128
type and associated operations). The result is computed once on first call by
validating a minimal v128 WASM module, then cached for subsequent calls.
A public utility for consumers who want to feature-detect before calling
init(). The library requires SIMD for serpent, chacha20, and
constantTimeEqual; calls into those modules throw a branded error on
non-SIMD runtimes. sha2 and sha3 do not require SIMD.
Supported in all modern browsers and Node.js 16+.
Usage Examples
Converting between formats
import { hexToBytes, bytesToHex, utf8ToBytes, bytesToUtf8 } from 'leviathan-crypto'
// Hex round-trip
const bytes = hexToBytes('deadbeef')
console.log(bytesToHex(bytes)) // "deadbeef"
// 0x prefix is accepted
const prefixed = hexToBytes('0xCAFE')
console.log(bytesToHex(prefixed)) // "cafe"
// UTF-8 round-trip
const encoded = utf8ToBytes('hello world')
console.log(bytesToUtf8(encoded)) // "hello world"
Base64 encoding and decoding
import { bytesToBase64, base64ToBytes, utf8ToBytes, bytesToUtf8 } from 'leviathan-crypto'
const data = utf8ToBytes('leviathan-crypto')
const b64 = bytesToBase64(data)
console.log(b64) // "bGV2aWF0aGFuLWNyeXB0bw=="
// base64url variant (safe for URLs and filenames, no padding)
const b64url = bytesToBase64(data, true)
console.log(b64url) // "bGV2aWF0aGFuLWNyeXB0bw"
// Decoding (accepts both standard and url variants)
const decoded = base64ToBytes(b64)
console.log(bytesToUtf8(decoded)) // "leviathan-crypto"
Encrypt-then-MAC with SerpentCbc
If you use SerpentCbc or SerpentCtr directly with { dangerUnauthenticated: true }, you are responsible for authentication. The correct pattern is
Encrypt-then-MAC: encrypt first, then compute HMAC-SHA256 over the ciphertext,
and use constantTimeEqual to verify on decrypt.
import {
init, SerpentCbc, HMAC_SHA256,
constantTimeEqual, randomBytes, wipe, concat,
} from 'leviathan-crypto'
import { serpentWasm } from 'leviathan-crypto/serpent/embedded'
import { sha2Wasm } from 'leviathan-crypto/sha2/embedded'
await init({ serpent: serpentWasm, sha2: sha2Wasm })
const encKey = randomBytes(32)
const macKey = randomBytes(32)
const iv = randomBytes(16)
// โโ Encrypt โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const cbc = new SerpentCbc({ dangerUnauthenticated: true })
const ct = cbc.encrypt(encKey, iv, plaintext)
cbc.dispose()
// MAC covers iv || ct so the IV is authenticated too
const hmac = new HMAC_SHA256()
const tag = hmac.hash(macKey, concat(iv, ct))
hmac.dispose()
const envelope = concat(iv, ct, tag) // store or transmit this
// โโ Decrypt โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const receivedIv = envelope.subarray(0, 16)
const receivedCt = envelope.subarray(16, envelope.length - 32)
const receivedTag = envelope.subarray(envelope.length - 32)
const hmac2 = new HMAC_SHA256()
const expectedTag = hmac2.hash(macKey, concat(receivedIv, receivedCt))
hmac2.dispose()
// Always verify before decrypting โ never decrypt unauthenticated ciphertext
if (!constantTimeEqual(expectedTag, receivedTag)) {
wipe(expectedTag)
throw new Error('Authentication failed')
}
const cbc2 = new SerpentCbc({ dangerUnauthenticated: true })
const pt = cbc2.decrypt(encKey, receivedIv, receivedCt)
cbc2.dispose()
wipe(expectedTag)
[!NOTE]
SealwithSerpentCipherdoes all of this for you; key derivation, IV handling, Encrypt-then-MAC, and constant-time verification, with no manual steps. The pattern above is only relevant if you need direct access to the rawSerpentCbcprimitive.
Generating random keys and nonces
import { randomBytes } from 'leviathan-crypto'
const key = randomBytes(32) // 256-bit symmetric key
const nonce = randomBytes(24) // 192-bit nonce for XChaCha20
const iv = randomBytes(16) // 128-bit IV for Serpent-CBC
Wiping sensitive data after use
import { randomBytes, wipe } from 'leviathan-crypto'
const key = randomBytes(32)
// ... use the key for encryption / decryption ...
// When done, zero the key material so it does not linger in memory
wipe(key)
// key is now all zeroes
XOR and concatenation
import { xor, concat, randomBytes } from 'leviathan-crypto'
const a = randomBytes(16)
const b = randomBytes(16)
// XOR two equal-length arrays
const xored = xor(a, b)
// Concatenate two arrays
const combined = concat(a, b)
console.log(combined.length) // 32
Error Conditions
| Function | Condition | Behavior |
|---|---|---|
hexToBytes |
Odd-length string | Throws RangeError |
hexToBytes |
Invalid hex characters | Throws RangeError |
base64ToBytes |
Invalid length or characters | Throws RangeError |
constantTimeEqual |
Arrays differ in length | Returns false immediately |
constantTimeEqual |
Either array exceeds CT_MAX_BYTES |
Throws RangeError |
xor |
Arrays differ in length | Throws RangeError |
randomBytes |
crypto not available |
Throws (runtime-dependent) |
hasSIMD |
WebAssembly not available |
Returns false |
Cross-References
| Document | Description |
|---|---|
| index | Project Documentation index |
| architecture | architecture overview, module relationships, buffer layouts, and build pipeline |
| asm_ct | WASM module reference for constantTimeEqual: SIMD algorithm, zero-copy layout, instantiation model, and memory zeroing |
| serpent | Serpent modes consume keys from randomBytes; wrappers use wipe and constantTimeEqual |
| chacha20 | ChaCha20/Poly1305 classes use randomBytes for nonce generation |
| sha2 | SHA-2 and HMAC classes; output often converted with bytesToHex |
| sha3 | SHA-3 and SHAKE classes; output often converted with bytesToHex |
| argon2id | passphrase-based encryption; uses constantTimeEqual for hash verification |
| examples | full HMAC-SHA256 custom protocol example using constantTimeEqual |
| types | public interfaces whose implementations rely on these utilities |
| test-suite | test suite structure and vector corpus |