---
layout: default
title: "FreeHSM v1.5.0: the integrity self-test that never ran"
---

# FreeHSM v1.5.0: the integrity self-test that never ran

*2026-07-18*

The headline of this release is not a feature. It is a defect we shipped,
in every signed build, for months — and that our own tests never caught,
because the one place it would have surfaced was switched off everywhere.

## The self-test that self-approved

FIPS 140-3 §7.10.2 requires a cryptographic module to verify its own
integrity at start-up: hash the loaded binary, compare against a digest
embedded at build time, refuse to run on a mismatch. FreeHSM has that
check. It is wired into `C_Initialize`. It is documented. And until
v1.5.0 it never actually verified anything.

The embedded-digest slot was declared like this:

```c
const uint8_t fhsm_module_integrity_digest[32] = { 0 };
```

with a comment, one line above, stating that a `volatile` qualifier
"prevents the compiler from constant-folding the digest into the code
generator." The rationale was written. The keyword was not there.

The digest is patched into the binary *after* compilation by the signing
script. Without `volatile`, GCC is free to assume the slot still holds its
initialiser — and it folded the explicitly-initialised element `[0]` to a
literal zero. So byte 0 of every comparison came from the code generator
and bytes 1–31 from memory:

```
computed = 52fad82b6ccecf78306ca4249d37a173031b514276c318ab742755faa7a94e5a
embedded = 00fad82b6ccecf78306ca4249d37a173031b514276c318ab742755faa7a94e5a
           ^^
```

Every correctly signed build failed its own integrity check on that one
byte. Nobody noticed, because the entire toolchain — unit tests, the
external harness, CI — runs with `FHSM_INTEGRITY_ALLOW_UNSIGNED=1`, which
downgrades the failure to a warning. A security mechanism exercised only
from behind its own bypass is not exercised at all. That process gap, not
the missing keyword, is the real bug.

The fix is one qualifier. The lesson is the test we added alongside it:
`make test-integrity` now runs the check **without** the bypass, on a
freshly signed binary, and asserts three things — an unsigned binary must
fail, a signed binary must pass, and a signed-then-tampered binary must
fail. It is gating in CI. We confirmed it catches the original bug by
reverting the fix and watching it go red.

Worth noting: a v1.2.1 fix had already removed a *different* bypass in the
same function (an unconditional `return CKR_OK`). That turned "always
passes" into "always fails", which the env var then masked. The check has
never actually gated anything until now.

## 361 → 13 against a harness that doesn't know us

The rest of v1.5.0 is the result of running FreeHSM against
[pkcs11-check](https://github.com/dmitrymingulov/pkcs11-check), Denis
Mingulov's vendor-neutral PKCS#11 behavioural harness — 100k+ checks that
know nothing about our implementation. Over the campaign, failures went
from **361 to 13** and crashes from **7 to 0**.

Several of the findings were not "a test is red" but "the module does the
wrong thing and no counter was measuring it":

- **AES-CMAC was advertised under the wrong mechanism ID.** The module
  published its CMAC implementation at `0x108C`, which is
  `CKM_AES_XCBC_MAC` in the spec, and used `0x108A` (the real
  `CKM_AES_CMAC`) for GMAC. A caller asking for CMAC silently got a
  different algorithm. Verified against the OASIS header, not a
  recollection of it.
- **The PQC parameter set was unselectable.** `CKA_PARAMETER_SET` sat on
  `CKA_MODIFIABLE`'s code point, so every ML-DSA / ML-KEM / SLH-DSA
  key-gen silently used the default — asking for ML-DSA-87 gave you
  ML-DSA-65, no error.
- **The v3.0 function-list was off by one.** `C_CancelFunction` was never
  wired, shifting every later slot; a v3.0 caller dispatching through the
  table invoked the wrong function and eventually segfaulted.
- **`CKA_LOCAL` lied about provenance.** It reported "generated on this
  token" for keys that were imported.
- **A key-extraction path (Tookan §3.3) was open.** Wrapping a sensitive
  key and unwrapping it with `CKA_SENSITIVE=False` produced a readable
  copy. Closed the spec way, via `CKA_UNWRAP_TEMPLATE` on the wrapping
  key.
- **A forked child inherited the parent's session objects and its
  logged-in state** — the module had no fork detection at all.

## One breaking change, stated plainly

Correcting the AES-MAC code points is a breaking change. If your code
hard-codes `0x108C` for AES-CMAC, move it to `0x108A`. Callers using
`0x108A` (including OpenSC's pkcs11-tool, which was right all along) are
unaffected and now get real CMAC. The binary name `libfreehsm.so`
and every other PKCS#11 identifier are unchanged.

## What we did not fix, and won't

Thirteen harness failures remain, and they should. Roughly half are
design positions (the CBC-PAD padding oracle is inherent to the mode — the
harness says so itself; GCM IV-reuse detection needs state a stateless
software token does not keep) or gaps in the harness (two tests drive
non-FIPS RSA PKCS#1 v1.5 encryption unconditionally, which a FIPS-strict
build correctly does not offer). The full triage — fixable vs. design vs.
report-upstream — is published in the repo, next to the security
advisories we file against ourselves. Same logic as always: a green number
bought by weakening the module is not a win.

## The rename, briefly

This is also the first release under the new name. The repository is now
`freehsm` (was `freehsm-c`; old URLs redirect). FreeHSM is the Apache-2.0
PKCS#11 library; Simorgh PKI is the product being built on it; Simorgh
Labs is the org. Nothing consumer-facing changes: binary name, PKCS#11
identifiers, and the GPG signing key are all stable across the rename.

---

*v1.5.0 is signed with GPG key `743A 6A59 04A1 4616 46A6 408D E485 6016
2DBB F28A 2` and available on GitHub, GitLab, and Codeberg.*
