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 SerpentCbc or SerpentCtr. If you use the dangerUnauthenticated primitive directly and compute your own HMAC-SHA256 tag, compare that tag with constantTimeEqual. 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_SHA256 or HMAC_SHA512 and 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] Seal with SerpentCipher does 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 raw SerpentCbc primitive.


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