11 KiB
██╗ ██╗███████╗███╗ ███╗ ███████╗███╗ ███╗██╗ ██╗██╗ █████╗ ████████╗ ██████╗ ██████╗
██║ ██║██╔════╝████╗ ████║ ██╔════╝████╗ ████║██║ ██║██║ ██╔══██╗╚══██╔══╝██╔═══██╗██╔══██╗
███████║███████╗██╔████╔██║ █████╗ ██╔████╔██║██║ ██║██║ ███████║ ██║ ██║ ██║██████╔╝
██╔══██║╚════██║██║╚██╔╝██║ ██╔══╝ ██║╚██╔╝██║██║ ██║██║ ██╔══██║ ██║ ██║ ██║██╔══██╗
██║ ██║███████║██║ ╚═╝ ██║ ███████╗██║ ╚═╝ ██║╚██████╔╝███████╗██║ ██║ ██║ ╚██████╔╝██║ ██║
╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝
A software Hardware Security Module that compiles to a real Cryptoki (PKCS#11) shared object. Load it with
pkcs11-tool, OpenSSL, or any PKCS#11 host the same way you would a real smartcard or HSM — it speaks the C ABI byte-for-byte.
Why PKCS#11 in Zig
PKCS#11 (Cryptoki) is the C-ABI standard that smartcards, YubiKeys, and cloud HSMs all speak. A conforming module is a .so that exports one function — C_GetFunctionList — returning a 68-entry table of function pointers in a fixed canonical order. Get one struct offset or one pointer slot wrong and the host loads garbage.
That makes it a perfect showcase for Zig's C interop: extern struct with natural alignment, callconv(.c), a version script that exports exactly one symbol, and a hand-written ABI that is machine-checked against the official OASIS headers at build time.
What Works Today (M0)
- Loads cleanly under OpenSC
pkcs11-tool0.26.1 — enumerates the slot and token (-L) and advertises 19 mechanisms (-M) - Exports only
C_GetFunctionList(verified withobjdump -T) - The full v2.40 ABI hand-written in
src/ck.zig: every type, 200+ constants, every struct, and the 68-entryCK_FUNCTION_LISTin canonical order - A build-time cross-check (
zig build test) that translates the vendored OASIS headers and asserts@sizeOf/@offsetOf/ constant equality and per-function C-ABI signatures againstck.zig— the spec compliance is a compile-time invariant, not a hope - General + slot/token entry points implemented for real; session, object, crypto, key-management, and RNG entry points are typed stubs returning
CKR_FUNCTION_NOT_SUPPORTEDuntil their milestone lands
Quick Start
git clone https://github.com/CarterPerez-dev/Cybersecurity-Projects.git
cd Cybersecurity-Projects/PROJECTS/advanced/hsm-emulator
./install.sh
install.sh checks for Zig 0.16, OpenSC, and OpenSSL, builds the module in ReleaseSafe, runs the ABI cross-check + smoke test, and confirms pkcs11-tool can load it. Then drive it like any real token:
pkcs11-tool --module zig-out/lib/libhsm.so -L # list slots and token
pkcs11-tool --module zig-out/lib/libhsm.so -M # list mechanisms
Available slots:
Slot 0 (0x0): AngelaMos HSM Emulator Slot 0
token state: uninitialized
[!TIP] This project uses
justas a command runner. Typejustto see everything.just spy -Lwraps the module inpkcs11-spy.soand logs every Cryptoki call — the fastest way to watch the ABI work.Install:
curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin
Architecture
The same three-layer split SoftHSM2 uses: a thin C-ABI façade over typed core state over the store and crypto backends.
PKCS#11 host (pkcs11-tool, OpenSSL, p11-kit)
│ C ABI
▼
┌───────────────────────────────────────────┐
│ C_GetFunctionList (src/main.zig) │ one exported symbol,
│ 68-entry CK_FUNCTION_LIST │ one version script
└───────────────────────┬─────────────────────┘
│
┌───────────────────────┴─────────────────────┐
│ ABI façade src/ck.zig + src/api/*.zig │ hand-written Cryptoki ABI
│ general · slot_token · session · object · │ + per-call entry points
│ crypto_ops · keymgmt · random │
└───────────────────────┬─────────────────────┘
│
┌───────────────────────┴─────────────────────┐
│ core state src/core/{state,lock}.zig │ global instance, init args,
│ │ C-boundary-safe locking
└───────────────────────┬─────────────────────┘
│
┌───────────────────────┴─────────────────────┐
│ store + crypto (built milestone by │ in-memory → encrypted file
│ milestone: sessions, objects, AES/EC/RSA) │ backend at rest
└───────────────────────────────────────────────┘
Design decisions: non-RSA crypto is pure-Zig std.crypto; RSA links libcrypto (OpenSSL EVP) since std.crypto has no public RSA. RNG is sourced from getrandom(2) directly (there is no std.Io at the C boundary, and std.crypto.random was removed in Zig 0.16). The ABI is structured for v2.40 with room to add the v3.0 C_GetInterface surface later.
Build and Test
zig build # build the module → zig-out/lib/libhsm.so
zig build test # ABI cross-check vs OASIS headers + unit tests
zig build smoke # dlopen the built .so and exercise the ABI as a host would
just ci # fmt-check + test + smoke
The smoke harness in examples/smoke.zig is not a unit test — it dlopens the actual built shared object and calls through the function list exactly like an external host, so it catches export and ABI-shape bugs that in-process tests cannot.
Run in Docker
No Zig or OpenSC on the host? The container builds the module and drives it end-to-end through pkcs11-tool — token init, RSA + EC keygen and signing, AES-CBC round-trip — all inside the image.
just docker-demo # build the image, then run the full pkcs11-tool demo
Or with Docker directly:
docker build -t angelamos-hsm:latest .
docker run --rm angelamos-hsm:latest
A multi-stage build compiles the module in ReleaseSafe in a debian-slim builder, then ships only the .so plus opensc and libssl3 in a ~96 MB runtime image. The demo exits non-zero if any signature fails to verify.
Project Structure
hsm-emulator/
├── build.zig # addLibrary(.dynamic), version script, test + smoke steps, translate-c
├── build.zig.zon # package manifest
├── pkcs11.map # version script — exports only C_GetFunctionList
├── src/
│ ├── ck.zig # the hand-written Cryptoki v2.40 ABI (types, constants, structs, list)
│ ├── config.zig # identity strings, key-size bounds, mechanism list (no magic numbers)
│ ├── util.zig # comptime helpers (space-padded fixed fields)
│ ├── main.zig # exported C_GetFunctionList + the wired 68-slot table
│ ├── core/
│ │ ├── state.zig # global instance, init-args parsing, atomic init flag
│ │ └── lock.zig # spinlock wrapper (std.Thread.Mutex is gone in 0.16)
│ └── api/
│ ├── general.zig # C_Initialize / Finalize / GetInfo (locking template)
│ ├── slot_token.zig # slot + token + mechanism queries
│ └── session.zig, object.zig, crypto_ops.zig, keymgmt.zig, random.zig
├── tests/abi_test.zig # @sizeOf/@offsetOf/constant asserts, incl. cross-check vs OASIS
├── examples/smoke.zig # loads the built .so via dlopen and drives it
└── vendor/pkcs11/ # unmodified OASIS v2.40 headers (build-time cross-check only)
Roadmap
Each milestone ends with a proof from a real external tool — no feature is "done" until pkcs11-tool or OpenSSL exercises it.
| Milestone | Scope | Proof |
|---|---|---|
| M0 ✅ | Scaffold + hand-written ABI + loadable .so |
pkcs11-tool -L/-M, objdump -T |
| M1 | Sessions + login + PIN (Argon2id, lockout) | pkcs11-tool --init-token --init-pin --login --change-pin |
| M2 | Objects + find (in-memory), CKA_PRIVATE gating |
pkcs11-tool -O --read-object |
| M3 | RNG + SHA + HMAC + AES-GCM/CBC | --hash --encrypt --decrypt --generate-random |
| M4 | ECDSA P-256/384 + keygen | --keypairgen EC --sign, cross-verify with OpenSSL |
| M5 | RSA via libcrypto (v1.5 / PSS / OAEP) | OpenSSL pkcs11 provider signs through the module |
| M6 | Encrypted file backend at rest (AES-256-GCM under Argon2id KEK) | persist across restart; tamper → fails closed |
| M7 | Hardening (secret zeroization, fail-closed) + Docker | — |
| M8 | Learn modules + mechanism reference + final docs | — |