
Zig Hardware Security Module library for PIV, CAC, and YubiKey tokens via PC/SC. Supports certificates, PIN management, signing, and decryption.
A Hardware Security Module (HSM) library for Zig providing PC/SC access to PIV, CAC, and YubiKey tokens.
| Aspect | Info |
|---|---|
| API Stability | Development |
| Zig Version | 0.16.0 |
| Platforms | Linux, macOS, Windows |
| License | MIT |
PIV Support (NIST SP 800-73-4)
CAC Support (Common Access Card)
YubiKey Support
Security
anyerror)Parallel Operations (via thread_pool)
-Denable_tp=true); disable with -Denable_tp=falselibpcsclite-dev (Debian/Ubuntu) or pcsc-lite-devel (Fedora)# Debian/Ubuntu
sudo apt-get install libpcsclite-dev pcscd
# Fedora/RHEL
sudo dnf install pcsc-lite-devel pcsc-lite
# Start the PC/SC daemon
sudo systemctl start pcscd
sudo systemctl enable pcscd
# Build and run unit tests
make
# Or using zig directly
zig build test
# Run simulator integration tests
zig build test -Dintegration_sim=true
# Build examples (requires PC/SC library installed)
zig build examples -Dlink_pcsc=true
With -Dfips=true, every security-relevant cryptographic primitive
routes through a linked, validated OpenSSL 3.x FIPS provider via the
crypto_backend seam, and the simulator gains post-quantum PIV token
operations (ML-DSA-65 signing, ML-KEM-768 encap/decap) that are
available only in FIPS mode. The flag is off by default with zero
overhead; the fips dependency is resolved only under -Dfips=true.
# Default build — std.crypto backend, no OpenSSL link
zig build test
# FIPS build — OpenSSL FIPS provider (run `make deps` from fips first)
OPENSSL_CONF=/usr/local/ssl/ssl/openssl.cnf \
zig build test -Dfips=true -Dopenssl_path=/usr/local/ssl
# Both modes in one shot
make test-dual
See docs/FIPS.md for the seam design, the
fips-exempt policy, and the PQC token operations.
Security Note: The allow_pcsc_env_override option is disabled by default to prevent malicious library injection attacks (CWE-427). Only enable this for development/testing:
# Production build (secure, env var ignored)
zig build
# Development build (allows HSM_PCSC_LIB_PATH)
zig build -Dallow_pcsc_env_override=true
When enabled, libraries are validated before loading:
const std = @import("std");
const hsm = @import("hsm");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// List available tokens
const tokens = try hsm.listTokens(allocator, false);
defer {
for (tokens) |*t| t.deinit();
allocator.free(tokens);
}
if (tokens.len == 0) {
std.debug.print("No tokens found\n", .{});
return;
}
// Open the first token
var token = try hsm.openToken(allocator, tokens[0].id, .{});
defer token.close();
// Verify PIN
try token.verifyPin("123456");
// Read certificate
const cert = try token.getCertificate(.authentication);
defer allocator.free(cert);
std.debug.print("Certificate: {} bytes\n", .{cert.len});
// Sign a digest
var digest: [32]u8 = undefined;
std.crypto.hash.sha2.Sha256.hash("Hello, PIV!", &digest, .{});
const signature = try token.sign(.authentication, .ecdsa_p256, &digest);
defer allocator.free(signature);
std.debug.print("Signature: {} bytes\n", .{signature.len});
}
All operations return errors from HsmError:
PcscUnavailable, ReaderGone, CardRemoved, TimeoutPinIncorrect, PinLocked, PinLengthInvalidNotSupported, SlotNotFound, AlgorithmNotSupportedInvalidTlv, CertificateNotFound, InvalidDigestLengthSecurityConditionNotSatisfied, The parallel module provides batch HSM operations using the thread_pool library. It is built as a separate Zig module and available when -Denable_tp=true (the default). Set -Denable_tp=false to compile out the seam entirely; the public functions still exist and fall back to a sequential implementation.
Below the threshold, operations execute sequentially without thread pool overhead. The underlying PKCS#11 calls are scaffolding; integrate your PKCS#11 backend by implementing the signData, decryptData, discoverToken, and retrieveCert functions in src/parallel.zig.
# Run all unit tests
zig build test
The library includes a PIV card simulator for testing without hardware:
# Run simulator tests
zig build test -Dintegration_sim=true
The simulator uses embedded test keys (see src/sim/key_material.zig). No external key files or environment variables are needed.
For testing with real hardware:
# Read-only tests (certificate reading)
HSM_HIL=1 zig build test -Dhsm_hil=true
# Tests requiring PIN
HSM_HIL=1 HSM_PIN=123456 zig build test -Dhsm_hil=true
# Dangerous tests (write operations) - USE WITH CAUTION
HSM_HIL=1 HSM_PIN=123456 HSM_DANGEROUS=1 zig build test -Dhsm_hil=true
# Continuous fuzzing (stop manually)
zig build test --fuzz -- --test-filter fuzz
# Build examples (requires PC/SC library installed)
zig build examples -Dlink_pcsc=true
# List tokens
./zig-out/bin/list_tokens
./zig-out/bin/list_tokens --sim # Use simulator
# Sign with PIV (PIN entered via interactive TTY prompt)
./zig-out/bin/piv_sign
./zig-out/bin/piv_sign --sim
# Or supply PIN via environment variable (less secure)
HSM_PIN=123456 ./zig-out/bin/piv_sign --sim
PIN Handling: PINs are never stored in token structures and are zeroized after use.
TLV Parsing: All TLV parsing has depth (10) and length (64KB) limits to prevent resource exhaustion.
Error Handling: Unknown status words result in explicit errors, not silent failures.
Logging: Debug logging never includes sensitive data (PINs, keys, etc.).
Memory: Sensitive buffers are zeroized using hsm.zeroize() which prevents compiler optimization.
PC/SC Library Loading: The library loads PC/SC from trusted absolute paths. An explicit override is available via HSM_PCSC_LIB_PATH (must be absolute).
hsm ships an opt-in OpenTelemetry seam for span tracing of HSM
operations. The seam is off by default and produces zero overhead
when off — the build never resolves the otel dependency, every
helper compiles to a comptime-known no-op, and the produced artifacts
contain no otel or observability linked symbols.
zig build test # default: -Dwith_otel=false (no-op)
zig build test -Dwith_otel=true # opt in: spans emitted
When enabled, every public Token.sign / Token.decrypt call emits
an hsm.{operation} span (e.g. hsm.sign, hsm.decrypt) with two
attributes:
crypto.algorithm — algorithm tag (e.g. ecdsa_p256,
rsa2048_pkcs1v15).crypto.key.id — slot identifier (e.g. authentication,
signature, key_management, card_auth).The seam is engineered around one rule: NEVER export cryptographic material. Span attributes are exported off-host (typically to an OTLP collector) and end up in traces/logs that may have weaker access controls than the HSM operation itself.
hsm.observability.startHsmOperationSpan.const hsm = @import("hsm");
pub fn main() !void {
// Park the Otel value on a stable address — either a `var` in
// main()'s stack frame for the process lifetime, or a heap
// allocation. The init helper is only present when
// `-Dwith_otel=true`; under the disabled build,
// hsm.observability_init resolves to an empty struct and this
// block compiles to a no-op.
if (comptime hsm.observability.enabled) {
var otel = try hsm.observability_init.Otel.init(allocator, "my-service");
defer otel.deinit();
otel.installGlobals();
}
// ... rest of main, including any hsm.Token.sign calls — each one
// now emits an `hsm.sign` span attached to your service.
}
The seam reads OTEL_* environment variables (sampler, exporter
endpoint, service.name, etc.) per the OpenTelemetry environment-variable
spec.
Defaults: parentbased_traceidratio sampler at 5%, OTLP/HTTP-protobuf
exporter to http://localhost:4318.
The full integration recipe (build wiring, test harness, no-op
verifier) is documented in the otel rollout repo:
otel/docs/integration/RECIPE.md.
Three gates must pass before merging changes that touch the seam:
zig build test # default flag = false
zig build test -Dwith_otel=true # flag on
scripts/verify-consumer-noop.sh # symbol-leak check
The verify-consumer-noop.sh gate builds the library with
-Dwith_otel=false and walks zig-out/ with nm --defined-only,
failing if any otel/observability symbol survived into the
artifacts. It mirrors the cross-repo otel/scripts/verify-consumer- noop.sh (which only runs in the sibling-repo layout
<root>/otel//<root>/hsm/) so contributors without that layout can
still gate the seam locally.
src/
├── hsm.zig # Main API and types
├── root.zig # Module entry point
├── apdu.zig # APDU encoding/decoding
├── tlv.zig # BER-TLV parsing
├── parallel.zig # Parallel operations (thread_pool)
├── pcsc/
│ └── pcsc.zig # PC/SC transport layer (Linux/macOS/Windows)
├── piv/
│ └── piv.zig # PIV implementation
├── cac/
│ └── cac.zig # CAC implementation
├── yubikey/
│ └── yubikey.zig # YubiKey detection
└── sim/
├── sim.zig # Simulator entry point
├── transport.zig # Simulated transport
├── piv_card.zig # Simulated PIV card
└── key_material.zig # Embedded test keys
tests/
├── integration.zig # Basic integration tests
├── sim_integration.zig # Simulator integration tests
└── hil_tests.zig # Hardware-in-loop tests
examples/
├── list_tokens.zig # Token discovery example
└── piv_sign.zig # Signing example
See CONTRIBUTING.md for guidelines.
MIT License - see LICENSE for details.
Built with Zig 0.16.0 | PIV | CAC | YubiKey | PC/SC
| Option | Default | Description | Security Impact |
|---|
integration_sim | false | Run simulator integration tests | None |
hsm_hil | false | Run hardware-in-loop tests (requires real token) | None |
link_pcsc | false | Link PC/SC library for examples | None |
include_simulator | Debug: trueRelease: false | Include PIV simulator with test keys | CWE-321: Test keys in production |
allow_pcsc_env_override | false | SECURITY RISK: Allow HSM_PCSC_LIB_PATH env var | CWE-427: Untrusted library loading |
fips | false | Route security crypto through the linked FIPS-140-3 provider (zero overhead when off) | None when off |
openssl_path | (unset) | OpenSSL install prefix for non-standard installs (e.g. /usr/local/ssl) | None |
| Type | Description |
|---|
TokenKind | Token type: .piv, .cac, .yubikey, .unknown |
TokenInfo | Discovered token metadata |
Token | Open token handle for operations |
Slot | Key slot: .authentication (9A), .signature (9C), .key_management (9D), .card_auth (9E) |
Algorithm | Crypto algorithm: .ecdsa_p256, .ecdsa_p384, .rsa2048_pkcs1v15, etc. |
Capabilities | Token capability flags |
PcscScope | PC/SC context scope: .user (default), .system |
| Function | Description |
|---|
listTokens(allocator, use_sim) | List available tokens |
openToken(allocator, id, opts) | Open a token by ID |
token.reconnect() | Reconnect to token and reset auth state |
token.verifyPin(pin) | Verify PIN |
token.changePin(old, new) | Change PIN |
token.unblockPin(puk, new_pin) | Unblock PIN with PUK |
token.getCertificate(slot) | Get DER-encoded certificate |
token.sign(slot, alg, digest) | Sign a digest |
token.decrypt(slot, alg, ciphertext) | Decrypt data |
token.capabilities() | Get token capabilities |
token.close() | Close token connection |
AuthenticationFailed| Function | Description | Threshold |
|---|
parallelSign(allocator, inputs, results) | Batch signing across slots | 2+ items |
parallelDecrypt(allocator, inputs, results) | Batch decryption across keys | 2+ items |
parallelTokenDiscover(readers, results) | Token discovery across readers | 2+ readers |
parallelCertRetrieve(allocator, requests, results) | Certificate retrieval across slots | 2+ requests |