FreeHSM C

An open-source PKCS#11 v3.2 cryptographic module under Simorgh Labs

View the Project on GitHub afchine1337/freehsm

FreeHSM C v1.2.2 → v1.3.0 — The methodology meets an external reporter

An open-source PKCS#11 v3.2 cryptographic module under Simorgh Labs. Published 2026-06-28.


FreeHSM C v1.2.2 went out on 2026-06-27 as the 21st consecutive GPG-signed release of the project, and the first one shipping a third-party-reporter contribution. Four hours later, v1.3.0 followed — the 22nd — closing the promises v1.2.2 had to defer. Together they fix two defects that Denis Mingulov reported via the pkcs11-check testing harness on 2026-06-26, plus an operational gap that surfaced during the disclosure itself, plus an extension of the boot-time regression methodology that the report exposed as a coverage gap.

The release sequence ships with three publicly published GitHub Security Advisories : GHSA-xpxx-66pp-pf99 (Denis’s two findings, HIGH), GHSA-wgv9-m9cv-4647 (a retrospective on a v1.1.0 maintainer-key leak that I had self-disclosed in DRAFT 14 days earlier and never published, MEDIUM), and GHSA-6jx9-gh48-5qf6 (the v1.2.1 integrity-verify defect I had self-disclosed at the time, HIGH). All three were published the same day, 2026-06-28, as part of formalizing the project’s complete public disclosure record.

I want to write about how this fits into the v1.2.0 / v1.2.1 / v1.2.2 → v1.3.0 sequence, because each release in this micro-quadrilogy was supposed to teach me one thing about how the project’s quality methodology should work, and the v1.2.2 → v1.3.0 pair is where the methodology had to face an external test — and then had to extend itself to honor a promise faster than the news cycle around the original disclosure.

The micro-quadrilogy

v1.2.0 (2026-06-20) decomposed C_CreateObject into a pure parser and an OpenSSL EVP builder, and pointed a new libFuzzer harness at the production parser TU. The justification for the minor bump was the ALC_DVS-grade improvement on the evidence base : exercising the production code surface instead of an inline mirror surfaces real disagreements that the mirror would have buried. The first CI run of the new harness found a harness/parser contract disagreement within 26 seconds. The bug was in the test, not the parser. v1.2.0 shipped with the fix and an honest CHANGELOG that said “this is exactly what the refactor was designed to find”.

v1.2.1 (2026-06-21) came 24 hours later. I was investigating a dev-environment integrity quirk on my VM — the kind of thing that I would normally have filed as “ergonomic friction, side issue”. But the rule I’d been following since v1.1.13 said : anything that disappears under FHSM_INTEGRITY_ALLOW_UNSIGNED deserves an hour minimum before being filed as friction. I applied the rule. Within that hour I had found and fixed a critical defect in src/fhsm_integrity.c::do_verify that had been silently disabling FIPS 140-3 §7.10.2 in every signed release since v1.1.0. The methodology had turned on itself and found an adjacent surface bug. The CHANGELOG entry was sober ; the GitHub Security Advisory (GHSA-6jx9-gh48-5qf6) was informational (no CVE in pre-certification, no known production deployments) ; the announcement framed it as “v1.2.0’s habit of distrust applied to a different surface 24 hours later”.

v1.2.2 is the third corner. Five days after the v1.2.0 release, an external reporter named Denis Mingulov ran the project through his pkcs11-check harness and sent me an email. He could not encrypt it : my published GPG key was sign-only. He sent the headline finding in clear and held the rest until the encrypted channel was working.

The headline finding was that no third-party verifier could check any ECDSA signature the module produced via the raw CKM_ECDSA mechanism.

v1.3.0 is the fourth corner. It shipped four hours after v1.2.2 the same day and closed the function-list completion narrative + extended the export-roundtrip boot KAT pattern from ECDSA to six more cryptographic surfaces. The remaining promises of the v1.2.2 advisory were honored fast enough that no v1.2.x patch series window opened.

The Finding 1 defect

The technical defect is small. In src/fhsm_pkcs11.c::sign_asymmetric and the symmetric verify_asymmetric, the raw mechanisms (CKM_ECDSA bare, CKM_RSA_PKCS bare) routed through EVP_DigestSignInit_ex(ctx, &pctx, mdname = NULL, ...). I had read the OpenSSL 3.x man page for this function and convinced myself it would treat the input as a pre-computed digest : the documentation says so. What I had not done was confirm by experiment that the OpenSSL 3.x default provider’s ECDSA digest_sign function honoured the contract.

It does not. When mdname is NULL on the 3.5.x default provider, the ECDSA digest-sign function applies an internal default digest (SHA-256, observed by Denis and reproduced locally) before signing. So the module signed SHA-256(input) instead of input. Anyone calling pkcs11-tool --sign --mechanism ECDSA --in digest.bin got back a signature over SHA-256(digest.bin) ; running openssl dgst -sha256 -verify pub.pem -signature sig.der msg.txt then failed because openssl correctly computed SHA-256(msg.txt) — which equals digest.bin, not SHA-256(digest.bin).

The module’s own C_Verify path applied the same default digest symmetrically, which is why I had never noticed : module sign + verify cycles cancelled the double-hash out at both ends. The Wycheproof CI corpus exercises verification only against pre-computed signatures, so the sign path was never exercised externally.

The fix is six lines and obvious in hindsight :

if (hash == NULL) {
    pkctx_raw = EVP_PKEY_CTX_new(pkey, NULL);
    if (!pkctx_raw) goto cleanup;
    if (EVP_PKEY_sign_init(pkctx_raw) <= 0) goto cleanup;
    if (mech_is_pss(op->mechanism)) { /* PSS setup */ }
    if (EVP_PKEY_sign(pkctx_raw, sig, &out_len, data, data_len) <= 0)
        goto cleanup;
} else {
    /* the existing EVP_DigestSign path, unchanged */
}

EVP_PKEY_sign is the canonical OpenSSL 3.x API for signing a pre-computed digest. It does not go through any provider’s digest-sign wrapper, so there is no place for an internal default digest to creep in. Verify path gets the symmetric fix using EVP_PKEY_verify.

Why this was invisible

Wycheproof’s ECDSA corpus is a verify-only test : the test vectors are pre-computed reference signatures from the canonical implementations, and the module’s job is to verify them. The module’s C_Verify path with mdname = NULL happens to work correctly for Wycheproof, because the signature was made by a reference implementation that signed the actual digest. The module’s verify applies the SHA-256 default, gets the digest of the digest, and… fails to verify.

Wait. Let me think about this again. If Wycheproof’s signatures were made over digest, and the module’s verify computes SHA-256(digest) internally and compares against that as the message hash, then the verify would FAIL on Wycheproof, not pass. So why was Wycheproof passing CI ?

I had to investigate this after writing the v1.2.2 fix to make sure I understood the picture. The answer is in the provider behaviour : when mdname = NULL is passed to EVP_DigestVerifyInit_ex, the provider may or may not apply a default. In OpenSSL 3.x default provider for ECDSA, on verify the behaviour is to not apply a digest (no double-hash on verify), while on sign the behaviour is to apply the default. This asymmetry between the two operations is what kept Wycheproof green : verify-only tests with mdname = NULL saw the correct behaviour, while sign-then-export-then-verify tests would have caught the bug.

I do not have a citation for why the provider treats sign and verify asymmetrically at the digest_sign / digest_verify boundary ; my reading of OpenSSL 3.x source is that the implementations diverged for historical reasons. Either way, the practical consequence is that the module’s sign path applied an internal SHA-256, the verify path did not, and the asymmetry was exactly what made the bug invisible internally.

The v1.2.2 boot KAT regression guard exercises the full sign + serialize + reload + verify chain ; if either side of that chain ever applies an internal digest the chain doesn’t account for, the boot KAT fails.

The discovery + correction protocol meets an external reporter

The five-step protocol I documented in v1.2.1 (Security Target §13.8) was written for the case where the maintainer triages their own dev-environment quirk. When Denis’s report came in, two assumptions of the protocol failed at step zero :

Step zero is now codified as §13.8.1 in the Security Target. The two operational expectations :

  1. Encrypted-channel availability is a precondition. I added a cv25519 encryption subkey to my primary key, published to keys.openpgp.org with my email verified, updated SECURITY.md, and dated the encryption-capability restoration in the commit history. Future reporters can use the channel without friction.

  2. Reproduce before challenging. When CI is green and a reporter says “your module is broken”, the right reaction is to take their reproduction script, run it locally, and look at the result before opening the source code. The v1.2.2 fix took less than 90 minutes from git clone of Denis’s reproduction to a tested local fix because I followed this order. If I had instead spent that hour cross-referencing CI passes against the report, I would have wasted the hour. CI tests what it tests ; the reporter typically tests something CI does not.

Why the boot KAT regression guard

The fix would be enough on its own. But Denis’s report exposed that “the OpenSSL 3.x raw-sign API matches its man page” was an assumption I had made without testing. There’s no reason to believe that future OpenSSL versions, or future providers, will not introduce similar quiet defaults somewhere along the way. The boot KAT regression guard is the institutional response to that.

For each of P-256, P-384, P-521, at every C_Initialize, the module :

  1. Generates a fresh keypair via EVP_PKEY_Q_keygen.
  2. Computes the digest of a fixed reference message with the matching SHA.
  3. Signs the digest via EVP_PKEY_sign (raw mode — exactly what production does post-fix).
  4. Serializes the public key via i2d_PUBKEY (exactly what production does in C_GenerateKeyPair).
  5. Reloads the public key via d2i_PUBKEY (exactly what an external verifier does).
  6. Verifies the signature via EVP_PKEY_verify on the reloaded public key.

If steps 3 and 6 ever stop agreeing — because EVP_PKEY_sign starts applying a quiet default, or because i2d_PUBKEY / d2i_PUBKEY ever stops being byte-stable for EC keys, or because some future provider does something else surprising — the KAT fails at C_Initialize and the module refuses to start. Any deployment running the new version sees the failure immediately ; nobody discovers it three weeks later by running pkcs11-check.

The pattern generalises. v1.2.2 ships the pattern for ECDSA only. v1.3.0 closes the gap.

v1.3.0 — the deferred promises closed in four hours

The v1.2.2 advisory carried two promises that I could not honor in the same release : the two function-list slots that Denis flagged but that turned out to not be implemented at all (C_CopyObject slot 21, C_SetAttributeValue slot 25), and the export-roundtrip boot KAT pattern that the v1.2.2 advisory promised to generalize but only had time to implement for ECDSA.

v1.3.0 honored both promises and added one more.

Function-list completion. C_CopyObject (PKCS#11 v3.2 §C.6.7.3) and C_SetAttributeValue (§C.6.7.5) are implemented and wired, with the PKCS#11 one-way state transitions enforced on the template (CKA_SENSITIVE false → true only, CKA_EXTRACTABLE true → false only, CKR_TEMPLATE_INCONSISTENT on violations, CKR_ATTRIBUTE_READ_ONLY on immutables, CKR_ATTRIBUTE_TYPE_INVALID on the rest). C_WaitForSlotEvent (§C.6.5.4) is also wired into slot 66 — the last unwired position in the PKCS#11 v2.40 dispatch table — with software-token semantics (CKF_DONT_BLOCK returns CKR_NO_EVENT immediately ; blocking mode returns CKR_FUNCTION_NOT_SUPPORTED rather than hang indefinitely). After v1.3.0, the v2.40 dispatch table is 51 of 67 slots wired (was 47/67 in v1.2.2, 44/67 in v1.2.1 pre-Denis). All five slots Denis flagged are entirely resolved across v1.2.2 + v1.3.0.

Export-roundtrip boot KAT extension. The methodology codified in v1.2.2 for ECDSA is extended in v1.3.0 to six more cryptographic surfaces : RSA-2048-PSS-SHA256, RSA-2048-OAEP-SHA256, Ed25519, ML-DSA-65, ML-KEM-768, ECDH (P-256/384/521). The total boot KAT count grew from 54 (v1.2.2) to 62 (v1.3.0). Coverage of external-roundtrip surfaces : 6 of 7. SLH-DSA is excluded by design ; the existing 33 ms SLH-DSA-SHA2-128f selftest would approximately double if we added a sign+verify-with-reloaded-pubkey, and the runtime-budget trade-off is documented as a known gap (Security Target §9.1.3).

The class of bug — “module signs the wrong thing, internal sign+verify cancels, external verifier fails” — can no longer hide silently on any external cryptographic surface FreeHSM C exposes. If the next OpenSSL upgrade re-introduces the silent-default-digest behavior anywhere along the EVP_DigestSign / EVP_PKEY_sign / i2d_PUBKEY / d2i_PUBKEY chain, six different boot KAT vectors will fail at C_Initialize and the module will refuse to start.

The cost of this extension : ~80 lines of helper per surface in kat/cavp_extended.c, ~5 ms boot cost per vector, total ~+15 ms boot impact (well within the AGD_PRE §6.2 < 2 s budget).

The retrospective advisory — 14 days of administrative debt

Publishing GHSA-wgv9-m9cv-4647 the same day as GHSA-xpxx-66pp-pf99 was not part of the original Denis-driven plan. It was a side-effect of the cleanup I did before publishing Denis’s advisory.

While auditing the project’s existing security advisories to make sure the v1.2.2 GHSA referenced the right related advisories, I found a 14-day-old DRAFT advisory I had forgotten about : a self-disclosure of an accidental private GPG key leak during the v1.1.0 initial open-source release on 2026-06-12. The leak was real, the mitigation (revocation cert + new key + history rewrite) was complete on 2026-06-12 within ~14 hours of the leak, but the GHSA was filed as DRAFT on 2026-06-13 and never published.

The DRAFT also carried a typo : the description listed the same fingerprint twice (the new key fingerprint in both the “previous compromised” and “new” positions, which is mathematically impossible because GPG fingerprints are unique hashes of the key material). The same typo had been live in SECURITY.md for 14 days. The canonical record in the rotation commit message 2e6a413 had the correct fingerprints all along ; only the human-facing surfaces (DRAFT advisory + SECURITY.md addendum) were wrong.

Both surfaces were corrected and the advisory published as GHSA-wgv9-m9cv-4647 on 2026-06-28. The 14-day administrative delay is acknowledged openly in the advisory description. The technical mitigation was complete from day one ; only the public-facing record was delayed. The lesson here is operational : DRAFT advisories that are not published within 24 hours of mitigation completion accumulate administrative debt. The project will now treat DRAFT GHSA-as-administrative-debt as a class equivalent to “open PR with unresolved review comments” — they get a recurring reminder if they sit longer than a week.

What I took away

There are four lessons in this micro-quadrilogy, in the order I learned them.

v1.2.0 taught me that the methodology is worth investing in even when it finds nothing exotic. The decomposition + fuzz refactor was 250 lines of new test infrastructure for a parser that was already working. The first CI run found a harness / parser contract disagreement and the announce kit was framed around it. The lesson : the methodology is the value, not the bug count.

v1.2.1 taught me that the methodology, once internalised, applies sideways. I was investigating an integrity-verify quirk because I had a standing rule that said I had to. The fix took an hour. The defect had been live for nineteen releases. The lesson : the methodology is what makes adjacent surfaces visible 24 hours after the surface that motivated it. The cost is paid on the first surface ; the second-order effects are paid out across the adjacent ones.

v1.2.2 taught me that the methodology has to face an external reporter eventually, and that the operational scaffolding around it (encrypted channel, transparency-first disclosure, credit attribution) is part of the methodology, not separate from it. When Denis’s report came in, the question wasn’t whether I could fix the bug — the bug was fixable in 90 minutes — but whether I had built the institutional response that lets an external contribution land cleanly. I had not, for the encrypted channel ; that gap is now closed. The lesson : the methodology is incomplete until it survives a contribution from someone you’ve never met. v1.2.2 is the first time mine has.

v1.3.0 + the retrospective advisory taught me that the operational scaffolding extends to the historical record itself. Publishing the function-list completion + export-roundtrip extension four hours after v1.2.2 was easy : the deferred work was already drafted before v1.2.2 went out. Publishing a 14-day-old retrospective advisory the same day as a fresh disclosure was harder, because it required overcoming the impulse to hide the administrative debt under the fresh-news framing. The lesson : the public record is the methodology too. When you disclose something today that should have been disclosed two weeks ago, the right move is to publish both at the same time and acknowledge the delay openly. The alternative — never publishing the older item because the timing has become awkward — is the worse outcome.

What’s next

Denis’s two remaining findings (memory-safety + key-handling) are inbound on the encrypted channel when he’s ready ; the v1.3.x line will absorb them. If the findings are spot-patchable, a v1.3.1 will ship in days. If they touch surfaces I’d rather refactor than spot-patch, a v1.4.0. Either way, the same transparency-first disclosure model and the same credit treatment as Findings 1 + 2.

The v2.40 PKCS#11 function-list dispatch table is now 51 / 67 wired. The remaining 16 unwired slots are families that are intentional no-ops in the current implementation (e.g., C_DigestEncryptUpdate / C_DecryptDigestUpdate — the encrypt-while-digesting + decrypt-while-digesting cousins). The roadmap calls for them to land as a coherent v1.4.0 family when the underlying multipart state machine is refactored. They are documented as not-supported placeholders, not silently unwired.

For now : if you run FreeHSM C between v1.1.0 and v1.2.1, please upgrade to v1.3.0 today (v1.2.2 is sufficient to close the Denis findings as reported, but v1.3.0 is the recommended target). If you produce raw ECDSA signatures via this module that anyone external is supposed to verify, the upgrade is the only repair — the signatures from v1.1.0 - v1.2.1 cannot be retroactively fixed.

If you maintain a cryptographic module with an external-API contract that depends on a third-party verifier matching your output : write a boot KAT that exercises the external-roundtrip property today. The v1.3.0 pattern in kat/cavp_extended.c (see helpers run_ecdsa_export_roundtrip, run_rsa_pss_export_roundtrip, run_rsa_oaep_export_roundtrip, run_evp_digestsign_export_roundtrip, run_mlkem_export_roundtrip, run_ecdh_export_roundtrip) is the template. ~80 lines per surface, ~5 ms boot cost. It’s the cheapest insurance policy I know of against the class of bug that hides behind a green CI line for three weeks until an external reporter catches it.

Validation

Boot KAT                                62 / 62 vectors green
   --- of which the 9 new export-roundtrip vectors :
   ECDSA-P256-export-roundtrip / SHA-256  PASS (~1900 us)
   ECDSA-P384-export-roundtrip / SHA-384  PASS (~1700 us)
   ECDSA-P521-export-roundtrip / SHA-512  PASS (~1900 us)
   RSA-2048-PSS-SHA256                    PASS (~82400 us)
   RSA-2048-OAEP-SHA256                   PASS (~74000 us)
   Ed25519-export-roundtrip               PASS (~500 us)
   ML-DSA-65-export-roundtrip             PASS (~1500 us)
   ML-KEM-768-export-roundtrip            PASS (~400 us)
   ECDH-P256/P384/P521-export-roundtrip   PASS (~500/2500/2100 us)

Denis's killer test (external openssl verify after raw CKM_ECDSA sign):
   P-256 : OK   P-384 : OK   P-521 : OK

PKCS#11 v2.40 dispatch table : 51/67 slots wired
   (was 47/67 in v1.2.2, 44/67 in v1.2.1 pre-Denis)

CI lint / build / sign / smoke   : green
CI reproducibility               : byte-identical
CI test-coverage matrix          : 24 / 0 / 8
CI test-fips-mode                : green (this is the path that
                                          exercises the raw ECDSA fix
                                          end-to-end in production FIPS)
Wycheproof full sweep            : 6 978 / 0 unchanged from v1.2.1

Acknowledgements

Denis Mingulov (mingulov.com) for the careful report, the responsible-disclosure framing despite the encrypted-channel friction, the clean reproduction script, and the noise-aware framing on pkcs11-check raw output. This is the first external contribution to the project and a meaningful milestone for its maturation. Denis’s two remaining findings (memory-safety + key-handling) are pending on the encrypted channel ; they will be addressed in a v1.3.1 or v1.4.0 release as appropriate.


FreeHSM C v1.3.0 is the 22nd consecutive GPG-signed release of the project, signed by Ed25519 fingerprint 743A 6A59 04A1 461A 6464 08DE 4856 0162 DBBF 28A2. The module is licensed under Apache-2.0.