βββ βββββββββββββββ ββββ ββββββββββββ βββββββ ββββββ ββββββ βββββββββ βββββββ βββββββ
βββ ββββββββββββββββ βββββ βββββββββββββ ββββββββ ββββββ ββββββββββββββββββββββββββββββββββ
βββββββββββββββββββββββββββ ββββββ ββββββββββββββ ββββββ ββββββββ βββ βββ βββββββββββ
βββββββββββββββββββββββββββ ββββββ ββββββββββββββ ββββββ ββββββββ βββ βββ βββββββββββ
βββ ββββββββββββββ βββ βββ βββββββββββ βββ βββββββββββββββββββββββ βββ βββ ββββββββββββ βββ
βββ ββββββββββββββ βββ βββββββββββ βββ βββββββ βββββββββββ βββ βββ βββββββ βββ βββ
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, generates and stores keys, signs and encrypts, and keeps private key material sealed on disk and zeroized in RAM.
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 near-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. On top of that ABI sits a real HSM: a login model, an attribute-bag object store, a full set of cryptographic mechanisms, and encrypted-at-rest key storage.
This is not a stub. A host can drive the module through a complete key lifecycle, and every capability below is exercised by a cross-process pkcs11-tool run, an in-process smoke harness against the built .so, and unit tests.
The ABI and the module
- Loads under OpenSC
pkcs11-tool0.26.1, enumerates the slot and token (-L), advertises 21 mechanisms (-M) - Exports only
C_GetFunctionList(verified withobjdump -T); 34__ubsan_*symbols are kept out of the dynamic table - 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) translates the vendored OASIS headers and asserts@sizeOf/@offsetOf/ constant equality and per-function C-ABI signatures againstck.zig. Spec compliance is a compile-time invariant, not a hope
Tokens, sessions, login
C_InitToken/C_InitPIN/C_SetPIN/C_Login/C_Logoutwith a real Security Officer and User role split- PINs are stretched with Argon2id (t=3, m=64 MiB, p=1); only salt and hash touch disk, never the PIN
- Three wrong attempts trip a lockout (
CKR_PIN_LOCKED), reflected in the token flags - Read-only versus read-write session enforcement and the full session state machine
Objects
C_CreateObject/C_CopyObject/C_DestroyObject/C_GetObjectSize- Two-call
C_GetAttributeValue, per-attribute, withCKR_ATTRIBUTE_SENSITIVEfor sealed key material C_FindObjectstriad withCKA_PRIVATElogin gating (private objects are invisible before login)CKA_MODIFIABLE/CKA_DESTROYABLEhonored
Cryptography
- Digest: SHA-256 / SHA-384 / SHA-512, single-shot and multi-part
- HMAC: HMAC-SHA-256 / 384 / 512, with constant-time tag verification
- AES: CBC, CBC-PAD, and GCM (128 and 256-bit keys), encrypt and decrypt, with streaming GCM that buffers until the tag verifies
- ECDSA: P-256 and P-384 keygen,
CKM_ECDSAandCKM_ECDSA_SHA256, cross-verified against OpenSSL - RSA via libcrypto: 2048 to 4096-bit keygen, PKCS#1 v1.5, PSS, and OAEP; sign / verify / encrypt / decrypt and Sign / VerifyRecover
- Key management:
C_GenerateKey(AES),C_GenerateKeyPair(RSA / EC),C_WrapKey/C_UnwrapKey(AES-KEY-WRAP RFC 3394 and RSA-OAEP),C_DeriveKey(ECDH),C_DigestKey - RNG:
C_GenerateRandomdrawn fromstd.Io.randomSecure(the OS CSPRNG)
Encrypted at rest, zeroized in RAM
- Token objects persist to a file. Sensitive attribute values are sealed with AES-256-GCM under a per-token master key
- The master key is wrapped under a single User-PIN keyslot (Argon2id-derived KEK). There is no Security-Officer keyslot for user secrets by design
- On
C_Logout,C_CloseAllSessions, andC_Finalize, sealed secrets are re-sealed and the master key is wiped withstd.crypto.secureZero. A failed re-seal fails closed (the plaintext is scrubbed in place)
See learn/CONFORMANCE.md for the precise return code at every deliberate boundary.
git clone https://github.com/CarterPerez-dev/Cybersecurity-Projects.git
cd Cybersecurity-Projects/PROJECTS/advanced/hsm-emulator
./install.shinstall.sh checks for Zig 0.16, OpenSC, and OpenSSL, builds the module in ReleaseSafe, runs the ABI cross-check plus the smoke test, and confirms pkcs11-tool can load it. Then drive it like any real token. Point both storage paths at a scratch directory so you do not write into $HOME:
export ANGELAMOS_HSM_TOKEN=/tmp/hsm-token
export ANGELAMOS_HSM_OBJECTS=/tmp/hsm-objects
MOD=zig-out/lib/libhsm.so
pkcs11-tool --module $MOD -L # list slots and token
pkcs11-tool --module $MOD -M # list 21 mechanisms
pkcs11-tool --module $MOD --init-token --label demo --so-pin 12345678
pkcs11-tool --module $MOD --init-pin --so-pin 12345678 --pin 1234
pkcs11-tool --module $MOD -l --pin 1234 \
--keypairgen --key-type rsa:2048 --label signer
pkcs11-tool --module $MOD -l --pin 1234 \
--sign --mechanism SHA256-RSA-PKCS --label signer \
--input-file message.bin --output-file sig.binAvailable slots:
Slot 0 (0x0): AngelaMos HSM Emulator Slot 0
token state: uninitialized
Tip
This project uses just as a command runner. Type just to see everything. just spy -L wraps the module in pkcs11-spy.so and logs every Cryptoki call with its arguments and return code. It is the fastest way to watch the ABI work.
Install: curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin
This project ships a full teaching track. Read it in order, or jump to what you need.
| Doc | What it covers |
|---|---|
learn/00-OVERVIEW.md |
What an HSM is, why it exists, and a 10-minute tour |
learn/01-CONCEPTS.md |
Key sensitivity, the login model, encryption at rest, constant-time, padding oracles, with real breaches |
learn/02-ARCHITECTURE.md |
The three-layer design, object model, locking, the threat model |
learn/03-IMPLEMENTATION.md |
A code walkthrough of the ABI, an end-to-end operation, and the secret-handling patterns |
learn/MECHANICS.md |
How each cryptographic mechanism actually works, byte by byte |
learn/CONFORMANCE.md |
The v2.40 conformance statement: every narrowed behavior and its exact return code |
learn/04-CHALLENGES.md |
Extension ideas from beginner to expert |
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 β argument and FSM validation
βββββββββββββββββββββββββ¬ββββββββββββββββββββββ
β
βββββββββββββββββββββββββ΄ββββββββββββββββββββββ
β core state src/core/*.zig β global instance behind a lock,
β state Β· session Β· object_store Β· token β sessions, objects, PIN, master key
βββββββββββββββββββββββββ¬ββββββββββββββββββββββ
β
βββββββββββββββββββββββββ΄ββββββββββββββββββββββ
β crypto src/crypto/*.zig β pure-Zig std.crypto for AES/EC/
β digest Β· mac Β· cipher Β· ecdsa Β· rsa Β· β hash/HMAC/ECDH, libcrypto for RSA,
β keystore Β· pin Β· openssl β Argon2id KDF, GCM envelope at rest
βββββββββββββββββββββββββββββββββββββββββββββββββ
Design decisions: non-RSA crypto is pure-Zig std.crypto. RSA links libcrypto (OpenSSL EVP) because std.crypto has no public RSA. The RSA binding is hand-written extern declarations, not @cImport, so the production .so exports nothing but C_GetFunctionList. The RNG is std.Io.randomSecure, which draws fresh entropy from the OS on every call (getrandom(2) on Linux) and keeps no CSPRNG state in process memory. The ABI is structured for v2.40 with room to add the v3.0 C_GetInterface surface later.
zig build # build the module β zig-out/lib/libhsm.so (Debug)
zig build --release=safe # the shipped artifact: ReleaseSafe, UB checks as traps
zig build test # ABI cross-check vs OASIS headers + the unit suite
zig build smoke # dlopen the built .so and exercise the whole ABI as a host would
just ci # fmt-check + test + smokeThe 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. It walks a full lifecycle: init, login, keygen, sign, encrypt, wrap, derive, GCM streaming, dual-function, recover, operation-state, and the conformance edges.
Note
Plain zig build produces a Debug binary. The shipped artifact is --release=safe (ReleaseSafe), which keeps every undefined-behavior check live and turns it into a fail-closed trap rather than silent corruption. Set both ANGELAMOS_HSM_TOKEN and ANGELAMOS_HSM_OBJECTS to a scratch path for tests and tool runs, or the module falls back to $HOME/.angelamos-hsm-*.
No Zig or OpenSC on the host? The container builds the module and drives it end to end through pkcs11-tool: token init, RSA and EC keygen and signing, AES-CBC round-trip, all inside the image.
just docker-demo # build the image, then run the full pkcs11-tool demoOr with Docker directly:
docker build -t angelamos-hsm:latest .
docker run --rm angelamos-hsm:latestA multi-stage build compiles the module in ReleaseSafe in a debian-slim builder, then ships only the .so plus opensc and libssl3 in a roughly 96 MB runtime image. The demo exits non-zero if any signature fails to verify.
hsm-emulator/
βββ build.zig # addLibrary(.dynamic), version script, sanitize_c=.trap,
β # libcrypto link, translate-c ABI cross-check, test + smoke
βββ 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 behind a lock, init-args parsing, generation counter
β β βββ lock.zig # spinlock wrapper over std.atomic.Mutex
β β βββ env.zig # reads std.c.environ for storage paths at the C boundary
β β βββ token.zig # token record: PIN slots, fail counters, wrapped master key
β β βββ session.zig # session table, op-state unions, the RUP-safe GCM buffer
β β βββ object_store.zig# attribute-bag objects, the selective-sealing codec
β βββ api/
β β βββ general.zig # C_Initialize / Finalize / GetInfo / WaitForSlotEvent
β β βββ slot_token.zig # slot + token + mechanism queries, InitToken / InitPIN / SetPIN
β β βββ session.zig # OpenSession / Login / Logout / Get+SetOperationState
β β βββ object.zig # CreateObject / Find / GetAttributeValue
β β βββ crypto_ops.zig # the digest / sign / verify / encrypt / decrypt / dual surface
β β βββ keymgmt.zig # GenerateKey(Pair) / WrapKey / UnwrapKey / DeriveKey
β β βββ random.zig # GenerateRandom / SeedRandom
β βββ crypto/
β βββ openssl.zig # hand-written extern EVP/BN/OSSL_PARAM declarations
β βββ pin.zig # Argon2id derive / verify (constant-time)
β βββ digest.zig # SHA-2 hasher union + serializable op-state
β βββ mac.zig # HMAC-SHA-2 union
β βββ cipher.zig # AES-CBC/CBC-PAD/GCM + RFC 3394 key wrap
β βββ ecdsa.zig # P-256/384 keygen, sign, verify, ECDH, curve OID + EC point DER
β βββ rsa.zig # the stateless libcrypto RSA bridge
β βββ keystore.zig # master-key gen, wrap/unwrap, seal/unseal envelope
βββ tests/abi_test.zig # @sizeOf/@offsetOf/constant/signature cross-check vs OASIS
βββ examples/smoke.zig # loads the built .so via dlopen and drives the whole lifecycle
βββ vendor/pkcs11/ # unmodified OASIS v2.40 headers (build-time cross-check only)
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) | --init-token --init-pin --login --change-pin |
| M2 β | Objects + find, CKA_PRIVATE gating |
-O --read-object, two-call attribute fetch |
| M3 β | RNG + SHA-2 + HMAC + AES-CBC/CBC-PAD/GCM | --hash --sign --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) | sign / verify / encrypt / decrypt through the module |
| M6 β | Encrypted store at rest (AES-256-GCM under Argon2id KEK) | survives restart; wrong PIN and tamper fail closed |
| M7 β | Hardening (secret zeroization, fail-closed relock) + Docker | leak-checked build, just docker-demo exits 0 |
| M9 β | Key management: wrap / unwrap, ECDH derive, digest-key | RFC 3394 KAT, ECDH matches openssl pkeyutl -derive |
| M10 β | Crypto surface: GCM streaming, dual-function, Sign/VerifyRecover, op-state | chunked GCM equals one-shot, recover round-trips |
| M11 β | Conformance pass + CONFORMANCE.md |
every N/A boundary asserted against the built .so |
| M12 β | Learn modules + mechanism reference | this learn/ track |
AGPL 3.0. The vendored OASIS headers under vendor/pkcs11/ keep their original copyright and are used only for the build-time cross-check.
