18 KiB
Extension Challenges
You have a working software HSM. Now make it yours. These challenges are ordered by difficulty, and each one names the real files and functions you will touch so you are not hunting. Start easy to learn the codebase's shape, then go deeper.
The golden rule of this project applies to every challenge: nothing is done until an external tool exercises it. Add a unit test against a published vector, then prove it through pkcs11-tool or the smoke harness.
Easy Challenges
Challenge 1: Add SHA-224 as a digest mechanism
What to build: Support CKM_SHA224 alongside the existing SHA-256/384/512 digests.
Why it's useful: SHA-224 is still required by some compliance profiles, and adding it teaches you the exact path a new mechanism takes through the layers.
What you'll learn:
- The four-step "add a mechanism" path described in 02-ARCHITECTURE.md
- How the ABI test cross-checks a new constant for free
Hints:
- Add
CKM_SHA224tock.zig. The "every hand-coded constant equals its OASIS value" test intests/abi_test.zigwill check the value against the header automatically. - Add a
sha224arm to theHasherunion incrypto/digest.zig(Zig's std hassha2.Sha224), and give it a new state tag for operation-state serialization. - Add it to
config.supported_mechanismsand to the digest arm ofC_GetMechanismInfoinslot_token.zig.
Test it works:
pkcs11-tool --module zig-out/lib/libhsm.so --hash --mechanism SHA224 --input-file msg.bin
Compare against sha224sum msg.bin. Add a unit test in digest.zig against the SHA-224("abc") vector.
Challenge 2: Expose a token-info detail you control
What to build: Make the token model string or serial number configurable through an environment variable instead of a fixed constant.
Why it's useful: Real deployments label tokens. It is a gentle introduction to the config and slot-token layers.
What you'll learn:
- How
config.zigcentralizes every constant - How
env.zigreads the environment at the C boundary (there is nostd.processarena here)
Hints:
C_GetTokenInfoinslot_token.zigfills themodelandserialNumberfields fromconfig.token_modelandconfig.token_serialthroughutil.padded.- Add an env lookup in
env.zigand fall back to the constant when it is unset, mirroring how the storage paths work. - Remember the fields are space-padded fixed arrays, not NUL-terminated strings.
Test it works: pkcs11-tool -T should show your value, and an unset variable should show the default.
Challenge 3: Add a just recipe that signs and then verifies with OpenSSL
What to build: A recipe that generates an EC key in the module, signs a file, exports the public key, and verifies the signature with the openssl command line.
Why it's useful: Cross-verifying against an independent implementation is exactly how this project proves correctness. You are turning that into a one-liner.
What you'll learn:
- How
pkcs11-toolandopensslinteroperate through the module - Why an independent oracle is stronger proof than a self-test
Hints:
- Look at the existing
justfilerecipes and the smoke flow for the command sequence. pkcs11-tool --read-object --type pubkeyexports the public key;openssl dgst -verifychecks the signature.
Test it works: The recipe exits 0 and prints Verified OK.
Intermediate Challenges
Challenge 4: AES-192
What to build: Support 192-bit AES keys for CKM_AES_KEY_GEN, CKM_AES_CBC, CKM_AES_CBC_PAD, and CKM_AES_GCM.
Why it's useful: It is currently unsupported because Zig's standard library exposes no 192-bit AES (see CONFORMANCE.md section 3.3). Solving it teaches you the same libcrypto-bridge pattern RSA uses.
Real world application: Some FIPS profiles and legacy systems require AES-192 specifically.
Implementation approach:
- Decide the backend. Zig std has no Aes192, so route 192-bit keys through libcrypto's EVP AES, the way
rsa.zigroutes RSA. - Add the extern declarations you need to
openssl.zig(the EVP cipher functions). - Branch on key length in
cipher.zig's block and GCM functions, calling libcrypto for 24-byte keys and keeping std for 16 and 32. - Update
cipher.validKeyLenand theCKR_KEY_SIZE_RANGEchecks to accept 24.
Hints:
- The
encBlockRaw/decBlockRawfunctions currentlyswitch (key.len)over 16 and 32 withunreachableotherwise. That switch is where the 24-byte arm goes. - Keep the constant-time and zeroization patterns identical to the existing arms.
Test it works: A NIST AES-192-CBC vector in cipher.zig, plus a pkcs11-tool round-trip with a 24-byte key.
Challenge 5: A per-object file backend
What to build: Replace the single object file with a directory where each token object is its own file, the way SoftHSM2 does it.
Why it's useful: A single file means rewriting everything on every change. Per-object files let you write only what changed and make backup a per-object copy.
What you'll learn:
- Storage design trade-offs (one file versus many)
- How the persistence codec in
object_store.zigseparates serialization from I/O
Implementation approach:
- Keep the existing
serialize/parsefor a single object's attributes; you are changing only how they are grouped on disk. - Give each object a stable on-disk id (the handle is monotonic and never reused, which helps).
- Write the sealing logic unchanged; each object file still seals its sensitive values under the master key.
Hints:
- The seal-on-write and mark-sealed-on-read logic in
object_store.zigdoes not care how many files there are. Keep it. env.zigresolves a path today; you will resolve a directory.
Test it works: Create several objects, restart the process, and confirm they all reload. Corrupt one object file and confirm only that object is lost, not the whole store.
Advanced Challenges
Challenge 6: Implement the PKCS#11 v3.0 interface discovery
What to build: Export C_GetInterface alongside C_GetFunctionList, returning a CK_FUNCTION_LIST_3_0 for hosts that ask for v3.0.
Why this is hard: You are extending the ABI itself, the part that must match the host byte for byte. A mistake here means hosts crash, not just misbehave.
What you'll learn:
- How v3.0 interface discovery works and why hosts fall back to
C_GetFunctionListwhenC_GetInterfaceis absent (the OpenJDK fallback path) - How
CK_FUNCTION_LIST_3_0shares a prefix withCK_FUNCTION_LISTand appends the new pointers
Architecture changes needed:
C_GetInterface(name, version, &iface, flags)
│
▼
CK_INTERFACE { pInterfaceName, pFunctionList -> CK_FUNCTION_LIST_3_0, flags }
│
(C_GetFunctionList still returns the v2.40 CK_FUNCTION_LIST prefix)
Implementation steps:
- Add
CK_FUNCTION_LIST_3_0andCK_INTERFACEtock.zig, with the 24 new v3.0 function-pointer typedefs. - Build one larger table and hand out a pointer typed either way, setting the version field correctly per caller.
- Export
C_GetInterfaceinmain.zigand add it to the version script inpkcs11.map. - Extend
tests/abi_test.zigto cross-check the new structs against the v3.0 OASIS headers (vendor them alongside the v2.40 ones).
Gotchas:
- The first N members of
CK_FUNCTION_LIST_3_0must be byte-identical toCK_FUNCTION_LIST. The cross-check test is your safety net. - New v3.0 functions you do not implement still need real, correctly-typed stub functions in the table. Never null.
Resources: The PKCS#11 v3.1 specification, and the 08-pkcs11.md research note in docs/zig/reference/ which covers the v3.0 surface.
Challenge 7: The v3.0 message-based AEAD API
What to build: Implement C_EncryptMessage / C_DecryptMessage (and the begin/next/final variants) for AES-GCM.
Why this is hard: The message API is how the OpenSSL 3.x provider increasingly prefers to do GCM, and it has a different nonce-management contract than the classic C_Encrypt.
What you'll learn:
- The v3.0 message API shape and how it differs from the v2.40 single-shot and update/final flows
- How to keep your RUP-safe stance under the new API
Implementation approach:
- Requires Challenge 6 first (the v3.0 table).
- The message API passes the nonce and AAD through
CK_MESSAGE-family parameters rather than the mechanism parameter. - Keep the buffered, verify-before-release behavior for decrypt that the classic path already has in
GcmStream.
Hints: Your existing GCM accumulator and the strict parameter validation in buildCipher are most of the work; the new part is the message-API parameter parsing.
Challenge 8: Fork safety
What to build: Detect a fork() and refuse to operate in the child with stale state inherited from the parent, or re-initialize cleanly.
Why this is hard: A child process inherits the parent's initialized state, including an unwrapped master key, which is a real hazard. The PKCS#11 answer is the CKF_INTERFACE_FORK_SAFE flag, but only if you actually handle it.
What you'll learn:
- Why inherited crypto state across a fork is dangerous
- How to track the process id and invalidate state on change
Implementation approach:
- Record the pid in the
InstanceatC_Initialize. - In
state.acquire(), compare the current pid; on a mismatch, treat the library as not initialized in the child (force a re-init) rather than operating on the parent's secrets. - Only then advertise
CKF_INTERFACE_FORK_SAFE(a v3.0 interface flag, so this pairs with Challenge 6).
Gotchas: The master key inherited by a child must be wiped, not used. Be careful that the detection happens before any operation can touch inst.mk.
Expert Challenges
Challenge 9: A SQLite storage backend with multi-token support
What to build: Replace the file backend with SQLite, and lift the one-token limit so the module can host several tokens across several slots.
Estimated time: A week or more.
Prerequisites: You should have done Challenge 5 (per-object storage) first, because this builds on separating the codec from the I/O.
What you'll learn:
- How a real software HSM (SoftHSM2 has exactly this) structures multi-token storage
- How to shard the global state per slot without losing the single-lock simplicity, or how to move to per-slot locks
Planning this feature:
Before you code, think through:
- How does multi-slot change the global
Instance? Today it is one token, one session table, one object store. You need a collection keyed by slot. - Does the single global mutex still serve, or do you want per-slot locks? Per-slot locks add real concurrency but also real deadlock risk.
- How do you migrate an existing single-file token into the database on first run?
High level architecture:
slots: [N] of Token
each Token: its own PIN state, master key, object store
storage: SQLite (one table for tokens, one for objects, sealed values as BLOBs)
locking: either the one global mutex (simple) or per-slot (concurrent)
Implementation phases:
Phase 1: Multi-token in memory. Turn the single token/store/sessions in Instance into slot-indexed collections. Keep the file backend. Get C_GetSlotList returning several slots and the per-slot operations routing correctly.
Phase 2: The SQLite backend. Link SQLite (the libcrypto bridge in openssl.zig is your template for binding a C library cleanly). Move serialize/parse to read and write rows. Keep the sealing logic untouched; values are still GCM-sealed BLOBs.
Phase 3: Migration and concurrency. Migrate an existing file token on first open. Decide and implement the locking model.
Phase 4: Polish. Error handling for a locked or corrupt database, and the full test pass through pkcs11-tool against multiple tokens.
Success criteria:
C_GetSlotListreturns more than one slot- Two tokens hold independent keys and PINs
- Keys survive a restart, loaded from SQLite
- A corrupt or locked database fails closed, never returns a wrong key
- The ABI test and smoke harness still pass unchanged
Mix and Match
Combine challenges into bigger projects:
A v3.0-native module. Challenge 6 (interface) plus Challenge 7 (message API) plus Challenge 8 (fork safety) gives you a module a modern OpenSSL 3.x provider talks to natively.
A production-shaped store. Challenge 5 (per-object) plus Challenge 9 (SQLite, multi-token) gives you something with SoftHSM2's storage capabilities.
Real World Integration Challenges
Drive the module from the OpenSSL pkcs11 provider
The goal: Use this module as the key store behind OpenSSL itself, so openssl commands sign and decrypt through it.
What you'll need:
- The
pkcs11-provider(the modern OpenSSL 3.x provider) configured to point atlibhsm.so - An OpenSSL config that loads the provider
Watch out for: The provider exercises corners pkcs11-tool does not, especially around RSA-PSS parameters and GCM. This is a great way to find conformance gaps. Anything it trips is a real bug or a documented narrowing in CONFORMANCE.md.
Use the module from Java via SunPKCS11
The goal: Have a Java program generate a key and sign through the module using java.security and the SunPKCS11 provider.
What you'll learn: SunPKCS11 is the classic C_GetInterface-fallback consumer, so this also validates that your C_GetFunctionList path is solid (and motivates Challenge 6 if you want the v3.0 path).
Performance Challenges
Reduce lock contention under concurrent sessions
The goal: Let many sessions do crypto in parallel instead of serializing on the one global mutex.
Current bottleneck: Every entry point takes state.mutex. Two sessions signing at once serialize, even though their operations are independent.
Approaches:
- Per-session operation state, lock only for shared state. The op-state already lives in the
Session; you could do the crypto outside the lock and only lock to fetch and commit, the wayC_Loginalready does for Argon2id. - Per-slot locks. Pairs with the multi-slot work in Challenge 9.
Benchmark it: Drive N threads each signing in a loop and measure throughput before and after. Watch for races; the generation-counter pattern in state.zig is your model for safe lock-release-relock.
Security Challenges
Prove constant-time behavior with Valgrind
The goal: Use Valgrind's memcheck and Zig's classify / declassify to detect any branch or memory access that depends on a secret.
What you'll learn: The closest thing to a constant-time verifier Zig has. classify marks secret memory as uninitialized to Valgrind, which then flags any conditional that depends on it.
Implementation: Add a debug build that classifies the PIN hash and key material before the crypto, run it under valgrind --tool=memcheck --track-origins=yes, and chase down any "conditional jump depends on uninitialised value" reports. The 16-constant-time-security.md research note in docs/zig/reference/ walks through the API.
Testing the security: A clean run with no conditional-on-secret warnings through a full sign and decrypt is the goal.
Fuzz the persistence codec
The goal: Throw malformed token and object files at parse and confirm it always fails closed (clears to empty) and never crashes or reads out of bounds.
Threat model: This protects against a malicious or corrupted store file. The codec already has length and bounds checks; fuzzing proves they are complete.
Implementation: Generate random and mutated record bytes, feed them to object_store.parse and token.loadFrom, and assert the result is either a valid parse or a clean empty store, never a panic. The existing "rejects a bad magic" and "fails safe on a truncated record" tests are your starting points.
Contribution Ideas
Finished something? Share it back:
- Fork the repo.
- Implement your extension in a branch, with unit tests against a published vector and a smoke-harness or
pkcs11-toolproof. - Document it: update CONFORMANCE.md if you changed a boundary, and add a note to the relevant learn doc.
- Open a PR with the implementation, the tests, and the external-tool proof.
Good extensions (a new mechanism with vectors, a real storage backend, the v3.0 surface) are exactly the kind of thing that makes the project more useful to the next person.
Challenge Yourself Further
Build something new
Use what you learned here to build:
- A PKCS#11 provider for a different key type, like Ed25519 or ML-DSA (both are in Zig's std), following the ECDSA path.
- A minimal host that loads any PKCS#11 module and exercises it, the consumer side of the interface you just implemented.
Study real implementations
Read these and steal their good ideas:
- SoftHSMv2 for the per-token directory store and the OpenSSL/Botan crypto abstraction.
- OpenSC
pkcs11-spyfor how a transparent shim logs every call, which is a clean piece of ABI engineering in its own right.
Getting Help
Stuck on a challenge?
- Debug systematically. What did you expect, what happened, what is the smallest input that reproduces it? Run the failing case through
just spyand read the exact call and return code. - Read the existing code. The mechanism you are adding almost certainly has a sibling already implemented. The path is the same.
- Lean on the cross-check. If you touched
ck.zig, runzig build test; the ABI test will tell you precisely which field or constant diverged from the OASIS headers.
Challenge Completion
Track your progress:
- Easy 1: SHA-224 digest
- Easy 2: configurable token info
- Easy 3: OpenSSL cross-verify recipe
- Intermediate 4: AES-192 via libcrypto
- Intermediate 5: per-object file backend
- Advanced 6: v3.0
C_GetInterface - Advanced 7: v3.0 message API
- Advanced 8: fork safety
- Expert 9: SQLite, multi-token
Finished them all? You have built a multi-token, v3.0-capable software HSM with a real database backend and proven constant-time behavior. At that point you understand key custody at a level most working engineers never reach. Go read a real HSM vendor's PKCS#11 docs; you will recognize every design decision they made and why.