Node Addon Library using Zig

zig-crypto: Zero-allocation N-API native extension for Node.js


zig-crypto is a hobby project that bundles high-performance backend utilities — ID generation (nanoid, Snowflake), codecs (base64, base58, hex), and encrypted tokens (ZST) — into a single N-API native module written in Zig. It ships prebuilt binaries for Linux (x64/ARM64, glibc/musl), macOS (x64/ARM64), and Windows (x64). No node-gyp, no compilation, npm install zig-crypto and it works.

Repository: GitHub - Coderx85/zig-crypto-library: A zero-allocation, N-API-stable Node.js native extension for high-performance cryptographic primitives and ID generation, written in Zig. Prebuilt binaries for Linux (x64/ARM64), macOS (x64/ARM64), and Windows (x64). No node-gyp or compilation required. · GitHub

Why I built this

I was tired of installing four separate npm packages (nanoid, uuid, base-x, jsonwebtoken) and watching them allocate Uint8Array after Uint8Array in hot paths. I wanted to see if Zig’s “zero-cost everything” philosophy could translate to Node.js land. The answer: yes, but the boundary layer (N-API) is where all the complexity lives.

Interesting things I learned

N-API is tedious but worth it. I started with node-addon-api (C++) and immediately hit ABI breakage across Node versions. Switching to raw N-API (C ABI) meant writing verbose napi_get_cb_info / napi_create_string_utf8 boilerplate, but the binary I compile today will load on Node 18, 20, 22, and whatever comes next. That stability is the whole point of a native extension.

Arena allocators are perfect for request-scoped work. Every N-API call gets an ArenaAllocator backed by std.heap.page_allocator. All temp memory — parsed JSON strings, base64 decode buffers, decrypted plaintext — lives in that arena and is freed in one deinit() call when the function returns. No individual free() tracking, no leaks, no fragmentation across calls.

A 64KB CSPRNG pool eliminates syscall overhead. nanoid() and zst.generateKey() both pull from a threadlocal 64KB pool refilled via getrandom/arc4random_buf/BCryptGenRandom. For nanoid specifically, each byte maps to the 64-char alphabet via byte & 0x3F — zero modulo bias, zero branching, no rejection sampling. A single nanoid takes < 1 µs because there’s no allocator traffic and no OS call.

Base58 without BigInt. Most JS base58 implementations use BigInt division in a loop — O(n²) for large inputs. I implemented limb arithmetic (base-256 to base-58 conversion using 58-bit accumulators) in Zig. The result is ~100× faster on multi-kilobyte inputs because it processes chunks instead of individual digits.

ZST: PASETO ideas, JWT API. ZST (Zig Secure Token) is my encrypted token format. It uses XChaCha20-Poly1305 (192-bit nonce = safe random nonces forever) with BLAKE2b for subkey derivation. The header has no alg field — that prevents the infamous alg:none attack. It also has a built-in rev (revocation counter) claim for O(1) “logout everywhere” semantics. The API surface matches jsonwebtoken (sign, verify, decode) so it’s a drop-in replacement.

Design decisions

Decision Rationale
N-API (C), not node-addon-api ABI stability across Node versions
Arena allocator per call All temp memory freed at once, zero tracking overhead
Stack allocation ≤8KB Heap only for outputs larger than a page
page_allocator + external finalizers for batch TigerBeetle pattern — JS owns the Buffer, Zig frees on GC
No async CPU-bound operations; sync is simpler and faster
ReleaseSmall + strip Binary < 500 KB per platform
Hand-rolled N-API registration 12 exports, explicit control over type marshalling

Architecture

index.ts (runtime arch/platform detection)
    ↓
N-API C ABI (src/napi.zig — 12 exports)
    ↓
Zig Core Engine
  ├── src/id/nanoid.zig      (CSPRNG pool, zero-modulo-bias)
  ├── src/id/snowflake.zig   (mutex-protected sequence)
  ├── src/codec/base64.zig   (SIMD decode, constant-time option)
  ├── src/codec/base58.zig   (limb arithmetic, no BigInt)
  ├── src/codec/hex.zig      (standard encode/decode)
  └── src/token/zst.zig      (XChaCha20-Poly1305 + BLAKE2b KDF)
    ↓
Crypto Primitives
  ├── src/crypto/blake2b.zig
  ├── src/crypto/rand.zig
  └── src/token/xchacha20.zig

Performance snapshot

Operation zig-crypto Pure-JS equivalent Speed-up
Token sign 15 µs 80 µs (jose HS256) ×5.3
Token verify 26 µs 102 µs (jose HS256) ×4.0
AEAD encrypt 15 µs 40 µs (@noble/ciphers) ×2.6
nanoid() < 1 µs ~3 µs (nanoid) ×3+

The gap comes from running the full pipeline in native code with arena-allocated temp memory and no JS object allocations. Pure-JS libs allocate multiple Uint8Array/ArrayBuffer per operation, which triggers GC pressure at scale.

Supported Zig versions

Developed and tested with Zig 0.11.0. The build.zig uses standard build system features that should remain compatible with 0.12+ (no deprecated APIs).

AI / LLM usage disclosure

I used LLM assistance (Claude/Kimi) for brainstorming API design, writing the TypeScript wrapper layer (index.ts), generating test cases, and drafting documentation. The Zig native code — N-API bindings, crypto primitives, ID generation, and token logic — was written by hand. No LLM was used for the Zig core implementation.

2 Likes