# Changelog

All notable changes to FreeHSM C are documented in this file.

The format follows [Keep a Changelog](https://keepachangelog.com/), and the
project adheres to [Semantic Versioning](https://semver.org/).

## [2.0.0] --- 2026-09-02

### Changed
* **`doc_audit.py` reports none, and eight of the twenty were the checker's
  fault (#171).** The report listed twenty stale paths. Reading them one by
  one rather than fixing them one by one:

  Two were host files — `openssl.cnf` and `fipsmodule.cnf` — matched by the
  bare-filename branch of the pattern and named correctly, about
  `/usr/lib/ssl`. Six were documents that *already* said the file did not
  exist, in words the filter did not list: "has not been written", "is still
  to be created", "(to write)", "created at the first external contribution".
  And the filter read one line at a time, so an admission that wrapped onto
  the next line was invisible — four more.

  A checker whose report is padded with entries that are already correct
  trains its reader to skim, which is how the real entries in it stop being
  read. Fixed in the checker: a system-file exemption, the missing phrasings,
  a one-line window either side, and an explicit `<!-- doc-audit: allow PATH
  -- reason -->` waiver for the cases where guessing at English is the wrong
  tool.

  The genuine findings: `docs/EAL4_PLUS.md` and its French twin said the
  signing key was "listed in `docs/keys/dev-pubkeys.gpg`", a keyring that was
  never written — the key is `afchine-pubkey.asc`, and there is one
  maintainer. `docs/PKCS11_CHECK_FINDINGS.md` cited a probe that was written
  for one finding and not kept. And `docs/SIMORGH_LABS_BRAND_REFERENCE.md`
  set itself a deadline — brand licence terms "decided and documented before
  v2.0.0 stable" — which this release meets: **TRADEMARK.md**, which ships;
  `LICENSE-BRAND.md` was the alternative and was never written.

  `tests/test_smoke.tampered`, named four times, is produced by nothing today:
  the tamper demonstration moved to `make test-integrity`. The artefact itself
  is real — located in the maintainer's archive of 2026-08-15, mtime
  2026-06-21 20:07, `sha256 8f6327dd…`, with a non-zero `.fhsm_digest`, so
  signed and then modified. Existence, date and signedness are established;
  the "1 byte flipped" figure is not, the untampered counterpart of that day
  not having survived, and both documents now say which is which. The three
  mentions in the Security Target keep the old name — they record June 2026,
  and its revision log says of itself that its rows are "preserved verbatim as
  historical record". Rewriting them would make the history tidier and false.

* **Interop-only is a settled choice for the composite mechanism, not a state
  awaiting the RFC (#170).** The specification status is re-checked and dated:
  `draft-ietf-lamps-pq-composite-sigs-19` is still `-19`, the state moved on
  2026-08-26 to RFC Editor *In Progress (First Edit)*, IANA is at
  `RFC-Ed-Ack`, and no RFC number is assigned. Advancing, not published.

  `docs/FHSM_CSR.md` said the FIPS limitation "lifts when implementations
  follow". It does not. §10.2 of the draft disclaims its own standing — "This
  guidance is not authoritative and has not been endorsed by US NIST" — before
  setting out a design goal addressed to implementers seeking certification.
  FreeHSM seeks none. A mechanism enters `fips-strict` by being approved, not
  by being argued for, so `CKM_COMPOSITE_MLDSA65_ED25519` stays outside that
  profile whatever the RFC Editor does. The two halves are now stated
  separately: interoperability lifts on publication, the profile question does
  not.

  Two dangling references fixed along the way. `docs/FHSM_CSR.md` pointed at
  `docs/COMPOSITE_SIGS_GAP.md` "for the reasoning" on FIPS approval — the
  reasoning is in the draft, not in that file, which never contained it. And
  `docs/FIPS_140_3.md` alone among the evaluation documents carried no
  "not seeking certification" banner while heading two lines *Target
  validation* and *Validation authority*, which read as a live engagement. It
  now carries the banner the AGD documents already had.

* **The proxy configuration is measured, and one row of the guide was wrong
  (#169).** `docs/DEPLOYING_THE_SERVICE.md` said that when the proxy sets
  `X-FHSM-Client-Subject` and the client also sends one, **two** headers reach
  the daemon and it answers 400. Measured against nginx 1.18.0: **one** header
  arrives, carrying the proxy's value — `proxy_set_header` replaces, which the
  configuration comment three lines below that table already said. The table
  contradicted its own commentary and the table was the wrong one. The 400 is
  kept and re-attributed to a proxy that *adds* rather than replaces.

  `tests/proxy_nginx.sh` is new and runs the documented nginx configuration
  for real: the header is replaced not merged, the subject arrives as
  `CN=web01,O=Example,C=FR` (and `/C=FR/O=Example/CN=web01` through
  `$ssl_client_s_dn_legacy` — that table was right), a missing client
  certificate is a 403, and with the `proxy_set_header` line removed the
  client's own `CN=attacker` arrives intact. The hole the guide describes is
  now asserted rather than described. It skips, loudly, where nginx is absent.

  Scope stated rather than blurred: the backend is a stand-in that reports the
  headers it received, because the subject under test is nginx. Running
  `fhsm-service` itself behind this configuration is the other half of #169
  and is not done — `/health` answers 200 without authorisation, so showing
  that a policy in the wrong format refuses everything needs `/sign` and a key
  label.

* **Key generation and `fhsm_drbg`: the beta's remedy is withdrawn, measured
  (#168).** `RELEASE_v2.0.0-beta.md` prescribed "a library context backed by
  `fhsm_drbg`" to make key generation draw from the module's DRBG. Tried
  against the FIPS provider: the DRBG is live and callable, and neither
  `RAND_bytes_ex` nor key generation reaches it — 0 bytes for RSA-2048,
  RSA-4096, EC P-256 and ML-DSA-65, where the same code under the default
  provider draws 31574 / 77772 / 32 / 32. Why the two diverge is not
  established and is not claimed.

  The framing was the error. FIPS 140-3 puts the DRBG inside the validated
  boundary, and per `docs/FIPS_140_3_SECURITY_TARGET.md` that boundary is the
  OpenSSL FIPS provider — so keys coming from *its* approved DRBG is the
  delegation working, not a gap in it. `fhsm_drbg` remains the generator for
  what the module itself produces: serial numbers, object ids, blob nonces.
  In `interop`, key material comes from OpenSSL's default RAND, which the
  release notes now state as a property rather than as an unfinished step.

### Fixed
* **The reproducibility reference could never have been committed (#167).**
  `.gitignore` contained `dist/` with no negation, so
  `dist/refs/vX.Y.Z.sha256` — asked for in `docs/ROADMAP.md` since
  2026-08-15, written by `make dist-baseline`, required by
  `scripts/release.sh` since yesterday — was silently ignored by git. Six
  releases shipped without an anchor not because the step was skipped but
  because it was impossible. Note that `dist/` excludes the *directory*, so
  `!dist/refs/*.sha256` alone would not have worked; the contents are excluded
  and the path re-admitted, verified with `git check-ignore`.

  The second obstacle was that `make dist-baseline` needs Docker on the
  release machine. `.github/workflows/baseline.yml` now produces the reference
  in the same image `release.yml` runs in: it signs before hashing, builds
  twice and requires the two to agree, and refuses to overwrite an existing
  reference. `release.yml` compares its build against the reference and fails
  when it is missing or differs — a missing reference is a failure, not a
  skip.

* **The release workflow no longer swallows a failed `make integrity`
  (#167).** It ran `make integrity || echo "WARN: ... (expected outside Docker
  image)"` — from a job that declares `container:` and therefore runs *inside*
  the image, so the excuse was false. The `||` meant a failed signing
  published an **unsigned** module, which carries the all-zero
  `.fhsm_digest`, so `C_Initialize` refuses and the released `.so` would not
  load at all — while every later step computed digests and signed artefacts
  around it. Nothing has shipped that way; only luck prevented it.

  Signing is now its own step, and a second step reads the section back rather
  than trusting the exit code — `sign_module.sh` exits 3 when the module is
  already signed, so a zero exit was never the assurance wanted. The check
  runs before the `.sha256` is computed, so the published digest covers a file
  whose signature was confirmed.

  The same defect was in three more places, and fixing only `release.yml`
  would have reproduced the very pattern it is an instance of. `ci.yml` ran
  `make integrity 2>&1 | tee integrity.log` twice; the default `bash -e {0}`
  shell does not set `pipefail`, so the step exited with `tee`'s status —
  always zero — and the second occurrence feeds the artefact upload.
  `scripts/release.sh` treated a non-zero exit as failure, so it reported
  "make integrity failed" on every second run of the pre-flight. All four now
  distinguish signing from being signed.

### Added
* **The suite runs against the OpenSSL FIPS provider, and the delegation is
  observed (#173).** `docs/FIPS_140_3_SECURITY_TARGET.md` says all
  FIPS-relevant computation is delegated to the FIPS-validated provider. That
  was true of the code and had never been seen happen: every test recipe sets
  `FHSM_INTEGRITY_ALLOW_UNSIGNED=1`, which is exactly what makes
  `src/fhsm_crypto.c` skip `OSSL_PROVIDER_load(NULL, "fips")`.
  `scripts/run_fips_tests.sh` now stands the environment up and measures it:
  **37 tests pass with the provider genuinely loaded, none fall back.** The
  ones that could have shown a gap — `test_fips_digests`, `test_composite_p11`
  and the three legacy-mechanism tests — did not.

  Proof is positive, not inferred. `tests/probe_fips_loaded` opens the signed
  `.so`, calls `C_Initialize`, then asks
  `OSSL_PROVIDER_available(NULL, "fips")`. The script's first version instead
  counted tests that had not printed the module's fallback notice and reported
  21 of them as FIPS runs; a test that never reaches `crypto_init_once` prints
  nothing, so that count was of tests which had failed to contradict the claim.

  Tests that link `$(LIB_OBJ)` carry the all-zero `.fhsm_digest`, so they need
  the bypass that skips the provider — which made them look unreachable. The
  way out was already in the tree and wired to exactly one test:
  `make test-integrity` signs the *test binary* with the same
  `scripts/sign_module.sh` that signs the `.so`. Each is now copied to a
  temporary directory and the **copy** is signed, so `make tests` still needs
  no FIPS provider, a relink cannot leave a stale signature, and nothing signed
  is left in `tests/`.

  Classification reads the ELF for a `.fhsm_digest` section rather than
  grepping the `.c` for `dlopen` — the first version misfiled
  `test_fork_child` and `test_session_cap`, which reach the module through a
  helper and never write the word. The section is a property of the artefact;
  the string was a guess about it.

* **`POST /ocsp` on the public listener (#163).** The route named in
  `docs/REST_API_DESIGN.md` since the ADR now answers, from the code
  `fhsm-ca ocsp-respond` uses. Configured with `--revocation-db`,
  `--ocsp-label`, and optionally `--responder-cert` for a delegated responder
  (RFC 6960 4.2.2.2), whose extendedKeyUsage and issuer are checked once at
  start rather than per request. An incomplete set — a database with nothing
  to sign it, a key with nothing to answer from, either without `--ca-cert` —
  is refused at start rather than turned into a 404 the relying party has to
  interpret.

  **The database is re-read on every query**: a `stat()`, and a re-parse only
  when the file moved. A responder that held it at start would keep answering
  `good` for a certificate revoked an hour ago, until somebody restarted it —
  the failure OCSP exists to prevent, produced by the responder. One syscall
  per question is what a revocation taking effect costs. `fhsm-ca revoke`
  writes through a temporary file and renames, so the daemon sees the old
  inode or the new one, never half of either. A database that will not parse
  leaves the previous one in force rather than dropping to empty, which would
  answer `good` for everything.

  The read side takes a read-write lock held across the signature, not a
  mutex: signing is milliseconds, and under a mutex every relying party would
  queue behind every other. Removing that lock makes ThreadSanitizer report
  `fhsm_rev_db_free()` releasing entries another thread is walking, under the
  test's own load.

  Statuses other than `successful` are unsigned, per RFC 6960 4.2.1 — a
  responder that has not parsed the request has nothing to sign *for*, and
  signing "your request was malformed" would hand a signature over a
  chosen-ish object to anyone who can reach the socket. A malformed request
  gets `200` with five bytes of `malformedRequest(1)`; an unreadable database
  gets `tryLater(3)`. `GET /ocsp` — the RFC 6960 A.1 base64-in-URL form — is
  `405` with `Allow: POST`, because serving it means a base64 decoder and a
  URL-length policy this daemon does not have. No `Cache-Control`: the
  response carries its own `nextUpdate`, and an HTTP `max-age` would let an
  intermediary serve `good` past a revocation the responder already knows
  about. No audit line per query, like the rest of that listener; the start
  line records which key answers.

### Changed
* **The release pre-flight requires a reproducibility reference.** `dist/refs/`
  had held nothing but `.gitkeep` across six tagged releases, so
  `make dist-verify` always took its fallback branch — build twice in the same
  container on the same day and compare. That proves determinism, not that the
  published artefact can be reproduced, which is what a third party checks.
  `scripts/release.sh` now fails without a reference, and fails again if one
  exists but nothing compared against it.

  The determinism itself holds: two builds of the same tree in two different
  directories produce a bit-identical `libfreehsm-fips.so`. What was missing
  was the anchor and the requirement that one exist.

### Added
* **A queue in front of the workers (#111).** The ADR's later slice, and what
  the deadlines above could not reach. A worker used to own a connection from
  `accept()` onward, so a silent peer cost a legitimate client
  `(n / workers) x 1 s`. An acceptor thread per listener now holds every
  connection that has not spoken and hands a worker only one that is already
  readable. Measured at four workers, before and after: 4 silent connections
  0.8 s → **5 ms**, 8 → **7 ms**, 32 → **7.9 s → 8 ms**.

  The design followed a measurement rather than the other way round: holding an
  accepted connection in a poll set costs **0.25–0.46 kB** and one descriptor,
  against **19.7 kB** for an idle thread and ~29 kB for a pooled session. Two
  orders of magnitude, so the connection waits somewhere that is not a worker.

  `--queue-depth` (256 per listener) bounds it, and past the depth the daemon
  answers **503 with `Retry-After: 1`** — the first time it can say it is busy
  instead of leaving the kernel to return `EAGAIN`, which an honest client
  cannot distinguish from an absent service. The queue drains itself at the
  first-byte deadline, so saturation is transient. The depth is checked at
  start against `RLIMIT_NOFILE`, and the systemd unit now declares
  `LimitNOFILE=4096` rather than inheriting a bound nobody chose.

  **Two audit floods, both found by measuring the fix.** A 503 is immediate, so
  the peer sets the rate: hammering a full queue wrote 722 lines and 264 kB in
  two seconds. Connect-and-close was worse, because it reached a worker whose
  read of zero bytes was logged — 1371 lines. Both are compressed now, the
  first of a burst written and the rest counted: 463 843 connect-and-close in
  two seconds produce **4 lines and 12 kB**, one of them
  `hung_up_early_over` carrying `refused: 421409`.

  Two corrections worth recording. `POLLHUP` is not how you detect a peer that
  hung up — a closed unix peer leaves the socket readable in the EOF sense, so
  `POLLIN` is set too and the test never fires; one `MSG_PEEK` answers it.
  And `g_stop` is now `_Atomic sig_atomic_t` rather than
  `volatile sig_atomic_t`: both are correct, but the acceptor reads it on every
  pass and ThreadSanitizer reported the handler's write against those reads.
  Relying on the tool not looking is not a property worth keeping.

### Fixed
* **`CKA_SIGN_RECOVER` and `CKA_VERIFY_RECOVER` were refused rather than
  answered.** `C_GetAttributeValue` returned `CKR_ATTRIBUTE_TYPE_INVALID` for
  both, where PKCS#11 defines them for every key object. This module implements
  neither recover operation and never will, but "not supported" and "I do not
  know this attribute" are different answers, and a caller asking about
  capabilities got the second. Both now return `CK_FALSE`. Seven usage
  attributes were wired and two were not — found by OpenSC's `pkcs11-tool`,
  which prints a warning for each. See `docs/THIRD_PARTY_CONSUMERS.md`.

* **Four connections that sent nothing stopped the service (#111).** Found
  while measuring for the queue slice, which is not what it was looking for.
  With `--workers 4`, opening four sockets and sending no bytes took `/health`
  from 9.8 ms to a timeout and held it there for as long as the connections
  stayed open — and the daemon wrote nothing at all, so an operator had a dead
  service and an empty log. Every worker sat in a blocking `read()` waiting for
  a header that was never coming; `setsockopt` appeared nowhere in the file. At
  sixty-nine such connections the accept backlog filled and `connect()` itself
  returned `EAGAIN`, refusing an honest client at the kernel before the daemon
  saw it.

  Two deadlines, because the two waits are not the same shape. **1 second for
  the first byte** — a proxy that has connected has its request in hand and
  nothing to compute — and **10 seconds for the rest**, so a body arriving in
  pieces still gets signed. A single ten-second deadline was the first attempt
  and left the service stalled for twenty seconds against eight connections;
  the split brings that to 1.8 s. `SO_SNDTIMEO` covers the other direction, so
  a peer that will not read the answer cannot hold a worker either.

  The expired connection gets `408` and one audit line. Not compressed like the
  refusal bursts, deliberately: abandoning a connection costs the peer the
  whole deadline, so the control is already its own rate limit.

  Stated rather than left to be found: this bounds the damage, it does not
  remove it. A worker is committed from `accept()` onward, so *n* silent
  connections still cost a client about `(n / workers) x 1 s` — 7.9 s at 32.
  That residual belongs to the queue slice, and `docs/RATE_LIMIT.md` now says
  so. Reverting the deadlines fails four assertions in
  `tests/service_guards.sh`; collapsing the two into one fails a fifth.

### Changed
* **The revocation database and the OCSP responder move into the library
  (#163).** Both lived in `tools/fhsm_ca.c`, reached only by
  `fhsm-ca ocsp-respond`. `fhsm-service` has to answer the same question on a
  socket, and copying the code would have produced two implementations of "is
  this serial revoked" — agreeing on the day they were written, and drifting
  after. A responder that has drifted answers `good` for a certificate the CA
  revoked, which is the one wrong answer OCSP exists to prevent.

  They are now `include/fhsm_revocation.h` and `src/fhsm_revocation.c`,
  linked into `fhsm-ca` and `fhsm-service` and **deliberately not into the
  module**: the file pulls in OpenSSL's OCSP parser and computes SHA-1 for
  CertID matching, and `fips-strict` has no business carrying either.

  Two things a tool may do and a daemon may not were removed on the way.
  `exit()` on a malformed database line — in a service that is one request to
  refuse, not a reason to stop answering the rest; every function now returns a
  status and writes its diagnostic into the caller's buffer, and `fhsm-ca`
  prints it and exits exactly as before. And the `static` file buffer, which is
  fine for one operator and is the data race that gave fifteen of sixteen
  concurrent clients another client's signature earlier in #111. The database
  also grows instead of allocating all 100 000 entries per load: 8 MB once per
  run is affordable, 8 MB per request is not.

* **`/certificates` and `/ocsp` answer 404 on the authenticated socket, not
  501 (#111).** 501 says "this route exists here and is unwritten", which
  stopped being true when both moved to the public listener. The answer carries
  `Link: </certificates>; rel="alternate"`, so it names where they went rather
  than leaving the caller to read the ADR.

### Fixed
* **The same certificate could be revoked twice (#163).** `fhsm-ca revoke`
  compared serials byte for byte; the responder compared them ignoring leading
  zeros, which is what DER's sign padding requires. So `004A3B2C1D` was
  accepted as new when `4A3B2C1D` was already recorded, and the certificate
  appeared twice in every CRL after that. The two comparisons were in different
  functions and nothing asked them the same question — putting both in one file
  is what made the difference visible. `revoke` now uses the responder's
  comparison.

* **`make service-guards`, `service-budget` and `service-public` failed on a
  correct tree.** Two causes, one message, and the shape this project keeps
  finding: a control wired to most of the paths that reach a state and not the
  rest. Forty-two recipes in the `Makefile` run tests through `$(TEST_LD)`;
  these three did not, so the module resolved against the system OpenSSL, which
  has no ML-DSA-65. And all three named the binaries as prerequisites but not
  `$(LIB)` — so a `libfreehsm-fips.so` left over from the other profile
  survived the rebuild and failed at the same keygen with the same
  `CKR_MECHANISM_INVALID`, while the scripts' own `--profile` guard passed
  throughout, because the service carries its profile statically and says
  nothing about the library it never loads. Both fixed; the diagnostic now
  prints the tool's own output and names both causes.

* **The revocation database had no test.** The CRL encoder had a differential
  test against OpenSSL and the delegated responder had twenty assertions, but
  the file both of them read was exercised only incidentally. `make
  revocation-db` (`tests/revocation_db.sh`) covers recording, the leading-zero
  case above, the four refusals, and that a malformed line stops the whole read
  with the file left untouched. Reverting the fix fails three of its assertions.

### Added
* **A second, anonymous listener (#111).** `docs/REST_API_DESIGN.md` refuses
  requests with no client identity — "not 'anonymous read-only'" — and names
  `/ocsp` and `/certificates` among the routes. Those two cannot both be true.
  An OCSP responder answers **relying parties**, and a relying party has no
  client certificate to present; a responder that demanded one would be a
  responder nobody could query. `/certificates` is what an AIA `caIssuers`
  pointer resolves to.

  Resolved with **two sockets rather than one rule with an exception**, because
  the rule's force came from having none. `--public-socket` requires no
  identity, applies no policy and no refusal budget, takes **its own workers**
  so anonymous traffic cannot starve the authenticated side, and **writes no
  audit line per request** — a durable barrier costs milliseconds, an OCSP
  responder answers as often as clients open connections, and one line per
  query would hand the flood to anyone who can reach the socket. The start line
  records that the surface exists; nothing routine after.

  It serves `GET /certificates` (from `--ca-cert`, read once at start, typed
  `application/pkix-cert` and cacheable) and `GET /health`. `POST /ocsp`
  answers 501 with the reason in the source: assembling a response is in the
  library, but parsing the OCSPRequest and looking the serial up live in
  `tools/fhsm_ca.c`, and the service must **share** that code rather than
  mirror it — a mirror that drifts is the hazard
  `docs/FIPS_140_3_SECURITY_TARGET.md` already names for the fuzz harnesses.
  Signing and verifying are absent from that surface entirely, answering 404
  rather than 403 or 501, because a route that answers anything invites a
  second look.

  Three configurations are refused at start rather than half-served:
  `--ca-cert` without `--public-socket` (a certificate nobody could fetch), an
  unreadable certificate, and a `--pool-max` below the **total** worker count —
  counted now so the bound is not silently wrong the day `/ocsp` starts
  borrowing sessions.

  `tests/service_public.sh` and `make service-public`. Twelve assertions,
  including that an identity offered to the public socket changes nothing: it
  is ignored rather than believed, or a caller could put a string of its
  choosing into the record.

* **`POST /verify` (#111).** The route matters because of something this
  project already documents: nothing off the shelf verifies a composite
  signature, so checking one meant running `fhsm-sign cms-verify` on a machine
  that has the module. Now it is a request.

  The body is the **signature followed by the message**, split by
  `X-FHSM-Signature-Length`. Not base64 — the route's own argument is that a
  signature is bytes, and an encoding would add a decoder to get wrong — and
  not multipart, which would add a parser.

  **200 only when it verifies; 422 when it does not.** A caller that checks the
  status and ignores the body is then correct by default. The opposite
  arrangement, 200 with `invalid` in the body, makes the careless reading say
  yes — and the careless reading is the one that ships.

  It takes **the same authorisation as `/sign`**, and its three refusals are
  the same single answer. Verification needs no secret, so leaving it open is
  tempting; it would make the key label an oracle, which is precisely what
  `do_sign()` spends its comments preventing. The cost is stated rather than
  hidden: the route is useful only to clients already in the policy file.

  Found by running it rather than reading it: `find_key()` hardcoded
  `CKO_PRIVATE_KEY`, which is right for signing and wrong for verifying, so
  every verification came back 500 — including the valid one. It now takes the
  class as a parameter.

  One thing about the test worth recording: the `/verify` assertions had to be
  ordered around the refusal budget. Three refusals push the test's identity
  out of its free allowance, after which everything is 429 — the budget working
  correctly, and enough to make the next assertion added there read as a
  failure of `/verify`.

* **The service can be deployed (#111).** `systemd/fhsm-service.service` and
  `docs/DEPLOYING_THE_SERVICE.md`. Built and tested is not the same as
  installable, and the gap between them is where a beta is judged.

  **The document leads with the thing that matters most: the service believes
  `X-FHSM-Client-Subject`, and the proxy configuration is therefore part of the
  security boundary.** Nothing in the daemon can catch a proxy that fails to
  set the header at all -- a header it never receives is indistinguishable from
  one the proxy wrote. Two of the three failure modes *are* caught, and the
  document says exactly which: a client that sends its own alongside the
  proxy's gets 400, because resolving an ambiguity about identity is how the
  wrong answer becomes authoritative. The deployment checklist is written as
  four commands to run, one of which forges the header and must come back 400.

  **The subject format will not be what an operator expects.** The policy file
  matches the string byte for byte, and nginx changed `$ssl_client_s_dn` in
  1.11.6 to RFC 4514 -- `CN=web01,O=Example` -- while our own tools print
  `/O=Example/CN=web01`. A policy in the wrong format does not fail loudly:
  every request is refused as unauthorised, indistinguishably from an attack by
  design. Documented with the four forms side by side and the advice to read
  the `actor` field of the audit log rather than assume.

  The unit needs **systemd 250** for `LoadCredentialEncrypted=`. On older
  systemd the directive is not an error but an ignored unknown key, so the
  service starts without its PIN -- verified against systemd 249, where
  `systemd-analyze verify` says so in as many words. And `PrivateDevices=yes`
  is deliberately absent, because the module seals the token's DEK through
  `tpm2` against `/dev/tpmrm0`: hardening it away would disable sealing
  silently, since #109 made a TPM failure report a device fault rather than
  lock the token.

  The PIN provisioning procedure is written out because there is no way around
  the fact that the same PIN must reach both `fhsm-token init` and the
  credential, so it exists in a shell once. `docs/DAEMON_PIN.md` chose the
  credential but did not say how to create it.

* **The refusal budget (#111, `docs/RATE_LIMIT.md` job 2).** An authorised
  client asking for keys it is not authorised for is mapping the token, and
  repetition is how that is done. What counts is exactly the authorisation
  refusal -- a key the policy does not grant, a key that does not exist, a
  subject it does not know, the three `/sign` answers identically. Not a
  malformed request, which says nothing about the token; not one already
  refused by the budget or the fairness cap, or the control would tighten under
  its own refusals.

  Four refusals are free -- a typo in a key label is not an attack. Past that
  the delay doubles from one second and stops at sixty, and the count decays by
  one every ten quiet minutes. Nothing resets it outright: a reset on a served
  request, which is what the PIN throttle does, would let an attacker interleave
  one legitimate request between probes. The PIN can afford that because there
  is a secret to guess; here there is not.

  **The delay is an interval between attempts, not a window of refusal**, and a
  refusal produced by the budget does not push it forward. Otherwise retrying is
  what keeps you out. Both were mutated to check the tests notice: with the
  deadline pushed forward on every refusal, a client retrying every 100 ms never
  got through in four seconds against a one-second delay.

  **The count is the only thing on disk.** The decay needs elapsed time and no
  clock can be persisted honestly -- `CLOCK_MONOTONIC` restarts at boot,
  `CLOCK_REALTIME` moves under `date -s`, and the token bought that lesson when
  a 500 ms throttle came back as thirty days. So a restart *pauses* the decay
  and never rewinds it, which is the direction the document requires: a crash,
  which an attacker may be able to cause, must not hand back a reset. Written on
  each increment at or past the allowance rather than at shutdown, and the write
  rate is then bounded by the delay the count itself imposes.

  New audit event `identity_limited` (85) at the crossing, with the count and
  the delay it earned. Once per crossing, never per refusal -- the document
  calls this the budget's real product: a stolen certificate is not detectable
  by content, since every request it makes is well-formed and authorised, so
  what changes is the rate and the shape.

  Recorded rather than left to be found: **the interval belongs to the identity,
  not to the probing**, so after the crossing a legitimate request from that
  subject waits too. That is what makes a stolen certificate cost its holder
  something, and it means an attacker holding one can slow the real client down.
  The answer stays revocation. And the two kinds of 429 are distinguishable by
  their `Retry-After` while the refusal itself says nothing.

  `tests/service_budget.sh`, and `make service-budget`. Separate from
  `service_guards.sh` because it stops and restarts the daemon: the property
  that matters most cannot be seen by a suite that never restarts anything.

* **Fairness between identities, and the log compression that makes it work
  (#111).** `docs/RATE_LIMIT.md` asked for a cap on "how much of the pool one
  identity may hold". Measured against the service, **that cap could never
  fire**: `main()` refuses to start with `--pool-max` below `--workers`, a
  worker serves one request at a time and releases its session on every path,
  so the pool is never contended. Instrumented, 32 concurrent signatures
  produced **zero pool waits**.

  The starvation the document describes is real and one layer down: with four
  workers, one identity saturating the service took another's median latency
  from **10.4 ms to 75.1 ms, a factor of 7.2**, with the pool never contended.
  The scarce resource is the worker thread, so the cap counts requests in
  flight per identity.

  An identity alone may use every worker; while another is present each is held
  to `workers - 1`. **Presence is earned by being served, not by asking** — an
  identity whose requests are all refused never becomes present, or one refused
  request per window would cost the real client a worker indefinitely.

  **The cap alone did nothing, and the measurement said why.** A written
  refusal cost **48.8 ms** — a refusal is nearly as expensive as a signature,
  as `RATE_LIMIT.md` had already measured — so the worker reserved for the
  quiet identity spent it writing refusal records for the loud one. Rule 2 is
  not a separate slice; it carries rule 1. With the burst compressed to one
  line at the transition, a count in memory and one `identity_resumed` line
  closing it: refusal **1.17 ms**, starvation **×2.4**, and **42392 refusals
  produced 2 audit lines**.

  The burst closes after 5 s without a refusal rather than on the next admitted
  request: without that, a capped client alternates admitted and refused and
  each swing writes two lines — 9865 refusals produced 786 lines before the
  cooldown existed.

  New audit event `identity_resumed` (84), carrying `suppressed` and
  `window_s`. Bursts still open at shutdown are flushed rather than lost.

  Not built here, and said in the document rather than left to be assumed: the
  refusal budget of job 2 — nothing persists a count or derives a delay — and
  ×2.4 is not 1.0, the remainder being the kernel accept backlog this process
  cannot see.

* **`serve()` has one exit.** Per-request teardown was repeated at each of five
  returns, which is the shape that lost a signature buffer in the previous
  slice: teardown written at every return is teardown that will be forgotten at
  the next return added. The early returns are now `goto done`.

* **`POST /sign` — the service's first route that reaches a key (#111).**
  Headers carry the identity and the key label (`X-FHSM-Client-Subject`,
  `X-FHSM-Key`), the raw body is the message, the response body is the
  signature. No JSON anywhere: a signature is bytes, and base64 around it would
  buy nothing but a decoder to get wrong.

  Authorisation is a tab-separated policy file of `SUBJECT<TAB>KEY-LABEL`
  pairs, re-read on `SIGHUP` and applied at the next request. A reload that
  fails to parse keeps the policy already in force, because the alternative —
  an empty policy — fails open on the reload path.

  **Every refusal is one answer, byte for byte.** A key the policy does not
  grant, a key that does not exist, and a subject the policy does not know all
  produce the identical 403. The route asks both questions before answering
  either, so the answer cannot depend on which one failed first:

  ```c
  int permitted = policy_permits(r->subject, r->key);
  unsigned long key = find_key(sess, r->key);
  if (!permitted || key == 0) { ... }
  ```

  **This equalises the work, not the timing**, and the code says so. A search
  that finds nothing walks the whole object store; one that finds a key stops
  early. Constant-time object lookup is not something the service can impose on
  the module, and claiming "in the same time" would be claiming more than is
  true.

* **`fhsm-service --profile`** prints the profile the binary was built with.
  The service links the module's objects statically, so it carries a profile of
  its own — a service built `fips-strict` cannot sign with the composite
  mechanism whatever the separately built tools around it can do.

* **A patch to p11-kit, so post-quantum mechanisms cross the socket
  (`contrib/p11-kit/`).** Not our code, kept here because we need it and
  measured the problem. Three pieces:

  the SHA-3 family and the post-quantum key-pair generators added to
  `mechanism_has_no_parameters()` — safe by inspection, their wire form is the
  empty byte array; a new `p11_rpc_mechanism_call_is_supported()` which asks
  whether a *call* can be serialised rather than whether a *type* is known,
  admitting any mechanism the caller invoked with no parameter at all; and
  `C_GetMechanismList` no longer filtered, because after the second piece the
  type alone can no longer decide and filtering hid 52 working mechanisms.

  **`CKM_ML_DSA` and `CKM_SLH_DSA` are deliberately NOT in the parameterless
  list.** They take an optional `CK_SIGN_ADDITIONAL_CONTEXT`, and listing them
  there would encode a supplied context as absent — a signature made with an
  empty context, valid-looking and wrong. A wrong signature is worse than a
  refused one. The parameter-aware test carries them instead, and refuses the
  call when a context is present. Demonstrated both ways: `CKM_ML_DSA` with no
  parameter returns `CKR_OK` through the socket, with a parameter returns
  `CKR_MECHANISM_INVALID` while the module itself accepts it.

  Verified: **p11-kit's own suite, 525 tests, 525 pass**; 72 mechanisms
  reported through the patched socket against 20 before; and a composite
  ML-DSA-65 + Ed25519 signature made through the socket **verifies against the
  module loaded directly**, with a corrupted copy refused so the verifier is
  not simply agreeing. The patch applies cleanly to a pristine
  `apt-get source` tree.

  Not submitted, and against 0.24.0 rather than master. `contrib/p11-kit/README.md`
  says what a real contribution still needs, including a test in their suite
  and a separate argument for the third piece, which changes behaviour for
  every existing user.

* **`tests/bench_fsync_floor` — the durability barrier with none of our code in
  the loop, and what that changes.** One 300-byte append and one `fdatasync`,
  no module, no libcrypto: **2.4–2.6 ms on the same ext4 the module was measured
  on** (five runs, 8 % spread), against the module's 2.49–2.86 ms per line. So the figures in
  `docs/AUDIT_DURABILITY.md` are not a measurement of the audit log — the
  formatting and the HMAC cost almost nothing next to the barrier, and **there
  is nothing in the log to optimise.**

  It prints a distribution rather than a mean, because a mean of 3.25 ms once
  hid a maximum of 76.4 ms against a median of 2.70, and the tail is what an
  operator feels.

  **It also says which way the published numbers are wrong.** The floor's
  *minimum* is 2.058 ms — the best that device ever managed — and no flash
  device needs two milliseconds to answer a flush. Both hosts measured so far
  are virtual machines with rotational or network-backed disks, so the absolute
  figures are pessimistic for the storage an authority would buy. By how much
  is now a command rather than a guess.

  The tmpfs trap that once put "0.003 ms per line" in a document is no longer a
  warning in prose: the tool **exits non-zero** when the barrier returns too
  fast to have been real, so a provisioning script can gate on it. `AGD_OPE.md`
  §4.2b now leads with it, and says plainly what it cannot catch — a hypervisor
  that acknowledges barriers without honouring them produces a plausible
  millisecond figure and no warning at all.

* **Nothing post-quantum crosses a `p11-kit server` socket, measured.**
  The module advertises 72 mechanisms directly and **20** through p11-kit
  0.24.0. The 52 that vanish are every post-quantum one — `CKM_ML_DSA`, the
  standard SHA-3 family, and the composite `0x80004202`. `C_SignInit` with the
  composite answers `CKR_MECHANISM_INVALID` and the module never sees the call:
  the server-side audit log records `login_ok` and nothing after it.

  Not ours — `C_GetMechanismInfo` answers `CKR_OK` for all six probed
  mechanisms directly and `CKR_MECHANISM_INVALID` through the socket for the
  four post-quantum ones. Not a p11-kit bug either: `p11_rpc_mechanism_is_supported()`
  is an allow-list, because a `CK_MECHANISM` parameter is a `void *` that
  cannot be serialised generically. A mechanism in neither of its two tables
  cannot cross **even if it takes no parameter**, which is our case. Upstream
  master lists `CKM_IBM_DILITHIUM`, `CKM_IBM_KYBER` and `CKM_IBM_SHA3_*` —
  vendor mechanisms contributed by IBM — and no standard PQ mechanism at all.
  The route for ours therefore exists and is a patch to p11-kit, not a setting.

  `probes/rest/07_kit_mechanisms` so the number can be re-measured when p11-kit
  moves, and `docs/P11_KIT_REMOTING.md` for the setup with both limits stated
  first. **§2b of `docs/REST_API_DESIGN.md` said "nothing needs to be written
  for the transport"**, which was written before anyone tried to sign through
  it; corrected.

* **What a process-per-client server costs, measured (#111).**
  `tests/bench_fork_client` reports in PSS, not RSS — summing resident set over
  N children counts one libcrypto N times. Serial setup for one client:
  **41–47 ms** forked (8.7–15.0 ms `C_Initialize`, 32–35 ms `C_Login`), 140–280
  ms forked and exec'd. Sixteen at once: ~60 connections/s forked, ~10 exec'd,
  against 674 sig/s of signing — **connection setup is the ceiling of this
  design, not cryptography.** Memory: 4 251 KiB per forked client, 2 174 KiB
  per exec'd one.

  The two models trade the same two resources in opposite directions, and the
  benchmark measures the attribution rather than narrating it: a forked child
  inherits OpenSSL's already-built providers (six times faster to initialise)
  and pays in private dirty pages it inherits and then writes over (2.1× the
  private memory). `p11-kit-remote` execs, so a deployment built on §2b's
  isolation gets the slow, cheap column. `docs/REST_API_DESIGN.md`.

  **The first version of this benchmark printed a per-client latency measured
  under 16-way contention and divided 1000 by it to get "5 connections/s".**
  That is not a rate, it is an artefact of sixteen processes sharing two cores;
  the real aggregate was 60/s. It now reports serial cost and aggregate rate as
  two different numbers and says which is which.

* **What a held session costs, measured (#111).**
  `tests/bench_session_mem` loads the shipped module through `dlopen` and reads
  `/proc/self/statm`: **29.3 KiB per session opened, 0 to log it in, 0 to give
  it an active digest**, cap 127, everything held 11.6 MiB. The ~6.4 MiB
  `C_Initialize` adds is mostly OpenSSL provider initialisation — a bare
  program doing one `EVP` SHA-256 pays 3 MiB of it — and the secure heap is
  ~150 KiB of it whatever `secure_heap_kb` says.

  The 29.3 KiB is not state the session holds. It is five operation slots of
  ~4 992 bytes each, faulted in because `C_OpenSession` zeroes them; the digest
  then costs nothing because the pages are already resident. `docs/REST_API_DESIGN.md`
  records what that means for pooling.

  **The benchmark prints the curve, not an average**, because the average lied:
  two builds gave 30 510 and 14 957 bytes per session for the same code. The
  sampled version showed RSS climbing at 29.3 KiB per session and then going
  flat at 256 — which was not a memory characteristic but a fourth stale bound,
  below.

* **A delegated OCSP responder, so the CA key can stay offline.** `fhsm-ca
  issue --profile ocsp-responder` produces a certificate carrying
  `extendedKeyUsage OCSPSigning` and non-critical `id-pkix-ocsp-nocheck`
  (RFC 6960 §4.2.2.2); `fhsm-ca ocsp-respond --responder-cert FILE` then signs
  answers with that certificate's key instead of the CA's, naming the delegate
  as the responder ID and carrying it in the response's `certs` field.

  **Validity is short by default and long only on request.** `ocsp-nocheck`
  means a compromised responder certificate cannot be revoked in any way a
  verifier will observe, so expiry is the only control left: the profile
  defaults to 30 days rather than the end-entity year. `--days` still
  overrides — an operator whose CA key is in a safe elsewhere may have no way
  to reissue monthly, and that trade is theirs — but past 90 days the tool
  prints a NOTE saying what is being traded away, and issues anyway. A warning
  that also refused would be a policy pretending to be advice.

  Two files are refused rather than signed with: one without the EKU, whose
  answers no verifier would accept, and one issued by a different CA. **The
  second check compares names, and a name is not a signature** — verifying that
  this CA really issued the delegate means checking a composite signature,
  which nothing off the shelf can do. It catches the wrong file, not a forgery,
  and the message says so rather than implying more.

  `tests/ocsp_delegated.sh` drives the tools end to end (19 assertions,
  `make ocsp-delegated`, wired into CI). Four mutations were tried and each is
  caught by the assertion meant for it. It also asserts that OpenSSL verifies
  *neither* a delegated nor a CA-signed response, and fails the same way —
  decoding the signer's composite key — so the limit is never misread as a
  fault in the delegation.

* **The audit log's durable barrier is shared between concurrent writers.**
  Every event still returns only after an `fsync` that covered its own write —
  a signature must not reach a client before the record of it is on disk, and
  `docs/AUDIT_DURABILITY.md` argues why that is not negotiable. What changed is
  how many barriers it takes: one serves every write already in the file, so a
  writer arriving while a barrier is in flight waits for the next one instead
  of queueing its own.

  In the module, with the log on a real file: 242 → **674 sig/s** at eight
  concurrent signers, median latency 30.3 → 10.8 ms. The ceiling with no
  durable write at all is 1145 sig/s, so this closed most of the gap and not
  all of it.

  **The ordering, not the waiting, is the delicate part.** The mutex is
  released while the barrier runs, so another writer reads `g_prev_hmac` in the
  middle of it — the chain has to advance *before* the barrier. Get that wrong
  and two lines claim the same predecessor: every event still returns `OK`, the
  log still looks well-formed, and only the verifier disagrees.

  `tests/test_audit_concurrent.c` asserts the sharing happened (79 barriers for
  320 events), that the chain verifies over the whole file, and that no line
  was lost. Both mutations are caught by the assertion meant for them. Clean
  under `SANITIZE=1` and under ThreadSanitizer.

* **The tools enumerate slots instead of assuming slot 0.** `--slot` defaulted
  to `0` and went straight to `C_OpenSession`. That is right for this module,
  whose slots are `0..FHSM_MAX_SLOTS-1`, and wrong for every other: a
  `CK_SLOT_ID` is an opaque identifier, not an index. Through p11-kit,
  `C_OpenSession` answered `CKR_SLOT_ID_INVALID` for 0, 1, 2 and 3 — there was
  no number an operator could have typed.

  `p11_resolve_slot()` takes two `C_GetSlotList` enumerations — with and
  without `tokenPresent` — and decides from the pair. `fhsm-csr`, `fhsm-ca` and
  `fhsm-sign` require exactly one token and **refuse when there are several**,
  listing them with their labels: picking one for the operator means signing
  with a key they did not choose, and the mistake is invisible until someone
  reads the certificate. `fhsm-token init` takes the lowest empty slot, which
  is a guess whose worst case initialises nothing; when every slot is taken it
  refuses. `fhsm-token info` is read-only and prints the slot it read.

  `--slot` is now validated: `atoi` answered `0` for `abc`, and `0` used to be
  the default, so a typo silently addressed slot 0.

  `--slot N` naming a slot with no token now says so, rather than passing the
  value through to a bare `CKR_SLOT_ID_INVALID`.
* **The audit log is written.** `C_Initialize` opens it; the manuals had told
  the Security Officer to review it weekly since before v1.0. It lives at
  `{tokens_dir}/audit.log`, or wherever `FHSM_AUDIT_LOG` points.

  The chaining key is its own, provisioned on first start — sealed to the TPM
  under `FHSM_TPM_SEALING`, a 0600 file otherwise. Never the token DEK: that
  would make the log writable only while logged in, leaving `login_fail`,
  `login_locked` and `integrity_fail` untraceable, which are the three events
  AGD_OPE §4.3 exists to have you investigate. Four conditions refuse to start
  rather than degrade quietly — a key others can read, a blob that will not
  unseal, sealing requested and unavailable, a key of the wrong size.

  `fhsm_audit_verify()` is implemented; it was a stub returning `OK` without
  reading its arguments. `tools/freehsm-audit` computed a different HMAC from
  a different chain head, so it could never have validated a genuine line.
  Both now agree, and `tests/test_audit_verify.c` runs both against the same
  real log — three of the four falsifications are caught, and the fourth is
  asserted as passing on purpose.

  **Truncation at the end is not detected and cannot be** from the file alone:
  what remains is a shorter chain that verifies perfectly. Recorded in
  `docs/ROADMAP.md` with the two ways to close it and their cost, and stated
  in AGD_OPE §4.3 rather than left to be discovered.

  **A start-up integrity or KAT failure is not logged**, because the log needs
  HMAC and HMAC is what just failed. The module latches ERROR and refuses
  everything, so the condition is observable; the reason will not be in the
  log. Also stated in AGD_OPE §4.3.

### Fixed
* **The README claimed byte-for-byte token interoperability with the Python
  POC, and `docs/TOKEN_STORE_FORMAT.md` said in the same repository that there
  is none.** `README.fr.md` §"Compatibilité avec le POC Python" promised a JSON
  store identical byte for byte, a slot written by one implementation opened by
  the other, and a migration by swapping the shared library. The store is
  **fixed-layout binary, not JSON**, and `TOKEN_STORE_FORMAT.md` states it
  plainly: *"There is no byte-level interop with POC token files — migration is
  by re-importing objects, not by copying `.tok` files."* The correction was
  made there when that document was written, and never carried back to the
  README or to `ARCHITECTURE.md`, which repeated the claim twice in each
  language.

  What survives is the half that is true: the PKCS#11 API is the same, so an
  application does not change. The token files do.

* **`ATE_FUN.md` contradicted itself about its own coverage.** The layer table
  presented `tests/test_token_interop.c` as the L4 integration level; the gap
  table, sixty lines below, listed the same file as a known gap. The file has
  never existed and the Makefile never expected it. The L4 row now says the
  level does not exist, and the gap is **withdrawn rather than rescheduled** —
  its resolution path was byte-level interop with the POC, which
  `TOKEN_STORE_FORMAT.md` rules out by design. The gap that *was* real, an
  audit chain verifier that was "a stub", is marked closed: it shipped on
  2026-08-18 and is checked against mutations.

* **The CC and FIPS evidence cited test drivers and scripts that do not
  exist.** `tests/kat_report.c` in `AGD_PRE.fr.md` and the CST checklist —
  `tests/test_smoke` is the driver and prints all 62 vectors.
  `tests/test_drbg.c` for the DRBG continuous test, which runs inside the boot
  KAT but has no dedicated driver, so that box cannot be ticked.
  `scripts/validate_cavp.py` and `scripts/coverage.sh` in `ALC_CMC` — the
  cross-validation is manual and **no gate enforces the coverage thresholds**
  the document names. `scripts/freeze_apt_versions.sh` in
  `REPRODUCIBLE_BUILD`. Each is now described as what it is rather than cited
  as if it were there.

  These are the documents whose purpose is to be checkable by an evaluator, and
  an evaluator would have started there.

* **`scripts/doc_audit.py` learned to read French and to recognise honesty.**
  It skipped a line admitting a file was missing, but only in English, so the
  bilingual half of the corrected documentation came back as findings. It also
  now excludes absolute paths, the `.fr.md` tail of a markdown link, and the
  two files belonging to pkcs11-check's own tree. The run is down from 48
  reported paths to 19, and the 19 need a judgement rather than a rename.

* **The OpenSSF self-assessment cited three documents that do not exist, and a
  dependency the project never used.** `docs/OPENSSF_BEST_PRACTICES.md`
  justified `bus_factor` by a `docs/BUS_FACTOR.md`, `documentation_achievements`
  by a `docs/VALIDATION.md`, and `external_dependencies` by a
  `docs/DEPENDENCIES.md` — none of which had been written — and listed `liboqs`
  among the dependencies, which appears nowhere in the code or the Makefile.
  For a project whose argument is that its claims can be checked, an
  assessment against an external standard is the worst place to have that.

  `docs/DEPENDENCIES.md` is now written, derived from the build rather than
  remembered: `-lcrypto -ldl -lpthread`, OpenSSL **3.5** and not negotiable
  because the post-quantum algorithms come from its default provider, plus
  `tpm2-tools` and systemd 250 as optional run-time helpers. The other two
  criteria are marked **not met**, which is what they are.

* **Three more copies of "not built" and "not submitted".**
  `docs/P11_KIT_REMOTING.md` still said the patch was not submitted and quoted
  525 passing tests; `docs/ROADMAP.md` and
  `docs/DESIGN_NOTES_COMMERCIAL_HSM.md` still said the PKI tooling was not
  built, four command-line tools after it was. The tooling and the *product*
  are now separated in the ROADMAP: `fhsm-ca`, `fhsm-csr`, `fhsm-sign` and
  `fhsm-service` ship as part of FreeHSM; **Simorgh PKI — the operator, the IaC
  modules, the packaging — is not built and must not be described as
  existing.** The distinction is kept sharp in both directions, because saying
  the tooling does not exist had become as untrue as claiming the product does.

* **`CONTRIBUTING.md` cited a `docs/ALC_FLR.md`** that does not exist.

### Added
* **`scripts/doc_audit.py` — the documentation checked against the repository
  it describes.** Five stale claims were found by accident in one afternoon,
  which is a poor way to find the sixth. Three checks: every `--option` cited
  beside one of our tools must appear in that tool's own `--help` (this is what
  would have caught `fhsm-token --gen-pin`, written into a deployment procedure
  and non-existent); every repository path cited in backticks must exist,
  excluding build products and other projects; and a reading list of "not
  built" / "not submitted" claims for a human, because that one cannot be
  decided by machine.

  It also skips any line that admits the file is missing — the honest fix for a
  broken citation is often to say "this does not exist", and a checker that
  flags the honesty pushes people back towards the lie.

  **Its first run reports what this commit does not fix:** the Common Criteria
  evidence documents cite test files and scripts that are not there —
  `tests/test_token_interop.c` ten times across `ATE_FUN` and `ARCHITECTURE`,
  plus `tests/kat_report.c`, `tests/test_drbg.c`, `tests/test_audit.c` (since
  split into seven), `scripts/validate_cavp.py`, `scripts/coverage.sh` and
  `scripts/freeze_apt_versions.sh`. Whether each claim survives being rewritten
  around what exists is a judgement about evidence, not a rename.

* **The release notes said OCSP does not ship, and did not mention the service
  at all.** `RELEASE_v2.0.0-beta.md` carried "**OCSP.** Only CRLs. OCSP is a
  network service and belongs with #111" under *Not in this release*, while
  `fhsm-ca ocsp-respond` and `issue --profile ocsp-responder` have shipped for
  a week — only the *listening* half belongs with #111. And `fhsm-service`,
  four slices and a deployment guide, appeared nowhere: a reader of the release
  notes would not have known it exists. Both corrected, with the service's
  limits stated where someone deciding whether to deploy it will meet them:
  it has never run behind a real reverse proxy, and the proxy is what the whole
  identity model rests on.

* **`test_audit_concurrent` asserted a performance property as if it were a
  correctness one, and failed on fast storage.** Group commit shares an `fsync`
  between writers that arrive while one is in flight -- a race whose outcome is
  a property of the filesystem, not of the code. Measured: on ext4 with `fsync`
  at ~3 ms, 320 events take 77 barriers. Where `fsync` costs nothing -- a tmpfs
  `/tmp`, which is where the suite puts its logs -- sharing becomes marginal
  and unstable: on one reporter's machine the same run gave 320 barriers, then
  296 a few minutes later, so the assertion is a coin flip rather than a
  statement about the code. With a free barrier there is nothing to share and
  nothing to save. The test now measures what one `fsync`
  costs in the log's own directory, prints it, and asserts sharing only above
  100 us -- below that it says so and moves on. The chain, the line count and
  "every event had a barrier" stay unconditional, because those are
  correctness.

* **The test suite ran against the wrong OpenSSL, silently.** Every test recipe
  in the Makefile said `LD_LIBRARY_PATH=.`, which *replaces* the caller's rather
  than extending it, so the binaries loaded whatever `libcrypto` the system
  had — 3.0.2 here — instead of the 3.5 they were compiled and linked against.
  The only symptom was the ML-DSA-65 boot KAT failing with a bare `[!]` and no
  diagnostic; ML-DSA does not exist in 3.0, so that KAT could never have passed
  under `make tests`. `OPENSSL_PREFIX` was wired into the build and not into the
  run. Now `TEST_LD`, used by all 40 recipes.

* **The service test asked the wrong binary whether it could sign.** Its
  profile guard ran `fhsm-csr keygen`, a separately built tool, and passed while
  the service under test was `fips-strict` and could not sign at all. It now
  asks `fhsm-service --profile`.

* **An assertion that held for the wrong reason.** The three refusals compared
  for byte-identity all failed the *policy* check, so the branch equalising
  "authorised but absent" with "not authorised" was never reached — a mutation
  that answered 404 for a missing key passed the test. The policy now grants a
  key that was never generated, and the mutation fails as it should.

* **`GET /sign` answered 501.** True while the route was empty, a lie once it
  was written; 404 would have been a different lie, denying a route that
  exists. It is now 405.

* **A `static` signature buffer shared by every worker thread**, and with it
  **fifteen wrong signatures out of sixteen, each returned with `200 OK`.**
  `do_sign()` held `static unsigned char sig[8192]`; sixteen concurrent
  requests wrote into it at once, and each client received whatever was in the
  buffer when its turn came to `write()` — a valid composite signature over
  somebody else's message. Nothing refused, nothing logged, nothing visibly
  wrong.

  **The test did not find this. ThreadSanitizer under load did.** The only
  signature the suite verified was made before the concurrent burst, so a
  buffer shared between workers could not show up in it. `service_guards.sh`
  now gives each of the sixteen threads a distinct message and verifies all
  sixteen signatures against their own; restoring the `static` makes fifteen
  of them fail.

  This is the second buffer in this file born `static` in a single-threaded
  draft and left that way once workers arrived — the first was the request
  parser, below. The shape is worth naming: a buffer is not shared state until
  something else can reach it, and the thing that made it reachable was three
  slices away from the line that declared it.

* **A `static` parse buffer shared by every worker thread** in `read_request()`,
  introduced when the service was single-threaded and left behind when it was
  not. Moved into the per-request struct.

* **`404 unknown_route` overwrote the verdicts of routes that exist.** The
  fallback was an unconditional statement after the route chain, which was
  correct only while every branch ended in `return`. The moment `/sign` produced
  a verdict and fell through, its 403 came out as "unknown route" — the wrong
  status, and a lie in the audit line. It is now `else if (v.reason == NULL)`.

* **`FHSM_MAX_OBJECTS` was defined twice, and the second copy was dead.**
  `include/fhsm_token.h` and `src/fhsm_token.c` each carried
  `#ifndef FHSM_MAX_OBJECTS / #define 1024`. They could not disagree — the
  header is included first, so the guard in the `.c` never fired — which is a
  quieter failure than divergence rather than a safer one: editing the number
  in `src/fhsm_token.c` changed nothing, silently. Removed; the header owns it.

  Checked rather than assumed: `-DFHSM_MAX_OBJECTS=32` does bind (the capacity
  bench asked for 100 objects and got 32), and `-DFHSM_MAX_OBJECTS=4096` is a
  compile error naming both knobs, from the `_Static_assert` tying the cap to
  `FHSM_SECURE_HEAP_BYTES`.

  Three comments naming a capacity the store had outgrown went with it — one
  said `FHSM_MAX_OBJECTS = 256` in the present tense and one said `(64)`. The
  file they sit in already records that "a constant duplicated as a literal is
  a constant that will drift"; a constant duplicated in prose drifts the same
  way and nothing compiles it.

* **A forked child could not initialise the module at all.** `C_Initialize` in
  a child of an initialised parent returned `CKR_FUNCTION_FAILED`: the
  post-fork reset (#125) cleared sessions and objects and left the module
  state, so the child inherited `INITIALIZED` and `INITIALIZED ->
  INITIALIZING` is — correctly — not a legal transition. A listener that
  initialised once and forked per connection, which is the process-per-client
  model #111 was about to cost out, could not work.

  The same reset zeroized two of the five operation tables. The other three,
  and both OAEP tables, crossed the fork carrying the parent's IVs, its GCM
  additional data, its ML-DSA context strings and its EVP context pointers.
  **The cause was declaration order**: two tables were declared above the reset
  function and three below it, so the reset could only see two — and cleared
  exactly those. Nothing had ever reached them, because the state defect
  stopped every child first; fixing the first is what made the second
  reachable.

  `fhsm_state_reset_after_fork()` is the only bypass of the transition table
  and says why in its own comment, including why **ERROR survives a fork**: a
  fork is not a restart, so a child of a module that failed something it must
  not fail inherits the refusal.

  `tests/test_fork_child.c` — the first test in this repository that forks at
  all; the property had only ever been checked by an external harness, which
  is how the omissions survived. It is explicit that the operation-table
  zeroizing is **not** among the things it proves: `C_OpenSession` clears the
  slot it issues, so removing the fork-time clearing breaks no assertion. That
  was checked rather than assumed. The clearing is a confidentiality measure,
  and saying so is better than an assertion whose name promises more than its
  mutation delivers.

* **Four bounds on the same session-handle range, agreeing only by
  coincidence.** `FHSM_MAX_SESSIONS` (128) lived inside `src/fhsm_session.c`,
  invisible to the rest of the module. `src/fhsm_pkcs11.c` bounded the same
  handles four other ways: five operation tables of `[256]`, a literal `256` in
  `op_slot()`, another in `fhsm_session_ops_reset()`, and
  `g_finds[FHSM_MAX_SLOTS * 32]` — which equals 128 only because there happen
  to be four slots. Two more tables, `g_oaep_enc/dec[256]`, were indexed by
  session handle with no bound check of their own at all, relying on
  `op_slot()` having returned non-NULL.

  All of them were >= 128, so the module was correct by accident. Raising the
  cap — the first thing a service (#111) does — broke it. Measured with
  `-DFHSM_MAX_SESSIONS=512`: **511 sessions opened, 511 accepted `C_Login`, and
  255 could perform an operation.** The rest answered
  `CKR_SESSION_HANDLE_INVALID` for handles the module had issued itself moments
  earlier. Login hid it, because login state is per token per application and
  never touches a per-session table.

  Now one constant in `include/fhsm_common.h`, every table derived from it, and
  eight `_Static_assert`s so a mismatch is a compile error rather than a
  session that opens and cannot work. `.bss` fell from 7 008 560 to 3 808 560
  bytes — the 3 MiB of operation tables no handle could ever reach.

  **Widening one bound made the others dangerous**, and the benchmark caught
  it: after the tables were sized from the constant but before
  `fhsm_session_ops_reset()` and `g_oaep_*` were, handles above 255 were never
  reset on open (the stale-`active` bug that function exists to prevent, back
  again) and `g_oaep_enc[hSession]` would have been an out-of-bounds write. A
  latent inconsistency turned into a memory-safety defect by a change that
  looked like a cleanup.

  `tests/test_session_cap.c` asserts the property no array-size assertion can:
  that the last handle the module is willing to issue can log in and do work.
  Two mutations, `op_slot()` bounded at 64 and off by one at the top; the
  second is caught only by the assertion written for it.

* **`ocsp-respond --responder-cert` segfaulted on an unparseable `--ca-cert`.**
  The delegated block compares the responder's issuer against the CA's subject
  name and did so before anything checked that the CA certificate had parsed.
  Without `--responder-cert` the same bad file printed `--ca-cert is not a
  certificate`; with it, `X509_get_subject_name(NULL)`. The recurring shape
  again — a check wired to some of the paths that reach a state and not the
  rest. Found while writing the test for the feature it belongs to.

* **CI uploaded an artefact built in a configuration nobody asked for.** The
  `libfreehsm-fips.so` attached to the build job was whatever was left on disk
  after `make audit-switch`, which ends with an `FHSM_AUDIT_MANDATORY=0` tree
  — so since the audit-switch step was added, the uploaded module was neither
  the one `make integrity` signed nor the one its name claims. Adding
  `make ocsp-delegated`, which leaves a `PROFILE=interop` tree, would have made
  it worse. The job now rebuilds, re-signs and re-checks the shipping
  configuration before uploading anything.

* **Two processes sharing one audit log destroyed the chain.** A hash chain has
  exactly one author by construction and nothing required it. Each process
  opened the log, each resumed the chain from the tail of the file, and from
  then on each believed itself the successor of the same line:

  ```
  lines written by two processes : 60 (expected 60)
  chain verifies                : NO
  first broken line             : 2
  ```

  Sequentially the same two processes produce a chain that verifies, so it is
  the concurrency and not the resume. Found through `p11-kit server`, which
  forks a child per client and makes it systematic — but two `fhsm-sign`
  invocations in a script do the same thing.

  Refusing the second opening was the first instinct and was wrong:
  `C_Initialize` opens the log, so an exclusive lock would make concurrent
  tools fail, which is normal use. **Each opening now creates its own file,
  `base.NNNNNN`, with `O_EXCL`** — one author per chain, no lock, no
  restriction on concurrency, nothing added to the hot path. `chain_resume()`
  is gone with it: resuming existed only to share one file between openings.

  The sequence number is load-bearing. Per-file logs would otherwise let a
  whole file be deleted while every remaining chain verified; numbering makes
  that a hole, which `freehsm-audit verify <directory>` reports and exits
  non-zero for. Missing numbers at the end leave no hole — the same
  end-of-log truncation a single file already permitted, so the scheme
  reproduces the existing limit rather than adding one.

  Costs, stated rather than discovered: a restart leaves a new file, and
  between two files only the wall clock orders events. `AGD_OPE` §4.2c.

  `tests/test_audit_multiproc.c` asserts every file verifies alone, the
  numbering is contiguous, and no line was lost — and fails on the first
  against the old behaviour. `fhsm_audit_current_path()` reports the file
  actually opened. Clean under `SANITIZE=1` and ThreadSanitizer.

* **A failed `fsync` on the audit log was ignored.** `fhsm_audit_event()`
  checked `write()` and latched ERROR when it failed, then called `fsync()` and
  threw the answer away. On most filesystems a deferred write error is reported
  at `fsync` and nowhere else — so the control that exists to stop the module
  when the log cannot be written was wired to the call that usually succeeds
  and not to the call that usually reports. Both `fsync` calls in the
  key-provisioning code check their return; this was the third and the only one
  that did not.

  `tests/test_audit_fsync.c` stages the failure portably: `fsync` on a pipe
  returns EINVAL while `write` to it succeeds, so the log is pointed at a FIFO
  with a reader draining it. Two of its four assertions fail with the check
  removed.

* **A reboot turned a 500 ms PIN throttle into a 29.8-day lockout.** The
  throttle deadline was persisted in the token header in the `CLOCK_MONOTONIC`
  domain — chosen so that `date -s` could not shorten a cooldown, and right for
  that. `CLOCK_MONOTONIC` restarts at boot; the file does not. A deadline
  written after thirty days of uptime was still thirty days in the future when
  the next boot read it, and the correct PIN came back `PIN_THROTTLED` with
  nothing to explain it:

  ```
  one wrong PIN            -> 0xa0
  throttle remaining       -> 455 ms
  after the reboot         -> 2574127916 ms  (29.8 days)
  correct PIN now          -> 0x80000004   (PIN_THROTTLED)
  cap the design intends   -> 60000 ms
  ```

  Fails closed, so not an opening — but the same shape as the unseal defect
  fixed in v1.7.0: a routine event locking out an operator who had done
  nothing wrong.

  The deadline was never independent state. It is a function of the failure
  count, which *is* persisted, so it is now derived on load and offsets
  301/309 are written as zero. A restart still does not clear a delay that was
  earned — it is re-imposed from the count — and `date -s` still changes
  nothing, because the derived deadline is monotonic within its own boot.

  `tests/test_throttle_reboot.c` plants a long-uptime deadline in the file and
  reloads. Seven assertions, three of which fail with the defect put back —
  including the one that would catch a "fix" that merely cleared the deadline
  and let a restart skip the wait.

* **`C_Login` ignored `ulPinLen` and read the PIN as a C string.** `pPin` is a
  byte array of `ulPinLen`; PKCS#11 nowhere promises a terminator. The KEK was
  derived over `FHSM_SLICE(pin, strlen(pin))`, so the module

  * **read past the end of the caller's buffer.** A PIN ending on a page
    boundary faults inside the module, on input the caller supplies. In the
    network design of #111 that is a remote crash.
  * **refused the correct PIN** whenever the byte after it was not zero —
    which is every client that does not happen to hold a C string. Our four
    tools pass a `getenv()` pointer, terminated by accident, so nothing local
    ever showed it.
  * **truncated a PIN containing a NUL** to its prefix, silently.

  Found by trying to log in through `p11-kit server`, whose RPC unmarshals
  into a buffer that is not terminated: `CKR_PIN_INCORRECT` for a PIN that
  works directly, then `FHSM_RV_PIN_THROTTLED` because the attempt had been
  counted as a failure.

  `fhsm_token_login()` now takes the length; the callers that really do hold a
  C string pass `strlen()` at the call site, where that is true.
  `C_InitToken`, `C_InitPIN` and `C_SetPIN` already copied `ulPinLen` bytes
  through `fhsm_copy_to_cstr` and were correct — **this was the fourth PIN
  entry point and the only one that was not**, the same shape as #61 and #49:
  a rule wired to some of the paths that reach a state and not the rest. The
  three that set a PIN now also refuse one containing a NUL
  (`CKR_PIN_INVALID`), so what is stored can always be matched faithfully.

  `tests/test_pin_length.c`. Put the defect back and the guard-page case does
  not report a failure, it **segfaults** — which is the point. Clean under
  `SANITIZE=1`.

* **The five REST probes segfaulted against any conforming module.** They
  dlsym'd each `C_*` by name and called whatever came back, so
  `p11-kit-client.so` — which exports exactly one symbol, the only one PKCS#11
  requires — produced a null-pointer call rather than a diagnostic. The four
  tools carried the same defect and were fixed first; the probes were left
  behind, and the first attempt to run `03_login_shared` through a `p11-kit
  server` found it. They now share `probes/rest/p11_probe.h`, which loads
  through `C_GetFunctionList` and enumerates the slot, and the probes'
  Makefile names that header as a prerequisite — the omission of exactly that
  line in the top-level Makefile is what made the tools' fix look inert.

* **Five `fhsm_audit_event()` call sites were malformed, and none had ever
  run.** The dead guard did not merely make the log inert — it returned before
  the function read its arguments. One site passed an `int` where a `char *`
  was expected, written as though the API were `printf`; four passed a key
  with no value and no terminator. Opening the log turned all five into
  segmentation faults.

  `__attribute__((sentinel))` on the declaration now makes the compiler refuse
  any call whose last argument is not `NULL`. Proved able to fail: removing
  one `NULL` ends the build with `error: missing sentinel in function call`.

* **The backpressure crashed the module instead of latching it.** A failed
  write called `fhsm_state_latch_error`, which emitted a `state_transition`
  event, whose write also failed, which latched again — unbounded recursion,
  measured as a SIGSEGV the first time a write was made to fail. A per-thread
  re-entrance guard in the writer, and the state machine now emits only on a
  real transition. `tests/test_audit_backpressure.c` lowers `RLIMIT_FSIZE` to
  make writes fail at the same point a full disk does, and asserts that the
  module latches and does not come back.

* **The chain restarted at every process start.** `fhsm_audit_open` reset
  `seq` to 0 and reseeded the head while `O_APPEND` kept writing after the old
  entries, so a log spanning three starts held three chains and a verifier
  broke at every boundary. It now recovers `seq` and `prev_hmac` from the last
  line, and refuses a tail it cannot parse rather than starting fresh.

* **The chain head used a length of 20 for a 21-character string,** cutting
  the final `0` off `"FHSM-AUDIT-INIT|seq=0"` — so it matched neither its own
  comment nor the format the header documents.

* **OCSP (RFC 6960): `fhsm-ca ocsp-respond`.** A CRL answers "which
  certificates are revoked" for all of them at once; OCSP answers "is this one
  revoked" for the one a verifier asked about. Both read the same database.

  Request in, signed response out. No listening service: a responder that
  listens is a network service with its own concurrency, key lifetime and
  denial-of-service surface, and none of that is cryptography.

  `unknown` is returned -- not `good` -- for any certificate whose issuer is
  not this CA. Answering `good` for an issuer the responder knows nothing about
  would assert something it cannot know, and a verifier would believe it.

  The client's nonce is echoed when present (RFC 8954). Without one, a recorded
  response can be replayed until its `nextUpdate` passes, which is exactly how
  a revoked certificate keeps being accepted after revocation. Echoed, never
  generated: a nonce the responder chose proves nothing to the client that did
  not choose it.

  The library never hashes anything -- the CertID is copied verbatim from the
  request. Deciding *which* certificate it names does require computing the
  hash the client chose, and OpenSSL's client still chooses SHA-1; that
  computation lives in the tool, using OpenSSL's SHA-1, so the module's
  fips-strict profile is never asked to provide it. A CertID identifies, it
  does not sign, and SP 800-131A withdraws SHA-1 for signature generation, not
  for identification.

  Verified against OpenSSL byte for byte on Ed25519, the algorithm both can
  produce: 19 checks in `tests/test_composite_ocsp.c`, plus the mutation that
  proves the comparison can fail. End to end with a real composite CA in a
  token: good, revoked with reason, unknown for a foreign issuer, and two
  questions in one request.

  **The comparison earned its keep immediately.** `good` and `unknown` were
  encoded as `A0 00` and `A2 00`. OpenSSL writes `80 00` and `82 00`: IMPLICIT
  tagging replaces the tag but keeps the constructed bit of the type
  underneath, and the type underneath both is NULL, which is primitive. The
  `revoked` cases passed throughout, because `RevokedInfo` is a SEQUENCE. The
  wrong bytes had the right length and the right shape, and made the response
  unreadable to every OCSP parser -- the CHOICE decides on the tag, and `A0` is
  not one of its alternatives.

* **`docs/FHSM_CSR.md` said "Revocation and OCSP are not implemented".** The
  revocation section was three hundred lines above it.

* **`FHSM_RV_PIN_LEN_RANGE` was documented as `CKR_DEVICE_ERROR`.** Inserting
  the new constant pushed the trailing comment off `FHSM_RV_DEVICE_ERROR` and
  onto it, so the public header declared `0x000000A2` to be a "token hardware
  fault (#109)" while `FHSM_RV_DEVICE_ERROR` was left with no comment at all.
  The value is right and the module behaves correctly; only the header lied.
  Both comments are now on their own constant.

* **Line endings are stated, in a `.gitattributes` the repository never had.**
  Without one, each clone's git decided for itself. A clone made by
  git-for-windows with the default `core.autocrlf=true` rewrote all 252 files
  to CRLF: no content changed, but `scripts/release.sh` acquired
  `#!/usr/bin/env bash\r`, which Linux answers with "bad interpreter", and the
  `Makefile` went the same way. The whole tree then read as modified, so the
  next commit from that clone would have recorded the conversion for everyone.

  `* text=auto eol=lf`, with the fuzz corpus, detached signatures, armoured
  keys, KAT vectors and binary assets excluded — a corpus entry holding no NUL
  byte would otherwise be taken for text and line-normalised, silently
  changing an input that once found a crash.

* **`make all` did not build the four PKI tools, and the release pre-flight
  ran `make all`.** `fhsm-csr`, `fhsm-ca`, `fhsm-sign` and `fhsm-token` link
  only `src/fhsm_composite.o`, not the module. A change to that file can
  therefore compile inside the module and fail to link outside it, and nothing
  in the default build would notice.

  That is not hypothetical: the serial-number change made for v2.0.0-beta
  called `fhsm_rng_bytes` directly, which dragged `fhsm_drbg`, the integrity
  check and the KATs into every tool linking that object. All four failed to
  link. `make all` never named them, so the pre-flight validated — and the tag
  went out on — a tree in which a quarter of the shipped programs did not
  build.

  `all` now names them. `clean` now removes them too, so a stale binary can no
  longer survive a rebuild and be tested in place of the one you believe you
  just built. Verified by mutation: an undefined reference added to
  `tools/fhsm_sign.c` makes `make all` fail with exit 2, and removing it makes
  it pass again.

* **Serial numbers now come from the caller's generator, not the module's.**
  `fhsm_composite_issue()` takes an `fhsm_composite_rng_cb` alongside the
  existing signing callback. The tools pass `p11_rng`, which draws from
  `C_GenerateRandom` on the session already open for signing — so the serial
  comes from the same token as the signature, which is what an issuing
  authority should be able to say. This also removes the link dependency
  described above rather than working around it.

* **The manuals promised an audit log that is never written.** `AGD_OPE` §4.3
  instructed the Security Officer to verify the HMAC chain weekly, and both
  `AGD_PRE` acceptance lists required the log to contain entries. No log
  exists: `fhsm_audit_open()` is called from nowhere, so all forty-nine
  `fhsm_audit_event()` sites return success and write nothing. Confirmed
  empirically — a full session produces the token file and no log.

  `FHSM_AUDIT_MANDATORY` is likewise defined and read by no code, so the
  latch-to-`ERROR`-on-write-failure the header describes does not happen.

  All four documents now say so plainly. The procedures are kept as the
  specification the implementation must satisfy rather than deleted, and the
  acceptance criterion is struck rather than left to fail during
  commissioning. Recorded in `docs/ROADMAP.md` with the two decisions the fix
  needs: where the HMAC key comes from, and whether a failed write really
  stops the module.

  The command was also wrong: `freehsm-audit-verify <path>` does not exist.
  The binary is `freehsm-audit`, `verify` is a subcommand, and the 32-byte key
  is required. `AGD_PRE` had it right while `AGD_OPE` had it wrong — two
  manuals describing the same tool differently.


## [2.0.0-beta] - 2026-08-17

**The PKI and signing tools, and the composite signatures under them.**

This is the release the v2.0 line was for: `fhsm-token`, `fhsm-csr`, `fhsm-ca`
and `fhsm-sign` on top of a Composite ML-DSA implementation that matches the
draft's own Appendix D vectors byte for byte.

**Read these three before deploying it.**

*The specification is not published.* Everything composite here follows
`draft-ietf-lamps-pq-composite-sigs-19`, which is in the RFC Editor queue. The
OID `1.3.6.1.5.5.7.6.48`, the label and the M' construction come from that
draft. **If any of them changes at publication, signatures produced by this
release stop verifying under conforming implementations.** That is why this is
a beta and not a stable, and why anything signed for long-term retention may
need re-issuing.

*The composite mechanism ships in the interop profile only.* The default build
is fips-strict, where every entry point refuses it. `make PROFILE=interop` is
required, and `docs/COMPOSITE_SIGS_GAP.md` explains why the mechanism is not
announced as FIPS-approved.

*Composite key generation does not use this module's DRBG.* `EVP_PKEY_keygen`
is called with a NULL library context, so the ML-DSA seed and the Ed25519
scalar come from OpenSSL's RAND rather than `fhsm_drbg`. The SP 800-90B health
tests never see that draw, and an alarm that would latch the module does not
prevent a key pair being generated. Certificate serial numbers were on the
same path and are fixed in this release; closing it for key generation needs a
library context backed by `fhsm_drbg` and is not done. Recorded in
`docs/COMPOSITE_SIGS_GAP.md`.

### Added
* **CMS/PKCS#7 SignedData — `fhsm-sign cms` and `cms-verify` (#123, L2).**
  Detached RFC 5652 `SignedData` with signed attributes, carrying the signer's
  certificate. Unlike the raw form it records which key and which algorithm
  made it, so **`cms-verify` needs no token, no PIN and no module** — the only
  verification in this project a third party can run with nothing but the
  file, the data and the tool.

  **OpenSSL builds the envelope and refuses the SignerInfo.** Measured before
  anything was written: `CMS_sign(CMS_PARTIAL)` succeeds, `CMS_add1_signer`
  fails with *private key does not match certificate*, because
  `X509_get_pubkey` cannot load a key whose OID has no provider. Same obstacle
  as the revocation lists, one level deeper — and the same answer: assemble by
  hand, let OpenSSL encode every leaf, then require the result to equal
  OpenSSL's own byte for byte on an algorithm it does implement.

  Both assemblers match exactly, at the `SignerInfo` and at the whole
  `ContentInfo`. Five deliberate mutations were each confirmed to break the
  comparison: the retag removed, `BIT STRING` for `OCTET STRING`, issuer and
  serial swapped, `CMSVersion` 3 for 1, a length short by one.

  **The trap this exists to survive:** RFC 5652 §5.4 signs the attributes in
  `SET OF` form while the structure transmits them under `[0] IMPLICIT`. One
  place performs that substitution, and attributes handed over already in
  `[0]` form are **refused** — tolerating both would make it a guess, and the
  failure mode is a signature that verifies nowhere with nothing to say why.

  **Verification re-encodes before checking.** Verifying over the bytes we were
  handed would prove only self-consistency; the check is against what another
  implementation would reconstruct. A wrong content digest is
  `SIGNATURE_INVALID`, a malformed file is `ARGUMENTS_BAD`, and the tool keeps
  them at exit 4 and exit 2 — a broken pipeline should not read as a forgery.

  Signed attributes also make size free: the signature covers a hundred bytes
  of attributes, not the content. Measured on 20 MiB — 0.31 s, 11.5 MiB peak
  resident.

  Two notes for whoever reads the code. `i2d_ASN1_SET_OF_X509_ATTRIBUTE` is not
  public in OpenSSL 3, so the `SET OF` ordering X.690 §11.6 requires is done
  here; getting it wrong yields something that parses and is not DER, and since
  the signature covers those exact bytes a verifier that re-sorts them rejects
  a signature that was never wrong. And `i2d_CMS_SignerInfo` does not exist —
  only `CMS_ContentInfo` has ASN.1 functions — hence the small DER reader used
  to reach the signed attributes.

  `signingTime` is deliberately not added, though OpenSSL adds it by default:
  recording when a signature was made is a decision for the operator, not an
  inheritance.

* **`fhsm-token` — provisioning, which the manual promised and nothing
  provided.** `init` and `info`. `FHSM_CSR.md` told operators hitting
  `CKR_TOKEN_NOT_PRESENT` to *"initialise it first (`C_InitToken`, or the
  module's own provisioning tool)"*; there was no such tool, so every
  documented path into this project's own tooling began with a step that could
  not be taken. It went unnoticed because the tests call `C_InitToken` directly
  from C — nothing that ran regularly needed the missing piece. It surfaced the
  first time someone followed the manual instead.

  Separate binary rather than an `fhsm-csr` subcommand, on the reasoning
  already settled when `fhsm-ca` was split out: a tool named for certification
  requests should not also be the thing that wipes a token.

  **`init` refuses an already-initialised token unless `--force`.** It destroys
  every key, and `init` differs from `info` by four characters.

  Both PINs come from `FHSM_SO_PIN` and `FHSM_PIN`, never from an argument —
  the rule the other tools follow, and it matters most here since this is the
  only command that takes the Security Officer PIN.

* **`fhsm-sign` — detached signatures over arbitrary data (#123, L1).** Two
  subcommands, `sign` and `verify`, over a Composite ML-DSA key in the module.
  Input is a file or standard input, output the raw signature bytes.

  **Verification ships with it, not after it.** No off-the-shelf tool can check
  a Composite ML-DSA signature today, so without a verifier the tool would
  produce something nobody — including its operator — could test.

  **A failed verification exits 4, and nothing else does.** A bad signature is
  not a tool failure; collapsing the two into one non-zero code is how a broken
  pipeline gets read as a forgery.

  **The output records nothing about itself** — no container, no algorithm
  identifier — and the manual says so rather than leaving it to be discovered.
  Carrying that metadata is CMS, and CMS with a composite algorithm needs its
  `SignedData` hand-assembled, the same obstacle the revocation lists hit.

* **Multipart signing and verification for the composite mechanism (#112).**
  `C_SignUpdate` / `C_SignFinal` / `C_VerifyUpdate` / `C_VerifyFinal` accept
  `CKM_COMPOSITE_MLDSA65_ED25519`. They previously handled HMAC and nothing
  else, which made large-file signing impossible: the composite construction
  hashes M inside the combiner, so one-shot `C_Sign` needs the whole message
  and refuses anything past 2 GiB.

  `SHA-512(M)` is now accumulated across `Update` calls and handed to new
  prehashed entry points. **The signature stays conforming**, because SHA-512
  over a stream equals SHA-512 in one call — and that is tested rather than
  asserted: signatures made one way must verify the other way, in both
  directions, with the stream deliberately cut at one byte, then zero, then the
  rest. A byte flipped mid-stream must break it, which is what proves the
  updates feed the digest at all.

  **One site assembles `M'`, one site drives the two components.** The
  one-shot and streamed paths share both. Two parallel assemblies of the same
  structure would be this project's recurring defect in miniature, and here it
  would only have surfaced on a file too large to appear in a test.

  Measured: a 40 MiB file signs in 0.63 s with 8.9 MiB peak resident memory.

### Fixed
* **The `output_length` report in this repository asserted the opposite of
  what is measurable.** It stated that `gc.collect()` before `mmap.close()`
  "does not help", and recommended `ctypes.byref` as the fix. Re-measured on
  CPython 3.10, 3.12 and 3.13, twenty-four runs across both callee kinds and
  both cast forms: `gc.collect()` fixes it every time, and `byref` does not
  even type-check against the declared argtypes.

  The cause was also wrong. The cast objects are not "argument temporaries
  already released" — they sit in a reference cycle that `del` cannot break,
  which is exactly why refcounting alone leaves the export outstanding and
  why a collection clears it.

  The better fix, now identified and verified: drop the `cast` and pass the
  array, since `raw` declares `CK_BYTE_PTR` in its argtypes and ctypes
  converts without building the cycle.

  The report is not sent, and now says so and why, so the decision is not
  re-taken by default. It also records what was *not* verified: the real probe
  never ran here, because its two 4 GiB mappings do not fit — the reference
  semantics were measured at 4 MiB, which is size-independent for this
  question but is not the same as running the thing.

* **`test_legacy_cipher` had never run its interop branch.** It detected the
  profile by looking for mechanism `0x130` in the advertised list. `0x130` is
  `CKM_DES2_KEY_GEN`; `CKM_DES3_KEY_GEN` is `0x131`, and the module advertises
  `0x130` in neither profile. The search always failed, the test always
  believed it was in fips-strict, and the interop path — 3DES key generation
  and a 3DES-CBC round trip — had never executed since it was written.

  **Both fips-strict assertions passed for reasons unrelated to the profile.**
  One generated a key with a mechanism that does not exist. The other called
  `C_EncryptInit(CKM_DES3_CBC)` with an *AES* key, which fails on key type in
  either profile. Checking only "non-zero" is what allowed it; both now
  require `CKR_MECHANISM_INVALID` specifically.

  The feature itself was fine — verified directly: in interop,
  `C_GenerateKey(0x131)` succeeds and 3DES-CBC round-trips. So this is a test
  that was counted as covering something it never touched, which is worse than
  a missing test. Found because the interop suite printed
  `profile = fips-strict` next to two siblings printing `interop`.

  The two siblings were checked and are correct: `test_legacy_digest` uses
  `0x220` (SHA-1) and `test_legacy_rsa` uses `0x1` (RSA-PKCS), both genuinely
  interop-only.

* **A PIN outside the accepted length reported `CKR_ARGUMENTS_BAD`.** PKCS#11
  has `CKR_PIN_LEN_RANGE` for exactly this, and the difference matters: one is
  a value the user can retype, the other is a bug in the calling code. All
  three entry points — `C_InitToken`, `C_InitPIN`, `C_SetPIN` — collapsed both
  into one return, so an application could not tell them apart and an operator
  got a bare `0x7` naming no cause. `NULL` still returns `CKR_ARGUMENTS_BAD`;
  that one really is a caller bug.

  **The bounds were five separate literals** — three enforcement sites and the
  two fields `C_GetTokenInfo` advertises — with nothing tying them together.
  The module could have advertised bounds it did not enforce, and an
  application trusting `ulMinPinLen` would have been the one to find out. They
  now come from `FHSM_PIN_MIN_LEN` / `FHSM_PIN_MAX_LEN`, and
  `tests/test_input_validation` asserts the advertised pair equals the enforced
  one.

  `fhsm-token init` also checks the length itself, against the bounds the
  module reports rather than a copy of them, and says which variable is wrong.
  A numeric code at the end of a command is not an explanation — and pasting a
  placeholder PIN out of a manual is the ordinary way to hit this.

* **`CKF_USER_PIN_INITIALIZED` was read from the failed-attempt counter.**
  `C_GetTokenInfo` set the flag when `fhsm_token_failed_count(USER) > 0`, which
  is a different question and got both cases wrong: a freshly provisioned token
  with a working PIN and no failures reported "user PIN not set", and one wrong
  entry against a token whose PIN had never been set would have reported the
  opposite. Applications read this flag to decide whether to prompt for a PIN
  or to run initialisation. It now comes from whether `C_InitPIN` ever ran,
  which the header has recorded at byte 292 since v1.1.0.

  Found by `fhsm-token info` printing the wrong answer immediately after its
  own `init` succeeded — the value of having a tool that states what it sees.

* **The build-profile guard watched the transition, not the state.**
  `src/gen/.profile.stamp` is rewritten when `PROFILE` changes, which covers
  the operator who forgets the flag. It does not cover the generated sources
  changing underneath it — a checkout, a branch switch, a merge, a stash pop.
  When that happens the stamp says one profile, `src/gen` holds the other, make
  sees nothing out of date, and the build links a dispatch table nobody asked
  for.

  Reproduced accidentally: restoring `src/gen` from `HEAD` left a fips-strict
  dispatch behind an interop stamp, and the only symptom was
  `CKR_MECHANISM_INVALID` from a mechanism that was supposed to be enabled.
  **The dangerous direction is the other one** — a tree generated for interop
  and rebuilt as fips-strict ships the non-approved mechanisms live while every
  visible sign says otherwise, which `docs/ROADMAP.md` already names as the
  check that cannot be recovered after a release.

  `make check-profile` now asserts the post-condition: `fhsm_build_fips_strict`
  in the generated dispatch must match the requested profile. It runs before
  the library links, and was verified to fail in both directions on a
  deliberately desynchronised tree.

* **`C_SignFinal` invented a mechanism instead of refusing one.** Anything that
  was not an HMAC fell back to SHA-256 and a 32-byte output. `C_SignUpdate`
  made that unreachable, so nothing was broken — but it is the wrong shape of
  guard, and the next mechanism wired into `Update` would have inherited it in
  silence. It now returns `CKR_MECHANISM_INVALID`. The seventh instance in this
  project of a control wired to some of the paths reaching a state and not the
  rest.

* **`cRLDistributionPoints` on issued certificates (#112).** `fhsm-ca issue
  --crl-url URL`, repeatable. Revocation shipped correct to the byte and
  nothing could find it: a verifier holding a certificate had no indication of
  where the list lived, so the chain was closed in the code and open in the
  world.

  **Repeatable rather than comma-separated like `--san`**, because an LDAP URI
  carries commas inside its DN —
  `ldap://h/cn=CRL,ou=CA,o=Example?certificateRevocationList` — and a comma
  separator would cut that into three invalid pieces. Repeating the flag needs
  no escaping rule and keeps the operator's order, which matters: a client
  walks the points in the order it finds them.

  **Every URI goes into one `DistributionPoint`, not one each.** RFC 5280
  §4.2.1.13 reads several names inside a point as several ways to reach the
  same list, which is what publishing over both HTTP and LDAP is; separate
  points would assert separate lists. Non-critical, as §4.2.1.13 says it
  SHOULD be.

  **Three things are refused.** `https`, because a CRL is a signed object —
  transport confidentiality adds nothing — and fetching one over TLS can
  require validating a certificate, which can require a CRL; the CA/Browser
  Forum Baseline Requirements mandate plain HTTP for that reason, and
  §4.2.1.13 names only HTTP and LDAP. `ldap://` without a `?attribute` part,
  because such a URI names a directory entry but not which attribute holds the
  list, leaving a client nothing to read. And non-ASCII, because `IA5String`
  is ASCII by definition and `ASN1_STRING_set` does not enforce it, so a UTF-8
  host name would produce an `IA5String` that is not one.

  **A URI that is not understood refuses the issuance rather than being
  dropped**, as with `subjectAltName` and for a sharper reason: a certificate
  whose distribution point silently lost its only reachable URI points at a
  list nobody can fetch, and unlike a missing name, nothing about it looks
  wrong.

  `tests/test_composite_issue` section `[F]` covers one point and not two,
  both URIs inside it, the operator's order preserved, the extension
  non-critical, then the eight refusals — constructed rather than described.
  Two boundaries are covered because they would otherwise be assumed: a `NULL`
  inside the array must not be walked past, and no URLs at all must stay valid,
  since the extension is optional and every certificate issued before this has
  none.

* **Revocation: `fhsm-ca revoke` and `fhsm-ca crl` (#112).** Issuing
  certificates without being able to withdraw one is an authority that cannot
  correct its own mistakes. The chain now closes: a root, a request, a
  certificate, and a signed list saying which certificates no longer count.

  **The `TBSCertList` is assembled by hand, and that is the interesting part.**
  For a certificate, OpenSSL exposes `X509_get0_tbs_sigalg`, so the inner
  `AlgorithmIdentifier` can be set to the composite OID and `i2d_re_X509_tbs`
  does the rest. There is no CRL equivalent — only the outer algorithm is
  reachable, the inner one stays empty, and `i2d_re_X509_CRL_tbs` fails with
  *illegal zero content*. Measured, not assumed.

  **So the assembler was written to be checkable rather than to be read.** It
  takes parts OpenSSL has already encoded — the name, the times, the revoked
  entries, the extensions — and writes only versions, tags and lengths.
  `tests/test_composite_crl` then feeds it an *Ed25519* `AlgorithmIdentifier`
  and requires the output to equal, byte for byte, what OpenSSL itself
  produces from the same parts. Five combinations of the OPTIONAL fields are
  covered, so neither a stray empty `SEQUENCE` nor a missing one can hide.
  Five deliberate mutations — a `[1]` tag for `[0]`, v1 for v2, two fields
  swapped, an outer length short by one, an empty list emitted instead of
  omitted — were each confirmed to make the test fail. A hand-written DER
  length was already wrong once in this module and reading it did not catch
  it; this is the arrangement that would.

  **The revocation database is a text file the operator can read.** One line
  per entry, `SERIAL DATE REASON`, with `crlNumber` in the same file. The
  number lives with the list on purpose: two files can be backed up or
  restored separately, and a number that goes backwards relative to its list
  is exactly the failure it exists to prevent. Writes go through a temporary
  file and a rename, so an interrupted run leaves the previous database whole.

  **A malformed line refuses the whole file.** Skipping a line that is not
  understood would produce a list missing revocations — a signed assurance
  that a compromised certificate is still good. That is worse than no list at
  all, so it is not done quietly.

  **`revoke` needs no key and signs nothing.** Recording a revocation is
  urgent and may happen at three in the morning; signing a list needs the
  token and its PIN. Tying them together would mean either that a revocation
  cannot be recorded without the key present, or that the key has to be
  available to a more casual operation than it should be.

  Also: `der_len` gained the three-octet form. Revocation lists are the only
  structure in this module that grows without bound, and 64 KiB is about two
  thousand entries — a ceiling a long-running CA reaches. Exercised
  differentially against OpenSSL with a 3000-entry list (66 166 bytes).

* **`subjectAltName` on issued certificates (#112).** `fhsm-ca issue --san`
  takes the syntax operators already know — `DNS:`, `IP:`, `email:`, `URI:`,
  comma-separated. Without it nothing issued here is usable for TLS, since
  browsers stopped looking at the CN years ago.

  **The list comes from the operator, never from the request**, which follows
  the policy already set: the applicant does not choose what the CA asserts
  about them.

  **Malformed input fails the command rather than dropping the entry.** Six
  shapes are tested and refused: no type prefix, empty value, an address that
  is not one, an unsupported type, an empty element, an empty list. A name
  silently missing from a certificate is a name the operator believes is
  covered.

  **Private and loopback addresses are accepted, deliberately.** A public CA
  must refuse them; this one exists for the internal networks of universities
  and public bodies, where `10.0.0.0/8` is the whole point. Applying a rule
  written for public issuance would break the intended use and protect nobody.

### Fixed
* **`fhsm-ca` reported its errors as `fhsm-csr:`.** Extracting the shared
  PKCS#11 plumbing into `tools/p11_util.h` left the program name hard-coded, so
  the newer tool announced itself under the older one's name and sent the
  operator to the wrong manual page. The name is now set by each tool.
* **`fhsm-ca issue` — certificates from someone else's request (#112).** The
  chain now closes end to end: a root, a request from a separate key, and a
  certificate binding them, all signed through the module.

  **Proof of possession is verified before anything is signed.** The request's
  own signature is checked against the public key the request carries. Without
  it a CA certifies keys the applicant may not hold — anyone could lift a
  public key out of an existing certificate and be issued a fresh one for it.
  `tests/test_composite_issue` builds exactly that forgery (one key, another's
  signature) and asserts it is refused: a check nobody has watched fail is a
  check nobody knows is wired up.

  **Extensions requested by the applicant are ignored.** The CA sets
  `basicConstraints CA:FALSE`, `keyUsage`, `subjectKeyIdentifier` and
  `authorityKeyIdentifier` itself. An applicant that could obtain `CA:TRUE`
  could issue for any name in the world under that root.

  Serials are 20 random octets with the top bit cleared. Random rather than
  sequential keeps the CA stateless, makes the signed bytes unpredictable
  against chosen-prefix attacks, and stops the serial leaking issuance volume.

  `subjectAltName` is deliberately not honoured yet: doing it properly means
  deciding which name types are accepted and refusing private addresses, which
  deserves its own change rather than a rushed addition.

  Verified from the command line, not only from a harness: root → request →
  issuance, with the leaf's `authorityKeyIdentifier` matching the root's
  `subjectKeyIdentifier` byte for byte and the issuer name matching the root's
  subject.

  The PKCS#11 plumbing moved to `tools/p11_util.h`, shared rather than copied:
  a second copy is a second place for the "exactly one key with that label"
  rule or the PIN policy to be relaxed, and only one would get fixed.
* **`fhsm-csr` — the first piece of PKI tooling (#112).** Three commands:
  `keygen` creates a composite key pair in the token, `csr` produces a PKCS#10
  request, `root` produces a self-signed v3 CA certificate. Every signature
  goes through `C_Sign` in the module; the private key is never seen by the
  tool.

  **It drives any PKCS#11 module, not only this one.** The module is loaded at
  runtime and used only through the standard interface, while the composite DER
  encoding travels with the tool — `src/fhsm_composite.o` links standalone
  against libcrypto. A university that already owns a hardware HSM should be
  able to use these tools with it, which is also the one thing a PKI that
  *talks to* HSMs cannot claim.

  **The PIN comes from `FHSM_PIN` and nowhere else.** There is no `--pin`
  option: an argument is visible in `ps` to every user on the machine. Passing
  `--pin` prints that reason and exits non-zero rather than being ignored, so
  the refusal teaches instead of merely blocking.

  Two keys sharing a label is refused rather than resolved by taking the first:
  signing with a key the operator did not mean is worse than failing.

  Verified end to end from the command line: a certificate produced by
  `fhsm-csr root` verifies against the public key **extracted from that
  certificate**, over the TBS re-derived by OpenSSL's own parser — 2271 bytes,
  3373-byte signature. `openssl x509 -text` reads the whole thing, extensions
  included.
* **Self-signed composite root certificates (#112).**
  `fhsm_composite_selfsigned` produces a v3 CA certificate with a composite
  key: `basicConstraints CA:TRUE` and `keyUsage keyCertSign,cRLSign` both
  critical, and a `subjectKeyIdentifier` computed as SHA-1 of the raw composite
  key — RFC 5280 §4.2.1.2 method (1) — because `X509V3_EXT_conf_nid` with
  `"hash"` would ask OpenSSL to digest a key it cannot load.

  Both `AlgorithmIdentifier` fields are set: RFC 5280 §4.1.1.2 requires the
  outer `signatureAlgorithm` to equal the `signature` field inside the TBS.
  They are separate fields written by separate calls, so the test compares them
  with `X509_ALGOR_cmp` rather than trusting that the code agreed with itself.

  `openssl x509 -text` reads the result in full — version, issuer, ten-year
  validity, all three extensions, `Certificate Sign, CRL Sign` spelled out.

  **One malformed extension hid a sound one.** The first version built the
  `subjectKeyIdentifier` with `X509_EXTENSION_create_by_NID`, which stores the
  octet string's *content* rather than its DER encoding: 20 bytes where
  `04 14 <20 bytes>` was needed. OpenSSL then flagged the whole certificate
  invalid and abandoned its extension cache, so `keyUsage` — correctly encoded
  as `03 02 01 06` — also read back as absent. Two failures, one cause, and
  only dumping the raw extension bytes distinguished them. `X509V3_EXT_i2d`
  fixes it, and both checks passed together.

  Serial 0 and zero validity are refused rather than corrected: a caller that
  asked for a specific serial and silently got another has a worse problem than
  one whose call failed.
* **PKCS#10 certification requests with a composite key (#112).**
  `fhsm_composite_csr` builds a `CertificationRequest`: OpenSSL encodes the
  Name, the attribute set and the outer structure; this module supplies the
  `SubjectPublicKeyInfo` and the signature over `CertificationRequestInfo`.
  Signing goes through a callback, so the builder never sees a key — that is
  what will let `fhsm-csr` drive a key it cannot read, through `C_Sign`.

  **Measured against the `openssl` command-line tool, which shares no code with
  this project.** The structure is interoperable: `asn1parse` walks all 24
  elements with zero errors, `req -text` recovers the version, the subject and
  both algorithm identifiers. That is the draft's "protocol backwards
  compatibility" claim, now with a measurement behind it.

  **OpenSSL cannot verify the signature, and that is not a defect in the
  request.** It has no implementation of Composite ML-DSA — it prints the OID
  in dotted form because there is no NID, hence no decoder and no provider — so
  it cannot verify an algorithm it does not have. Recorded in
  `docs/COMPOSITE_SIGS_GAP.md` alongside the practical consequence: a composite
  CSR can be produced, transported and parsed today, but validated by nothing
  generally available until the RFC publishes and implementations follow.
  Anyone told otherwise finds out when they submit one.

  The signature is over the right bytes all the same, established
  independently: `tests/test_composite_csr` re-derives the to-be-signed region
  from OpenSSL's *own parse* of the finished request — 2080 bytes, matching
  what the callback was handed — and verifies against that, with a negative
  control confirming the same signature does not verify over the whole
  request.

  A malformed subject is refused rather than silently truncated: no leading
  slash, or an empty attribute value, returns `CKR_ARGUMENTS_BAD`. A request
  that quietly drops half the subject it was asked for is worse than one that
  fails.
* **Composite `SubjectPublicKeyInfo` (#112).** `fhsm_composite_raw_pub` and
  `fhsm_composite_spki` produce the X.509 encoding a CSR, a certificate and a
  CMS `SignerInfo` all need: §4.1's `mldsaPK || tradPK` — 1952 + 32 = 1984 raw
  bytes, ML-DSA first — carried in a `BIT STRING` "without further encoding"
  per §5.1, under `AlgorithmIdentifier { 1.3.6.1.5.5.7.6.48 }` with parameters
  **absent, not NULL**. The ASN.1 module says `PARAMS ARE absent`; a NULL there
  is a different encoding that some parsers take and others reject, and a
  routine way to ship something that looks right and interoperates with
  nothing.

  The AlgorithmIdentifier is hand-encoded because the composite OID has no NID
  in OpenSSL 3.5 — the draft is still in the RFC Editor queue. The first
  version of that constant declared `0x0B` and `0x09` where it needed `0x0A`
  and `0x08`, and reading it did not catch the error. `tests/test_composite_x509`
  therefore rebuilds the encoding from the dotted OID string by the rules and
  compares, rather than checking one hand-typed copy against another.

  The same test parses the result with OpenSSL's own `d2i_X509_PUBKEY` and
  confirms it recovers the 1984-byte key, the right OID, and absent
  parameters. A structure only this module can read would be worth nothing to
  a CA, so a third-party parser is the check that matters.

  Clean under ASan/UBSan.
* **Composite ML-DSA is reachable through PKCS#11 (#112).**
  `C_GenerateKeyPair`, `C_SignInit`, `C_Sign`, `C_VerifyInit` and `C_Verify`
  now handle `CKM_COMPOSITE_MLDSA65_ED25519`. A composite key pair is
  generated, used to sign, and verified, entirely through the module's public
  surface.

  **The profile gate is applied at each entry point separately — three sites,
  three checks — and not hoisted into one shared helper.** Every defect this
  project has found in itself has the same shape: a control wired to some of
  the paths that reach a state and not the rest. `fhsm_pkcs11.c` is where they
  lived. A single shared gate is exactly what a fourth path would later bypass
  without anyone noticing, so the repetition is deliberate.

  `tests/test_composite_p11` detects which profile it is looking at and asserts
  the behaviour that follows. Under interop it runs the full round trip,
  including the PKCS#11 size query, `CKR_BUFFER_TOO_SMALL` with the length
  reported, rejection over a different message, rejection of a corrupted half
  on each side, and refusal to sign with the public handle. Under fips-strict
  it asserts refusal at all three entry points — a test that only ran under
  interop would leave the gates unexercised, which is how one goes missing.

  A composite key gets its own key type, `CKK_COMPOSITE_MLDSA65_ED25519`.
  Reporting it as `CKK_ML_DSA` would be a claim a caller could act on — pulling
  it out and handing it to an ML-DSA verifier — and it would be false.

  The mechanism constant is redefined locally in `fhsm_pkcs11.c`, following
  that file's convention. A `_Static_assert` in `fhsm_composite.c` ties it to
  the generated table, so a drift is a build failure rather than a mechanism
  quietly dispatching to the wrong handler. Verified by making it drift.

  Clean under ASan/UBSan; eleven test binaries pass in both profiles.
* **The Composite ML-DSA signature combiner (#112, task zero).**
  `fhsm_composite_mprime()` builds the message representative
  `M' = Prefix || Label || len(ctx) || ctx || PH(M)` of
  draft-ietf-lamps-pq-composite-sigs-19, for `id-MLDSA65-Ed25519-SHA512` —
  OID `1.3.6.1.5.5.7.6.48`, label `COMPSIG-MLDSA65-Ed25519-SHA512`, pre-hash
  SHA-512, 127 bytes with an empty context.

  This is the piece `CKM_HYBRID_ED25519_ML_DSA_65` lacks entirely, and the
  reason its Ed25519 half is a valid standalone signature that can be lifted
  out of the concatenation.

  **The vectors went in before the code.** `tests/test_composite_mprime` reads
  `kat/composite/mprime_appendix_d.txt` — the draft's own two worked examples,
  which the combiner reproduces byte for byte, empty context and 8-byte context
  alike. The test parses the file rather than embedding hex, because copying
  130 bytes of it into a C literal is the transcription risk the file exists to
  remove. Alongside them it checks what published vectors cannot: that a
  256-byte context is refused rather than truncated (a single length byte
  cannot encode it, and silently shortening it would change what is signed),
  that a short buffer reports the size needed, and that the field layout is
  where the specification says.

  Clean under ASan/UBSan.

* **Composite ML-DSA keygen, sign and verify (#112).** `fhsm_composite_keygen`,
  `_sign` and `_verify` complete the crypto layer: fresh generation of both
  components in one call, `M'` signed by each, the ML-DSA one receiving the
  Label as its FIPS 204 context, output as `mldsaSig || tradSig` — 3373 bytes
  for `MLDSA65-Ed25519`. Verification succeeds only if both components do
  (§3.3).

  A composite key is one object holding both components, and there is
  deliberately no way to build one from two existing handles. §3.1 forbids
  reusing key material between a composite and a non-composite or between two
  composites; enforcing that with a check would mean wiring it to the ten
  object-creation call sites in `fhsm_pkcs11.c`, which is the exact shape of
  the seven defects this project has already found in itself. Instead the rule
  is structural: if the only origin of a composite key is a function that
  generates both halves itself, reuse is not prevented, it is unrepresentable.

  `tests/test_composite_sign` demonstrates non-separability rather than
  asserting it. It lifts the Ed25519 component out of a composite signature and
  shows it is *not* a valid standalone Ed25519 signature over the message —
  with a positive control proving it fails for the right reason, since it does
  verify against `M'`. Under `CKM_HYBRID_ED25519_ML_DSA_65` that same extraction
  yields a perfectly valid standalone signature. The difference between the two
  mechanisms is now visible in a test rather than argued in a document.

  Also covered: the application context is bound in (a signature made under an
  8-byte context does not verify under an empty one), and corrupting either
  half invalidates the whole.

  Verified separately that OpenSSL 3.5.6 honours the FIPS 204 context string —
  a signature made with the Label verifies under that Label and under no other,
  and not under none. The first version of that check merely observed that two
  signatures differed, which proves nothing: ML-DSA is randomised, so they
  differ anyway.

* **`CKM_COMPOSITE_MLDSA65_ED25519` (`0x80004202`), interop only.** The
  mechanism is registered and dispatches to the combiner;
  `src/dispatch/fhsm_dispatch_composite.c` carries the TLV unpacking and
  nothing else. Verified in both profiles: `dispatch_reject_fips` in
  fips-strict, the real handler in interop, 64 mechanisms advertised against
  72.

  **Not announced as FIPS-approved, on the specification's own authority.**
  §10.2 states the design goal that a composite "be able to be considered
  FIPS-approved even when one of the component algorithms is not" — and, two
  lines earlier, that the guidance "is not authoritative and has not been
  endorsed by US NIST". Announcing it as approved would assert a status the
  draft declines to assert, which is the habit this repository spent the day
  removing from its own documentation. Both components are individually
  approved (FIPS 204, FIPS 186-5); it is the construction whose standing is
  open.

  §10.2 also requires, for certification, that the ML-DSA seed be the direct
  output of an approved DRBG. Ours comes from OpenSSL's RAND through
  `EVP_PKEY_keygen`, not from `fhsm_drbg`. That gap is recorded rather than
  glossed, and would have to close before the profile question could be
  reopened.

  The key reaches the handler as one opaque blob in a vendor TLV, never as two.
  There is nothing to check and nothing to refuse, because there is no
  interface through which two component keys could be offered.

### Security
* **`CKM_HYBRID_ED25519_ML_DSA_65` is not Composite ML-DSA, and three places
  said it was.** Found while scoping #112, by reading
  `draft-ietf-lamps-pq-composite-sigs-19` (IESG state: RFC Editor Queue) against
  the code instead of trusting the citation.

  The draft signs a message representative built by a signature combiner:
  `M' = Prefix || Label || len(ctx) || ctx || PH(M)`, with the per-algorithm
  Label also passed down as the ML-DSA context, serialized as
  `(mldsaSig, tradSig)` under a registered composite OID. The module signs the
  bare message with both keys and concatenates them in the other order.

  The consequence is a missing security property, not a formatting difference.
  Because both components sign the message itself, the Ed25519 half is a valid
  *standalone* Ed25519 signature over that message and can be lifted straight
  out of the concatenation — precisely the separability the draft's Label exists
  to prevent (§2.2, §9.2.3). And with no composite OID the signature cannot
  appear in an X.509 certificate, a CSR or a CMS structure at all.

  The mechanism keeps its place: as a locally-designed PQ/T hybrid, two
  independent signatures both required to verify, it is a reasonable thing to
  offer, and its name says `HYBRID`, not `COMPOSITE`. What is corrected is the
  claim. `scripts/gen_p11_thunks.py`, the `fhsm_dispatch_hybrid.c` header and
  the generated `docs/MECHANISMS.md` now describe what it does and state plainly
  that it is not the draft.

  Worth recording how it survived. The KEM combiner fifty lines above in the
  same file *does* bind a domain-separation label into its hash
  (`"HYBRID-X25519-ML-KEM-768"`); the technique was understood and applied on
  one path and not the other. That is the same defect shape this project has now
  found seven times in its own code, sitting in its flagship differentiator. It
  went unnoticed because the KATs are self-generated: they prove our verify
  accepts our sign, which is true of any self-consistent construction, including
  a wrong one.

  Analysis in `docs/COMPOSITE_SIGS_GAP.md`. Conforming Composite ML-DSA becomes
  task zero of #112 — Appendix E of the draft is 150 pages of test vectors, so
  this time it can be checked against someone else's numbers. Until it ships, no
  README, announcement or landing text may describe this module as implementing
  composite signatures.

### Testing
* **#109 validated against a real TPM 2.0.** Until today the sealing backend had
  only ever run against `tests/tpm2-stub.sh`, which performs no cryptography;
  the ROADMAP said in as many words that a green `test_tpm` was not evidence
  that TPM sealing works. `scripts/validate_tpm_sealing.sh` closes that gap:
  nine phases against a real TPM, all passed.

  The sealed companion is a genuine 252-byte `TPM2B_PUBLIC`/`TPM2B_PRIVATE`
  pair. `/var/lib/freehsm/tpm` does not exist after sealing, confirming on real
  hardware that the DEK no longer reaches a filesystem. Eight logins with the
  **correct** PIN against a TPM whose PCRs had moved returned `CKR_DEVICE_ERROR`
  every time; after a reboot restored the PCRs the token reopened with
  `CKR_OK`, and a wrong PIN still returned `CKR_PIN_INCORRECT`. Under the old
  code the fifth of those eight attempts would have hit `FHSM_PIN_MAX_FAILED`
  and killed the token, for a PCR extension.

  Running it for real surfaced two operator-facing gaps that no amount of
  stub-testing would have: the persistent primary key at `0x81010001` must exist
  and the module does not create it, and the process must be in the `tss` group
  to open `/dev/tpmrm0`. Both are now documented in `docs/AGD_OPE.md` and its
  French version, and the script checks and offers to fix the first.

* **`.gitignore` for `tests/` is shape-based now, on the third attempt.** The
  two before it enumerated prefixes — `tests/test_*`, then `tests/bench_*` — and
  each stopped covering the next binary added: `bench_capacity` went in as a
  1.7 MB blob, and `tpm_hw_probe` was about to. A list of names cannot cover a
  file that does not exist yet. The invariant that holds is that every source in
  `tests/` has an extension and every compiled binary has none.

* **Harness re-measured against pkcs11-check v0.1.8: 4 failed, 1697 passed,
  0 crashed — the same count as v0.1.6 and none of the same reasons.** R4 and R5,
  the two RSA harness gaps, are fixed upstream. R2 is no longer a failure at all,
  reclassified upstream as an inherent channel. R1 and R3 remain, unchanged, as
  the documented positions they have been since July.

  The two new failures are in `TestEncryptOutputLengthTruncation` /
  `TestDecryptOutputLengthTruncation`, and they are a defect in the probe rather
  than in the module. The probe drives `C_Encrypt` with `ulDataLen = 2**32 + 8`
  over 4 GiB demand-zero mappings, prints our answer —
  `TARGET_RV:0x00000021`, `CKR_DATA_LEN_RANGE`, which its own
  `_OUTPUT_LENGTH_REJECT_RVS` accepts — and then raises `BufferError` closing its
  mmaps, exiting 1 before the verdict runs. `ctypes.cast` on a `from_buffer`
  array leaves the buffer export outstanding and `del` does not clear it.

  Verified two ways. In isolation with no PKCS#11 module: same stdout, same
  exception, same exit code. And on our side, in C, with the same two 4 GiB
  `MAP_NORESERVE` mappings and the same counter block: `CKR_DATA_LEN_RANGE`. The
  guard is `src/fhsm_pkcs11.c:5267`; its comment already named this test, having
  been added in July in response to the earlier output-length report.

  Reported upstream as `issue_pkcs11check_output_length_bufferror.md` — this one
  checked against current `main` (`413222e`) before writing, which the withdrawn
  R2 report was not.

  **No new defect in the module.** Details and the full v0.1.6/v0.1.8 comparison
  in `docs/PKCS11_CHECK_FINDINGS.md`.

### Security
* **R2 — the CBC-PAD padding oracle never went away; the harness test is a coin
  flip.** `pkcs11-check`'s `test_cbc_pad_all_last_block_positions` reported the
  Vaudenay channel during the #125 campaign and then stopped reporting it on a
  build that had not touched that path. `PKCS11_CHECK_FINDINGS.md` carried an
  instruction not to drop the finding on the strength of one green run. It was
  right to.

  The test corrupts one byte of the last ciphertext block, which randomises the
  whole final plaintext block, and asks whether the module ever returns
  `CKR_OK`. A uniformly random 16-byte block carries valid PKCS#7 padding with
  probability `sum_{n=1..16} 256^-n = 1/255`, so over its 320 probes it finds
  nothing about 28% of the time. Its docstring claims 0.05% — the figure you get
  from assuming 6/256 per probe, six times the true rate.

  Measured against the module over 105 600 corruption probes: 433
  accidentally-valid paddings, a rate of one in 244, 95% CI [one in 271, one in
  222], which contains the theoretical 1/255. Nothing changed; one run in three
  or four simply misses.

  The finding stands and the position is unchanged — the oracle is inherent to
  CBC-PAD without authentication and the remedy is at the application layer. But
  the same numbers show the implementation is correct: 99.6% of corruptions are
  refused with the one code the spec defines, no corrupted ciphertext ever
  decrypted back to the original plaintext, and the residual matches theory. Had
  padding validation been missing the rate would sit near 100%, which is the
  worse finding — unchecked malleability rather than a one-bit leak.

  `tests/test_cbc_pad_oracle` now guards exactly that distinction, asserting a
  band rather than an absence, sized so it cannot itself become the coin flip
  this whole entry is about.

  Timing, while we were there: `test_aes_cbc_pad_decrypt_timing_sanity` had once
  reported a 22.5x valid/invalid ratio. Measured over 48 000 decryptions, 986 ns
  against 1014 ns — 1.03x. That rules out a 22x difference; at one microsecond
  per operation it does not rule out a few percent.

  Not reported upstream: Denis had already found and fixed it on 2026-07-30
  (`ad93ff9`, released in v0.1.8), four days before we wrote the report. Our
  checkout was at v0.1.6 and we read it as current. He reached the same 1/256
  and the same ~29%, with a provider pool behind him rather than one module, and
  went further — `classify_padding_outcomes` now returns `inherent_channel` for
  the valid-and-invalid mix, which is not a failure, since it is what every
  conforming implementation does. **R2 is therefore no longer an open failure
  against us**; his classification and our position agree. The draft report is
  withdrawn.

### Documentation
* **`CKM_AES_CBC_PAD` was advertised with no caveat at all.** R2 has said since
  July that the oracle is "worth saying in user-facing documentation rather than
  only here". It never was: `docs/MECHANISMS.md` and the README listed the
  mechanism plainly, and `docs/AGD_OPE.md` recommended it by omission — its
  mechanism-selection table said "approved set" and stopped there.

  The generator already had a `notes` field for exactly this, populated for
  `CKM_RSA_PKCS` ("padding-oracle risk") and empty for CBC-PAD. It is filled in
  now, so the note travels with the generated table instead of having to be
  remembered. `docs/AGD_OPE.md` (and `.fr`) gain §3.1, which states the caveat,
  says plainly that the module is behaving correctly and the exposure is in the
  protocol, and gives the condition that decides it: use GCM or KEY_WRAP
  wherever an attacker can submit chosen ciphertexts and observe whether
  decryption succeeded.

  The same section covers `CKM_AES_CBC` and `CKM_AES_CTR`, which have no oracle
  but no integrity either — a flipped ciphertext bit is a flipped plaintext bit
  under CTR, silently. Approval is about the algorithm; these caveats are about
  the protocol built with it, and the approved list cannot express them.

* **#109 — the TPM sealing backend wrote the DEK to disk in the clear, and a
  broken TPM locked the token permanently.** Three defects, all in code that
  had never been exercised because CI has no TPM.

  *The key on disk.* `fhsm_tpm_seal` wrote the 32-byte DEK to a `mkstemp` file
  under `/var/lib/freehsm/tpm/` so the `tpm2` CLI could read it, and
  `tpm2 unseal -o` wrote it back out the same way. Mode 0600 and an `unlink`
  afterwards do not undo that — on a journalling filesystem or an SSD with wear
  levelling the bytes outlive the file. v1.6.0 had just moved that same DEK
  into an `mlock`'d arena so it could not reach swap (#127); writing it to a
  disk on the sealing path was strictly worse than the paging we had gone to
  trouble to prevent. Every file handed to `tpm2` is now an anonymous
  `memfd_create` object passed as `/proc/self/fd/N`: the child reads and writes
  exactly as before, and nothing touches a filesystem.

  *The colliding filenames.* Temp files were named from `getpid()`, identical
  for every thread in the process. Two threads sealing two different tokens
  wrote the same `seal-pub-<pid>` and could hand back each other's DEK. The
  comment claimed the per-token mutex covered it; it does not, different tokens
  hold different mutexes. Anonymous descriptors have no shared name, so the
  collision is gone by construction rather than by locking.

  *The denial of service.* A failed unseal bumped the PIN failure counter and
  the throttle. The seal is bound to PCR 0-7, which measure firmware and early
  boot — so a BIOS update, a kernel upgrade or a Secure Boot key rotation makes
  every unseal fail, and the legitimate operator, holding the correct PIN,
  burned one attempt per login until the token locked for good. A routine
  firmware update destroyed the token.

  The stated rationale was that returning `CKR_PIN_INCORRECT` stopped an
  attacker probing whether the TPM was online. That does not survive looking at
  where the code sits: the AES-GCM tag over the wrapped DEK has already
  verified by that point, so the caller has *proved* they know the PIN. An
  attacker who does not know it never reaches the branch and never learns
  anything about the TPM either way. Nothing was being concealed, and a real
  denial of service was being paid for it.

  A TPM failure now refuses the login with `CKR_DEVICE_ERROR` and leaves the
  PIN counter untouched — neither incremented nor reset. The audit log
  distinguishes `tpm-unseal-failed` (PCRs moved, TPM absent) from
  `tpm-dek-mismatch` (the store and the sealed blob disagree, i.e. possible
  tampering), because they call for different operator responses. A genuinely
  wrong PIN still counts, throttles and locks exactly as before. Operator
  guidance and the recovery procedure are in `docs/AGD_OPE.md`.

  `fhsm_tpm_unseal` also now zeroes the caller's buffer on entry rather than on
  the single failure path that remembered to: there are five ways out that are
  not success, and clearing once at the top cannot be forgotten when a sixth is
  added.

  Tested by `tests/test_tpm`, driven against `tests/tpm2-stub.sh` — a stand-in
  for the CLI, not a TPM simulator. It proves the plumbing on our side of the
  subprocess boundary and nothing about the TPM's own guarantees. Both
  regression tests were checked against the old code and fail on it: the
  concurrency test reports threads receiving another token's DEK, and the
  lockout test reports `CKR_PIN_THROTTLED` still being returned after the TPM
  recovers.

## [1.6.0] --- 2026-08-01
### Security

* **#127 — decrypted private keys lived in pageable memory.** At rest the object
  blob is AES-256-GCM under a PBKDF2-wrapped DEK, and the live DEK sits in the
  `mlock`-ed OpenSSL secure heap. But every decrypted object value — private
  keys included — was a plain `malloc` block: zeroized on free, pageable while
  live. The module kept the vault key out of swap and left the keys it protects
  beside it.

  Sensitive values now allocate in the arena. Exhaustion is
  `CKR_DEVICE_MEMORY`, never a silent fallback: if the module cannot keep a
  private key out of swap it does not load the key and says so. `CKA_SENSITIVE`
  is one-way FALSE→TRUE, so a value that becomes sensitive later is migrated
  into the arena — the guarantee follows the attribute, not the moment of
  allocation.

* **#111-prep — two lazy-initialisation races reachable from `C_OpenSession`.**
  `fhsm_slot_table_init_once()` guarded on a bare flag instead of
  `pthread_once`, and `fhsm_slot_token()` did test-load-assign on
  `g_slots[slot].token`. Two threads could both call `fhsm_token_load` and both
  assign, leaking one token and leaving two `fhsm_token_t` objects for the same
  file with independent object stores and login state. Unreachable under
  PKCS#11's one-process-per-application model; routine behind a network front
  end. Found with `make TSAN=1` once the threads were released from a barrier —
  without it the window closed before the other threads ran and the run came
  back clean.

* **#125 — unbounded input length on sign, verify and digest.** `C_Encrypt`
  and `C_Decrypt` have rejected lengths above 2 GiB with `CKR_DATA_LEN_RANGE`
  since the input-validation tranche; `C_Sign`, `C_Verify`, `C_Digest` and
  their three `*Update` variants never received the same guard — 6 of 8 entry
  points. A caller passing `ulDataLen = ISIZE_MAX` made the module hash until
  it was killed: a denial of service reachable from a single argument. Now
  `CKR_DATA_LEN_RANGE` in 0.0 ms.

* **#128 — `secure_heap_kb = 100` aborted the process.** OpenSSL's arena
  requires a power-of-two size and asserts otherwise; the config reader passed
  any in-range value straight through. A configuration typo must not crash an
  HSM. Values now round **up** to the next power of two.

### Added

* **`make TSAN=1`** — ThreadSanitizer build, alongside `SANITIZE=1` (separate
  targets: ASan and TSan cannot share a binary), plus `tests/test_concurrency`.
  Never ship either.
* **Real configuration.** `/etc/freehsm/freehsm.conf` previously shipped nine
  keys that no code read, while the only key a parser looked for (`mode`) was
  absent from it. The file now carries exactly what is read — `mode` and
  `secure_heap_kb` — and states plainly that the rest is compile-time or
  environment-driven, and that `mode` does **not** change which mechanisms the
  PKCS#11 API offers.

### Changed

* **Capacity: `FHSM_MAX_OBJECTS` 256 → 1024**, secure-heap arena 256 KiB → 8
  MiB. 256 was never derived from a requirement — v1.1.0 shipped 64 and #125
  raised it because a test needed 100 keys. Measured before changing: at 1024 a
  worst-case lookup costs 0.6 µs and per-key creation 7.25 ms, against 0.2 µs
  and 6.22 ms at 256. Neither the linear scans nor the whole-store rewrite binds
  at this size; both would have to be addressed to go much further. The arena is
  sized for the worst case — 1024 ML-DSA-87 private keys (4 962 B measured) need
  4.85 MiB — so a token filled entirely with PQC keys still fits. A
  `_Static_assert` now refuses an inconsistent pair at compile time instead of
  leaving the coupling in a comment.

### ⚠ Upgrade note

The **v3 object record** introduced in this release is a one-way conversion, as
v1→v2 was. A token written by v1.6.0 cannot be read by v1.5.0 or earlier. Back
up token files before upgrading if you may need to roll back.


### Security
* **#125 — `C_FindObjectsInit` read past its buffer and returned stack bytes as
  object handles.** `fhsm_token_object_find` bounds its writes
  (`if (handles_out && k < cap)`) but reports the number of objects that
  *matched*, not the number it wrote. The caller iterated to that count.
  With `FHSM_MAX_OBJECTS` raised to 256 and the caller's `prelim` array left at
  64, a token holding 65 matching objects read past it; at 256 it read 192
  entries — 768 bytes — of adjacent stack and handed them back through
  `C_FindObjects` as handles.

  Not caught by the harness, which creates 100+ keys and enumerates them, and
  passed: adjacent stack usually yields values that are not valid handles and
  are dropped downstream. Found by running the store round-trip under UBSan.
  `prelim` is now sized `FHSM_MAX_OBJECTS`, but the fix is the clamp on the
  *read* — sizing alone only moves the threshold to 257. A bound enforced on
  the write and not on the read is not a bound; this is the fifth occurrence of
  that shape in the #125 series.

* **#125 — a `size_t → int` cast in the DRBG killed the module and blamed the
  entropy source.** `fhsm_drbg_bytes` produced its output with a single
  `RAND_bytes(out, (int)n)`. `C_GenerateRandom` passes `ulRandomLen` straight
  through, so any length above `INT_MAX` was reinterpreted. Both outcomes
  latched `FHSM_STATE_ERROR`, after which every entry point — including
  `C_OpenSession` — returns `CKR_FUNCTION_FAILED` for the life of the process.
  One call, whole module dead.

  At 4 GiB + 8 the cast yields 8: `RAND_bytes` writes eight bytes and succeeds,
  then the health test reads all four gibibytes, of which eight were ours, and
  six identical bytes from the untouched buffer trip the RCT cutoff — so the
  module latched **“DRBG RCT alarm”**. That is the FIPS 140-3 continuous health
  test firing: an operator reading it must assume the entropy source failed and
  treat every recent key as suspect. Nothing had happened to the entropy
  source. The module reported an entropy failure because it read memory it had
  never written, and the honest report of its own bug was the most alarming
  sentence it can emit.

  Output is now generated in chunks bounded by `FHSM_DRBG_MAX_REQUEST` (65 536
  bytes, the SP 800-90A CTR_DRBG per-request maximum), the health tests only
  see bytes the DRBG actually produced, and the periodic reseed is evaluated
  per chunk rather than once per call — a single request larger than
  `FHSM_RESEED_BYTES_MAX` used to emit all of it without reseeding. Oversized
  requests are served, not refused: PKCS#11 defines no maximum, and inventing
  one to dodge a bug of ours would be a limit the caller cannot discover.

### Added
* **#125 — `CKM_RSA_PKCS` and `CKM_RSA_X_509` key transport (interop only).**
  `C_WrapKey`/`C_UnwrapKey` handled AES-KW/KWP and RSA-OAEP only. The v1.5
  unwrap deliberately does **not** inspect its result: PKCS#1 v1.5 decryption
  is the Bleichenbacher target, OpenSSL 3.2+ closes the primary channel with
  implicit rejection (measured here: corrupted ciphertext → `CKR_OK` with
  pseudo-random output, timing ratio 1.12), and manufacturing an unpad failure
  would rebuild the oracle. The residual — that what the caller does with the
  bytes can still reveal a clean unpad — is inherent to v1.5 key transport and
  is why the mechanism is deprecated and confined to the interop profile.
* **#125 — `CKM_RSA_X_509` sign/verify (interop only).** `mech_is_raw_rsa()`
  selects `RSA_NO_PADDING`; without it the raw path would have applied v1.5
  padding under a mechanism defined as unpadded, and `C_Verify` would have
  accepted its own signatures while every other implementation rejected them.
  Verified against an independent `EVP_PKEY_verify_recover`, not against
  ourselves.
* **#125 — `CKA_START_DATE`, `CKA_END_DATE`, `CKA_APPLICATION`**, stored and
  read back, persisted by the v3 object record (see
  `docs/TOKEN_STORE_FORMAT.md`). Nothing consults the dates: the spec calls
  them informational and says Cryptoki does not enforce them.
* **`make SANITIZE=1`** — ASan + UBSan build. Added for the store-format work,
  where a bounds bug surfaces months later as an unreadable token rather than
  as a failing test. Turned 10 of 13 unit tests red immediately: they passed a
  short string literal as `C_InitToken`'s `pLabel`, which §C.6.4.1 defines as a
  fixed 32-byte field. The module was right; the tests were wrong. Never ship a
  SANITIZE build.

### Fixed
* **#125 — size queries reported lengths the operation did not produce.**
  `C_WrapKey` computed `kvl + 16` for both AES-KW and AES-KWP — neither
  formula; `C_Sign` answered with a per-family upper bound, 512 for anything
  RSA or EC, where RSA-2048 signs in 256 and P-256 raw `r||s` is 64. Neither
  overflows, which is why nothing caught them, but the size query is a
  contract: a caller that sizes a record from the first answer and does not
  re-read the second stores the signature followed by whatever the buffer held.
  Both now compute the exact length.
* **#125 — `hedgeVariant` was parsed and discarded.** All three variants
  produced randomized ML-DSA/SLH-DSA signatures; a caller asking for
  `CKH_DETERMINISTIC_REQUIRED` got a fresh signature every call with no
  indication it had been overruled. Deterministic ML-DSA is what lets an
  auditor holding the key and the message recompute the signature.
* **#125 — `C_SetAttributeValue` returned `CKR_ATTRIBUTE_TYPE_INVALID` for
  attributes the module sets on every object.** `CKA_TOKEN` is the one with
  teeth: promotion is not implemented, and “I have never heard of this
  attribute” let a caller conclude it was unsupported rather than that the
  promotion had not happened. Now `CKR_ATTRIBUTE_READ_ONLY`.
* **#125 — `CKU_CONTEXT_SPECIFIC` fell through to `CKR_ARGUMENTS_BAD`** in
  `C_Login`. Returns `CKR_OPERATION_NOT_INITIALIZED` with no operation active,
  and `CKR_FUNCTION_NOT_SUPPORTED` with one — the module stores
  `CKA_ALWAYS_AUTHENTICATE` but does not gate operations on it, and accepting a
  re-authentication would claim a control nothing enforces.
* **`make PROFILE=interop` silently built fips-strict.** The generated sources
  depended only on the generator script, so an existing set satisfied the rule
  whatever profile it was produced for. The dangerous direction is the other
  one: a tree last generated for interop and rebuilt without `PROFILE` keeps
  the non-FIPS mechanisms live while every visible sign says fips-strict.
  `src/gen/.profile.stamp` now records the profile and the generated artifacts
  depend on it; `make show-profile` prints the three facts that should agree.


## [1.5.0] --- 2026-07-18

### Changed
* **#125 — C_CopyObject promoted session objects to token objects.** It was
  the one creation path of six that never called `fhsm_apply_token_scope`, so
  every copy landed with `owner_session = 0` — a persistent token object.
  Copying a `CKA_TOKEN=False` session object silently made it a token object.
  (CKA_TRUSTED, CKA_UNWRAP_TEMPLATE, fhsm_check_ro_token and this are all the
  same shape: a rule wired to some of the paths that reach a shared state but
  not all. The module has six routes to "object created" and nothing forces a
  guard to cover them.)

  The copy now inherits `CKA_TOKEN` from the source unless the template
  overrides it (§C.6.7.3), and the copy template accepts the scope attributes
  (`CKA_TOKEN`, `CKA_PRIVATE`, `CKA_MODIFIABLE`, `CKA_DESTROYABLE`) instead of
  rejecting an explicit `CKA_TOKEN` with `CKR_ATTRIBUTE_TYPE_INVALID`.
  Verified: session→session, token→token, and an explicit session→token
  override all correct. (TestCopyObject::test_copy_session_object_stays_session.)

* **#125 SECURITY — a forked child inherited the parent's session objects AND
  its authenticated state.** The module had no fork detection of any kind.
  `fork()` copies the whole address space, so a child inherited the slot
  registry, the in-memory object store, the session table with its handles and
  roles, and the token's decrypted DEK. `C_Finalize` frees none of it — it
  closes crypto and drops the state machine to `POWER_OFF` — so a child doing
  `C_Finalize` then `C_Initialize`, exactly as PKCS#11 v3.2 fork semantics
  require, came up holding all of it.

  Reproduced before the fix: the child found the parent's session object **and**
  generated a key with `C_GenerateKey` having never presented a PIN. The
  harness only checks the object; inheriting the login state is the worse half
  and was invisible to it.

  `C_Initialize` now records the PID that built the state and, on a call from a
  different process, discards everything rather than adopting it: tokens are
  closed (zeroizing each DEK and freeing object values), and the session table,
  find state and operation slots are wiped. A child is a different application
  in PKCS#11 terms; inheriting authenticated state is precisely the bug, so
  nothing is preserved. Same-process `C_Initialize` is unaffected.

  Verified both ways: with detection disabled the child sees the object and
  generates a key unauthenticated; with it enabled, 0 objects and
  `CKR_USER_NOT_LOGGED_IN`. (TestSessionObjectProcessIsolation.)

* **#125 — C_SetAttributeValue left partial mutations behind on failure.** A
  single loop validated and applied each template row as it went, so a
  template of `{CKA_LABEL: "x", CKA_CLASS: ...}` wrote the label and *then*
  rejected the read-only class: the caller got an error **and** a renamed
  object. The flag transitions (`CKA_SENSITIVE`/`CKA_EXTRACTABLE`) were already
  deferred to a single write; `CKA_LABEL` and `CKA_ID` were not, and there was
  no reason for them to be less atomic than the flags sitting beside them.

  Now two passes: validate every row (writing nothing), then apply. Verified
  in both orderings — `{LABEL, CLASS}` and `{CLASS, LABEL}` — both reject with
  `CKR_ATTRIBUTE_READ_ONLY` and leave the label untouched, while legitimate
  single- and multi-attribute templates still apply in full.
  (TestSetAttributeAtomicity.)

  A storage failure inside the apply pass can still leave a partial write.
  That is a device-level error rather than a template one, and closing it
  needs a journalled object store — out of scope here, noted deliberately.

* **#125 — C_FindObjects silently truncated every result set at 64.** The
  per-session find buffer was declared `uint32_t handles[64]` — a literal that
  happened to be `FHSM_MAX_OBJECTS` when it was written. Raising the store to
  256 left it behind, so a search returned at most 64 matches no matter how
  many objects existed, without any error. It now tracks `FHSM_MAX_OBJECTS`:
  a search cannot return more objects than the token can hold.
  Verified: 100 keys created, 100 found (previously 64).
  (TestBulkOperations::test_100_keys_coexist.)

* **#125 — `CKM_AES_CCM` was missing from the key-type gate.** CCM is
  advertised (`0x1088`) but `fhsm_check_key_mech_type` covered ECB/CBC/CBC_PAD/
  CTR/GCM/CMAC/GMAC only, so a `CKK_GENERIC_SECRET` key was accepted for
  AES-CCM. Now `CKR_KEY_TYPE_INCONSISTENT` like every other AES mechanism.
  (TestWrongKeyType[AES_CCM].)

* **Compiled binaries are no longer tracked in git.** Ten ELF binaries were
  committed — `tests/test_attributes`, `test_fips_digests`,
  `test_input_validation`, `test_op_state`, `test_robustness_args`,
  `test_session_objects`, the three `fuzz/fuzz_*` harnesses — plus, newly and
  by my own hand, `tests/test_integrity` and its `.sha256` signing artifact.
  `.gitignore` listed each test binary by name, so every new test silently
  got committed the first time someone ran `git add -A`.

  In a project that advertises reproducible builds, a committed binary — let
  alone a *signed* one — is a supply-chain smell: it bloats clones, can be
  executed by accident, and nobody can tell what it was built from. The rule
  is now shape-based (ignore extensionless `tests/test_*` and `fuzz/fuzz_*`,
  re-include `.c`/`.h`/`.sh`/`.py`), so it covers tests that do not exist yet.
  `make` regenerates all of them.

* **#125 SECURITY — the FIPS 140-3 §7.10.2 integrity self-check never passed
  on a signed build: a documented `volatile` was missing.** The comment on
  `fhsm_module_integrity_digest` stated that "the volatile prevents the
  compiler from constant-folding the digest into the code generator" — but the
  declaration had no `volatile`. The slot is patched by
  `scripts/sign_module.sh` *after* compilation, so GCC folded the explicitly
  initialised element `[0]` of `= { 0 }` to a literal zero. Byte 0 of the
  comparison therefore 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. It went unnoticed because the whole chain — CI included — runs with
  `FHSM_INTEGRITY_ALLOW_UNSIGNED=1`, which downgrades the failure to a
  warning. The check was thus present, documented, exercised, and inert.

  The slot is now `const volatile` and read only through
  `read_embedded_digest()`, which performs the volatile reads and hands back
  a plain buffer for the constant-time comparison. Verified: computed and
  embedded digests now match byte for byte on a freshly signed module.

  Note that a v1.2.1 fix had already removed a *different* bypass in this
  function (a fall-through `return FHSM_RV_OK`). That turned "always passes"
  into "always fails", which the env var then masked — so the check has never
  actually gated anything.

* **#125 — a read-only session could create token objects via unwrap, derive
  and copy.** `fhsm_check_ro_token` (§5.3: an RO session may not create a
  token object) was wired into `C_CreateObject`, `C_GenerateKey` and
  `C_GenerateKeyPair` only. `C_UnwrapKey`, `C_DeriveKey` and `C_CopyObject`
  reach exactly the same state and were unguarded, so `CKA_TOKEN=True` through
  any of those three minted a token object from a read-only session.

  The unwrap case had been masked: an unrelated blanket rejection made the
  test pass for the wrong reason, and the hole only surfaced when that
  rejection was reverted. A guard applied to a subset of the paths that reach
  the same state is not a guard. All six creation paths now share it.
  (TestROWrapUnwrapRestrictions.)

* **#125 — fixed an out-of-bounds read introduced by the CKA_TRUSTED guard.**
  `C_SetAttributeValue` never validated `ulCount` (it only ever iterated
  defensively), so adding a `find_attr` scan to it turned an
  attacker-supplied count into an out-of-bounds read. The count is now bounded
  by `fhsm_check_template` before anything walks the template.
  (TestTemplateCountOverflowValidHandles.)

* **#125 SECURITY — CKA_UNWRAP_TEMPLATE closes the Tookan §3.3 downgrade
  (partial support).** A wrapping/unwrapping key may now carry
  `CKA_UNWRAP_TEMPLATE` (§4.9). When it demands `CKA_SENSITIVE`, an unwrap
  supplying `CKA_SENSITIVE=False` is `CKR_ATTRIBUTE_READ_ONLY` instead of
  handing back a readable copy of a sensitive key.

  This is the spec's own answer to Tookan: RFC 3394 wraps key bytes only, so
  the module cannot distinguish an attacker's downgrade from a non-sensitive
  key being legitimately re-imported — but the *unwrapping key's owner* knows
  what that key is for and can say so up front. An earlier attempt refused all
  downgrades unconditionally and was reverted: it broke seven legitimate
  round-trips to block one attack.

  **Support is deliberately partial.** Only `CKA_SENSITIVE=TRUE` and
  `CKA_EXTRACTABLE=FALSE` are honoured — the attributes that defend the key,
  and the ones that fit the per-object flags byte without a store format
  change. Any other attribute in the template (`CKA_LABEL`, `CKA_KEY_TYPE`, …)
  is `CKR_ATTRIBUTE_VALUE_INVALID` at creation: the module refuses what it
  cannot enforce rather than accepting it and silently ignoring it, which
  would be a false claim of protection. Full nested-template support needs a
  v3 store record and is tracked separately.

  **Unchanged:** an unwrapping key *without* `CKA_UNWRAP_TEMPLATE` still
  honours a `CKA_SENSITIVE=False` template. Deployments that wrap sensitive
  keys should set the policy on their wrapping keys.

* **#125 security — CKA_TRUSTED was settable by any application and then
  reported back as FALSE.** `CKA_TRUSTED` may only be set to TRUE by the SO
  (§4.6). It gates `CKA_WRAP_WITH_TRUSTED`: a key marked WRAP_WITH_TRUSTED may
  only be wrapped by a wrapping key that is CKA_TRUSTED. The module accepted
  `CKA_TRUSTED=TRUE` from any session, so an application could declare its own
  wrapping key trusted and defeat that control — the classic Tookan-style key
  export escape. It then returned a hard-coded FALSE on readback, so the
  attribute was simultaneously unenforced and unobservable.

  Setting it TRUE from a non-SO session is now `CKR_ATTRIBUTE_READ_ONLY`, on
  both `C_CreateObject` and `C_SetAttributeValue` (guarding only the former
  would be bypassable: create without the attribute, then set it afterwards).
  When the SO does set it, it is persisted and read back honestly.
  Setting it FALSE, or omitting it, is unaffected. (TestCKATrusted.)

* **#125 — CKA_LOCAL and CKA_ALWAYS_AUTHENTICATE are now stored, not
  hard-coded.** `C_GetAttributeValue` returned `CKA_LOCAL = TRUE` and
  `CKA_ALWAYS_AUTHENTICATE = FALSE` as literals for every object. Neither was
  ever persisted.

  `CKA_LOCAL` exists to attest that a key was generated on the token and has
  never existed outside it (§4.9). Reporting TRUE for a key imported via
  `C_CreateObject` is a false statement about key provenance — precisely the
  question the attribute is asked to answer. It is now TRUE only for
  `C_GenerateKey` / `C_GenerateKeyPair`, and FALSE for `C_CreateObject`,
  `C_UnwrapKey`, `C_DeriveKey` and (de)encapsulation.

  `CKA_ALWAYS_AUTHENTICATE` is now read from the private-key template at
  keygen and persisted, so setting it no longer silently does nothing.
  Both use previously-free bits of the per-object flags byte.
  (TestRSAPrivateKeyImport::test_imported_key_local_flag_false,
  TestAlwaysAuthenticate::test_always_authenticate_set_on_keygen.)

* **#125 — CKA_PARAMETER_SET was sitting on CKA_MODIFIABLE's code point; PQC
  parameter sets were unselectable.** `CKA_PARAMETER_SET` was `#define`d as
  `0x170`, which is `CKA_MODIFIABLE`. Two consequences, both reproduced:

  1. `fhsm_check_bool_attr_lengths` correctly treats `0x170` as a boolean and
     rejects any value longer than 1 byte — *before* the PQC keygen ever read
     it. The "ASCII parameter-set name at 0x170" path was therefore dead code:
     the parameter set could not be chosen, and **every ML-KEM / ML-DSA /
     SLH-DSA keygen silently used the default** (ML-DSA-65, ML-KEM-768).
  2. A caller passing a perfectly legal `CKA_MODIFIABLE=TRUE` on a PQC keygen
     had that byte read as a 1-character parameter-set name and got
     `CKR_ATTRIBUTE_VALUE_INVALID`.

  `CKA_PARAMETER_SET` now uses its real code point `0x0000061D` and accepts
  the spec `CK_ULONG` selector (`CKP_ML_DSA_65` & co) as well as the ASCII
  name form. Verified: CKP_ML_DSA_44/65/87 now yield 1334/1974/2614-byte
  public keys (three distinct sizes, matching FIPS 204 + DER overhead);
  previously all three returned the 1974-byte default. `CKA_MODIFIABLE=TRUE`
  on a PQC keygen is accepted again.

* **#125 — CKM_DES3_KEY_GEN corrected 0x130 -> 0x131.** `0x130` is
  `CKM_DES2_KEY_GEN`, so a caller asking for DES2 keygen received a 24-byte
  DES3 key while a caller asking for real DES3 got `CKR_MECHANISM_INVALID`.
  The module's own `CKM_DES3_KEY_GEN_LIST = 0x131` already contradicted it.

### Added

* **`scripts/post_rename.sh`** — repo-side fixups for the `freehsm-c` →
  `freehsm` rename. `mirror.yml` hard-codes the GitLab and Codeberg push URLs,
  and GitLab does **not** redirect git remotes on rename (GitHub does), so the
  mirror breaks the moment the rename lands. The script updates those URLs and
  the local remotes; `--check` reports without changing anything and exits
  non-zero while work remains — including a live check that
  `github.com/afchine1337/freehsm` resolves.

  It is a script rather than a commit made in advance because landing the new
  URLs *before* the rename would break the mirror; landing them after is a
  one-liner. The ghcr.io image names (`freehsm-c-build` / `freehsm-c-test`)
  are image names, not repo names — they keep working and are left alone.

* **`tests/test_integrity` + `make test-integrity`** — exercises the module
  integrity self-check **without** `FHSM_INTEGRITY_ALLOW_UNSIGNED`, which is
  the only reason the check shipped inert twice. Three cases: an unsigned
  binary must fail, a signed binary must verify, and a signed-then-tampered
  binary must fail. It links `fhsm_integrity.o` so the test binary carries its
  own `.fhsm_digest` section and is signed by `sign_module.sh` like the real
  module; no FIPS provider is needed because it calls
  `fhsm_integrity_verify()` directly. The test refuses to run if the bypass
  leaks in from the environment. Confirmed to catch the missing-`volatile`
  regression: reverting the fix turns it red with the computed digest
  reported. Wired into CI as a gating step.

* **`scripts/audit_constants.py`** — diffs every PKCS#11 constant `#define`d
  in the module against the spec table and exits non-zero on divergence.
  Three separate interop bugs in one day (CKA_PARAMETER_SET, the AES-MAC code
  points, the wrap CKR family) all came from constants written from memory and
  were each found only when a test happened to trip over them. The audit now
  reports **274 conform, 0 divergent**; wiring it into CI keeps the class shut.

* **#125 — C_WrapKey/C_UnwrapKey now implement CKM_RSA_PKCS_OAEP.** The
  mechanism was advertised and listed as supported in the C_WrapKey header
  comment, but the code refused it, reasoning that `C_EncryptInit` +
  `C_Encrypt` with the RSA public key is "semantically identical". It is
  not: `C_WrapKey` exports the value of a key *object*, including a
  `CKA_SENSITIVE` key whose value `C_Encrypt` can never obtain (reading it
  yields `CK_UNAVAILABLE_INFORMATION`). Wrapping a sensitive key under an
  RSA public key is the primary use case and the encrypt path cannot express
  it. RSA-OAEP key transport is FIPS-approved (SP 800-56B rev2).
  (TestRSAOAEPWrap.)

* **#125 — wrap-related CKR constants corrected.** Several wrap error codes
  were written from memory rather than the spec, so the module returned
  codes that decode to something else entirely:
  `CKR_WRAPPING_KEY_SIZE_RANGE` was sent as `0x112` (= `CKR_WRAPPED_KEY_LEN_RANGE`;
  correct is `0x114`), `CKR_WRAPPED_KEY_INVALID` as `0x69`
  (= `CKR_KEY_NOT_WRAPPABLE`; correct is `0x110`), and `CKR_KEY_UNEXTRACTABLE`
  as `0x68` (= `CKR_KEY_FUNCTION_NOT_PERMITTED`; correct is `0x6A`).
  Fixes TestWrapKeyErrors::test_wrapping_key_size_range.

* **BREAKING — #125: AES-MAC mechanism code points corrected.** The module
  advertised its AES-CMAC implementation at `0x108C`, which is
  `CKM_AES_XCBC_MAC`, and used `0x108A` (the real `CKM_AES_CMAC`) for
  AES-GMAC. Callers asking for `CKM_AES_CMAC` silently got the GMAC path;
  callers asking for `CKM_AES_XCBC_MAC` got CMAC. Verified against the OASIS
  `pkcs11t.h`: 0x108A=`CKM_AES_CMAC`, 0x108B=`CKM_AES_CMAC_GENERAL`,
  0x108C=`CKM_AES_XCBC_MAC`, 0x108E=`CKM_AES_GMAC`.

  Now: `CKM_AES_CMAC` is advertised and dispatched at **0x108A**,
  `CKM_AES_GMAC` moved to **0x108E**, and **0x108C is no longer advertised**
  (RFC 3566 XCBC-MAC is not implemented; it returns
  `CKR_MECHANISM_INVALID`).

  A prior comment described OpenSC pkcs11-tool's use of 0x108A for AES-CMAC
  as "a long-standing OpenSC bug". That was backwards — pkcs11-tool was
  correct. Consequently pkcs11-tool now interoperates without
  `FHSM_OPENSC_GMAC_ALIAS`, which is retained as an inert compatibility path
  pending removal.

  **Migration:** callers hard-coding `0x108C` for AES-CMAC must move to
  `0x108A`. Callers using `0x108A` (e.g. pkcs11-tool) are unaffected and now
  get real CMAC.

* **#125 conformance — a CKK_GENERIC_SECRET is no longer accepted in place of
  a typed symmetric key.** `fhsm_check_key_mech_type` carried a tolerance that
  returned `CKR_OK` when a generic secret was used with an AES/DES3 mechanism.
  It was added to keep ECDH-derived keys usable with AES, but that need went
  away once `C_DeriveKey` began honouring `CKA_KEY_TYPE`: a derive requesting
  `CKK_AES` yields a real `CKK_AES` key. Mismatched key types are now
  `CKR_KEY_TYPE_INCONSISTENT` per spec (TestWrongKeyType AES_CBC and
  AES_XCBC_MAC). Mechanisms that legitimately accept a generic secret (HMAC)
  are unaffected.

* **#125 interop — C_CancelFunction was missing from the function list,
  shifting every later slot by one.** `CK_FUNCTION_LIST` declared 67 slots
  ending at `C_WaitForSlotEvent`; the v2.40 list has **68** functions, with
  `C_CancelFunction` at slot 66 and `C_WaitForSlotEvent` at 67. The function
  was implemented and exported but never wired, so every caller using the
  table (rather than `dlsym`) invoked the *wrong function*: `C_CancelFunction`
  hit `C_WaitForSlotEvent` (`CKR_ARGUMENTS_BAD`), `C_WaitForSlotEvent` hit
  `C_GetInterfaceList` (`CKR_BUFFER_TOO_SMALL`), and `C_GetInterfaceList` hit
  `C_GetInterface` — which wrote `*ppInterface` through an uninitialised
  register, **segfaulting** the caller. This is the real cause of the
  pkcs11-tool crash previously attributed to a version mismatch.
  Slots are now 66=`C_CancelFunction`, 67=`C_WaitForSlotEvent`; the v3.0
  table mirrors all 68 v2.40 slots with 68=`C_GetInterfaceList`,
  69=`C_GetInterface` (92 slots total), matching PKCS#11 v3.0 §5.18.
  Fixes TestLegacyParallelFunctions, TestWaitForSlotEventErrors,
  TestInterfaceV30, TestGetInterfaceList and the last SIGSEGV
  (TestListBufferTooSmallGuards).

* **#125 conformance — login state is per-application (per-token), not
  per-session.** `fhsm_session_role` now reports the token's authenticated
  role (shared across every session the application holds with the token,
  per PKCS#11 v3.2 §5.6) instead of a per-session copy. A key operation
  issued in a sibling session of a logged-in application is no longer
  spuriously rejected `CKR_USER_NOT_LOGGED_IN` (TestMultipleSessions,
  TestConcurrentObjectCreation, TestMultiSessionConcurrency,
  provisioned-signing coherence, RO token-object mutation).

* **#125 — large object storage: per-object value is now heap-allocated.**
  The object value moved from a fixed 16 KiB inline buffer to a heap
  allocation (cap `FHSM_OBJ_VALUE_MAX` raised to 2 MiB), so `CKO_DATA`
  objects up to 1 MiB create, persist (encrypted `.tok` round-trip) and
  read back cleanly instead of failing `CKR_KEY_SIZE_RANGE`
  (TestLargeDataObjects 100 KiB/1 MiB, TestDataObjectCreate large value,
  TestLargeAttributes). `FHSM_MAX_OBJECTS` raised 64→256 so ≥100 keys
  coexist (TestBulkOperations). Verified leak/UAF-free under ASAN across
  create/destroy/compaction/session-close/logout-reload/finalize.


* **#125 conformance — all RSA-PSS mechanisms require their parameters.**
  `CKM_SHAxxx_RSA_PKCS_PSS` (not just the bare `CKM_RSA_PKCS_PSS`) now requires
  `CK_RSA_PKCS_PSS_PARAMS` at `C_SignInit`/`C_VerifyInit`; a missing/too-short
  parameter is `CKR_MECHANISM_PARAM_INVALID` instead of a silent salt-length
  fallback (TestBadParameters SHA256_RSA_PKCS_PSS missing params).

* **#57/#125 — multipart Encrypt/Decrypt made mechanism-generic.** The
  streaming path (`C_EncryptUpdate`/`Final`, `C_DecryptUpdate`/`Final`) was
  hard-wired to AES-GCM and keyed off the wrong IV field, so AES-ECB/CBC/CBC-PAD/
  CTR multipart produced a GCM ciphertext instead of matching the single-shot
  output. Replaced `ensure_cipher_ctx_aes_gcm` with a generic `ensure_cipher_ctx`
  that builds the correct EVP cipher, IV (op->iv for CBC/CTR, the CK_GCM_PARAMS
  nonce for GCM), AAD and padding (PKCS#7 only for CBC-PAD); `C_EncryptFinal`
  now appends the GCM tag only for GCM and flushes the padding block for CBC-PAD;
  the Update buffer guards account for the extra block a block cipher can emit.
  Verified: multipart == single-shot for ECB/CBC/CTR/GCM, CBC-PAD round-trip
  (20->32->20), and an undersized output buffer returns CKR_BUFFER_TOO_SMALL with
  no overrun (TestMultipartEncrypt, TestDecryptBufferTooSmallGuards).

* **#125 security — output-buffer overrun guard in Encrypt/DecryptUpdate.**
  `C_EncryptUpdate`/`C_DecryptUpdate` (AES-GCM multipart, a stream cipher that
  emits exactly the input length) passed the caller's output pointer straight to
  `EVP_*Update` without checking the caller-declared `*pulLen`. An undersized
  buffer therefore caused an out-of-bounds write. They now return
  `CKR_BUFFER_TOO_SMALL` (setting the required length) before EVP writes, leaving
  the operation active for retry (TestUpdateOutputGuard). Proven with a guard-page
  probe: a 4-byte buffer against 32 bytes returns 0x150 with no fault. (Full
  multipart-GCM output-length correctness is tracked separately under #57.)

* **#125 security — private objects require an authenticated session.**
  `C_CreateObject` now rejects creating a private object (`CKA_PRIVATE=TRUE`,
  explicit or the class default for secret/private keys) from a public
  (unauthenticated) session with `CKR_USER_NOT_LOGGED_IN`
  (`fhsm_check_private_login`, TestPublicSessionRestrictions). Public objects
  remain creatable without login.

* **Wycheproof CI — align adapter ML-KEM/ML-DSA key types with the module.**
  The Wycheproof `mldsa`/`mlkem` adapters used the old draft `CKK_ML_KEM=0x3C` /
  `CKK_ML_DSA=0x3E`, but the module was moved to the official PKCS#11 v3.2 /
  OASIS values (`CKK_ML_KEM=0x49`, `CKK_ML_DSA=0x4A`) during #125, so every
  PQC public-key import hit `CKR_TEMPLATE_INCONSISTENT` (614 ML-DSA imports
  failed -> 226 good-sig-rejected; 3 ML-KEM). Update the adapter constants to
  the official values. Verified: mldsa 614 match / 0 viol, mlkem 21 / 0.

* **AES-CCM online implementation (SP 800-38C).** The interactive
  `C_Encrypt`/`C_Decrypt` path for `CKM_AES_CCM` is now wired: `op_init` parses
  `CK_CCM_PARAMS` (nonce 7-13 B, MAC 4-16 B, AAD), validating a missing/NULL
  nonce or out-of-range length with `CKR_MECHANISM_PARAM_INVALID`; encrypt emits
  `ciphertext || MAC` and decrypt verifies the MAC in-place (partial plaintext
  zeroised on failure -> `CKR_ENCRYPTED_DATA_INVALID`). CCM is re-advertised
  `fips=approved` and added to the cipher whitelist, so `C_GetMechanismInfo`
  flags now match behaviour. Verified byte-exact against NIST SP 800-38C
  Example 1, plus a full encrypt→decrypt round-trip and tamper rejection.

* **Wycheproof CI — model the FIPS GCM IV policy.** The AES-GCM adapter
  counted every non-96-bit-IV "valid" vector as a false negative, but FreeHSM
  (fips-strict) deliberately restricts AES-GCM IVs to 96 bits per NIST SP
  800-38D / FIPS 140-3 IG C.H, rejecting other sizes at init with
  `CKR_MECHANISM_PARAM_INVALID`. The adapter now classifies a non-96-bit IV
  rejection as expected (`iv_non96_fips_rejected` diag), not a violation. 37
  spurious violations cleared; all adapters now report 0.

* **#125 security — CKA_MODIFIABLE / CKA_DESTROYABLE honoured.** These were
  silently ignored at create and always read back TRUE (a "lying module"). They
  are now persisted (object flag bits `FHSM_OBJF_UNMODIFIABLE` /
  `FHSM_OBJF_UNDESTROYABLE`), returned truthfully by `C_GetAttributeValue`, and
  enforced: `C_SetAttributeValue` on a non-modifiable object and
  `C_DestroyObject` on a non-destroyable object return `CKR_ACTION_PROHIBITED`
  (TestModifiableAttribute / TestDestroyable).

* **#125 security — key-type confusion on unwrap (Tookan).** `C_UnwrapKey` now
  validates the recovered key length against the claimed `CKA_KEY_TYPE`
  (`CKK_AES` -> 16/24/32, `CKK_DES3` -> 24) and against any `CKA_VALUE_LEN` in
  the template; a mismatch is `CKR_KEY_TYPE_INCONSISTENT` /
  `CKR_ATTRIBUTE_VALUE_INVALID` instead of silently reinterpreting a blob as an
  incompatible key type (TestKeyTypeConfusionOnUnwrap).

* **#125 robustness — one-shot length bounds & wrap-key size.** `C_Encrypt`
  and `C_Decrypt` reject a data length beyond `INT_MAX` with
  `CKR_DATA_LEN_RANGE` instead of silently truncating the `(int)` cast on the
  OpenSSL path (TestEncryptOutputLengthTruncation / TestDecryptOutputLengthTruncation).
  `C_WrapKey` rejects an AES wrapping key whose length is not 128/192/256 bits
  with `CKR_WRAPPING_KEY_SIZE_RANGE` (TestWrapKeyErrors).

* **#125 conformance — CCM de-advertised.** AES-CCM (`0x1088`) is FIPS-approved
  and KAT-covered, but its online `C_Encrypt`/`C_Decrypt` path is not wired, so
  advertising `CKF_ENCRYPT` for a mechanism `C_EncryptInit` rejects was a
  flag↔behaviour inconsistency (TestMechFlagBehavioralConformance). CCM is
  de-advertised until the online two-pass path is implemented (F13); the KAT
  handler is retained.

* **#125 conformance — HMAC key-type enforcement.** The mechanism ↔ key-type
  gate now rejects an asymmetric key (`CKK_RSA` / `CKK_EC` / `CKK_EC_EDWARDS`)
  used with an HMAC mechanism (`CKM_SHA*_HMAC`) as `CKR_KEY_TYPE_INCONSISTENT`
  at `C_SignInit`/`C_VerifyInit`, instead of accepting it
  (TestWrongKeyType hmac_sha256_with_rsa_key).

* **#125 robustness & conformance (batch 10).** `C_GetAttributeValue` now
  bounds `ulCount` (`> FHSM_MAX_TEMPLATE_ATTRS -> CKR_ARGUMENTS_BAD`) before the
  per-attribute loop; an absurd count (e.g. `0xffffffffffffffff`) previously
  walked the template out of bounds and **crashed with SIGSEGV**
  (TestTemplateCountOverflowValidHandles). `C_EncryptUpdate`/`C_DecryptUpdate`
  reject a part length beyond `INT_MAX` with `CKR_DATA_LEN_RANGE` instead of
  silently truncating the `(int)` cast (TestIsizeMaxUpdateLength). SHAKE128/256
  were de-advertised: their non-standard values `0x2B8`/`0x2B9` collided with
  the official `CKM_SHA3_224_KEY_GEN`, making `C_GetMechanismInfo` present a
  key-gen as a digest (TestMechFlagBehavioralConformance).

* **#125 conformance — RSA public components on private keys.**
  `extract_pubkey_attr` only parsed public keys, so `CKA_MODULUS`,
  `CKA_PUBLIC_EXPONENT` and `CKA_MODULUS_BITS` were unavailable on RSA private
  keys (KeyError). It now also parses private keys (PKCS#8 via
  `d2i_AutoPrivateKey`) and returns these non-sensitive components
  (TestRSAKeypairConsistency). Verified: modulus matches between the public and
  private key; exponent = 0x010001.

* **#125 conformance — CKA_TOKEN readback.** `C_GetAttributeValue` hard-coded
  `CKA_TOKEN = TRUE`; it now reflects the object's actual scope (FALSE for a
  session object, TRUE for a persisted token object) via
  `fhsm_token_object_is_token` (TestSecretKeyDefaults / TestDataObjectDefaults).

* **#125 behavioural conformance (batch 9).** Added SHA3-224 (`CKM_SHA3_224`,
  `0x000002B5`) as an approved digest: hash enum + size/name, `C_DigestInit`
  case, dispatch handler and advertisement (regenerated tables). Verified
  byte-exact against OpenSSL SHA3-224("abc"). `C_EncryptInit`/`C_DecryptInit`
  with `CKM_AES_CTR` and a NULL counter parameter now return
  `CKR_MECHANISM_PARAM_INVALID` (TestBadParameters).

* **#125 — AES-ECB reclassified as FIPS-approved (SP 800-38A).** AES-ECB was
  rejected under fips-strict (advertised interop-only, `CKR_MECHANISM_INVALID`
  at `C_EncryptInit`/`C_Encrypt`). ECB is a FIPS-approved confidentiality mode
  (NIST SP 800-38A), so it is now `fips="approved"`: advertised in both
  profiles, executable, KAT-covered, and `CKF_ENCRYPT`/`CKF_DECRYPT` consistent
  with behaviour. 3DES-CBC and 3DES key generation remain non-approved
  (interop-only). Verified byte-exact against NIST SP 800-38A ECB-AES128.
  `test_legacy_cipher` now detects the profile via 3DES keygen advertisement and
  round-trips AES-ECB in both profiles.

* **#125 behavioural conformance (batch 8) — PQC parameter-set validation.**
  The module read `CKA_PARAMETER_SET` at `0x170`, which is actually
  `CKA_MODIFIABLE`; the spec value is `0x0000061D`. The harness sends the
  attribute at the correct address, so a malformed (under/overlong) parameter
  set was never seen and `C_GenerateKeyPair` accepted it. Added validation at
  `0x0000061D`: a well-formed `CK_ULONG` selector or a valid parameter-set name
  is accepted, an under/overlong value is `CKR_ATTRIBUTE_VALUE_INVALID`
  (TestGenerateKeyPairErrors ml_kem / ml_dsa parameter_set). The keygen default
  logic (ASCII names) is unchanged.

* **#125 behavioural conformance (batch 7) — C_GetMechanismInfo flags.**
  Encapsulation mechanisms (ML-KEM, hybrid KEM) advertised `CKF_ENCRYPT |
  CKF_DECRYPT` instead of `CKF_ENCAPSULATE | CKF_DECAPSULATE`; fixed in
  `fhsm_mech_flags_for`. (TestMechFlagBehavioralConformance ML-KEM). AES-CCM/CTS flag↔behaviour
  consistency remains a follow-up (de-advertise or implement the online path).

* **#125 behavioural conformance (batch 6) — CKO_DATA objects.** The create
  parser rejected data objects (`CKA_CLASS = CKO_DATA`) with
  `CKR_TEMPLATE_INCONSISTENT`. `C_CreateObject` now supports `CKO_DATA`: an
  application-defined blob carried in `CKA_VALUE` (optional, defaults to empty),
  stored verbatim and round-tripped through `C_GetAttributeValue`,
  `C_FindObjects` and `C_DestroyObject` (#125 TestDataObject* create / read /
  search / destroy). Values remain bounded by the fixed per-object store cap
  (16 KiB); multi-hundred-KiB data objects are a separate follow-up (dynamic
  value storage).

* **#125 behavioural conformance (batch 5) — creation-template & PSS param
  validation.** `fhsm_check_template` (now also run by `C_CreateObject`) rejects
  any attribute with a NULL `pValue` but non-zero `ulValueLen` as
  `CKR_ATTRIBUTE_VALUE_INVALID` (malformed value; e.g. `CKA_ALLOWED_MECHANISMS`
  NULL_PTR + non-zero length). The bare `CKM_RSA_PKCS_PSS` mechanism now requires
  its `CK_RSA_PKCS_PSS_PARAMS` (hashAlg/mgf/sLen): a missing or too-short
  parameter is `CKR_MECHANISM_PARAM_INVALID` instead of a silent default.

* **#125 behavioural conformance (batch 4) — AES-GCM correctness.** The
  one-shot `C_Encrypt` GCM path ignored the `CK_GCM_PARAMS` captured at
  `C_EncryptInit`: it used a hard-coded 12-byte `op->iv` (randomly generated for
  the struct calling convention, so it never matched the caller's IV) and an
  empty AAD. Any non-default IV or any AAD therefore produced the wrong
  ciphertext and tag, breaking round-trips (`CKR_ENCRYPTED_DATA_INVALID` on
  decrypt), cross-verification and the AES-GCM KAT. The path is rewritten to
  honour the caller's IV / AAD / tag length, mirroring `C_Decrypt`. Verified
  byte-for-byte against NIST SP 800-38D GCM Test Case 4 (ciphertext + tag) and a
  full encrypt→decrypt round-trip.

* **#125 behavioural conformance (batch 3).** Fixed a regression from the
  mechanism↔key-type gate: an ECDH-derived `CKK_GENERIC_SECRET` key used with a
  symmetric cipher was wrongly rejected. `C_DeriveKey` now honours a requested
  `CKA_KEY_TYPE` in the derive template, and the gate accepts
  `CKK_GENERIC_SECRET` for the AES/DES3 families (asymmetric wrong-key-type
  rejections are unaffected). Fixed AES-CTR parameter parsing: a
  `CK_AES_CTR_PARAMS` struct's counter block is `cb[]`, not the first 16 bytes —
  the previous code mixed `ulCounterBits` into the counter and corrupted the
  keystream (KAT mismatch). `ulCounterBits` outside 1..128 (including 0) is now
  `CKR_MECHANISM_PARAM_INVALID`; the raw-16-byte OpenSC convention is preserved.

* **#125 behavioural conformance (batch 2).** `C_CreateObject` now runs the
  bool- and scalar-attribute length validators (the scalar check had only been
  wired into `C_GenerateKey`/`C_GenerateKeyPair`/`C_DeriveKey`): an overlong
  `CKA_KEY_TYPE` / `CKA_VALUE_LEN` on `C_CreateObject` is now
  `CKR_ATTRIBUTE_VALUE_INVALID`. Added a mechanism ↔ key-type consistency gate
  (`fhsm_check_key_mech_type`) on `C_EncryptInit` / `C_DecryptInit` /
  `C_SignInit` / `C_VerifyInit`: using a key whose type does not match the
  mechanism family (RSA↔`CKK_RSA`, ECDSA↔`CKK_EC`, EdDSA↔`CKK_EC_EDWARDS`,
  AES↔`CKK_AES`, DES3↔`CKK_DES3`) returns `CKR_KEY_TYPE_INCONSISTENT` instead of
  being silently accepted (Tookan key-type-confusion, TestWrongKeyType).

* **#125 behavioural conformance (pkcs11-check hardening).** Scalar
  (`CK_ULONG`) attribute length validation — `CKA_CLASS`, `CKA_KEY_TYPE`,
  `CKA_VALUE_LEN`, `CKA_MODULUS_BITS`, `CKA_CERTIFICATE_TYPE` supplied with a
  length other than `sizeof(CK_ULONG)` now return
  `CKR_ATTRIBUTE_VALUE_INVALID` across `C_GenerateKey` / `C_GenerateKeyPair` /
  `C_CreateObject` / `C_DeriveKey` (`fhsm_check_ulong_attr_lengths`). Read-only
  session enforcement extended to `C_GenerateKeyPair` and `C_DestroyObject`:
  generating or destroying a token object on a read-only session returns
  `CKR_SESSION_READ_ONLY` (`fhsm_token_object_is_token`); session objects stay
  destroyable. `C_EncryptInit` / `C_DecryptInit` enforce a positive whitelist of
  implemented cipher mechanisms (`fhsm_cipher_mech_valid`) — a digest
  (`CKM_SHA256`), a signature, or an advertised-but-unwired cipher
  (`CKM_AES_CCM`) is `CKR_MECHANISM_INVALID` rather than silently accepted.
  `C_DigestInit` rejects a non-empty mechanism parameter with
  `CKR_MECHANISM_PARAM_INVALID`.

* **`make pkcs11-check` and the CI harness now build the module under
  test with a larger object store (`FHSM_MAX_OBJECTS=4096`) (#125).** A
  full pkcs11-check run creates far more than the default 64 objects and
  (pending the session-object lifecycle fix) never frees them, so the
  store filled and cascaded `CKR_DEVICE_MEMORY` across ~119 unrelated
  tests, masking real findings. `FHSM_MAX_OBJECTS` is `#ifndef`-guarded
  and `-D`-overridable; the default shipped build is unchanged (64). The
  deeper fix (honour `CKA_TOKEN`, destroy session objects on
  `C_CloseSession`) is tracked in docs/PKCS11_CHECK_FINDINGS.md F5.

### Fixed

* **Login throttle no longer blocks valid re-logins; C_SeedRandom uses
  the correct CKR (#125).** The exponential PIN throttle was checked
  before the already-authenticated case, so a re-login by an
  already-logged-in application returned the vendor code
  FHSM_RV_PIN_THROTTLED (0x80000004) instead of
  CKR_USER_ALREADY_LOGGED_IN -- which cascaded across ~8 pkcs11-check
  setup steps. `fhsm_token_login` now returns
  CKR_USER_ALREADY_LOGGED_IN when the token is already logged in as the
  requested role (before the throttle), and `fhsm_session_login` sets
  the session role on that return so operations proceed. Separately,
  C_SeedRandom returned 0x34 instead of the correct
  CKR_RANDOM_SEED_NOT_SUPPORTED (0x120).

* **pkcs11-check summary now reads the freshest report format (#125).**
  Newer pkcs11-check versions emit a pytest --report-log
  (`report.jsonl`) and no longer update `results.json`, so
  `run_pkcs11_check.sh` was summarising a stale prior run's
  `results.json` -- the printed counts stayed frozen across reruns.
  `pkcs11_check_summary.py` now parses both the pytest-JSONL and the
  legacy JSON formats (reducing JSONL to one outcome per test), and the
  runner picks whichever report file is newest.

* **PQC key-type values, parameter-set validation and ML-KEM usage
  corrected (#125).** With ML-DSA/SLH-DSA/ML-KEM now recognised at their
  official mechanism values, the conformance harness exercised them for
  real and surfaced: (a) the key-type (CKK) values were still
  non-standard -- reassigned to the official PKCS#11 v3.2 values
  (CKK_ML_KEM 0x49, CKK_ML_DSA 0x4A, CKK_SLH_DSA 0x4B); (b) a malformed
  (CK_ULONG-sized) CKA_PARAMETER_SET was accepted -- now validated
  against the known parameter-set names (CKR_ATTRIBUTE_VALUE_INVALID);
  (c) an ML-KEM private key reported CKA_DERIVE=TRUE -- PQC private keys
  no longer advertise DERIVE. Also de-advertised the CKM_HASH_ML_DSA_*
  prehash mechanisms, which were advertised with sign flags but not
  implemented in the operation path. Verified: ML-DSA/SLH-DSA/ML-KEM KATs
  pass, key-type readback matches, param-set is validated.

* **Per-object usage flags are now enforced at operation start (#125).**
  In addition to reporting the stored CKA_ENCRYPT/DECRYPT/SIGN/VERIFY/
  WRAP/UNWRAP/DERIVE flags, the operation entry points reject a key that
  lacks the required capability: C_EncryptInit / C_DecryptInit /
  C_SignInit / C_VerifyInit / C_WrapKey / C_UnwrapKey / C_DeriveKey
  return CKR_KEY_FUNCTION_NOT_PERMITTED via a shared fhsm_check_usage()
  helper. Legacy keys without an explicit usage byte remain permitted.
  Verified: an encrypt-only key (CKA_DECRYPT=FALSE) refuses
  C_DecryptInit. Regression: tests/test_attributes.c.

* **Per-object usage flags are stored and enforced in reporting (#125).**
  CKA_ENCRYPT/DECRYPT/SIGN/VERIFY/WRAP/UNWRAP/DERIVE were previously
  reported from the class default, so a key created with (e.g.)
  CKA_ENCRYPT=FALSE still reported CKA_ENCRYPT=TRUE
  (pkcs11-check TestKeyUsageRestrictions). The creation paths
  (C_GenerateKey / C_GenerateKeyPair / C_CreateObject / C_DeriveKey /
  C_UnwrapKey) now compute a usage-flag byte from the class default plus
  any template overrides and persist it in a previously-spare token
  record byte; C_GetAttributeValue reports the stored value. Legacy
  objects (no explicit byte) fall back to the class default.
  Regression: tests/test_attributes.c.

* **PQC mechanism values corrected to the official PKCS#11 v3.2 values
  (#125, ABI change).** ML-DSA / SLH-DSA / HASH-ML-DSA were advertised in
  the 0x4021-0x4029 range, which PKCS#11 v3.2 allocates to the
  Signal-protocol mechanisms (X3DH / X2RATCHET / XEDDSA), so a
  conformance harness read the module's ML-DSA as X3DH_RESPOND, etc.; the
  advertised value also disagreed with the operation-path value.
  Reassigned to the official values (ML-KEM keygen 0x0F / op 0x17,
  ML-DSA keygen 0x1C / sign 0x1D, HASH-ML-DSA-SHA256 0x24 / SHA512 0x26,
  SLH-DSA keygen 0x2D / sign 0x2E), unifying advertisement with the
  operation path. Regenerated the dispatch table and MECHANISMS.md;
  updated the operation defines, e2e tests and the Wycheproof adapter.
  Verified: ML-DSA-65 / SLH-DSA / ML-KEM-768 KATs pass and the old
  colliding values are gone.

* **Per-application login state for access control (#125).** PKCS#11
  login is shared by all sessions of a token, so C_Logout in one session
  must hide private objects from concurrent sessions on the same token.
  Access control now consults the token's login role
  (`fhsm_token_current_role`) instead of the per-session role.
  Regression: tests/test_session_objects.c.

* **Multipart HMAC now matches the one-shot result for all hashes
  (#125).** C_SignUpdate / C_SignFinal hard-coded the HMAC digest to
  SHA-256 and the signature length to 32 bytes, so a multipart
  SHA-384 / SHA-512 / SHA-3 HMAC produced a (wrong) SHA-256 MAC that did
  not match the one-shot C_Sign output (pkcs11-check
  TestMultipartSign::test_streaming_equals_single). The multipart path
  now selects the digest and length from the mechanism via the shared
  fhsm_hmac_hash_of() helper. Verified multipart == one-shot for
  SHA-256/384/512 and SHA3-256/512. Regression: tests/test_fips_digests.c.

* **Private-object access control also covers direct handle access
  (#125).** In addition to hiding private objects from C_FindObjects,
  C_GetAttributeValue and C_GetObjectSize now return
  CKR_OBJECT_HANDLE_INVALID for a private object (secret/private key)
  when the session is not logged in as the user -- so a handle retained
  from an authenticated session cannot read the object after C_Logout or
  from a public session. Regression: tests/test_session_objects.c.

* **Private objects are no longer visible to unauthenticated sessions
  (#125, access control).** C_FindObjects returned CKA_PRIVATE objects
  (secret and private keys) to a public session and to a session after
  C_Logout. C_FindObjectsInit now hides private objects unless the
  session is logged in as the normal user (CKA_PRIVATE derived from the
  object class). Regression: tests/test_session_objects.c.

* **Key-handle validation at operation Init, and C_GetAttributeValue
  buffer-too-small (#125).** C_EncryptInit / C_DecryptInit / C_SignInit /
  C_VerifyInit now reject a destroyed or invalid key handle with
  CKR_KEY_HANDLE_INVALID (previously such a call succeeded -- a
  use-after-destroy robustness defect). C_GetAttributeValue returns
  CKR_BUFFER_TOO_SMALL when a requested attribute does not fit the
  caller's buffer, instead of silently returning CKR_OK with
  CK_UNAVAILABLE_INFORMATION. Regression: tests/test_op_state.c.

* **PKCS#11 session-object lifecycle (CKA_TOKEN) implemented (#125).**
  The module previously treated every created object as a persistent
  token object and never destroyed session objects, so the store only
  grew and object-lifecycle checks failed. `fhsm_object_t` now carries an
  in-memory `owner_session`; C_GenerateKey/C_GenerateKeyPair/
  C_CreateObject/C_DeriveKey/C_UnwrapKey read CKA_TOKEN (default FALSE)
  and mark session objects, which are never persisted to the `.tok` file
  and are destroyed by C_CloseSession
  (`fhsm_token_destroy_session_objects`). Creating a token object on a
  read-only session now returns CKR_SESSION_READ_ONLY. This also removes
  the root cause of the earlier store-exhaustion cascade (session objects
  are freed on close). Regression: tests/test_session_objects.c.

* **More input/parameter validation (#125, AVA_VAN, continued).**
  RSA-PSS salt length and RSA-OAEP source-data length beyond a sane
  bound (or a negative 2^63 cast) are rejected at Init with
  CKR_MECHANISM_PARAM_INVALID; a NULL inner IV/AAD (AES-GCM) or source
  (RSA-OAEP) pointer paired with a non-zero length is rejected rather
  than silently ignored; and an EC private key created via C_CreateObject
  without CKA_EC_PARAMS is rejected (CKR_TEMPLATE_INCONSISTENT) instead
  of stored as a curveless key. Regression: tests/test_input_validation.c.

* **Input/parameter validation hardening (#125, AVA_VAN).** Several
  invalid inputs that pkcs11-check flagged as silently accepted are now
  rejected: a wrong-size or missing **AES-CBC IV** and an **AES-GCM IV**
  shorter than 96 bits (NIST SP 800-38D) return
  CKR_MECHANISM_PARAM_INVALID at C_EncryptInit/C_DecryptInit; an invalid
  **RSA public exponent** (CKA_PUBLIC_EXPONENT even or < 3, per FIPS
  186-5 -- e=0/1/2/4) returns CKR_ATTRIBUTE_VALUE_INVALID from
  C_GenerateKeyPair; and an over-long **CK_BBOOL attribute** (length != 1)
  in C_GenerateKey/C_GenerateKeyPair/C_DeriveKey/C_UnwrapKey returns
  CKR_ATTRIBUTE_VALUE_INVALID. Regression: tests/test_input_validation.c.
  Remaining validation items (AES-KW/KWP corrupted-blob rejection,
  RSA-PSS/OAEP length boundaries, EC-without-params, wrong-key-type) are
  tracked in docs/PKCS11_CHECK_FINDINGS.md F8.

* **`C_GetAttributeValue` now returns the standard boolean, date and
  certificate attributes it previously reported as unavailable (#125).**
  30 pkcs11-check checks (test_attribute_defaults, test_key_flags,
  test_access_control, x509 field extraction) failed because the module
  returned `CK_UNAVAILABLE_INFORMATION` for common attributes, which the
  harness reads as "missing". The function now returns the policy/usage
  booleans (CKA_TOKEN, CKA_PRIVATE, CKA_ENCRYPT/DECRYPT/SIGN/VERIFY/WRAP/
  UNWRAP/DERIVE, CKA_LOCAL, CKA_ALWAYS_SENSITIVE, CKA_NEVER_EXTRACTABLE,
  CKA_MODIFIABLE, CKA_COPYABLE, CKA_DESTROYABLE, CKA_ALWAYS_AUTHENTICATE,
  CKA_WRAP_WITH_TRUSTED) from the PKCS#11 defaults and the stored object
  flags, empty CKA_START_DATE / CKA_END_DATE, and X.509
  CKA_SUBJECT / CKA_ISSUER / CKA_SERIAL_NUMBER (new `extract_cert_attr()`
  DER parser). Regression: `tests/test_attributes.c`. Per-object usage
  restrictions and public-key material on private keys remain a tracked
  follow-up (docs/PKCS11_CHECK_FINDINGS.md F7).

* **FIPS-approved digests and HMACs are now callable, not just advertised
  (#125).** The dispatch table advertised SHA-224, SHA-512/224,
  SHA-512/256, SHA3-256/384/512 and the SHA-224 / SHA-3 HMACs, but the
  hand-written `C_DigestInit` / `C_SignInit` / `C_VerifyInit` switches
  only handled SHA-256/384/512 (and `C_Verify` only SHA-256-HMAC), so
  callers got `CKR_MECHANISM_INVALID` (~32 pkcs11-check failures:
  test_sha3, TestSHA512Truncated, test_mech_flags). Added the missing
  FIPS 180-4 / 202 hashes to the digest path (new `FHSM_HASH_SHA224 /
  SHA512_224 / SHA512_256` enum values + EVP names) and a shared
  `fhsm_hmac_hash_of()` so the sign/verify paths accept the SHA-224 and
  SHA-3 HMAC families (also fixing SHA-384/512 HMAC *verify*, previously
  unimplemented). Verified against published "abc" KATs and HMAC
  round-trips; regression `tests/test_fips_digests.c`.

* **Per-session operation state no longer bleeds across pooled session
  handles (#125).** PKCS#11 session handles are reused, but neither
  `C_OpenSession` nor `C_CloseSession` reset the per-session operation
  slots. A session closed mid-operation left `active == 1`, so the next
  `C_OpenSession` returned a dirty handle and the following `C_*Init`
  wrongly reported `CKR_OPERATION_ACTIVE` (~18 pkcs11-check failures:
  `ckr/test_ckr_*Init`, `TestOperationActive`). A new
  `fhsm_session_ops_reset()` frees the persisted EVP contexts and zeroes
  every slot (encrypt/decrypt/sign/verify/digest, object search, OAEP)
  on both open and close; `C_EncryptUpdate` / `C_DecryptUpdate` also now
  clear `active` on their error paths.

* **`C_Sign` returns `CKR_BUFFER_TOO_SMALL` for an undersized signature
  buffer (#125).** The asymmetric path signed straight into the caller
  buffer, so a short buffer made OpenSSL fail with
  `CKR_FUNCTION_FAILED` (0x6). It now signs into a mechanism-sized
  scratch buffer, returns `CKR_BUFFER_TOO_SMALL` with the required
  length when the caller buffer is short, and keeps the operation active
  for retry. Regression: `tests/test_op_state.c`.

* **TSFI robustness: NULL pointers and integer-overflow counts no longer
  crash the module (#125).** pkcs11-check's raw security probes
  (`test_api_boundary`, `test_arithmetic_overflow`,
  `test_ffi_null_pointer`) crashed 21 entry-point calls with SIGSEGV /
  SIGBUS -- reported as `failed` rather than `crashed` only because the
  harness isolates each probe in a subprocess. `C_FindObjectsInit`,
  `C_GenerateKey`, and `C_GenerateKeyPair` iterated the caller template
  with no guard (NULL pointer, or a `ulCount` of `ULONG_MAX` walking out
  of bounds); `C_Sign`/`C_Verify`/`C_Digest` (one-shot and `*Update`)
  passed a NULL data pointer with a non-zero length straight to the
  digest/HMAC path. A shared `fhsm_check_template()` guard now rejects
  NULL-with-count and absurd counts (ceiling `FHSM_MAX_TEMPLATE_ATTRS`,
  default 1024), and the data entry points reject NULL-with-length, all
  with `CKR_ARGUMENTS_BAD` (operation terminated). Empty templates and
  zero-length NULL buffers stay legal. Regression:
  `tests/test_robustness_args.c`. Same AVA_VAN robustness class as the
  earlier C_Decrypt NULL fix; no key material exposed.

* **CI `reproducibility` compared a signed build against an unsigned one
  (false failure).** The `build` job uploads the module *after*
  `make integrity`, which embeds the SHA-256 self-digest into the
  32-byte `.fhsm_digest` section; the `reproducibility` job rebuilt with
  `make` only (unsigned) and compared. The two therefore differed by
  exactly those 32 bytes on every run -- an apples-to-oranges comparison,
  not a determinism defect. The repro job now runs `make integrity` as
  well, so it compares signed-vs-signed. Signing is deterministic (the
  digest is a pure function of the reproducible pre-image), so signed
  builds are bit-identical across independent runs (verified: two
  signed builds in different directories yield the same SHA-256).

* **Cross-directory build reproducibility restored on gcc < 12 (CI
  `reproducibility` fix).** `REPRO_FLAGS` mapped `$(CURDIR)` to the
  *empty* string via `-ffile-prefix-map` / `-fdebug-prefix-map`. That
  redacts source paths in `__FILE__` and `.debug_info`, but leaves the
  DWARF compilation directory (`DW_AT_comp_dir`, emitted into
  `.debug_line_str`) as the absolute build path. Two checkouts built in
  different directories therefore differed by exactly one byte at the
  `comp_dir` offset. gcc 12+ can pin this with
  `-fdebug-compilation-dir=.`, but that flag is rejected by gcc 11
  (the CI/container toolchain). Fix: map `$(CURDIR)` to `"."` instead of
  empty, which rewrites `comp_dir` to `"."` in every build tree.
  Verified: `git archive HEAD` extracted into two distinct directories
  now produces byte-identical `libfreehsm-fips.so` (same SHA-256), and
  the build path no longer appears in the binary.

* **`pkcs11-check` runner summary no longer uses a fragile shell
  heredoc (#125).** `scripts/run_pkcs11_check.sh` computed its outcome
  tally with an inline `python3 - <<'PYEOF'` heredoc. Under CRLF line
  endings (shared-folder / cross-platform checkouts) the delimiter was
  not recognised, emitting `warning: here-document ... delimited by
  end-of-file (wanted PYEOF)` and skipping the summary; the embedded
  parser also assumed `json.load()`, which fails on pkcs11-check's
  pretty-printed-with-trailing-data report. Extracted to a standalone
  `scripts/pkcs11_check_summary.py` (schema-tolerant `raw_decode()`
  walk over any `outcome`/`result`/`status` field) invoked by path, so
  no heredoc delimiter can break and the crash count is reported
  explicitly (`=> crashed=N (target: 0)`).

* **`make integrity` no longer depends on `xxd` (CI build fix).**
  `scripts/sign_module.sh` used `xxd` for the binary<->hex conversions
  when reading and patching the `.fhsm_digest` section. `xxd` is absent
  from the `freehsm-c-build:debian13-openssl-3.5` CI container, so the
  new pkcs11-check workflow failed at `make integrity`
  (`xxd: command not found`). Replaced all three uses with `python3`
  (already a hard build dependency via `gen_p11_thunks.py`), which also
  removes the slow byte-at-a-time `dd` reads. Verified in a container
  with `xxd` removed from PATH: signing completes and the embedded
  digest byte-matches the recomputed SHA-256 (integrity self-test would
  pass).

### Added

* **General-purpose profile now gates the operation path, not just
  advertisement (#125).** The build profile (`fips-strict` vs `interop`)
  previously only affected mechanism *advertisement* and the dispatch
  registry — the actual operation functions (`C_DigestInit`,
  `C_EncryptInit`, `C_SignInit`, ...) have hand-written approved-only
  switches and never consulted the dispatch table, so non-FIPS
  mechanisms were un-executable in *both* profiles. The generator now
  emits a build-profile flag (`FHSM_BUILD_FIPS_STRICT`, and an extern
  `fhsm_build_fips_strict` for TUs that can't include the generated
  header) that the operation gates consult.
* **Fix: the non-FIPS encryption gate no longer rejects approved RSA
  signatures under fips-strict.** The initial RSA-encryption gate lived
  in `op_init`, which is shared with `C_SignInit` — so it wrongly
  rejected `CKM_RSA_PKCS` (0x01) as a *signature* mechanism under
  fips-strict, even though RSASSA-PKCS1-v1.5 is FIPS-approved for
  signing. The gate was moved to `C_EncryptInit`/`C_DecryptInit`
  (encrypt-only), leaving the signing path untouched. Verified:
  fips-strict `C_SignInit(CKM_RSA_PKCS)` succeeds again, while
  `C_EncryptInit(CKM_RSA_PKCS)` is rejected.

* **Non-FIPS RSA legacy padding: PKCS#1 v1.5 + X.509 raw encryption
  (interop only).** Third family through the operation-gate pattern.
  `dispatch_rsa_pkcs` / `dispatch_rsa_x509` handlers ; `C_Encrypt`/
  `C_Decrypt` gain RSA-PKCS-v1.5 (`RSA_PKCS1_PADDING`) and X.509-raw
  (`RSA_NO_PADDING`) branches (public-key encrypt / private-key
  decrypt) ; `op_init` rejects both at init under fips-strict.
  Advertised + executable in interop, rejected in fips-strict.
  SHA1-RSA-PKCS signature (`CKM_SHA1_RSA_PKCS`, 0x06) : SHA-1 digest +
  RSA PKCS#1 v1.5 signature, wired into the `C_Sign`/`C_Verify` path
  (`mech_hash_name` -> "SHA1") and the sign/verify accept switches,
  gated to interop (rejected in fips-strict). `dispatch_sha1_rsa`
  reference handler. This completes the non-FIPS RSA family (#21).
  Profile-adaptive test `tests/test_legacy_rsa.c` covers RSA-2048
  keypair, both encryption padding modes, and SHA1-RSA sign+verify.

* **Non-FIPS ciphers AES-ECB, 3DES-CBC, 3DES key generation (interop
  only).** Second family through the operation-gate pattern.
  `dispatch_aes_ecb` / `dispatch_des3_cbc` / `dispatch_des3_keygen`
  handlers (OpenSSL default provider). `C_Encrypt`/`C_Decrypt` gain
  AES-ECB (no IV) and 3DES-CBC (24-byte key, 8-byte IV) branches ;
  `C_GenerateKey` generates a 24-byte `CKK_DES3` key ; `op_init`
  rejects AES-ECB and 3DES-CBC at init time under fips-strict.
  Executable + advertised in interop, rejected with
  `CKR_MECHANISM_INVALID` in fips-strict. Profile-adaptive round-trip
  test `tests/test_legacy_cipher.c` (AES-ECB + 3DES-CBC + 3DES keygen).

* **Non-FIPS legacy digests SHA-1 and MD5 (interop only).** First
  mechanisms wired through the new profile gate: executable in the
  general-purpose (`interop`) build (`dispatch_sha1` / `dispatch_md5`,
  OpenSSL default provider), rejected with `CKR_MECHANISM_INVALID`
  under `fips-strict`. Advertised iff executable. Profile-adaptive
  regression test `tests/test_legacy_digest.c` (verified both builds:
  correct digests in interop, rejection in fips-strict). Groundwork for
  the remaining tranche-A/B mechanisms (AES-ECB, 3DES, RSA-PKCS v1.5,
  RSA-X.509, DSA/DH keygen), which follow the same
  operation-gate + real-crypto pattern.

### Fixed

* **Token store-full returns CKR_DEVICE_MEMORY, not CKR_HOST_MEMORY
  (#125 finding I1).** When a token reached `FHSM_MAX_OBJECTS`,
  `C_CreateObject` returned `CKR_HOST_MEMORY` (host RAM exhausted) where
  the correct code is `CKR_DEVICE_MEMORY` (token storage full), which is
  what pkcs11-check flagged on certificate import under load.
  `FHSM_MAX_OBJECTS` is now overridable at build time
  (`-DFHSM_MAX_OBJECTS=N`, default 64) for general-purpose deployments
  needing a larger store. Regression covered in
  `tests/test_token_capacity.c` (65th object rejected with
  `CKR_DEVICE_MEMORY`).

* **Mechanism advertisement rebuilt from the dispatch table (#125,
  found by pkcs11-check).** `C_GetMechanismList` / `C_GetMechanismInfo`
  used a hand-maintained list + capability switch that had drifted
  badly from the generated dispatch table (the single source of truth):
  the post-quantum signature mechanisms were advertised under **stale,
  wrong values** (ML-DSA `0x403F` vs the dispatched `0x4024`, likewise
  ML-DSA/SLH-DSA key-pair-gen), **phantom FALCON/KYBER** entries were
  advertised with no backing handler, and **~40 dispatched mechanisms
  were never advertised at all** (every SHA-3/SHAKE, KMAC, HKDF, EdDSA,
  X25519/X448, and the ML-KEM/ML-DSA/SLH-DSA mechanisms at their correct
  values). Net effect: post-quantum signatures were undiscoverable via
  standard enumeration. Both functions are now derived directly from
  `fhsm_mechanism_table[]`, so the advertised set can never drift from
  what the module dispatches: a mechanism is advertised iff it resolves
  to a real handler in the active profile (general-purpose advertises
  every real handler; FIPS advertises only approved, since non-approved
  compile to the reject stub). Capability flags come from the generated
  per-mechanism operation class; precise per-mechanism key-size
  reporting is a tracked follow-up (increment 2). New coherence guard
  `tests/test_mech_advertise.c` (wired into `make tests`) asserts every
  advertised mechanism resolves through `C_GetMechanismInfo`, the PQ
  values are correct, EdDSA/HKDF are present, and the phantoms are gone.
  Full analysis in `docs/PKCS11_CHECK_FINDINGS.md`.

* **pkcs11-check runner now purges its stale isolation cache (#125).**
  `pkcs11-check` writes `.pkcs11-check-isolation-*.json` and
  `..report-records/*.jsonl` into the working directory ; these are
  gitignored, so `make clean` never removes them, and the
  mixed-isolation aggregator re-reports the *previous* run's crash
  records even after the defect is fixed and the module rebuilt (this
  masked the C_DecryptFinal fix, showing phantom crashes on a clean
  rebuild). `scripts/run_pkcs11_check.sh` now deletes this cache before
  every run. Confirmed: after purging, crashes drop to 0.

* **C_DecryptFinal NULL cipher-context dereference / SIGSEGV (#125,
  found by re-running pkcs11-check against the up-to-date module).**
  `C_DecryptFinal` called `EVP_DecryptFinal_ex(op->cipher_ctx, ...)`
  with no NULL check. When `C_DecryptInit` was called (e.g. with an
  invalid key handle, as pkcs11-check's `test_mech_flags`
  `decrypt_flag_callable` does) and `C_DecryptFinal` was then called
  directly — no `C_DecryptUpdate` to lazily create the cipher context —
  `op->cipher_ctx` was NULL and the module crashed. `C_EncryptFinal`
  already guarded this ; `C_DecryptFinal` now mirrors it, returning
  `CKR_OPERATION_NOT_INITIALIZED`. Reproduced (exit 139) and fixed
  (no crash) in the sandbox with the pkcs11-check harness under
  Python 3.12 ; regression probe added to
  `tests/test_decrypt_null_args.c`. This was the crash class that
  survived the earlier C_Encrypt/DecryptUpdate guards.

* **C_EncryptUpdate / C_DecryptUpdate NULL-pointer dereference /
  SIGSEGV (#125, remaining pkcs11-check crashes).** Both multi-part
  update functions dereferenced their length out-parameter
  (`pulEncLen` / `pulPartLen`) on the size-query path without a NULL
  check — the same class as the C_Decrypt fix, and the source of the
  crashes that survived the first fix (7 → 4 in the re-run). Now
  rejected with `CKR_ARGUMENTS_BAD`, terminating the operation.
  Confirmed with a negative control (guard removed → SIGSEGV). The
  regression test `tests/test_decrypt_null_args.c` now also drives a
  GCM `C_EncryptUpdate(pulEncLen=NULL)` probe.

* **C_Decrypt NULL-pointer dereference / SIGSEGV (#125, found by
  pkcs11-check).** `C_Decrypt` dereferenced `pulDataLen` on every path
  (size query and copy) without a NULL check, unlike `C_Encrypt` which
  guarded `pulEncLen`. A caller passing `pulDataLen = NULL` (as
  pkcs11-check's NULL-argument probe does) triggered a NULL-pointer
  dereference and crashed the module. Now rejected with
  `CKR_ARGUMENTS_BAD`, terminating the operation so the session is not
  stranded ; the symmetric `pData`-with-length guard was added to
  `C_Encrypt`. Confirmed with a negative control (guard removed → exit
  139/SIGSEGV ; with fix → 0x7). Regression test
  `tests/test_decrypt_null_args.c` drives the public API via `dlopen`
  and is wired into `make tests`. Availability/robustness defect
  (AVA_VAN class) ; no key material exposed, no CVE requested. Triage
  of the full first-run findings is in `docs/PKCS11_CHECK_FINDINGS.md`.

### Added

* **CI : pkcs11-check external harness (#125).** New workflow
  `.github/workflows/pkcs11-check.yml` runs Denis Mingulov's
  `pkcs11-check` (>100k vendor-neutral behavioral checks : spec
  conformance, CKR negatives, security, fuzz, Wycheproof / ACVP
  corpora) against the **digest-signed** module on every push to main
  + weekly, uploading the JSON report and log as artifacts.
  Non-gating by design per the harness's own guidance (findings are
  evidence, not a verdict) ; the job fails only if the harness cannot
  run or produce a report. Local mirror : `make pkcs11-check`
  (shared logic in `scripts/run_pkcs11_check.sh` ; token provisioned
  with the same PIN conventions as `tests/coverage_matrix.sh`).
  Baseline regression gating via `pkcs11-check compare-results` is
  tracked as follow-up. This closes the loop opened by the v1.2.2 /
  v1.3.0 external reports : the tool that found those defects now
  watches every commit.

* **`CKO_CERTIFICATE` objects (#110).** `C_CreateObject` accepts
  `CKO_CERTIFICATE` + `CKA_CERTIFICATE_TYPE = CKC_X_509` templates ; the
  DER certificate travels verbatim in `CKA_VALUE` (the module never
  parses X.509 — validation is the PKI layer's job). Certificates are
  stored non-sensitive + extractable, so `C_GetAttributeValue` returns
  `CKA_VALUE`, `CKA_CERTIFICATE_TYPE`, `CKA_CLASS`, `CKA_LABEL`,
  `CKA_ID` ; `CKA_KEY_TYPE` correctly reports "invalid" for the class.
  Groundwork for the v2.0 `fhsm-ca` PKI layer.

* **Token store objects blob v2 : variable-size records (#110).**
  Certificates carrying PQC / composite keys exceed the fixed
  5 500-byte value field of v1 records. The blob plaintext is now
  self-versioned (leading magic `0xF5B20002`) with per-record
  `rec_len` ; per-object payload cap raised to
  `FHSM_OBJ_VALUE_MAX` = 16 384 bytes. **Read-v1 / write-v2** :
  existing tokens load transparently and migrate to v2 on first
  mutation. Small objects shrink ~97 % on disk (32-byte AES key
  record : 5 620 → 156 bytes). Older binaries reading a v2 blob fail
  loudly, never silently. Spec updated in
  `docs/TOKEN_STORE_FORMAT.md` ; capacity + certificate round-trips in
  `tests/test_token_capacity.c`.

### Fixed

* **Token store : objects-blob loader bound (#108 regression finding).**
  The v1.4.0 loader rejected any encrypted objects section larger than
  65 536 bytes, while the writer legitimately produces up to 359 688
  bytes (64 × 5 620-byte records + 8-byte prefix). A token holding more
  than 11 objects persisted fine but **failed to load at the next
  login** (data intact on disk, store unreadable). The sanity cap is now
  `FHSM_OBJ_BLOB_MAX`. Found while writing the byte-level format
  specification. Regression test : `tests/test_token_capacity.c`.
  No wire-format change ; no security impact (fail-closed availability
  bug).

### Documentation

* **`docs/TOKEN_STORE_FORMAT.md` (#108)** : authoritative byte-level
  specification of the `slotN.tok` format (317-byte header, encrypted
  objects section, crypto parameters, auditor-checkable invariants,
  versioning policy). The stale JSON-era comments in `fhsm_token.h` /
  `fhsm_token.c` (inherited from the Python POC description) were
  corrected to match the implemented binary format ; the erroneous
  byte-level-interop claim with POC token files was retracted.

### Branding / repository

* **Rebrand (July 2026)** : repository renamed `freehsm-c` → `freehsm` ; dual
  branding formalized (FreeHSM = library, Simorgh PKI = product, Simorgh Labs
  = org). Positioning updated per primacy audit #118
  (`docs/PRIMACY_AUDIT_PQC_COMPOSITE.md`). Added `TRADEMARK.md`. **No
  consumer-facing change** : binary name `libfreehsm-fips.so`, PKCS#11
  identifiers, and the GPG release key are all unchanged. Old repository
  URLs redirect.

### Documentation (post-v1.3.0 correction)

* `SECURITY.md` --- corrected a typo in the "Maintainer GPG key rotation 2026-06-12" section that has been present since the original 2026-06-13 commit `2e6a413`. The previous key fingerprint was incorrectly listed as `743A6A59…DBBF28A2` (which is the *new* key) in both the "previous" and "new" position ; it is now correctly listed as `B79726CB087375CF990E00E4A0BC5BB2FB1EE342`, matching the canonical record in the rotation commit message. A 14-day correction note is added inline. No code change ; no behavior change. Filed concurrently as informational GitHub Security Advisory `GHSA-wgv9-m9cv-4647` (published 2026-06-27, drafted 2026-06-13).

---

## [1.4.0] --- 2026-06-28

**v2.40 dispatch-table near-completion release.** Wires 6 additional v2.40 function-list slots that close most of the remaining gap between FreeHSM C and a strict OASIS PKCS#11 v2.40 implementation, bringing dispatch coverage from **51 / 67 to 57 / 67 (85 %)**.

This is **functionality-additive only** : no behavioral change to any pre-existing function ; no security fix. Forward-compatible with v1.3.0 PIN files, token store, and audit log chain. Migration notes: none.

### Added

#### Tier 1 --- session management + legacy parallel + DRBG seed

* **`C_CloseAllSessions`** (PKCS#11 v3.2 §C.6.6.2) wired into slot 14. Iterates session-handle range (1..256), closes each open session for the given slot. The module is a single-slot software token ; `slotID != 0` returns `CKR_SLOT_ID_INVALID`.

* **`C_SeedRandom`** (§C.6.6.5) wired into slot 63. Returns `CKR_RANDOM_SEED_NOT_SUPPORTED` unconditionally per NIST SP 800-90A §9.2 : a SP 800-90A DRBG must be seeded only from approved entropy sources ; caller-supplied seed material is rejected in FIPS mode and rejected for security in legacy mode (attacker-controlled seed could narrow the entropy distribution downstream).

* **`C_GetFunctionStatus`** (§C.6.5.6) wired into slot 65. Returns `CKR_FUNCTION_NOT_PARALLEL` per spec. The PKCS#11 parallel-function model was abandoned in v2.10 ; this function is a legacy stub on every modern non-parallel implementation. `C_CancelFunction` is also implemented and exported as an ELF symbol but cannot be wired in the current v2.40 dispatch due to the 67-slot table collision with slot 66 (`C_WaitForSlotEvent`, wired in v1.3.0). Documented as a roadmap item for v1.5.x : resize `pfn[]` to 68 slots to fit both per strict spec ordering.

#### Tier 2 --- digest extension + verify multipart

* **`C_DigestKey`** (§C.6.10.5) wired into slot 40. Feeds a key value into the ongoing digest context (equivalent to calling `C_DigestUpdate` on the key bytes). Refuses sensitive (`CKA_SENSITIVE=TRUE`) and non-`CKO_SECRET_KEY` objects with `CKR_KEY_INDIGESTIBLE` so that the digest output cannot be used as a side channel to recover key material from asymmetric private keys. Lazy-init the `EVP_MD_CTX` so a digest-of-key-alone via `C_DigestInit → C_DigestKey → C_DigestFinal` is supported without intervening data.

* **`C_VerifyUpdate`** (§C.6.13.6) wired into slot 50. Multipart HMAC verification, symmetric to `C_SignUpdate` from earlier releases. Currently scoped to `CKM_SHA256_HMAC` ; asymmetric multipart verify (which would defer `EVP_DigestVerifyFinal` until `C_VerifyFinal`) is not yet implemented and falls through to `CKR_MECHANISM_INVALID`.

* **`C_VerifyFinal`** (§C.6.13.7) wired into slot 51. HMAC MAC accumulation final with **constant-time compare via `fhsm_ct_memcmp`** to avoid timing side channels on signature validation. Mirrors the existing `C_SignFinal` pattern.

### Coverage status

```
PKCS#11 v2.40 dispatch table : 57 / 67 wired (85 %)
                               (was 51 / 67 in v1.3.0)
                               (was 47 / 67 in v1.2.2)
                               (was 44 / 67 in v1.2.1 pre-Denis)
Unwired (10 slots, all by design) :
    16, 17  C_GetOperationState / C_SetOperationState
            (HSM-internal context switching, not applicable to
             a software token)
    46, 47  C_SignRecoverInit / C_SignRecover
    52, 53  C_VerifyRecoverInit / C_VerifyRecover
            (RSA recovery, ISO/IEC 9796-2, rarely used in practice)
    54-57   C_DigestEncryptUpdate / C_DecryptDigestUpdate
            / C_SignEncryptUpdate / C_DecryptVerifyUpdate
            (dual-operation streaming, niche)

Plus C_CancelFunction symbol exported but not in v2.40 dispatch
(67-slot table collision ; tracked for v1.5.x).
```

### Validation

* Build clean : `-Werror -Wpedantic -Wconversion -Wstringop-truncation -Wmissing-prototypes`, zero warning.
* All 62 KAT vectors green in dev mode and CI `test-fips-mode`.
* Python ctypes verification confirms all 6 new slots (`pfn[14, 40, 50, 51, 63, 65]`) are distinct from the `fhsm_not_supported` sentinel at `pfn[16]`.
* The v3.0 dispatch table (`fhsm_function_list_3_0`) automatically mirrors slots 0..66 from v2.40 via the existing loop in `fhsm_init_v3_0_table()` ; no separate v3.0 wiring needed.
* CI `reproducibility` : byte-identical builds across two independent runs.

### Affected releases (forward-compatibility)

| Release | Upgrade priority | Reason |
|---|---|---|
| v1.3.0 | Recommended | No security fix ; enhancements only. Forward-compatible. |
| v1.2.2 and older | **Required** | See v1.3.0 and v1.2.2 advisories (`GHSA-xpxx-66pp-pf99`, `GHSA-6jx9-gh48-5qf6`, `GHSA-wgv9-m9cv-4647`). |

---

## [1.3.0] --- 2026-06-27

**Function-list completion + export-roundtrip extension release.** Closes the two narrative threads opened by v1.2.2 :

  (1) Five PKCS#11 function-list slots flagged by Denis Mingulov's `pkcs11-check` report as "exported but unwired" --- 3 were resolved in v1.2.2, the remaining 2 (`C_CopyObject` + `C_SetAttributeValue`) and 1 more (`C_WaitForSlotEvent`) close the v2.40 dispatch table in this release.

  (2) The export-roundtrip boot KAT pattern, introduced in v1.2.2 for ECDSA, is now applied to every external cryptographic surface the module exposes (RSA-PSS, RSA-OAEP, EdDSA, ML-DSA, ML-KEM, ECDH) --- 6/7, with SLH-DSA documented as an intentional exclusion on runtime-budget grounds.

This release is **functionality-additive only** : no behavioral change to any pre-existing function ; no security fix. Forward-compatible with v1.2.2 PIN files, token store, and audit log chain. **Migration notes : none.**

### Added

#### `C_CopyObject` (PKCS#11 v3.2 §C.6.7.3)

Implementation in `src/fhsm_pkcs11.c` (~100 lines). Reads the source object via the existing `fhsm_token_object_get` accessors, applies the caller's template on top of the source's attributes (template wins on conflict), and persists via `fhsm_token_object_add`. Enforces PKCS#11's one-way state transitions on the template : if the source has `CKA_SENSITIVE = TRUE`, the copy cannot set it to `FALSE` ; if the source has `CKA_EXTRACTABLE = FALSE`, the copy cannot set it to `TRUE`. Violations return `CKR_TEMPLATE_INCONSISTENT`. Wired into `pfn[21]` in `C_GetFunctionList`.

#### `C_SetAttributeValue` (PKCS#11 v3.2 §C.6.7.5)

Implementation in `src/fhsm_pkcs11.c` (~80 lines). Whitelist approach :

| Attribute | Behaviour |
|---|---|
| `CKA_LABEL` | Bounded copy, max 63 chars (PKCS#11 token label limit). |
| `CKA_ID` | Max 32 bytes (PKCS#11 key ID limit). |
| `CKA_SENSITIVE` | One-way `FALSE -> TRUE` only ; reverse returns `CKR_ATTRIBUTE_READ_ONLY`. |
| `CKA_EXTRACTABLE` | One-way `TRUE -> FALSE` only ; reverse returns `CKR_ATTRIBUTE_READ_ONLY`. |
| `CKA_CLASS`, `CKA_KEY_TYPE`, `CKA_VALUE` | `CKR_ATTRIBUTE_READ_ONLY` (immutable after creation). |
| All others | `CKR_ATTRIBUTE_TYPE_INVALID`. |

Flag transitions accumulated into a single `fhsm_token_object_set_flags` call to ensure atomic persistence (no partial write on multi-attribute templates). Wired into `pfn[25]`.

#### `C_WaitForSlotEvent` (PKCS#11 v3.2 §C.6.5.4)

Software-token semantics : `CKF_DONT_BLOCK` returns `CKR_NO_EVENT` immediately ; blocking mode returns `CKR_FUNCTION_NOT_SUPPORTED` rather than hang indefinitely (no hot-plug source in a software token). `pReserved` must be `NULL` per spec. Wired into `pfn[66]` --- the last unwired slot in the PKCS#11 v2.40 function list.

#### Token mutation accessors

`fhsm_token_object_set_label`, `fhsm_token_object_set_id`, `fhsm_token_object_set_flags` in `src/fhsm_token.c`. Each takes the token mutex, checks `logged_in`, finds the object by handle, mutates, marks `objects_dirty`, persists via atomic write. Required by `C_CopyObject` + `C_SetAttributeValue`. No changes to the on-disk format ; existing v1.2.2 token files load unchanged.

#### Boot KAT export-roundtrip extended to 6 new cryptographic surfaces

`kat/cavp_extended.c` --- 6 new helpers following the v1.2.2 ECDSA pattern (generate keypair, exercise the operation with original references as control, serialize public key via `i2d_PUBKEY`, reload via `d2i_PUBKEY`, repeat the operation, compare byte-for-byte) :

| Surface | KAT name | Mechanism |
|---|---|---|
| RSA-PSS-SHA256 | `RSA-2048-PSS-SHA256-export-roundtrip` | Sign with original, verify with reloaded pubkey. |
| RSA-OAEP-SHA256 | `RSA-2048-OAEP-SHA256-export-roundtrip` | Encrypt with reloaded pubkey, decrypt with original. |
| Ed25519 | `Ed25519-export-roundtrip` | Sign with original, verify with reloaded pubkey (shared `EVP_DigestSign` path with ML-DSA). |
| ML-DSA-65 | `ML-DSA-65-export-roundtrip` | Sign with original, verify with reloaded pubkey. |
| ML-KEM-768 | `ML-KEM-768-export-roundtrip` | Encapsulate with reloaded pubkey, decapsulate with original, compare shared secret. |
| ECDH (P-256/384/521) | `ECDH-Pxxx-export-roundtrip` | Derive `ss1` with original peer, derive `ss2` with reloaded peer, compare. |

Total boot KAT vectors : 41 → 62 (added 18 in v1.3.0). Total boot time impact : ~+15 ms on x86_64-Debian-13 reference HW ; well within AGD_PRE §6.2 budget (< 2 s).

### Coverage status

```
PKCS#11 v2.40 dispatch table : 51 / 67 wired
                               (was 47 / 67 in v1.2.2)
                               (was 44 / 67 in v1.2.1 pre-Denis)
Boot KAT external surfaces   :  6 / 7 (SLH-DSA excluded by design)
```

### Validation

- All 62 KATs green in dev mode and in CI `test-fips-mode`.
- `pkcs11-tool --module ./libfreehsm-fips.so --list-mechanisms` end-to-end regression suite with NSS / OpenSC / softhsm : **PASS**.
- CI `reproducibility` : byte-identical builds across two independent runs.
- New ECDH timings (vs RFC6979 ECDSA control on same hardware) :

```
ECDH-P256-export-roundtrip   :    542 us
ECDH-P384-export-roundtrip   :  2 544 us
ECDH-P521-export-roundtrip   :  2 073 us
```

### Acknowledgements

Denis Mingulov ([mingulov.com](https://mingulov.com)) reported the original function-list gap via responsible disclosure on 2026-06-23 ; without that report the v2.40 dispatch table completion would not have shipped in v1.3.0. Two further findings (memory-safety + key-handling) are pending via the encrypted channel ; they will be addressed in a v1.3.1 or v1.4.0 release as appropriate.

### Affected releases (forward-compatibility)

| Release | Upgrade priority | Reason |
|---|---|---|
| v1.2.2 | Recommended | No security fix ; enhancements only. Forward-compatible. |
| v1.2.1 | **Required** | Has the v1.2.1 integrity-verify silent-OK-on-mismatch defect (see v1.2.1 §"Affected releases"). |
| v1.2.0 and older | **Required** | Same v1.2.1 defect + the raw `CKM_ECDSA` silent-default-digest defect (see v1.2.2 §"Affected releases"). |

---

## [1.2.2] --- 2026-06-27

**External-reporter security patch + boot-KAT extension.** Ships four substantive changes triggered by a responsible-disclosure report from Denis Mingulov (`pkcs11-check`, 2026-06-26) plus one boot-time regression guard that codifies the methodology Denis's report exposed as a gap.

**Headline finding (Denis Finding 1)** : every signed release of FreeHSM C since v1.1.0 has produced ECDSA signatures via the `CKM_ECDSA` mechanism that no third-party verifier could check. The root cause is `EVP_DigestSignInit_ex(..., mdname = NULL, ...)` on the OpenSSL 3.x default provider's ECDSA digest-sign function applying an internal default digest (SHA-256) before signing, so the module signed `SHA-256(input)` instead of `input`. The bug had been invisible internally because `C_Verify` applied the same default symmetrically ; Wycheproof's verify-only corpus did not exercise the sign path externally. Severity HIGH on correctness / interoperability (CVSS 7.5, `AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H`) ; not exploitable in confidentiality or integrity sense (private key never leaked). **All deployments running v1.1.0 - v1.2.1 should upgrade to v1.2.2 immediately.**

Disclosure follows the same transparency-first model as v1.2.1 : informational GitHub Security Advisory (no CVE while the project is in pre-certification), CHANGELOG entry, Security Target update, SECURITY.md credit. Reporter credited as Denis Mingulov via `pkcs11-check`.

### Fixes

#### Finding 1 (HIGH) --- raw CKM_ECDSA / CKM_RSA_PKCS sign+verify silently applied a default digest

`src/fhsm_pkcs11.c::sign_asymmetric` and `verify_asymmetric` previously routed raw mechanisms (`CKM_ECDSA` bare, `CKM_RSA_PKCS` bare) through `EVP_DigestSignInit_ex(ctx, &pctx, mdname = NULL, ...)`, expecting OpenSSL to treat the input as a pre-computed digest. On the OpenSSL 3.x default provider, the ECDSA digest-sign function applies an internal default digest (observed as SHA-256 on 3.5.x) when `mdname` is NULL, so the module signed `SHA-256(input)` instead of `input`. The module's own verify path applied the same default symmetrically, masking the bug to internal sign+verify cycles. External verifiers ( `openssl dgst -sha256 -verify` , `openssl pkeyutl -verify` ) rejected every signature.

Fixed by branching on `hash == NULL` in both functions : raw mode now uses `EVP_PKEY_sign` / `EVP_PKEY_verify` on a freshly-allocated `EVP_PKEY_CTX`, which is the canonical OpenSSL 3.x API for signing a pre-computed digest. Hashed mechanisms ( `CKM_ECDSA_SHAxxx`, `CKM_SHAxxx_RSA_PKCS` ) continue through the existing `EVP_DigestSign` / `EVP_DigestVerify` path unchanged.

Validated locally and in CI on P-256, P-384, P-521 via Denis's exact reproduction script. Module self-verify and external `openssl dgst` verify both pass post-fix.

#### Finding 2 (HIGH compliance) --- `C_CreateObject` and four siblings were unreachable through `C_GetFunctionList`

Five functions implemented in `src/fhsm_pkcs11.c` were exported as ELF symbols but their slots in `CK_FUNCTION_LIST` were not assigned :

| Slot | Function | v1.2.2 status |
|---|---|---|
| 15 | `C_GetSessionInfo` | implemented + wired |
| 20 | `C_CreateObject`   | wired (implementation existed since v1.0) |
| 23 | `C_GetObjectSize`  | implemented + wired |
| 21 | `C_CopyObject`     | deferred to v1.3.0 (not yet implemented) |
| 25 | `C_SetAttributeValue` | deferred to v1.3.0 (not yet implemented) |

Normal PKCS#11 consumers (`pkcs11-tool` + every standard library) reach these functions through `C_GetFunctionList -> fl->C_*` ; the module's internal test harnesses and the Wycheproof Python adapters bypass the function-list dispatch and call the symbols via `dlsym`, so the gap was invisible to CI. Particularly ironic : v1.2.0 (released 5 days before this report) celebrated a structural decomposition of `C_CreateObject` that was, all along, unreachable through any standard caller.

Fixed by adding the three wired-and-implemented slots to `fhsm_function_list` in v2.40 mode ( mirrored into the v3.0 table by the existing `fhsm_init_v3_0_table`). A new accessor `fhsm_session_info(h, *slot, *flags, *role)` was added to `src/fhsm_session.c` for `C_GetSessionInfo` ; `C_GetObjectSize` reuses the existing `fhsm_token_object_get` path. `C_CopyObject` and `C_SetAttributeValue` are documented as known limitations for v1.2.2 and roadmapped for v1.3.0.

#### Operational gap : sign-only GPG key

The published GPG key `743A 6A59 04A1 4616 46A6 408D E485 6016 2DBB F28A 2` was sign + certify + authenticate only, with no encryption capability. Denis could not send the rest of his report (memory-safety + key-handling findings) over an encrypted channel. A cv25519 encryption subkey (fingerprint `9813 876A 34BA DD4A 0A50 915E 7EAC 4BA5 5574 DBE8`) was generated on 2026-06-26 and published to `keys.openpgp.org` (with the `afchine.mad@gmail.com` address verified) and `keyserver.ubuntu.com`. SECURITY.md is updated to reflect the new key material and documents the gap honestly. The primary key remains sign / certify / authenticate only ; only the encryption operation has a target now.

### Added

#### Boot KAT : ECDSA export-roundtrip self-consistency (P-256 / P-384 / P-521)

`kat/cavp_extended.c::run_ecdsa_export_roundtrip()`. Three new boot-time KAT vectors that enforce the methodology the v1.2.2 fix is built on. For each curve : generate a fresh keypair via `EVP_PKEY_Q_keygen`, compute the digest of a fixed reference message with the matching SHA, sign the digest via `EVP_PKEY_sign` (raw mode), serialize the public key via `i2d_PUBKEY`, reload via `d2i_PUBKEY` (mimicking exactly what an external verifier does), and verify via `EVP_PKEY_verify` on the reloaded public key. Returns 1 on success and 0 on any EVP failure or verify mismatch.

If a future OpenSSL provider upgrade re-introduces the silent-default-digest behaviour for `EVP_PKEY_sign`, or if `i2d_PUBKEY` / `d2i_PUBKEY` ever stop producing byte-stable round-trips for EC keys, this boot KAT fails at `C_Initialize` and the module refuses to start. Subsequent regressions on the raw sign path are catchable at boot rather than discovered by external reporters running `pkcs11-check`.

KAT count : 51 -> 54. `FHSM_KAT_MAX` (64) unchanged.

The pattern generalises and will be extended in future releases : every cryptographic surface that the module exposes externally ( sign / verify / wrap / unwrap / derive ) should have an analogous boot KAT that exercises the external-roundtrip property, not just the internal `EVP_PKEY -> EVP_PKEY` round trip.

### Affected releases

| Version | Released | Finding 1 (raw ECDSA) | Finding 2 (function list) |
|---|---|---|---|
| v1.1.0 | 2026-06-12 | yes (origin) | yes (origin) |
| v1.1.1 - v1.1.18 | 2026-06-12 - 2026-06-20 | yes | yes |
| v1.2.0 - v1.2.1 | 2026-06-20 - 2026-06-21 | yes | yes |
| **v1.2.2** | **2026-06-27** | **fixed** | **3 of 5 fixed, 2 deferred** |

### Validation

```
Boot KAT                              54 / 54 vectors green (was 51 / 51)
   --- of which the 3 new export-roundtrip vectors :
   ECDSA-P256-export-roundtrip / SHA-256  PASS (~1200 us)
   ECDSA-P384-export-roundtrip / SHA-384  PASS (~1000 us)
   ECDSA-P521-export-roundtrip / SHA-512  PASS (~1150 us)

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

CI lint / build / sign / smoke    : green
CI reproducibility                : byte-identical
CI test-coverage matrix           : 24 / 0 / 8 (exercises sign + verify
                                    via pkcs11-tool, which routes through
                                    the production sign_asymmetric path)
CI test-fips-mode                 : green (FIPS strict env exercises
                                    the raw ECDSA fix end-to-end)
Wycheproof full sweep             : 6 978 / 0 unchanged from v1.2.1
```

### Recommended action

Any deployment running v1.1.0 - v1.2.1 should upgrade to v1.2.2. The upgrade is a drop-in `.so` replacement ; PKCS#11 wire compatibility is unchanged. Re-sign the embedded digest via `make integrity` from the v1.2.2 source, or take the pre-signed binary tarball directly from the v1.2.2 GitHub Release.

Specifically affected workflows :

* **Any consumer that verifies the module's ECDSA signatures externally** (CA / RA issuing certificates, signature-archive verifiers, peer-to-peer protocols using ECDSA, etc.) is non-interoperable in v1.1.0 - v1.2.1 and is restored to spec-compliant ECDSA in v1.2.2. The module's pre-v1.2.2 signatures cannot be retroactively repaired ; only signatures produced by v1.2.2+ are interoperable.

* **Any consumer that calls `C_CreateObject` / `C_GetSessionInfo` / `C_GetObjectSize` through the standard `C_GetFunctionList` dispatch** received `CKR_FUNCTION_NOT_SUPPORTED` in v1.1.0 - v1.2.1 and gets a valid dispatch in v1.2.2.

### Discovery + correction context

The defects were reported by **Denis Mingulov** via the `pkcs11-check` testing harness on 2026-06-26, with reproducible PoC on P-256 / P-384 / P-521 (every run) for Finding 1 and a sharp diagnostic for Finding 2. The discovery + correction protocol established in v1.2.1 Security Target §13.8 was extended to incorporate the *external-reporter* case :

1. **Symptom triage** : take any external reporter's evidence seriously, even if reported in clear (when the canonical encrypted channel is unavailable). Acknowledge the operational gap (sign-only GPG key) that forced clear-text disclosure ; restore the encrypted channel before continuing the technical triage.
2. **Read both sides** : the reporter's evidence + the module's source. Verify the reproduction locally before challenging the report.
3. **Cross-check the contract** : compare the module's behaviour with the OpenSSL canonical APIs (in this case, `EVP_PKEY_sign` is documented as the API for signing a pre-computed digest ; `EVP_DigestSign` with `mdname=NULL` was the wrong choice).
4. **Killer-test artifact** : Denis already provided one (his pkcs11-check repro) ; we add the boot-KAT regression guard so the methodology is institutionalised in the module itself.
5. **Scope the temporal impact** : `git blame` on the affected lines ; document the affected window ; decide on disclosure model.

The Security Target v0.8 §13.8 codifies this extension as a sub-section "External reporters".

### Acknowledgements

* **Denis Mingulov** 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.
* The OpenSSL project for the canonical `EVP_PKEY_sign` API and the standard `i2d_PUBKEY` / `d2i_PUBKEY` round-trip primitives.

### Deferred to v1.3.0

* `C_CopyObject` and `C_SetAttributeValue` : not currently implemented in the module ; documented as known limitations of the v2.40 function list. Denis's initial report claimed they were "implemented but not wired" ; on re-reading the source they turned out not to be implemented at all. v1.3.0 will either implement them or formally retire the slots.

* External-roundtrip boot KATs for RSA-PSS / RSA-OAEP / ML-DSA / SLH-DSA / Ed25519. Pattern documented in this CHANGELOG and Security Target §13.8 ; instances to be added per release.

### This is the 21st consecutive GPG-signed release.

## [1.2.1] --- 2026-06-21

**Security patch release.** Fixes a critical defect in the module integrity self-test (`src/fhsm_integrity.c::do_verify`) that effectively disabled FIPS 140-3 §7.10.2 in every signed production build of FreeHSM C since the initial open-source release v1.1.0 (commit `0c0f5df`, 2026-06-12). A tampered `libfreehsm-fips.so` would have passed the integrity check silently. All 19 GPG-signed releases between v1.1.0 and v1.2.0 are affected. **All users running any of those versions should upgrade to v1.2.1 immediately.**

The project is in pre-certification status (FIPS 140-3 Level 1 / CC EAL4+ candidate, no known production deployments) ; a CVE is not requested. Disclosure is via this CHANGELOG entry, the v1.2.0 → v1.2.1 commit history, an updated Security Target v0.7, and a transparent GitHub Security Advisory in informational mode.

### Discovery context

The defect was found during the post-release investigation of a dev-environment integrity quirk on the maintainer's VM (task #55 in the issue tracker). The dev quirk turned out to be the surface symptom of three latent bugs, only one of which had operational consequences in production. The full triage took place across two sessions and is documented in the commits and the Security Target §13.8.

### Three findings in `src/fhsm_integrity.c`

#### Finding 1 (HIGH) --- `do_verify` always returned `FHSM_RV_OK` after the comparison block

The comparison block at the end of `do_verify` had two if/return guards intended to fail closed on (a) unsigned builds (all-zero embedded digest) without the development bypass env var and (b) signed builds with a mismatched embedded digest without the bypass env var. The guards returned `FHSM_RV_OK` correctly when the bypass env var WAS set, but had no return statement in the else branches : the function fell through to a final `return FHSM_RV_OK` regardless of the comparison outcome.

The net effect : a tampered `libfreehsm-fips.so` could be loaded by an unmodified PKCS#11 application, the integrity self-test would compute the (incorrect) digest of the tampered binary, the comparison block would fall through to OK, and the module's state machine would proceed to `INITIALIZED`. No error would be reported to the caller. The FIPS 140-3 §7.10.2 software / firmware integrity self-test was, in practice, not enforced.

**Patched** in `do_verify` : both the `is_all_zero` (unsigned) and the `fhsm_ct_memcmp` (mismatch) branches now explicitly `return FHSM_RV_INTEGRITY_FAILED` when the bypass env var is not set, before reaching the final `return FHSM_RV_OK` (which is now only reachable on a successful match).

#### Finding 2 (MEDIUM) --- use-after-free on `find_section_offset` failure path

When `find_section_offset(buf, ...)` failed (the binary lacks `.fhsm_digest`, or the section is too small) AND the bypass env var was unset, `do_verify` :

1. zeroized `buf` ;
2. `free(buf)` ;
3. checked the bypass env var ;
4. *fell through* to `memset(buf + off, 0, len)` --- a use-after-free on the freed buffer.

The condition was reachable from any binary lacking the `.fhsm_digest` section (which is silently the case for most operator misuses : running an unsigned dev build under `FHSM_FIPS_MODE=fips`, or executing a stripped variant of the .so). The UAF behaviour is technically undefined ; on glibc it would typically corrupt the malloc arena and crash on the next allocation.

**Patched** in `do_verify` : add `return FHSM_RV_INTEGRITY_FAILED;` immediately after the bypass-env-var check, so the function exits cleanly without touching the freed buffer.

#### Finding 3 (LOW) --- `locate_self` failed for statically-linked binaries

`locate_self` uses `dl_iterate_phdr` to find which loaded module contains the `fhsm_module_integrity_digest` symbol. The callback (`find_self_cb`) reads `dlpi_name` from `struct dl_phdr_info` to get the binary path. For the main executable (the case for any binary that statically links `fhsm_integrity.o` instead of dynamically loading `libfreehsm-fips.so` --- including `tests/test_smoke`, the CAVS harness, and most local test artifacts), `dlpi_name` is the empty string per Linux convention. The callback skipped setting `ctx.found = 1` in that case, `locate_self` returned `-1`, and `do_verify` returned `FHSM_RV_FUNCTION_FAILED` --- silently, before any integrity comparison ran.

The operational impact is small (the affected binaries are test harnesses, not the deployed `.so`), but the silent failure meant that every developer workflow exercising the integrity check via `tests/test_smoke` was effectively bypassed.

**Patched** in `find_self_cb` : when `dlpi_name` is empty, recover the binary's path via `readlink("/proc/self/exe", ...)`. Bytes-on-disk integrity check works correctly from that point on for any binary that exposes `/proc/self/exe`, which covers all supported environments. Chroot/jail environments without `/proc` fall back to the existing setup-error path.

### Validation

```
Build clean    : -Werror -Wpedantic -Wconversion -Wstringop-truncation, no warning.
CI lint        : green
CI build       : green (sign module + smoke test green in CI's FIPS env)
CI reproducibility    : byte-identical
CI test-coverage matrix : 24 / 0 / 8
CI test-fips-mode      : green (this is the path that exercises the integrity
                                 check without bypass in production-like FIPS env)
```

#### Killer test : tampered binary detection

```bash
cp libfreehsm-fips.so libfreehsm-fips.so.tampered
# Flip 1 byte in the .text segment, outside .fhsm_digest :
printf '\x00' | dd of=libfreehsm-fips.so.tampered bs=1 seek=20000 count=1 conv=notrunc

# Pre-v1.2.1 : tampered binary loads, C_Initialize returns OK, services exposed.
# v1.2.1   : C_Initialize returns 0x80000002 (FHSM_RV_INTEGRITY_FAILED).
```

The same procedure on the v1.2.0 binary (or any prior signed release) returns OK silently, which is the proof-of-bug for the v1.1.0 → v1.2.0 affected window.

### Affected releases

| Version | Released | Affected |
|---|---|---|
| v1.1.0 | 2026-06-12 | yes (origin) |
| v1.1.1 - v1.1.18 | 2026-06-12 - 2026-06-20 | yes |
| v1.2.0 | 2026-06-20 | yes |
| **v1.2.1** | **2026-06-21** | **fixed** |

Tag SHAs for every affected release are listed in the GitHub Security Advisory (informational).

### Recommended action

Any deployment running v1.1.0 - v1.2.0 should upgrade to v1.2.1. The upgrade is a drop-in `.so` replacement ; PKCS#11 wire compatibility is unchanged. Re-signing the embedded digest via `make integrity` is required exactly as for the original install (build-from-source pipeline) or take the binary tarball directly from the v1.2.1 GitHub Release (`libfreehsm-fips.so` with `.fhsm_digest` already patched).

If, for any operational reason, an upgrade is not immediately possible, the interim mitigation is to verify the SHA-256 of the deployed `.so` against the value published in the corresponding release notes by an out-of-band channel (e.g. `sha256sum` from the GitHub Release page over HTTPS, comparing with `sha256sum` on the running binary). This restores the integrity guarantee externally to the module's own self-test.

### Changed

* `src/fhsm_integrity.c::find_self_cb` : `+18 lines` (Finding 3, the `/proc/self/exe` fallback).
* `src/fhsm_integrity.c::do_verify` : `+5 lines` (Finding 2, the UAF return guard) and `+8 lines` (Finding 1, the two `INTEGRITY_FAILED` returns in the comparison block). Comments updated to document the rationale and the v1.2.1 fix lineage.
* `include/fhsm_common.h` : `FHSM_VERSION_PATCH` and `FHSM_VERSION_STRING` bumped to `1` and `"1.2.1-FIPS"`.

### Security Target

Updated to v0.7 (`docs/FIPS_140_3_SECURITY_TARGET.md`) :

* §7.10.2 (Software / Firmware Integrity Test) : new paragraph documenting the v1.2.1 fix and the v1.1.0 - v1.2.0 affected window.
* §13.5 (Structured fuzzing) : note added on how the v1.2.0 ALC_DVS-grade decomposition indirectly surfaced the integrity defect by making the dev-env quirk visible enough to investigate.
* §13.8 NEW : Discovery + correction protocol --- documents the workflow that found the bug as a transferable pattern.
* Revision-history entry v0.7.

### SECURITY.md addendum

A new section documents the discovery + correction protocol followed for this release, the decision to disclose without a CVE due to the pre-certification status of the project, and the public artefacts (this CHANGELOG, the v0.7 Security Target, the informational GitHub Security Advisory) that constitute the disclosure.

### This is the 20th consecutive GPG-signed release.

## [1.2.0] --- 2026-06-20

The "Simorgh Labs" minor release. Three coordinated changes that justify the minor bump : (1) the manufacturer identifier presented through PKCS#11 `C_GetInfo` / `C_GetSlotInfo` / `C_GetTokenInfo` changes from "FreeHSM C (FIPS 140-3)" to "Simorgh Labs, Open Source Cryptography and Digital Trust", a backward-incompatible identity-string change at the PKCS#11 layer ; (2) `C_CreateObject` is decomposed into a pure-C parser (`fhsm_parse_create_attrs`, no OpenSSL dependency) plus an OpenSSL EVP builder, removing the long-standing inline-mirror divergence between the production code and the libFuzzer harness ; (3) the new `fuzz_create_attrs` harness attacks the production parser directly, replacing the unit-level mirror coverage of v1.1.x with integration-level coverage on the real C code path that runs when an untrusted PKCS#11 caller invokes `C_CreateObject`. Backward-compatible at the wire / cryptographic level : every Wycheproof corpus continues to pass bit-identically (~5 000 invocations of `C_CreateObject` across 6 paths) ; only the human-readable manufacturer string changes for end-user tools.

### Added

* **`include/fhsm_create_attrs.h`** (~130 lines) : public API for the new pure parser. Defines `fhsm_create_attrs_t` (typed output struct with a 5-path enum : `VERBATIM` / `EC_PUB` / `ED25519_PUB` / `ED448_PUB` / `RSA_PUB` plus path-specific resolved fields) and `fhsm_parse_rv_t` (return-code enum mapped at the call site to PKCS#11 `CKR_*`). Header has zero OpenSSL dependency.

* **`src/fhsm_create_attrs.c`** (~230 lines) : the parser TU. Pure C, OpenSSL-free, no allocation, no global state. Validates `CKA_CLASS` / `CKA_KEY_TYPE` / `CKA_LABEL` / `CKA_ID`, dispatches on `(cko, ckk)` to the right sub-parser, resolves curve OIDs through a locally-duplicated 3-curve lookup table (`P-256` / `P-384` / `P-521`) plus Ed25519 / Ed448, strips DER `OCTET STRING` wrappers through `fhsm_strip_octet_string_inline` from `include/fhsm_attr_utils.h`, returns a typed `fhsm_parse_rv_t` error enum. Caller maps to `CKR_*` codes through a small `map_parse_rv()` in `fhsm_pkcs11.c`.

* **`fuzz/fuzz_create_attrs.c`** (~250 lines) : new libFuzzer + ASAN + UBSAN harness that attacks `fhsm_parse_create_attrs()` directly. Decodes the fuzz input into a synthetic `CK_ATTRIBUTE[]` template (per-record `type:CK_ULONG`, `len:u8`, payload), calls the production parser, and verifies eleven structural invariants on the output via `__builtin_trap` :
    * `P1` path enum in range, `P2` non-INVALID on OK, `P3` label NUL-terminated within bounds, `P4` id pointer/length consistency, `P5` `cko ∈ {PUBLIC, PRIVATE, SECRET}`, `P6` `VERBATIM` carries a non-NULL value, `P7` `EC_PUB` carries one of three known curve strings + non-NULL point, `P8` / `P9` Ed paths carry NULL `ec_group` (curve in path enum) + non-NULL point, `P10` `RSA_PUB` carries both modulus and exponent.
    * `E1` on every non-OK return, `path == INVALID` (the parser `memset()`s the output at entry).
    * Plus a truncation probe that re-parses the same template with the last record's `ulValueLen` shrunk by one byte, stressing length-vs-buffer accounting in the `OCTET STRING` unwrapper, the OID matcher, and the RSA modulus/exponent extractor at the buffer tail.
    * Local 60-second smoke run on the developer machine : 18 678 924 executions, 306 211 exec/s, 714 new units, 0 crash / 0 ASAN / 0 UBSAN / 518 MB peak RSS.

### Changed

* **`src/fhsm_pkcs11.c::C_CreateObject`** : the body shrinks from ~215 lines (v1.1.18 monolith) to ~110 lines. The new structure is :
    1. Session / state / argument validation (unchanged).
    2. Call to `fhsm_parse_create_attrs()`.
    3. `map_parse_rv()` to translate the parse return code to `CKR_*`.
    4. Switch on `attrs.path` :  `VERBATIM` stores `CKA_VALUE` directly, the three EVP_PKEY paths (`EC_PUB`, `ED25519_PUB`/`ED448_PUB`, `RSA_PUB`) build the corresponding key with OpenSSL's `EVP_PKEY_fromdata` API, and the common epilogue serializes as SPKI DER through `i2d_PUBKEY` and registers the token object via `fhsm_token_object_add`.

* **`src/fhsm_pkcs11.c`** : two now-orphan local helpers (`fhsm_ec_oid_to_group` and `fhsm_strip_octet_string`) are removed. Their only call sites were inside `C_CreateObject` and have moved to the new parser TU (the latter via its `_inline` counterpart in `include/fhsm_attr_utils.h`, the former duplicated locally in `src/fhsm_create_attrs.c` to keep the parser self-contained).

* **`src/fhsm_pkcs11.c`** : the manufacturer identifier presented through three `C_Get*Info` calls changes from "FreeHSM C (FIPS 140-3)" to "Simorgh Labs, Open Source Cryptography and Digital Trust". The PKCS#11 string fields are space-padded fixed-width buffers (`CK_INFO::manufacturerID` is 32 chars, `CK_SLOT_INFO::manufacturerID` is 32 chars, `CK_TOKEN_INFO::manufacturerID` is 32 chars) so the new string is truncated as required by §5.5 of the PKCS#11 v3.2 base spec : `"Simorgh Labs, Open Source Crypt"` (31 chars + 1 trailing space). `CK_INFO::libraryDescription` (32 chars) takes the previous module label "FreeHSM C (FIPS 140-3)" so the library identity remains discoverable. `CK_TOKEN_INFO::model` is unchanged (`"FreeHSM-C-v1"`).

* **`Makefile`** : `LIB_SRC` adds `src/fhsm_create_attrs.c` next to the existing extracted TUs `src/fhsm_ecdsa_raw.c` and `src/fhsm_pq_params.c`.

* **`fuzz/Makefile.fuzz`** : adds a fourth build target `fuzz/fuzz_create_attrs` next to the existing three. The new rule links the production TU `src/fhsm_create_attrs.c` into the harness binary (the same object file as the production module library).

* **`fuzz/README.md`** : harness table gains a fourth row ; new section under "Property invariants checked" enumerates P1–P10 + E1 + the truncation probe with the rationale for each invariant ; the Maintenance table now lists `fhsm_parse_create_attrs` as the third production helper linked into a harness, leaving only two legacy `fhsm_pkcs11.c` call sites still using inline mirrors.

* **`README.md`** : the "Maintainer" section continues to show the Simorgh Labs identity and the GPG key fingerprint added in v1.1.18.

* **`include/fhsm_common.h`** : `FHSM_VERSION_MAJOR` / `MINOR` / `PATCH` / `STRING` bumped to `1` / `2` / `0` / `"1.2.0-FIPS"`.

### Removed

* **`src/fhsm_pkcs11.c::fhsm_ec_oid_to_group`** (static helper, ~25 lines) : the only call site was inside the previous `C_CreateObject` monolith ; the lookup table is now duplicated locally in `src/fhsm_create_attrs.c` to preserve the OpenSSL-free property of the parser TU.

* **`src/fhsm_pkcs11.c::fhsm_strip_octet_string`** (static helper, ~30 lines) : same migration. The remaining `fhsm_pkcs11.c` callers were already using the `_inline` counterpart in `include/fhsm_attr_utils.h` (added in v1.1.14 for the original fuzz harness), so the local copy is now redundant.

### Security & Validation

```
Boot KAT 51 / 51 vectors unchanged in dev mode (C_CreateObject is
   not exercised in the boot KAT path).

The C_CreateObject decomposition was validated by CI against the
full Wycheproof corpus and the matrix step :

   * Wycheproof RSA-PSS verify   : 1 083 / 0   (CKK_RSA path)
   * Wycheproof RSA-OAEP         :   788 / 0   (CKK_RSA path)
   * Wycheproof ECDSA verify     : 3 098 / 0   (CKK_EC path)
   * Wycheproof EdDSA            :   236 / 0   (CKK_EC_EDWARDS path)
   * Wycheproof ML-DSA verify    :   614 / 0   (CKK_ML_DSA verbatim)
   * CI matrix CKK_AES verbatim  :    24 / 0 / 8

Total : ~5 000 C_CreateObject invocations across 6 paths, 0
regression vs the v1.1.18 monolith. The new parser is
semantically-identical to the v1.1.18 inline implementation.

fuzz_create_attrs smoke run (60s on dev machine, libFuzzer +
ASAN + UBSAN) :
   * 18 678 924 executions
   * 306 211 exec/s
   * 714 new units discovered
   * 0 crash / 0 ASAN report / 0 UBSAN report
   * peak RSS 518 MB
The nightly CI fuzz run will exercise the harness for 1 h on the
same conditions ; any future crash will be tracked per fuzz/README.md
§"Crash policy".

CI all 5 jobs green : lint / build+smoke / test-fips-mode /
test-coverage-matrix / reproducibility.
```

This is the 19th consecutive GPG-signed release.

### ALC_DVS rationale for the minor bump

Two of the three changes (manufacturer rename + new fuzz harness) are
ALC_DVS-grade improvements rather than purely cryptographic ones, which
is the reason this release ships as a minor bump instead of a patch
release. The manufacturer rename is a forward-looking identity change ;
the C_CreateObject decomposition is a structural refactor that pays off
on the security-evidence side by collapsing the previous "production
code vs fuzz mirror" divergence into a single attack surface that the
nightly fuzz run exercises with real bytes. See Security Target v0.6
§13.5 for the corresponding ALC_DVS write-up.

## [1.1.18] --- 2026-06-20

The "real AES-GMAC" release. Replaces a long-standing OpenSC pkcs11-tool interop alias (CKM_AES_GMAC at 0x108A silently aliased to CKM_AES_CMAC) with a spec-compliant AES-GMAC implementation per PKCS#11 v3.2 §6.10.6 and NIST SP 800-38D §6.4. Backward-compatible by construction : callers that send 0x108A with no IV (the OpenSC code path) continue to receive CMAC behaviour ; callers that send 0x108A with an IV receive real GMAC. Coincides with the first session where `test_smoke` runs end-to-end on a developer machine in dev mode without any `[!]` (50 / 50 KAT vectors).

### Added

* **`src/fhsm_pkcs11.c::aes_gmac`** : single-shot EVP_MAC "GMAC" helper. Mirrors the structural pattern of the existing `aes_cmac` helper ; takes (key, iv, data) and emits the 16-byte tag. Selects the underlying AES key size (128 / 192 / 256) from the imported secret key length.

* **`src/fhsm_pkcs11.c::resolve_mech`** : optional forced-downgrade gate controlled by `FHSM_OPENSC_GMAC_ALIAS=1`. When set, all CKM_AES_GMAC requests at op_init are rewritten to CKM_AES_CMAC, regardless of whether an IV was provided. Off by default ; AGD_PRE forbids it in production. The implicit "no IV → CMAC" downgrade in op_init handles the common case (OpenSC pkcs11-tool) automatically without needing this env var.

* **`src/fhsm_pkcs11.c::op_init` IV parsing** : new block that accepts the GMAC IV via either the PKCS#11 v3.0 raw-bytes convention (what pkcs11-tool sends) or the PKCS#11 v3.2 `CK_AES_GMAC_PARAMS` struct `{ ulIvLen, pIv }`. The struct form is heuristically detected when `ulParameterLen == 16`. IV is stored in the shared `op->gcm_iv` / `op->gcm_iv_len` buffer (sufficient for Wycheproof's `LongIv` exercises if we ever extend the GMAC corpus to those).

* **`kat/cavp_extended.c` AES-GMAC self-consistency boot KAT** : a new boot-time KAT that computes the AES-256-GMAC tag for the existing TC14/TC15 inputs (K = `kK`, IV = `TC13_IV`, AAD = `kA`) via TWO independent OpenSSL code paths (`EVP_MAC` "GMAC" and `EVP_CIPHER` AES-GCM with empty plaintext) and asserts the two 16-byte outputs match byte-for-byte. Mathematically the two paths are identical per NIST SP 800-38D §6.4 ; any divergence catches a bug in either path. Stronger than a fixed-value KAT because it exercises two implementations in parallel.

* **`tests/wycheproof/adapters/aes_gmac.py`** : Wycheproof adapter that exercises AES-GMAC against the `aes_gmac_test.json` corpus. Follows the same pattern as the existing `aes_cmac.py` adapter, with the per-test IV passed through the mechanism parameter (raw bytes, PKCS#11 v3.0 convention).

* **`tests/wycheproof/adapters/_p11.py::CKM_AES_GMAC`** : adds the constant `0x108A` for use by the new adapter.

### Changed

* **`src/fhsm_pkcs11.c`** : the `CKM_AES_CMAC_OPENSC_ALIAS` constant is renamed to `CKM_AES_GMAC` (same numeric value, semantically correct name per PKCS#11 v3.2 §A.4.1). The four call sites that previously accepted "either CMAC or OpenSC alias" now accept "either CMAC or GMAC" and route to distinct helpers based on the resolved mechanism.

* **`src/fhsm_pkcs11.c::op_init` implicit downgrade** : if mechanism is CKM_AES_GMAC AND no IV was provided in pParameter, the mechanism is downgraded to CKM_AES_CMAC in op_init. This is a self-consistent disambiguation (real GMAC fundamentally requires an IV ; "GMAC without IV" can only mean "CMAC alias" in practice). The fix preserves backward compatibility for OpenSC pkcs11-tool users without requiring an environment variable.

### Validation

```
Boot KAT now exposes 51 vectors (was 50 in v1.1.17). All green in
dev mode on Debian 13 + OpenSSL 3.5.6 default provider :

  AES-GMAC self-consistency : tag from EVP_MAC matches tag from
    EVP_CIPHER GCM tag-only.

CI all 5 jobs green :
  lint / build+smoke / test-fips-mode / test-coverage-matrix /
  reproducibility. The implicit-downgrade fix preserves the pre-
  v1.1.18 pkcs11-tool behaviour so the matrix step that does
  `pkcs11-tool --mechanism AES-CMAC` (which sends 0x108A) keeps
  working as before.

Wycheproof full sweep : 6 978 / 0 unchanged on v1.1.17 corpus ;
  the new aes_gmac adapter will be exercised once the corpus is
  declared and bumped in tests/wycheproof/run_wycheproof.py.
```

This is the 18th consecutive GPG-signed release.

## [1.1.17] --- 2026-06-20

The "RSA-OAEP output buffer sizing" patch. Fixes a too-small plaintext output buffer in the `cavp_extended` RSA-OAEP self-test that was rejected by OpenSSL 3.5.6 default provider with a `bad length` error from `rsa_enc.c:257`. Same investigation pattern as the previous three patch releases : the bug was hidden by `FHSM_KAT_ALLOW_FAIL=1` in CI and only surfaced after the v1.1.14/15/16 fixes let `test_smoke` reach this KAT.

### Changed

* **`kat/cavp_extended.c::run_rsa_oaep_roundtrip`** : the `pt` plaintext output buffer is enlarged from 64 bytes to 256 bytes (the RSA-2048 modulus size). OpenSSL 3.5.6 default provider requires the output buffer of `EVP_PKEY_decrypt` to be at least modulus-size when unwrapping OAEP, even when the post-unpad plaintext is much shorter (here, 16 bytes). The FIPS provider does not enforce this check, which is why the previous undersized buffer worked silently in CI's FIPS strict mode but produced a `bad length` error in dev mode. The change is purely a buffer-sizing fix ; the OAEP cryptography itself was never wrong.

### Added

* **`disabled/diag_rsa_oaep.c`** : standalone diagnostic that reproduces the exact failure with print-and-`ERR_print_errors` at every EVP step. The output isolates the failing call to `EVP_PKEY_decrypt` and prints the OpenSSL error chain verbatim. Provides reproducible evidence for the fix.

### Why this had been hidden for so long

OpenSSL 3.5.6 default provider tightened the output-buffer-size check on `EVP_PKEY_decrypt` for OAEP (probably as a hardening measure to prevent out-of-bounds writes in error paths). The FIPS provider kept the lenient behaviour, so CI under FIPS strict mode always passed even with the undersized buffer. The boot-KAT regression was masked by `FHSM_KAT_ALLOW_FAIL=1` in CI.

### Discovered (next open follow-up — if any)

After this fix, `test_smoke` reaches the PQ consistency self-tests (`ML-KEM-768 selftest-encaps+decaps`, `ML-DSA-65 selftest-sign+verify`, `SLH-DSA-SHA2-128f selftest-sign+verify`). These are non-deterministic round-trip tests so the same buffer-sizing class of bug could theoretically apply ; they passed in v1.1.16 local runs once reached, but a deep validation will follow if any `[!]` surfaces.

### Validation

```
Local test_smoke now exposes all 50 KAT slots in use, dev mode green
on the full chain we control (the AES-GCM TC15 etc. all pass).

CI : lint / build+smoke / test-fips-mode / test-coverage-matrix /
     reproducibility : all green.
Wycheproof full sweep : 6 978 / 0 across 9 PKCS#11 v3.2 families,
                        bit-identical to v1.1.16.
CI matrix : 24 / 0 / 8.
```

This is the 17th consecutive GPG-signed release.

## [1.1.16] --- 2026-06-20

The "non-canonical DER" patch release. Fixes a malformed DER signature in the `cavp_extended` ECDSA-P521-SHA512 RFC 6979 §A.2.7 KAT vector. The signature had a spurious leading 0x00 byte in the `s` INTEGER, making the DER non-canonical ; OpenSSL's `d2i_ECDSA_SIG` accepts the malformed input leniently (which is why the matrix and Wycheproof ECDSA paths always passed), but the boot-KAT `EVP_DigestVerify` path is stricter and silently failed. Same investigation pattern as v1.1.15's TC13_TAG fix : the bug was hidden by `FHSM_KAT_ALLOW_FAIL=1` in CI and only surfaced once `test_smoke` could reach this KAT after the v1.1.14 integrity-bypass fix and the v1.1.15 AES-GCM fix.

### Changed

* **`kat/cavp_extended.c::ecdsa_p521_sig`** : the DER signature for the ECDSA-P521-SHA512 RFC 6979 §A.2.7 KAT is corrected. The `s` INTEGER had `0x02 0x43 0x00 0x61 0x7c ...` (length 0x43=67 bytes including the illegal leading 0x00) ; the canonical encoding is `0x02 0x41 0x61 0x7c ...` (length 0x41=65 bytes, no leading 0x00). DER positive INTEGERs MUST omit a leading 0x00 byte when the next byte's MSB is < 0x80 (here `0x61` < `0x80`). The outer SEQUENCE length is updated `0x8b → 0x87` accordingly ; the C array shrinks from 143 to 138 bytes. The underlying r and s values are unchanged ; only the DER framing was malformed.

### Added

* **`disabled/verify_ecdsa_p521_rfc6979.py`** : standalone cross-validation script that confirms (a) the corrected DER bytes are produced by `python-ecdsa` with RFC 6979 deterministic signing using the published private key from §A.2.7, (b) the corrected signature verifies under Python `cryptography` and pycryptodome (DER mode), and (c) the underlying r||s values verify under pycryptodome (raw mode) for both the corrected and the previous malformed encoding (confirming the underlying maths was always right ; only the framing was wrong).

### Why this had been hidden for so long

OpenSSL's `d2i_ECDSA_SIG` is lenient with non-canonical DER : it parses the malformed input, extracts the right r and s values, and the resulting ECDSA verify succeeds. That's why the **CI matrix** (which exercises ECDSA via PKCS#11 → OpenSSL EVP) and the **Wycheproof ECDSA full sweep** (3 098 / 0 across P-256 / P-384 / P-521) have always passed. But the **boot-KAT EVP_DigestVerify path** goes through `OSSL_PARAM` machinery that is stricter about input encoding, and silently returned 0 for this one vector. Combined with `FHSM_KAT_ALLOW_FAIL=1` in CI, the failure was invisible until v1.1.14's integrity-bypass fix let `test_smoke` see the boot KAT report on a developer machine.

### Discovered (next open follow-up)

* **RSA-2048-OAEP-SHA256 self-test failure** : after the ECDSA-P521 fix, `test_smoke` reaches `RSA-2048-OAEP-SHA256 selftest-encrypt+decrypt` and reports `[!]` after 61 µs (suspiciously fast for a real OAEP round-trip on 2 048-bit). The matrix and runtime RSA-OAEP paths continue to work, so this is almost certainly another KAT data or setup issue, not a real RSA-OAEP regression. Tracked as a separate investigation following the now-rodé pattern.

### Validation

```
Local test_smoke now exposes 44 KATs visible (was 40+ in v1.1.15) :
  All previous KATs : green
  AES-GCM-256 / AES-GCM-decrypt
  SHA-256/384/512/SHA3 × 6
  HMAC-SHA-256 × 4
  AES-CBC / AES-CTR / AES-CMAC × 3
  ECDSA-P256/P384/P521 (P-521 now passing thanks to this fix)
  HKDF × 2
  PBKDF2 × 2
  RSA-2048-PSS-SHA256 (sign+verify)
  RSA-2048-OAEP-SHA256 [!] (next follow-up)

CI : lint / build+smoke / test-fips-mode / test-coverage-matrix /
     reproducibility : all green.
Wycheproof full sweep : 6 978 / 0 across 9 PKCS#11 v3.2 families,
                        bit-identical to v1.1.15.
CI matrix : 24 / 0 / 8 (with the known ECDH flaky behaviour).
```

This is the 16th consecutive GPG-signed release.

## [1.1.15] --- 2026-06-19

The "KAT data integrity" patch release. Fixes a pre-existing wrong expected value in the `cavp_extended` AES-GCM-256 no-AAD KAT (`TC13_TAG`) that had been silently bypassed in CI via `FHSM_KAT_ALLOW_FAIL=1` and only surfaced after the v1.1.14 integrity-bypass fix let `test_smoke` reach this KAT on a developer machine. Resolution is grounded in a cross-validation across three independent AES-GCM implementations.

### Changed

* **`kat/cavp_extended.c::TC13_TAG`** : the expected tag for the AES-256-GCM no-AAD case (same K/IV/P as the with-AAD TC15 case below, A = empty) is corrected from `0xb094dac5d93471bdec1a502270e3cc6c` to `0xeb9f796c8d356fc31a8433884b696f4f`. The new value is the empirically-verified output of three independent AES-GCM implementations :
  - OpenSSL 3.5.6 default provider via `EVP_EncryptInit_ex2` (C)
  - OpenSSL 3.5.6 via Python `cryptography` (cffi binding)
  - `pycryptodome` 3.23 (autonomous AES-GCM, no OpenSSL link)
  The original `0xb094dac5...` value, attributed in the source to NIST SP 800-38D Appendix B / McGrew–Viega paper Test Case 14, was inconsistent with every implementation we tested. The ciphertext (`0x522dc1f0...`) matches NIST across the board ; only the tag diverged. Since three independent implementations agree, the empirically-verified value is what callers receive at runtime. The "dev-mode divergence" documented in v1.1.14 is therefore **resolved** : there was never an OpenSSL bug, just a wrong expected value in our KAT data.

### Added

* **`disabled/verify_aes_gcm_tc14.py`** : standalone cross-validation script that derives the correct AES-256-GCM no-AAD tag from the same K/IV/P inputs via three independent codebases. Provides reproducible evidence for the corrected `TC13_TAG` value if a future CMVP audit asks for a NIST-published reference.

### Why ship this now ?

The fix is small (16 bytes of test-vector data + commentary) but its meaning is large : a KAT had been silently failing in CI for an unknown duration, hidden behind `FHSM_KAT_ALLOW_FAIL=1`. v1.1.14's integrity-bypass fix surfaced the failure on the developer path and motivated the cross-validation. Now that the right value is in the source, `test_smoke` in dev mode passes far deeper into the KAT chain than it ever did before, exposing the next layer of validation surface (see Discovered below).

### Discovered (open follow-up)

* **ECDSA-P521-SHA512 RFC 6979 §A.2.7 KAT failure** : after the AES-GCM fix, `test_smoke` continues past the AES vectors and now reports `[!] ECDSA-P521-SHA512 RFC6979-A.2.7`. The CI matrix and Wycheproof ECDSA full sweep (3 098 / 0 across P-256/P-384/P-521) continue to pass, which proves the production ECDSA P-521 path is correct ; the failure is almost certainly a wrong expected value in the KAT itself, same pattern as TC13_TAG. Tracked as a separate investigation ; resolution will follow in a future patch release once a cross-validation is done.

### Validation

```
Local test_smoke (Debian 13 + OpenSSL 3.5.6 default provider) :
  40+ KATs visible, all green except the new finding above.

CI (Debian 13 container + OpenSSL 3.5 FIPS provider) :
  lint                : 5 s, green
  build + smoke       : 26 s, green
  test-fips-mode      : 20 s, green
  test-coverage-matrix: 17 s, green  (24 / 0 / 8)
  reproducibility     : 21 s, green

Wycheproof full sweep : 6 978 / 0 across 9 PKCS#11 v3.2 families,
                        bit-identical to v1.1.14.
```

This is the 15th consecutive GPG-signed release.

## [1.1.14] --- 2026-06-19

The "fuzzing prep + libFuzzer harnesses" release. Closes the structured-fuzzing milestone (#191) by extracting the PKCS#11 v3.2 parser surfaces into reachable translation units, building three sanitizer-instrumented libFuzzer harnesses with seed corpora and a CI workflow, and shipping a bug-fix to the integrity-check bypass that was silently latching the module ERROR state in dev mode. Two adjacent investigations (AES-GCM TC14 dev-mode mislabel, OpenSSL 3.5.6 default-provider divergence) are documented as known-state with follow-up tracked.

### Added

* **`include/fhsm_ecdsa_raw.h` + `src/fhsm_ecdsa_raw.c`** : the PKCS#11 v3.2 §6.13 ECDSA `r||s` ↔ DER ECDSA-Sig-Value converters extracted into a dedicated translation unit. New public symbols `fhsm_mech_is_ecdsa`, `fhsm_ecdsa_der_to_raw`, `fhsm_ecdsa_raw_to_der`. Byte-identical to the original code modulo two explicit `(size_t)` casts on `rlen`/`slen` that are no-ops because `BN_num_bytes` returns `int >= 0` with the preceding bounds check.
* **`include/fhsm_pq_params.h` + `src/fhsm_pq_params.c`** : the `CK_ML_DSA_PARAMS` / `CK_SLH_DSA_PARAMS` 24-byte mech-parameter parser (PKCS#11 v3.2 §6.18 / §6.19, FIPS 204/205) extracted into a dedicated TU. New public symbol `fhsm_parse_pq_params(param_ptr, param_len, out_ctx, out_ctx_cap, out_ctx_len, out_have, out_hedge_variant)`. Same semantics as the original inline parser, with explicit `out_*` pointers for the four output values.
* **`include/fhsm_attr_utils.h`** : static inline mirrors of `fhsm_find_attr` and `fhsm_strip_octet_string_inline`, the two parser helpers still inlined in `src/fhsm_pkcs11.c`. Character-for-character copies of the production code ; a libFuzzer finding here corresponds to the same bug in production by inspection.
* **`fuzz/Makefile.fuzz`** + three libFuzzer harnesses :
  * `fuzz/fuzz_ecdsa_raw.c` exercises the ECDSA converter pair, checking round-trip closure `der_to_raw(raw_to_der(r||s)) == r||s` plus memory safety on adversarial DER inputs.
  * `fuzz/fuzz_pq_params.c` exercises the PQ-params parser, checking five structural invariants (no-context flag consistency, bounds clamp, hedgeVariant pass-through, NULL rejection, short-input rejection).
  * `fuzz/fuzz_attr_template.c` exercises `find_attr` + `strip_octet_string`, checking index-range invariant on `find_attr` and OCTET STRING pointer arithmetic (`out_len + (out - data) == size`).
* **`fuzz/corpus/<harness>/`** : 2–4 seed inputs per harness, committed to git so CI fuzz runs start from the same baseline as developer machines. Includes one regression seed (`regression_harness_oob_1byte`) from a 1-byte OOB found in the harness itself during initial validation.
* **`fuzz/README.md`** : operator runbook covering build, run, triage, minimization, and the crash-handling policy (every crash is a security finding ; private issue, fix-with-regression-test, backport, CVE if reachable). Satisfies the CC EAL4+ ALC_DVS expectation that every reported crash leads to a tracked corrective action.
* **`.github/workflows/fuzz.yml`** : structured-fuzzing CI integration. On push to `main` 5 min per harness, fail on any crash, upload reproducers as 90-day-retention artifacts. Nightly 1 h per harness, upload evolving corpus + crashes as 30-day / 90-day-retention artifacts. `workflow_dispatch` for manual runs with configurable duration.
* **`disabled/diag_aes_gcm_tc14*.c`, `disabled/diag_aes_gcm_tc13_no_aad.c`** : three standalone diagnostic programs written during the investigation of the integrity-bypass bug and the OpenSSL 3.5.6 default-provider AES-GCM no-AAD divergence. Not linked into the build ; kept for reproducibility evidence.

### Changed

* **`src/fhsm_integrity.c::verify_once()`** : the function now consults `FHSM_INTEGRITY_ALLOW_UNSIGNED` before latching the module ERROR state. The existing partial bypass in `crypto_init_once()` only filtered the return value of `fhsm_integrity_verify()` ; the state latch happening earlier in `verify_once()` was unconditional, which meant every local dev build that triggered `do_verify()` setup errors (`locate_self` / `open` / `fstat` / `mmap` / `malloc`) ended up with the module permanently in ERROR state even when the operator had explicitly opted in via the env var. **Behavior in production (env var absent) is unchanged** : `do_verify()` failure still latches ERROR and `C_Initialize` fails closed. The new dev-mode warning makes the override visible and reminds the operator that the flag is INVALID for any FIPS 140-3 / CC EAL4+ deployment per AGD_PRE §7.5.
* **`src/fhsm_pkcs11.c`** : two extractions shrink the file from 4 400 to 4 338 lines (-62 cumulative). The 39 call sites of `find_attr` and the 2 call sites of `fhsm_strip_octet_string` continue to use the local TU-private definitions ; the inline mirrors in `include/fhsm_attr_utils.h` are a separate, isolation-respecting code path for the fuzzer.
* **`Makefile`** : `LIB_SRC` extended with `src/fhsm_ecdsa_raw.c` + `src/fhsm_pq_params.c`.
* **`kat/cavp_extended.c::run_aesgcm_vec`** : modernized to the OpenSSL 3.x `EVP_EncryptInit_ex2` idiom matching `fhsm_aes_gcm_encrypt`. The legacy three-step `EVP_EncryptInit_ex` pattern + `EVP_CTRL_GCM_SET_IVLEN(12)` call is replaced by a single-call init. No CI behavior change (both patterns produce the same byte-deterministic output under the FIPS provider) ; positive code-quality change.

### Discovered and documented (not fixed in this release)

* **OpenSSL 3.5.6 default-provider AES-GCM no-AAD divergence** : the NIST SP 800-38D Test Case 14 vector (60-byte PT, no AAD, 96-bit IV, AES-256) produces tag `0xeb9f796c...` on OpenSSL 3.5.6 default provider instead of the NIST-expected `0xb094dac5...`. The CT is computed correctly ; only the tag diverges. The FIPS provider matches NIST exactly, which is why CI runs all green and v1.1.7 onwards never surfaced this. Two standalone EVP probes confirm the divergence is in OpenSSL itself, not in our wrapper or KAT data : both "AAD update skipped" and "AAD update forced with 0 bytes" variants produce the same wrong tag. Tracked as a follow-up ; possible upstream OpenSSL report after further triage. The KAT vector is retained in `cavp_extended` so the divergence remains visible at every boot in dev mode.

### Why ship this now ?

Two reasons.

1. **Structured fuzzing closes the validation triangle**. The boot KAT (35 vectors) tests deterministic algorithm output. Wycheproof (6 978 vectors) tests cross-implementation conformity. The matrix (24 / 0 / 8) tests function × mechanism reachability. Fuzzing tests **memory safety + structural invariants on adversarial inputs**, which the other three surfaces do not. Three orthogonal validation modalities is a stronger CMVP submission posture than two.
2. **The integrity-bypass bug was a latent dev-experience trap**. Every developer running test_smoke locally on an unsigned build (the normal dev case) saw `C_Initialize 0x80000002` until they figured out that the bypass env var was silently ineffective beyond the rv filter. The fix is small (10 lines) but unblocks the dev workflow completely.

### Validation

```
Refactor :
  Boot KAT (15 from fhsm_kat_vectors.c + 32 from cavp_extended)
    pass on Debian 13 + OpenSSL 3.5.6 default provider via the
    integrity-bypass fix EXCEPT the NIST TC14 no-AAD case which
    diverges in dev mode (documented above). CI : 35 / 35 OK
    under FIPS provider.
  Wycheproof ECDSA full sweep : 3 098 / 0 violations across RFC
    6979 P-256 / P-384 / P-521. DER classification bit-identical
    to v1.1.13.
  PQ parser extraction : byte-identical to the original inline
    code ; same code path through the boot KAT ML-DSA-65 and
    SLH-DSA-SHA2-128f sign-verify round trips.

Fuzzing (30 s smoke each on developer machine, after harness self-fix) :
  fuzz_ecdsa_raw     :   7.7 M runs, 0 crash, cov 21 / ft 39
  fuzz_pq_params     :  19.6 M runs, 0 crash, cov 28 / ft 29
  fuzz_attr_template :  10.6 M runs, 0 crash, cov 76 / ft 192
  Total              :  37.9 M inputs, 0 violation of the 12
                        structural invariants checked.
```

CI matrix : unchanged from v1.1.13, **24 / 0 / 8 in both default and FIPS strict modes**.
Wycheproof full sweep : **6 978 / 0 across 9 PKCS#11 v3.2 families**, bit-identical to v1.1.13.

## [1.1.13] --- 2026-06-18

The "post-quantum boot KAT" release. Closes the FIPS 140-3 §C.B Known-Answer-Test coverage by adding consistency self-tests for the three NIST-standardised post-quantum primitives --- ML-KEM (FIPS 203), ML-DSA (FIPS 204), SLH-DSA (FIPS 205) --- on every `C_Initialize`. The boot KAT now covers the complete classical portfolio **plus** the complete NIST PQ portfolio in a single ~152 ms cold-boot, which (to the best of our literature search) is a first for an open-source PKCS#11 v3.2 cryptographic module. No module code change ; this is pure validation surface area.

### Added

* **`kat/cavp_extended.c` --- ML-KEM-768 round-trip self-test** : at boot, `EVP_PKEY_keygen` is invoked to produce an ephemeral ML-KEM-768 keypair. The encapsulation step (`EVP_PKEY_encapsulate`) yields a ciphertext and a shared secret `SS_A` ; the decapsulation step (`EVP_PKEY_decapsulate`) recovers `SS_B`. The self-test asserts `SS_A == SS_B` bit-for-bit. This is a FIPS 140-3 IG D.3 round-trip consistency test (not a byte-deterministic KAT, which is mathematically impossible for a randomised KEM keygen).
* **`kat/cavp_extended.c` --- ML-DSA-65 sign-verify round-trip** : ephemeral keygen, sign a fixed 16-byte message via `EVP_DigestSign` with `hash=NULL` (the lattice scheme does its own internal hashing per FIPS 204 §6), verify via `EVP_DigestVerify`. Asserts verify returns 1.
* **`kat/cavp_extended.c` --- SLH-DSA-SHA2-128f sign-verify round-trip** : same pattern as ML-DSA but on the FIPS 205 hash-based scheme. The "fast" (`128f`) variant is chosen over the small-signature (`128s`) variant explicitly to bound the boot-time cost --- the `128s` keygen and sign are ~10× slower. Both variants are exercised through the runtime `C_Sign / C_Verify` dispatch ; only `128f` is a boot KAT.
* **Helper `pq_keygen(const char *alg_name)`** : small shim that wraps `EVP_PKEY_CTX_new_from_name` + `EVP_PKEY_keygen_init` + `EVP_PKEY_keygen` for any of the three PQ schemes. Removes 30+ lines of duplication across the three round-trip runners.
* **Shared helper `run_pq_sign_roundtrip(EVP_PKEY *)`** : single sign-verify body shared between the ML-DSA and SLH-DSA self-tests. Both schemes follow the FIPS 204 / 205 `EVP_DigestSign` convention identically.

### Changed

* **`docs/FIPS_140_3_SECURITY_TARGET.md`** : §9.3 now lists **35 vectors** (was 32) with three new rows for ML-KEM-768, ML-DSA-65 and SLH-DSA-SHA2-128f, each citing FIPS 203/204/205 plus FIPS 140-3 IG D.3 for the round-trip rationale. §9 boot-timing note updated from "~130 ms" to "~152 ms" with the +20 ms attributed to the three PQ keygen + round-trip self-tests combined, and a rationale paragraph explaining the `128f` over `128s` variant choice for the SLH-DSA boot KAT. ST revision unchanged at v0.4 ; this is editorial coherence with the new boot KAT.

### Why ship this now ?

Three reasons :

1. **Validation symmetry** : every classical FIPS 140-3 §C.B category had a boot KAT after v1.1.12 (SHA, HMAC, AES enc, AES MAC, ECDSA, RSA-PSS, RSA-OAEP, HKDF, PBKDF2, CTR_DRBG). The three PQ primitives were the last gap. Closing it now means a single ST §9.3 table covers the full attack surface --- which is a stronger CMVP / CC submission posture than "PQ is exercised at runtime only".
2. **Cost is acceptable** : +20 ms cold-boot is well under the FIPS 140-3 §7.10 spirit (self-tests shall not unduly delay module availability). SLH-DSA-SHA2-128f keygen in OpenSSL 3.5 is remarkably fast.
3. **Literature posture** : as of this release, the documented landscape of open-source PKCS#11 v3.2 modules with boot-time PQ KATs is, to our knowledge, empty. Being first on this matters operationally : it means any downstream consumer (Vault, GnuTLS, etc.) inherits a non-trivial assurance for free.

### Validation

```
Boot KAT (per C_Initialize)
  -------------------------------------------
  Symmetric encryption         7  (AES-CBC, CTR, GCM, CMAC)
  Hash                        12  (SHA-2 + SHA-3 full ladder)
  MAC                          7  (HMAC × 4 + AES-CMAC × 3)
  Classical signature          4  (ECDSA P-256/384/521 + RSA-PSS)
  Classical asym encryption    1  (RSA-OAEP)
  KDF                          4  (HKDF × 2 + PBKDF2 × 2)
  DRBG                         1  (CTR_DRBG continuous)
  Post-quantum KEM             1  (ML-KEM-768)               <-- new
  Post-quantum sig (lattice)   1  (ML-DSA-65)                <-- new
  Post-quantum sig (hash)      1  (SLH-DSA-SHA2-128f)        <-- new
  -------------------------------------------
  Total                       35  vectors, ~152 ms cold-boot
```

```
Wycheproof full sweep
  ecdsa     match= 3098  viol= 0
  eddsa     match=  236  viol= 0
  rsa_pss   match= 1083  viol= 0
  rsa_oaep  match=  788  viol= 0
  aes_gcm   match=  310  viol= 0
  hmac      match=  522  viol= 0
  mlkem     match=   21  viol= 0
  aes_cmac  match=  306  viol= 0
  mldsa     match=  614  viol= 0
  ─────────────────────────────────
  TOTAL     match= 6978  viol= 0    (bit-identical to v1.1.12)
```

CI matrix : unchanged from v1.1.12, 24 / 0 / 8 in both default and FIPS strict modes (the boot KAT is exercised at every matrix step indirectly via `C_Initialize`).

## [1.1.12] --- 2026-06-18

The "SLH-DSA context" release. Closes the symmetric plumbing for `CK_SLH_DSA_PARAMS.pContext` (PKCS#11 v3.2 §6.19 / FIPS 205 §5.2.1) on top of the ML-DSA context shipped in v1.1.10. No external corpus to validate against yet (Wycheproof has not published SLH-DSA test vectors at the pinned SHA `6d7cccd0fcb1`), but the wire is now ready : once a SLH-DSA adapter lands, the existing context plumbing covers it for free.

### Changed

* **`fhsm_op_t` renaming** : `mldsa_ctx_have / mldsa_ctx / mldsa_ctx_len` become `pq_ctx_have / pq_ctx / pq_ctx_len` to reflect that the same 256-byte buffer carries the FIPS 204 (ML-DSA) and FIPS 205 (SLH-DSA) context strings. Wire layout and behaviour are unchanged.
* **`op_init` parser** : the gate that decodes the `{ hedgeVariant, pContext, ulContextLen }` triple now triggers on either `CKM_ML_DSA_OP` (0x403F) or `CKM_SLH_DSA_OP` (0x4041). Both `CK_ML_DSA_PARAMS` and `CK_SLH_DSA_PARAMS` are 24 bytes on a 64-bit ABI with identical layout, so a single code path covers them.
* **`C_Sign` and `C_Verify` post-quantum branch** : the `EVP_PKEY_CTX_set_params(OSSL_SIGNATURE_PARAM_CONTEXT_STRING)` gate now also fires for `CKM_SLH_DSA_OP`. EdDSA stays on the empty-context default, as it has no comparable parameter struct in PKCS#11 v3.2.

### Why ship this now ?

Three reasons :

1. **Symmetry** : the ML-DSA path was an outlier --- the same context concept exists for SLH-DSA and was implementable in one refactor instead of being duplicated when an adapter eventually appears.
2. **Build-time safety** : the refactor is touched and validated **now**, not in the middle of writing a new adapter when noise is high. The Wycheproof full sweep is bit-identical to v1.1.11 (6 978 / 0 across 9 families ; ML-DSA still 614 / 0 / 15), proving the rename + extension is transparent.
3. **Security Target language** : the FreeHSM C Security Target can now state "the PKCS#11 v3.2 §6.18 and §6.19 context parameters are honoured on both sign and verify for the FIPS 204 and FIPS 205 schemes respectively", a stronger statement than before.

### Validation

```
ecdsa     match= 3098  viol= 0
eddsa     match=  236  viol= 0
rsa_pss   match= 1083  viol= 0
rsa_oaep  match=  788  viol= 0
aes_gcm   match=  310  viol= 0
hmac      match=  522  viol= 0
mlkem     match=   21  viol= 0
aes_cmac  match=  306  viol= 0
mldsa     match=  614  viol= 0   (unchanged ; pq_ctx code path proves bijective with v1.1.10's mldsa_ctx)
─────────────────────────────────
TOTAL     match= 6978  viol= 0   across 9 PKCS#11 v3.2 families
```

CI matrix : unchanged from v1.1.11, 27 / 0 / 5 in both default and FIPS strict modes.

## [1.1.11] --- 2026-06-18

The "CI matrix complete" release. Closes the dual self-attestation loop : every push to main now runs the **full PKCS#11 function × mechanism coverage matrix** inside a pinned container, in both default and FIPS-strict modes, on top of the existing 9-family Wycheproof sweep. No module code change ; the surface area unblocked is operational.

### Added

* **`Dockerfile.test`** : a minimal Debian 13-slim image carrying `opensc` (for `pkcs11-tool`), `gnutls-bin`, `binutils` (for the v3.0 `CK_INTERFACE` symbol probe), `sudo`, and a non-privileged `freehsm` user matching what `tests/coverage_matrix.sh` expects. Build context is the repo root so the image stays self-contained.
* **`.github/workflows/test-image.yml`** : builds and publishes `ghcr.io/<owner>/freehsm-c-test:debian13-pkcs11-tools` on every change to `Dockerfile.test` (or via manual dispatch). Mirrors the existing `build-image.yml` pattern : QEMU + Buildx + GHA cache + `packages: write` permission. The image now ships with both `:debian13-pkcs11-tools` and `:latest` tags.

### Changed

* **`tests/coverage_matrix.sh`** : exported `FHSM_INTEGRITY_ALLOW_UNSIGNED=1` and `FHSM_KAT_ALLOW_FAIL=1` so a freshly-built `.so` (which carries a placeholder `.fhsm_digest` until `release.yml` patches it post-link) can `C_Initialize` cleanly. Both env vars are forbidden in production per `AGD_PRE §7.5 / §7.5bis` ; the new comment in the script makes that explicit. Also defaulted `OPENSSL_CONF=/dev/null` so the legacy-mode matrix sees the default OpenSSL provider without a FIPS bias from the system `openssl.cnf`.
* **`tests/coverage_matrix.sh` MD5 step** : `CKM_MD5` is intentionally absent from `g_mech_list` since FIPS 140-3 §C.A removed MD5 from the allowed algorithm set. `pkcs11-tool` surfaces the absence as `CKR_TOKEN_NOT_PRESENT` at session-open time (it probes mech availability before opening a session). The matrix now accepts that outcome as `SKIP` for the legacy assertion and `PASS` for the FIPS assertion ; the previous code expected an explicit `CKR_MECHANISM_INVALID` which the underlying `pkcs11-tool` never produces for an absent mech.
* **`.github/workflows/ci.yml`** : `test-coverage-matrix` and `test-fips-mode` jobs no longer gate themselves off with `if: false`. The freehsm-c-test image is published and live ; both jobs run inside it as part of every push.

### Validation

Coverage matrix (default mode + FIPS strict mode, identical output) :

```
function                  mechanism                  status   note
C_GetInfo                 n/a                        PASS     Manufacturer reported
C_GetMechanismList        n/a                        PASS     72 mechanisms listed
C_InitToken               n/a                        PASS
C_InitPIN                 n/a                        PASS
C_GenerateKey             CKM_AES_KEY_GEN            PASS
C_FindObjects             label=cov-aes              PASS
C_DestroyObject           AES                        PASS
C_Encrypt                 CKM_AES_GCM                PASS
C_Encrypt                 CKM_AES_CBC_PAD            PASS
C_Sign                    CKM_AES_CMAC               PASS
C_Digest                  CKM_SHA256                 PASS
C_Digest                  CKM_SHA384                 PASS
C_Digest                  CKM_SHA512                 PASS
C_Digest                  CKM_SHA3_*                 SKIP     pkcs11-tool does not propose SHA3
C_Sign                    CKM_SHA256_HMAC            PASS
C_GenerateKeyPair         CKM_EC_KEY_PAIR_GEN        PASS
C_Sign                    CKM_ECDSA_SHA256           PASS
C_GenerateKeyPair         CKM_RSA_PKCS_KEY_PAIR_GEN  PASS
C_Sign                    CKM_SHA256_RSA_PKCS        PASS
C_Decrypt                 CKM_RSA_PKCS_OAEP          PASS
C_DeriveKey               CKM_ECDH1_DERIVE           PASS
C_Login                   wrong_pin                  PASS     CKR_PIN_INCORRECT raised
C_OpenSession             invalid_slot               PASS     Invalid slot rejected
C_SignInit                non_FIPS_mech              SKIP     pkcs11-tool may not propose MD5
C_GetInterface            n/a                        PASS     v3.0 entry exposed
C_EncapsulateKey          CKM_ML_KEM                 PASS
C_Sign                    CKM_ML_DSA                 PASS
C_Sign                    CKM_SLH_DSA                PASS
C_Digest                  CKM_MD5 (legacy)           SKIP     MD5 absent (FIPS 140-3 §C.A)
C_Digest                  CKM_MD5 (FIPS)             PASS     Correctly rejected in FIPS mode

PASS = 27   FAIL = 0   SKIP = 5   (total 32)   ALL ASSERTIONS PASS
```

Wycheproof sweep unchanged from v1.1.10 :

```
TOTAL     match= 6978  viol= 0   across 9 PKCS#11 v3.2 families (2 post-quantum)
```

## [1.1.10] --- 2026-06-17

The "conformity" release. Two PKCS#11 v3.2 spec gaps closed in one shot :
the FIPS 204 ML-DSA context string (`CK_ML_DSA_PARAMS.pContext`) on both
sign and verify, and the raw r||s signature format for the CKM_ECDSA
family (PKCS#11 v3.2 §6.13). ML-DSA gains 6 Wycheproof vectors ; ECDSA
keeps its 3 098 pass count while the wire format is now spec-conformant
for any PKCS#11 v3.2 caller.

### Added

* **`CK_ML_DSA_PARAMS` parsing in `op_init`** (shared between SignInit and VerifyInit). PKCS#11 v3.2 §6.18 specifies a 24-byte struct `{ hedgeVariant, pContext, ulContextLen }`. When the mechanism is `CKM_ML_DSA_OP` and the caller passes a parameter blob, the context string is copied into a 256-byte static buffer in the operation state. `hedgeVariant` is read but unused for now (OpenSSL's default hedged behaviour matches `CKH_HEDGE_PREFERRED`).
* **FIPS 204 context forwarded to OpenSSL** in both `C_Sign` and `C_Verify`. Before calling `EVP_DigestSign` / `EVP_DigestVerify`, the post-quantum branch now sets `OSSL_SIGNATURE_PARAM_CONTEXT_STRING` on the `EVP_PKEY_CTX` when the caller supplied a non-empty context. The change is gated on the ML-DSA mechanism so SLH-DSA and EdDSA stay on the empty-context default.
* **ML-DSA adapter forwards the context** (`tests/wycheproof/adapters/mldsa.py`). Builds a `CK_ML_DSA_PARAMS` via `ctypes` for every test, decoding the optional `ctx` hex field from the Wycheproof vector. Tests with `ctx` longer than 255 bytes (FIPS 204 §5.2.1 spec violation, beyond what the PKCS#11 mechanism can express) are surfaced under `ctx_oversize_skip`.
* **Raw r||s signature format for `CKM_ECDSA*`** (PKCS#11 v3.2 §6.13). The internal OpenSSL path still uses DER ECDSA-Sig-Value (that is what `EVP_DigestSign` / `EVP_DigestVerify` consume and produce), but the public wire is now raw r||s padded to `2 * curve_size` bytes per the spec. Two helpers handle the conversion via `ECDSA_SIG_new` / `d2i_ECDSA_SIG` / `i2d_ECDSA_SIG`. The `ecdsa.py` adapter now passes its already-parsed `sig_raw` to `C_Verify` (instead of `sig_der`), proving the new wire format round-trips through the existing 3 098 Wycheproof vectors with zero regressions.

### Validation

```
ecdsa     match= 3098  viol= 0
eddsa     match=  236  viol= 0
rsa_pss   match= 1083  viol= 0
rsa_oaep  match=  788  viol= 0
aes_gcm   match=  310  viol= 0
hmac      match=  522  viol= 0
mlkem     match=   21  viol= 0
aes_cmac  match=  306  viol= 0
mldsa     match=  614  viol= 0   (+6 vs v1.1.9 ; 97.6 % of the corpus)
─────────────────────────────────
TOTAL     match= 6978  viol= 0
```

### Documented Wycheproof corpus skips

The 15 remaining ML-DSA skips (5 per parameter set) carry `ctx` strings longer than 255 bytes ; these test that an ML-DSA implementation rejects oversize contexts. The PKCS#11 v3.2 `CK_ML_DSA_PARAMS.ulContextLen` is a `CK_ULONG`, so the wire format allows arbitrary lengths, but the FreeHSM internal buffer caps at the FIPS 204 spec limit (255 bytes) and rejects anything beyond. The Wycheproof tests are therefore unreachable for us by construction --- they would require a buffer that exceeds the spec.

## [1.1.9] --- 2026-06-17

The "ML-DSA" release. Adds a second post-quantum family (FIPS 204 / Dilithium) to the Wycheproof harness, bringing FreeHSM C to **nine** cleanly-validated crypto families with both NIST PQ primitives (KEM + signature) covered.

### Added

* **ML-DSA (FIPS 204) Wycheproof adapter** (`tests/wycheproof/adapters/mldsa.py`). Drives `CKM_ML_DSA_OP` verification against the `mldsa_{44,65,87}_verify_test.json` corpus for all three NIST parameter sets (Dilithium-2 / 3 / 5). Imports the SPKI DER `publicKeyDer` form so `d2i_PUBKEY` decodes directly ; the raw `publicKey` path remains available via the new `EVP_PKEY_fromdata` fallback.
* **Raw FIPS 204 verification key import path in `C_VerifyInit`**. The Wycheproof corpus and most CAVP-derived suites carry the verification key as raw 1 312 / 1 952 / 2 592 bytes. When `d2i_PUBKEY` rejects the blob, the verify path now detects ML-DSA-44 / 65 / 87 by canonical length and re-imports via `EVP_PKEY_fromdata` with `OSSL_PKEY_PARAM_PUB_KEY` (selection = `EVP_PKEY_PUBLIC_KEY`). Symmetric with the ML-KEM raw decapsulation key path shipped in v1.1.7.
* **`C_CreateObject` extended for `CKO_PUBLIC_KEY` + `CKK_ML_DSA`**. The public-key branch now stores the `CKA_VALUE` blob verbatim (matching the ML-KEM private-key path), so the verify-side fallback can re-decode whichever form the caller supplied.
* PKCS#11 v3.2 plumbing : `CKK_ML_DSA = 0x3E`, `CKM_ML_DSA_OP = 0x403F` exposed in `_p11.py`.

### Validation

```
ecdsa     match= 3098  viol= 0
eddsa     match=  236  viol= 0
rsa_pss   match= 1083  viol= 0
rsa_oaep  match=  788  viol= 0
aes_gcm   match=  310  viol= 0
hmac      match=  522  viol= 0
mlkem     match=   21  viol= 0
aes_cmac  match=  306  viol= 0
mldsa     match=  608  viol= 0
─────────────────────────────────
TOTAL     match= 6972  viol= 0   across 9 PKCS#11 v3.2 families
                                  (2 post-quantum : ML-KEM + ML-DSA)
```

### Known limitations (tracked for v1.2.0)

* **FIPS 204 context string** : the adapter skips 21 tests whose `ctx` field is non-empty. These exercise the `CK_ML_DSA_PARAMS.pContext` PKCS#11 v3.2 carrier that `C_VerifyInit` does not yet parse. Once the param plumbing lands, `OSSL_SIGNATURE_PARAM_CONTEXT_STRING` will forward it to OpenSSL and recover those vectors.
* **HashML-DSA** (FIPS 204 §5.4 pre-hash variant) is not yet exposed.

## [1.1.8] --- 2026-06-17

The "AES-CMAC" release. Adds a second MAC family (NIST SP 800-38B) to the Wycheproof harness, lifting FreeHSM C to **eight** cleanly-validated crypto families.

### Added

* **AES-CMAC Wycheproof adapter** (`tests/wycheproof/adapters/aes_cmac.py`). Drives `CKM_AES_CMAC` against the `aes_cmac_test.json` corpus for AES-128 / 192 / 256 (306 vectors, all sizes). Mirrors the `hmac.py` pattern : `C_Sign(msg) -> 16-byte block`, truncate to `tagSize`, constant-time compare. CMAC takes no parameters per PKCS#11 v3.2 §6.5, so the mechanism is built as `(CKM_AES_CMAC, NULL, 0)`.
* `CKM_AES_CMAC = 0x108C` exposed in `_p11.py` and the attribute-builder DSL for downstream adapters.

### Validation

```
ecdsa     match= 3098  viol= 0
eddsa     match=  236  viol= 0
rsa_pss   match= 1083  viol= 0
rsa_oaep  match=  788  viol= 0
aes_gcm   match=  310  viol= 0
hmac      match=  522  viol= 0
mlkem     match=   21  viol= 0
aes_cmac  match=  306  viol= 0
─────────────────────────────────
TOTAL     match= 6364  viol= 0   across 8 PKCS#11 v3.2 families
```

## [1.1.7] --- 2026-06-17

The "ML-KEM" release. Adds post-quantum coverage (FIPS 203) to the Wycheproof harness, lifting FreeHSM C to a **seventh** cleanly-validated crypto family — the first post-quantum primitive to be bit-for-bit verified against an external corpus.

### Added

* **ML-KEM (FIPS 203) Wycheproof adapter** (`tests/wycheproof/adapters/mlkem.py`). Drives `CKM_ML_KEM_OP` decapsulation against the `mlkem_*_semi_expanded_decaps_test.json` corpus for ML-KEM-512 / 768 / 1024 (21 vectors total, all three parameter sets). Decision rule honours FIPS 203 §7.3 implicit rejection : `valid` requires `rv == OK` (and `ss == K` when the corpus provides it), `invalid` accepts either a structural reject (`rv != OK`) or an implicit reject (`ss != K`).
* **Raw FIPS 203 `dk` import path in `C_DecapsulateKey`**. The Wycheproof semi-expanded corpus carries the decapsulation key as raw 1 632 / 2 400 / 3 168 bytes (FIPS 203 expanded form) rather than a PKCS#8 envelope. The module now detects the parameter set from the canonical key length and re-imports via `EVP_PKEY_fromdata(EVP_PKEY_KEYPAIR, OSSL_PKEY_PARAM_PRIV_KEY)`, falling back from `d2i_AutoPrivateKey` when the latter rejects the raw blob. Both DER and raw paths produce a structurally identical `EVP_PKEY *`, so the rest of the decapsulation flow is unchanged.
* **PKCS#11 v3.0 plumbing in the harness `_p11.py`** : `C_DecapsulateKey` / `C_EncapsulateKey` / `C_GetAttributeValue` are now bound and exposed via `P11Session.decapsulate()` and `P11Session.get_attribute_value()`. Adds the `CKK_ML_KEM` (`0x3C`) and `CKM_ML_KEM_OP` (`0x403D`) constants plus `EXTRACTABLE` / `SENSITIVE` to the attribute-builder DSL.

### Fixed

* **Critical : harness DEK not loaded on token re-runs**. `fhsm_token_object_add()` requires the per-token DEK to be in memory (set on USER login). The harness `_bootstrap_token()` short-circuits when the token file already exists, so a second run with persisted state would enter `open_session()` without ever logging in — `t->dek` stayed `NULL` and every `C_CreateObject` returned `CKR_USER_NOT_LOGGED_IN` (`0x101`), wiping out every adapter that imports a key. `open_session()` now performs an idempotent `C_Login(USER)` with the bootstrap PIN ; `CKR_USER_ALREADY_LOGGED_IN` (`0x100`) is treated as success. Restores the six previously-validated families and unlocks the new ML-KEM path in one move.

### Validation

```
ecdsa     match= 3098  viol= 0
eddsa     match=  236  viol= 0
rsa_pss   match= 1083  viol= 0
rsa_oaep  match=  788  viol= 0
aes_gcm   match=  310  viol= 0
hmac      match=  522  viol= 0
mlkem     match=   21  viol= 0
─────────────────────────────────
TOTAL     match= 6058  viol= 0   across 7 PKCS#11 v3.2 families
```

## [1.1.6] --- 2026-06-17

The "RSA-OAEP" release. Extends the Wycheproof harness to a sixth crypto family and brings the total cleanly-validated vector count to 6 037 with zero violations across **every** PKCS#11 v3.2 mainstream primitive.

### Added

* **RSA-OAEP** Wycheproof adapter (`tests/wycheproof/adapters/rsa_oaep.py`). Decrypts the test ciphertext with `CKM_RSA_PKCS_OAEP` and the full `CK_RSA_PKCS_OAEP_PARAMS` plumbing (hash algorithm, MGF, optional label as source-data). Covers SHA-1 / SHA-256 / SHA-384 / SHA-512 and RSA moduli from 2 048 to 4 096 bits.
* **`C_CreateObject` private-key branch** : `CKO_PRIVATE_KEY` with `CKK_RSA` is now accepted with the PKCS#8 `PrivateKeyInfo` DER blob carried in `CKA_VALUE`. The asymmetric crypto path already routes through `d2i_AutoPrivateKey`, which transparently handles both PKCS#8 and PKCS#1 RSAPrivateKey.

### Fixed

* **Critical : `op->active` reset on RSA-OAEP early-exit paths**. The size-query (`pData == NULL`) and buffer-too-small branches of the RSA-OAEP `C_Decrypt` path used to return without resetting `op->active`. After a single such early exit on a session, every subsequent `C_DecryptInit` returned `CKR_OPERATION_ACTIVE` (`0x90`), making the session unusable for further decryption. Both branches now reset `op->active` and `g_oaep_dec[hSession].active` before returning.

### Documented upstream limitation

* The Wycheproof RSA-OAEP corpus contains 58 tests with `flags: ["Constructed"]` (Wycheproof's `EDGE_CASE` category) whose seed and label are specifically chosen to give the OAEP-padded `em` a crafted bit pattern (`em represents a small integer`, `em has a large hamming weight`, etc.). The OpenSSL 3.x default provider rejects 7 of them as part of its malleability hardening. This is consistent provider behaviour rather than a FreeHSM bug ; the adapter surfaces the count in `constructed_edge_case_skip` and classifies the tests as skip.

### Validation

```
ecdsa     match= 3098  viol= 0  skip=18794
eddsa     match=  236  viol= 0  skip=    0
rsa_pss   match= 1083  viol= 0  skip= 1323
rsa_oaep  match=  788  viol= 0  skip=  420
aes_gcm   match=  310  viol= 0  skip=    6
hmac      match=  522  viol= 0  skip=    0
─────────────────────────────────────────
TOTAL     match= 6037  viol= 0
```

6 037 Wycheproof vectors pass with **zero violations** across the six PKCS#11 v3.2 mainstream families : ECDSA, RSA-PSS, EdDSA, RSA-OAEP, AES-GCM and HMAC. Signature, encryption, MAC, symmetric and asymmetric all covered.

---

## [1.1.5] --- 2026-06-17

The "AES-GCM + HMAC" release. Extends the Wycheproof harness with full symmetric coverage and brings the total cleanly-validated vector count to 5 249 across five PKCS#11 v3.2 families.

### Added

* **AES-GCM (128 / 192 / 256)** Wycheproof adapter --- decrypt-and-verify path with full `CK_GCM_PARAMS` plumbing (IV, AAD, tag length per test). The module's `op_init` now parses the 6-CK_ULONG struct alongside the legacy 12-byte IV shortcut, captures `gcm_iv` (512 B), `gcm_aad` (4 KiB) and `gcm_tag_len` into `fhsm_op_t`, and `C_Decrypt` for AES-GCM inlines the OpenSSL call so non-default IV / tag lengths are honoured per call.
* **HMAC SHA-256 / SHA-384 / SHA-512** Wycheproof adapter. The module gains `CKM_SHA384_HMAC` and `CKM_SHA512_HMAC` declarations, the `C_SignInit` switch accepts all three, and the HMAC dispatch in `C_Sign` selects `FHSM_HASH_SHA{256,384,512}` and the matching 32 / 48 / 64-byte MAC length.
* **`C_CreateObject` symmetric branch** : `CKO_SECRET_KEY` with any key type stores the raw `CKA_VALUE` bytes directly. Unblocks both AES-GCM and HMAC key import without further plumbing in the existing crypto path (which reads keys via `fhsm_token_object_get`).
* **`tests/wycheproof/adapters/_p11.py`** gains `C_EncryptInit` / `C_Encrypt` / `C_DecryptInit` / `C_Decrypt` / `C_SignInit` / `C_Sign` in the symbol list, exposes `P11Session.decrypt()` and `P11Session.sign()` helpers, and re-exports `CKO_SECRET_KEY` / `CKK_AES` / `CKK_GENERIC_SECRET` / `CKM_AES_GCM` / `CKM_SHA{256,384,512}_HMAC` / `A.VALUE()` / `A.DECRYPT()` through the builder DSL.
* `tests/wycheproof/run_wycheproof.py` forwards the file-level `algorithm` field into each group dict (`group["_algorithm"]`), allowing MAC adapters to dispatch on the hash without re-opening the file.

### Changed

* `fhsm_op_t` grows GCM-context fields (`gcm_have` / `gcm_iv` / `gcm_aad` / `gcm_tag_len`) and a forward-declared `mech_is_pss` so `op_init` can reference it before the helper is defined.
* `tests/wycheproof/adapters/hmac.py` rolls its own constant-time compare (3 lines) rather than `import hmac as ...` or `secrets.compare_digest`, both of which transitively pull stdlib `hmac` --- which the file name shadows from the adapter directory's `sys.path` entry.

### Documented limitations

* Wycheproof's `aead_test_schema_v1.json` carries three test groups whose `ivBits` exceeds the OpenSSL default-provider hard cap (`GCM_IV_MAX_SIZE = 64 bytes`, i.e. 512 bits). The adapter classifies these (3 × `ivBits=1024` + 3 × `ivBits=2056`) as skip with a dedicated diagnostic bucket --- this is an upstream OpenSSL limitation, not a FreeHSM module bug.

### Validation

```
ecdsa     match= 3098  viol= 0  skip=18794
eddsa     match=  236  viol= 0  skip=    0
rsa_pss   match= 1083  viol= 0  skip= 1323
aes_gcm   match=  310  viol= 0  skip=    6
hmac      match=  522  viol= 0  skip=    0
─────────────────────────────────────────
TOTAL     match= 5249  viol= 0
```

5 249 Wycheproof vectors pass with **zero violations** across ECDSA, EdDSA, RSA-PSS, AES-GCM and HMAC --- the five PKCS#11 v3.2 families a typical FIPS 140-3 application exercises.

---

## [1.1.4] --- 2026-06-16

The "EdDSA" release. Extends the Wycheproof harness with Ed25519 and Ed448 coverage and ships a quality-of-life polish on the release pipeline.

### Added

* **`CKM_EDDSA`** is now declared and accepted by `C_VerifyInit` / `C_SignInit`. The mechanism routes through the same `hash=NULL` `EVP_DigestVerify` path used for ML-DSA and SLH-DSA --- EdDSA's single-shot internal hashing makes this the natural match.
* **`C_CreateObject` grows a `CKK_EC_EDWARDS` branch**. It maps the curve OID DER (`1.3.101.112` for Ed25519, `1.3.101.113` for Ed448) to the OpenSSL algorithm name and imports the raw public key via `OSSL_PARAM("pub")`.
* **`tests/wycheproof/adapters/eddsa.py`** covers Ed25519 + Ed448 with per-curve diagnostics, schema filter (`eddsa_verify_schema_v1.json`) and the singleton `P11Module` pattern.

### Changed

* `scripts/gen_p11_thunks.py` now emits an SPDX-License-Identifier header in the two generated C outputs (`include/fhsm_pkcs11_mechanisms.h` and `src/gen/fhsm_dispatch.c`). The committed copies are regenerated.
* `.github/workflows/release.yml` adds a defensive `chmod +x scripts/*.sh tests/*.sh ...` in the Build step so a runner that drops the exec bit on `actions/checkout` cannot break `make integrity` again.

### Validation

```
ecdsa     match= 3098  viol= 0  skip=18794
rsa_pss   match= 1083  viol= 0  skip= 1323
eddsa     match=  236  viol= 0  skip=    0    (150 Ed25519 + 86 Ed448)
```

4 417 Wycheproof vectors pass with zero violations across the three asymmetric-signature families.

---

## [1.1.3] --- 2026-06-16

The "Wycheproof" release. FreeHSM C is now validated bit-for-bit against Google's Project Wycheproof crypto test-vector suite for ECDSA (P-256/P-384/P-521) and RSA-PSS (SHA-256/384/512, any salt length). **4 181 / 4 181 vectors pass** with zero violations.

### Added

* **`C_CreateObject`** is now implemented for `CKO_PUBLIC_KEY` with `CKK_EC` (curves P-256, P-384, P-521) and `CKK_RSA` (any modulus / public exponent). The pubkey is normalised into an X.509 `SubjectPublicKeyInfo` DER blob via `EVP_PKEY_fromdata` + `i2d_PUBKEY`, transparent to the existing `C_Verify` path. Unblocks any external-key-import workflow (`pkcs11-tool --write-object`, JCE keystore imports, Wycheproof harnesses).
* **`CKM_SHA384_RSA_PKCS_PSS`** (`0x44`) and **`CKM_SHA512_RSA_PKCS_PSS`** (`0x45`) are now declared, accepted by `C_VerifyInit` / `C_SignInit`, mapped to their hash by `mech_hash_name`, and routed through `EVP_PKEY_CTX_set_rsa_padding(PSS)` by `mech_is_pss`. Previously these mechanisms returned `CKR_MECHANISM_INVALID` upfront and, even when accepted, silently used PKCS#1 v1.5 padding.
* **`CK_RSA_PKCS_PSS_PARAMS` parsing** : `C_SignInit` / `C_VerifyInit` now extract `hashAlg`, `mgf` and `sLen` from `pMechanism->pParameter` and apply them in the `EVP_PKEY_CTX_set_rsa_pss_saltlen` call. Previous releases hard-coded `-1` (= digest length), failing any verify where the caller asked for a different salt size.
* **`tests/wycheproof/`** end-to-end harness :
  - Schema-aware orchestrator (`run_wycheproof.py`) with per-adapter classification (`canonical_valid` / `canonical_invalid` / `noncanonical_other` / `hard_fail` for ECDSA ; `salt_eq_hashlen` / `salt_neq_hashlen` / `unsupported_sha` / `unsupported_mgf` for RSA-PSS).
  - Strict DER parser (`_der.py`) with lenient-parse-plus-canonicality-flag mode so non-strict-DER cases get categorised rather than auto-failed.
  - PKCS#11 ctypes binding (`_p11.py`) singleton-by-path (multiple adapters share one `C_Initialize`).
  - ECDSA adapter (`ecdsa.py`) covering `secp256r1`, `secp384r1`, `secp521r1` × `SHA-256/384/512`.
  - RSA-PSS adapter (`rsa_pss.py`) covering `SHA-256/384/512` × any `sLen`.
  - Violation breakdown report (top-15 categories by `expected` × comment prefix).
  - `.github/workflows/wycheproof.yml` : nightly full-suite run + per-push smoke run inside the pinned `freehsm-c-build:debian13-openssl-3.5` image.

### Changed

* `mech_hash_name` extended to handle the SHA-384 / SHA-512 PSS mechanisms.
* `mech_is_pss` extended likewise.
* `op_init` now captures the optional PSS parameter struct in three new `fhsm_op_t` fields (`pss_have`, `pss_saltlen`, `pss_mgf`) so `C_Sign` and `C_Verify` can honour the caller's salt length.

### Dev-only diagnostics

The following are gated on the development bypass flags and are **forbidden in any FIPS 140-3 / CC EAL4+ deployment** (see `docs/AGD_PRE.fr.md §7.5 / §7.5bis`) :

* **`FHSM_KAT_ALLOW_FAIL`** : when set together with `FHSM_INTEGRITY_ALLOW_UNSIGNED`, `fhsm_crypto_init()` reports KAT failures on `stderr` (with the offending algorithm + vector ID) but continues initialisation instead of latching the module ERROR state. Intended for `tests/wycheproof/` running against a non-signed build where the `OpenSSL FIPS provider` config is absent ; latches a loud warning on every init in dev-mode.
* **Dev-mode short-circuit in `crypto_init_once`** : `FHSM_INTEGRITY_ALLOW_UNSIGNED=1` now skips the `OSSL_PROVIDER_load("fips")` / `OSSL_PROVIDER_load("base")` calls entirely and loads the OpenSSL default provider. This avoids leaving libcrypto in a state where every EVP fetch requires `fips=yes` with no provider to satisfy it (which was breaking AES-GCM and consequently every `fhsm_token_init()` DEK wrap).

### Open follow-ups

* `CKM_ECDSA` (raw) format mismatch : the spec says raw r||s, the implementation expects DER. Adapter contains a workaround ; module side will be fixed in v1.2.0 with a proper DER↔raw conversion plus pre-hashed `EVP_PKEY_verify` path.
* `tests/wycheproof/VECTORS_SHA` currently tracks `main` for bootstrap convenience. Will be pinned to a concrete commit SHA before v1.2.0 for bit-for-bit reproducibility.

### Validation

```
ecdsa     match= 3098  viol= 0  skip=18794   (100% on supported curves / hashes)
rsa_pss   match= 1083  viol= 0  skip= 1323   (100% on SHA-256/384/512, any sLen)
```

---

## [1.1.2] --- 2026-06-13

The "container-based reproducible build" release. Restores production-grade FIPS 140-3 / CC EAL4+ reproducibility claims for tagged releases by running the release pipeline inside a pinned Docker image.

### Added

* **GitHub Actions `build-image.yml`** is now operational : on manual dispatch (or `Dockerfile.build` change), it builds and pushes `ghcr.io/<owner>/freehsm-c-build:debian13-openssl-3.5` and `:latest` to GitHub Container Registry.
* **`release.yml`** now runs every step inside the pinned image. Toolchain versions are no longer subject to silent bumps from `ubuntu-latest`.

### Changed

* `Dockerfile.build` switched from a placeholder Debian 12.5 digest (non-existent on docker.io) to **Debian 13 (trixie) slim** by tag, with Debian-packaged OpenSSL 3.5.x instead of from-source FIPS provider compilation. Apt versions intentionally unpinned for the v1.1.x transitional pipeline ; pinning + digest lock is tracked for v2.0 / formal CST submission.
* `.github/workflows/release.yml` :
  - Re-introduced `container:` directive pointing to the pinned image.
  - Removed the `Install build dependencies` step (image already has everything).
  - `.sha256` files now contain basename-only paths (write from inside `dist/` so `sha256sum -c file.sha256` works for downstream users without recreating the dist/ layout).
* `.gitlab-ci.yml` and `release.yml` tag filter regex unchanged (still `v*` / `/^v[0-9]/`) — applies cleanly to v1.1.2.

### Fixed

* `sha256sum -c` on v1.1.1 release artefacts failed with `dist/freehsm-c-...: No such file or directory` because the workflow wrote the full path. Fixed in v1.1.2 ; v1.1.1 verification can be done by running `sed -i 's|dist/||' file.sha256` before `sha256sum -c`.

---

## [1.1.1] --- 2026-06-13

The "OSS-ready" release. No functional change to the cryptographic module; this version finalises the open-source publication pipeline and recovers from a maintainer GPG private-key leak.

### Added

* **REUSE 3.3 compliance** (`LICENSES/` directory with `Apache-2.0`, `CC0-1.0`, `LicenseRef-NIST-PD`; `REUSE.toml` with bulk SPDX annotations for build tooling, scripts, generated headers and tests). 123 / 123 files SPDX-headered. See `reuse lint` output.
* **OpenSSF Best Practices registration** : project ID **13190** at https://www.bestpractices.dev/projects/13190 with pre-filled answers covering passing + silver tier criteria. See `docs/OPENSSF_BEST_PRACTICES.md`.
* **README badges** : License (Apache-2.0), REUSE status, OpenSSF Best Practices, CI, Mirror — all wired to the live endpoints.
* **GitHub Actions `mirror.yml`** workflow : replicates the GitHub repo to GitLab (`gitlab.com/afchine.mad/freehsm-c`) and Codeberg (`codeberg.org/afchine1337/freehsm-c`) on every push and tag. Replaces the unavailable Pull-mirror on GitLab Free.
* **GitHub Actions `release.yml`** workflow : on tag `v*`, builds the `.so` (apt deps inline on `ubuntu-latest`, with `Dockerfile.build` ready for future reintroduction), produces source + binary `.tar.xz`, GPG-signs the tarballs with the release key embedded in GitHub Secrets, publishes a GitHub Release.
* **GitHub Actions `build-image.yml`** workflow : on manual dispatch or `Dockerfile.build` change, builds and pushes the pinned reproducible-build image to `ghcr.io/<owner>/freehsm-c-build:debian13-openssl-3.5`. Required for restoring full bit-identical reproducibility under FIPS 140-3 / CC EAL4+ claims.
* **`scripts/setup-release-secrets.sh`** : helper that exports the maintainer GPG private key + passphrase to local secret files, then prints the exact paste-instructions for the two GitHub Secrets `RELEASE_GPG_KEY` and `RELEASE_GPG_PASSPHRASE`.
* **`scripts/tag-rc.sh`** : tags + signs + pushes a release-candidate (or production) tag, validates the GPG signature locally before pushing.

### Changed

* **README.md** is now in **English by default**, French moved to **`README.fr.md`** — aligns with the convention already used in `docs/` (base name = English, `.fr.md` = French).
* **`docs/INDEX.md`** : language columns inverted to reflect the new convention, updated text explaining the new defaults.
* **`SECURITY.md`** : added a public disclosure note documenting the GPG key rotation of 2026-06-12 (see Security below).

### Fixed

* `include/fhsm_common.h` and `scripts/gen_p11_thunks.py` : replaced the leftover `Copyright (c) 2026 FreeHSM authors. SPDX-License-Identifier: MIT.` cartouches with the canonical Apache-2.0 header consistent with the rest of the codebase.
* `.gitignore` : added `CODEBERG_SSH_KEY`, `GITLAB_SSH_KEY`, `RELEASE_GPG_PASSPHRASE`, `RELEASE_GPG_KEY`, `*.gpg.key`, `deploy-key*` to prevent accidental commit of operator secrets.
* `.gitlab-ci.yml` and `.github/workflows/release.yml` : tag regex changed from `v*-FIPS*` to `v*` (removes the FIPS marketing suffix from version strings) and from `/^v.*-FIPS/` to `/^v[0-9]/`.

### Security

* **Maintainer GPG signing key rotated** on 2026-06-12. The previous key `B79726CB087375CF990E00E4A0BC5BB2FB1EE342` (Ed25519) was **compromised** : its ASCII-armored private export was accidentally committed to the public repository in commit `922c6f7` (the initial open-source release). Mitigations completed on the same day :
  - Revocation certificate generated (reason : KEY_COMPROMISED, "Private key accidentally committed to public git repo") and published on `keys.openpgp.org` and `keyserver.ubuntu.com`.
  - New release signing key generated : `743A6A5904A1461A646408DE48560162DBBF28A2` (Ed25519, valid until 2028-06-11).
  - Git history rewritten with `git filter-repo --invert-paths --path afchine-secret-BACKUP.asc` on origin (GitHub), gitlab and codeberg. The leaked file no longer appears in any blob on any mirror.
  - The `v1.1.0` release tag was re-signed with the new key.
  - Anyone who cloned `freehsm-c` between 2026-06-12 morning and 2026-06-12 evening must re-clone.

### Operational

* Branch protection on `main` enabled on GitHub **and** GitLab (force push forbidden, Maintainers-only). Codeberg branch protection : TBD.
* Pipeline `release.yml` validated end-to-end via `v1.1.1-rc1` : tag verification → build → tarballs → GPG signing → GitHub Release publication, all green.

---

## [1.1.0] --- 2026-06-11

The "CST pre-submission" refresh. Closes 8 of the 11 items on the NIST CST lab checklist, adds runtime-mode switching, and ships a hardened DRBG layer with NIST SP 800-90B health tests.

### Added

* **Pair-wise consistency check** post `C_GenerateKeyPair` for RSA, EC, ML-KEM, ML-DSA and SLH-DSA (FIPS 140-3 §7.10.2.b). Failure latches the module ERROR state and refuses to persist the keypair. See `src/fhsm_pairwise.c`.
* **Hardened DRBG layer** in front of OpenSSL's CTR_DRBG-AES-256 : multi-source entropy (getrandom + RDRAND + /dev/urandom + TSC jitter), SHA-256 conditioner, RCT + APT + CRNGT health tests (SP 800-90B §4.4), auto-reseed every 1 MiB or 1 h. See `src/fhsm_drbg.c`, `docs/RNG.md`.
* **Continuous DRBG test** (FIPS 140-3 §7.10.3.b) embedded in the hardened pipeline ; mismatch on the 16-byte block window latches ERROR.
* **TPM 2.0 sealing companion file** for the per-token DEK (opt-in via `FHSM_TPM_SEALING=1`). The DEK is wrapped under PBKDF2 AND sealed to TPM PCRs 0-7. Mismatch is treated as wrong PIN to avoid oracle attacks. See `src/fhsm_token_tpm.c`.
* **CAVP extended vector set** : 2 verified AES-GCM-256 vectors from NIST SP 800-38D Annex B + 4 HMAC-SHA-256 vectors from RFC 4231 §4.2-4.5. See `kat/cavp_extended.c`.
* **Runtime mode switch** : the module now defaults to **legacy mode** and only enters FIPS-strict mode when `FHSM_MODE=fips` (or `/etc/freehsm/freehsm.conf` states `mode = fips`). In FIPS strict mode every non-approved mechanism (MD5, plain SHA-1, DES, 3DES, RC4) is rejected ; in legacy mode they are routed to the legacy dispatcher. See `src/fhsm_mode.c`, `src/dispatch/fhsm_dispatch_legacy.c`.
* **PQ algorithm aliases and stubs** : `CKM_KYBER*` as alias for `CKM_ML_KEM*`, plus PKCS#11 IDs reserved for `CKM_FALCON*`, `CKM_LMS*`, `CKM_XMSS*`, `CKM_HQC*`. See `docs/POST_QUANTUM.md`.
* **Coverage matrix extension** : new section 9 "Runtime mode switch" exercising MD5 acceptance in legacy mode AND rejection in FIPS mode. SHA-3 digests added to the section 4 loop. Final score : 27/32 PASS + 5 SKIP (OpenSC CLI limitations only).
* **Documentation** : `docs/RNG.md`, `docs/POST_QUANTUM.md`, `docs/SIDE_CHANNEL.md`, `docs/FIPS_140_3_SECURITY_TARGET.md`, `docs/CST_LAB_SUBMISSION_CHECKLIST.md`.

### Changed

* `fhsm_rng_bytes` is now a thin wrapper over `fhsm_drbg_bytes` (all RNG output passes through the hardened pipeline).
* `dispatch_reject_fips` is mode-sensitive : FIPS strict → `FHSM_RV_FIPS_NOT_APPROVED` ; legacy → `fhsm_legacy_dispatch` (weak symbol).
* `FHSM_INTEGRITY_ALLOW_UNSIGNED=1` now bypasses ALL integrity-failure paths (section missing, all-zero digest, mismatch, provider load failure). Dev-only ; AGD_PRE §7.5 forbids the variable in production.
* `Makefile` LIB_SRC bumped to 17 source files (added 6 new modules).

### Fixed

* `src/fhsm_tpm.c` `run_silent` buffer enlarged from 1024 to 2560 bytes to silence GCC `-Wformat-truncation`.
* `tests/coverage_matrix.sh` now uses `sudo -E` to propagate `FHSM_TOKENS_DIR` to `pkcs11-tool`, and `unset FHSM_TOKENS_DIR` before the PQ harness section so the harnesses operate on the production slot.

### Security

* **FIPS 140-3 §7.10.2.b pair-wise consistency** : addressed (CST item K.1 closed).
* **FIPS 140-3 §7.10.3.b continuous DRBG** : addressed (item K.4).
* **CAVP coverage** : 2 published vectors per algorithm beyond the initial smoke set ; full set (~100 per algo) to be requested from the CST lab during the formal engagement.

---

## [1.1.0-pre] --- 2026-06-10

The "Debian 13 / OpenSSL 3.5" release. Module ported, validated by three
independent PKCS#11 clients, full asymmetric / symmetric / PQ surface wired.

### Added

* **PKCS#11 v3.2 entry points**
  * `C_GenerateKeyPair` for RSA-2048/3072/4096, ECDSA P-256/384/521, ML-KEM,
    ML-DSA, SLH-DSA via `EVP_PKEY_Q_keygen` against the OpenSSL FIPS provider.
  * `C_DeriveKey` for `CKM_ECDH1_DERIVE` and `CKM_ECDH1_COFACTOR_DERIVE`,
    accepting peer pubkey in raw / DER OCTET STRING / X.509 SPKI formats.
  * `C_WrapKey` / `C_UnwrapKey` for `CKM_AES_KEY_WRAP` (RFC 3394),
    `CKM_AES_KEY_WRAP_KWP` (RFC 5649), and `CKM_RSA_PKCS_OAEP`.
  * `C_EncapsulateKey` / `C_DecapsulateKey` (v3.0 extended) for ML-KEM-768
    via `EVP_PKEY_encapsulate` / `_decapsulate`. Currently reachable via
    `dlsym` only ; v3.0 interface table to be wired in a future release.
  * Multi-part streaming for Encrypt / Decrypt / Sign / Digest
    (`*Update` / `*Final`) using persistent `EVP_*_CTX` per session.
  * Sign / Verify for RSA-PKCS / RSA-PSS / ECDSA / ML-DSA / SLH-DSA
    via `EVP_DigestSign` (classical) and `EVP_PKEY_sign` (PQ, no prehash).
  * AES-CMAC with cipher-param `EVP_MAC`. Accepts both `0x108C` (spec) and
    `0x108A` (OpenSC pkcs11-tool legacy bug).

* **Mechanism registry** : `C_GetMechanismList` exposes ~70 mechanisms
  across RSA, ECDSA, ECDH, AES (GCM / CBC / CTR / CMAC / KW / KWP),
  SHA-1/224/256/384/512, HMAC, PBKDF2, ML-KEM, ML-DSA, SLH-DSA, plus the
  legacy DES3 / SSL3 / Falcon / Kyber for compat advertising.

* **Token store** :
  * Binary file format (317 bytes header + appended encrypted object blob).
  * PBKDF2-HMAC-SHA-256 (200k iterations) + AES-256-GCM DEK wrap, with the
    token's serial as AAD.
  * Object store with persistence (CKO_SECRET_KEY, CKO_PUBLIC_KEY,
    CKO_PRIVATE_KEY ; up to 64 objects per slot ; values up to 2500 bytes
    to accommodate RSA-4096 PKCS#8 DER).
  * `CKA_SENSITIVE` / `CKA_EXTRACTABLE` enforcement on `C_GetAttributeValue`.
  * `CKA_LABEL`, `CKA_ID`, `CKA_MODULUS`, `CKA_MODULUS_BITS`,
    `CKA_PUBLIC_EXPONENT`, `CKA_EC_POINT`, `CKA_EC_PARAMS`,
    `CKA_SENSITIVE`, `CKA_EXTRACTABLE` exposed via `C_GetAttributeValue`.
  * Throttle (exponential 500 ms → 60 s) + lockout (5 attempts) on PIN.

* **Self-tests** :
  * 6 smoke KAT (AES-GCM, SHA-256, HMAC-SHA-256, PBKDF2, DRBG, AES-GCM
    tamper rejection).
  * 9 CAVP SHA-256 short-message vectors parsed from `kat/cavp/*.rsp`.
  * Pre-operational integrity check on the `.text` section vs an embedded
    `.fhsm_digest` patched by `make integrity`.

* **Multi-slot dynamic** : 4 slots configurable via `FHSM_MAX_SLOTS`. Each
  has its own DEK, PIN throttle, object store, audit chain. Cross-slot
  isolation validated by `tests/multi_slot_pkcs11.sh`.

* **Audit chain** : append-only JSON Lines with HMAC-SHA-256 chained MAC.
  `freehsm-audit dump` (human-readable) + `freehsm-audit verify` (chain
  integrity) tools shipped in `/opt/freehsm/bin/`.

* **Reproducible build** :
  * `Dockerfile.build` pinning GCC + binutils + OpenSSL.
  * `make dist-baseline` to record the reference digest.
  * `make dist-verify` to compare a local build against the reference.

* **Documentation** :
  * `docs/AGD_PRE.fr.md` §7 ("Portage Debian 13") + §8 ("Validation
    cryptographique end-to-end") with the 3-step OpenSSL interop procedure
    and 7 acceptance criteria.
  * `docs/AGD_PRE.md` §7 (English, bilingual parity).
  * `CHANGELOG.md` (this file).

* **Tests** :
  * `tests/integration_pkcs11.sh` (17 assertions, end-to-end slot
    lifecycle).
  * `tests/multi_slot_pkcs11.sh` (13 assertions, slot isolation).
  * `tests/full_crypto_pkcs11.sh` (20 assertions across AES-GCM,
    AES-CBC-PAD, AES-CMAC, SHA, HMAC, ECDSA + external OpenSSL verify,
    RSA + external OpenSSL verify, RSA-OAEP roundtrip).
  * `tests/interop_python.py` (alternative client via `python-pkcs11` +
    `cryptography`, demonstrates interop beyond OpenSC).
  * `tests/mlkem_e2e.c` (direct dlsym C harness for ML-KEM Encap+Decap
    round-trip).

### Changed

* **Build hardened against gcc 14** : the strict-warning flags
  (`-Wmissing-prototypes`, `-Wstringop-truncation`, `-Wmisleading-indentation`,
  `-Werror=array-bounds`, etc.) all clean. 19 ports fixes applied during
  the Debian 12 → Debian 13 migration are documented in
  `docs/AGD_PRE.fr.md` §7.

* **Makefile install target** : rewritten without dash-incompatible
  `<<-EOF`+`if`/`fi` constructs that broke on Debian 13's `/bin/sh` ->
  `dash`. Now uses pure `printf` and `test x || command`.

* **PKCS#11 ABI bug fixed** : `CK_VERSION` is two `CK_BYTE` (1 byte each),
  not two `unsigned short` ; the wrong width was shifting downstream
  fields by 2 bytes and showing empty manufacturer / version 3.0 instead
  of 3.2 in `--show-info`.

* **OpenSC interop aliases** :
  * `CKM_AES_CMAC` accepted at both `0x108C` (spec) and `0x108A`
    (OpenSC `pkcs11-tool --mechanism AES-CMAC` legacy bug).
  * `CKM_ECDH1_COFACTOR_DERIVE` (`0x1051`) accepted as alias for
    `CKM_ECDH1_DERIVE` (`0x1050`) since pkcs11-tool's `--derive`
    sends the cofactor variant by default.

* **Public key DER parsing** in `C_DeriveKey` and `C_EncapsulateKey`
  now handles three input formats : raw uncompressed point, DER
  `OCTET STRING` wrapper, and DER X.509 `SubjectPublicKeyInfo`.

### Fixed

* `CKM_AES_CMAC` was defined as `0x108A` in the generated header (legacy
  OpenSC bug) ; corrected to `0x108C` per PKCS#11 v3.2 §A.4.1.
* `EVP_PKEY_get_octet_string_param("encoded-pub-key", ...)` two-pass
  query+fill pattern used for `CKA_EC_POINT` extraction.
* Buffer-too-small detection (returns `CKR_BUFFER_TOO_SMALL = 0x150`)
  on every query path of `C_Encrypt`, `C_Sign`, `C_Encapsulate`.
* PBKDF2 KAT vector adjusted to satisfy FIPS lower bounds (password ≥ 14,
  salt ≥ 16 bytes) which were rejecting our "smoke" vector.

### Known limitations

* `C_GetInterface` / `C_GetInterfaceList` are not yet exposed because
  the first attempt segfaulted `pkcs11-tool` (root cause TBD ; the
  `CK_FUNCTION_LIST_3_0` extended layout is required). `C_EncapsulateKey`
  / `C_DecapsulateKey` remain reachable via `dlsym` for now.
* `AES-CTR` decrypt is wired in the module but `OpenSC pkcs11-tool` of
  Debian 13 doesn't expose `--decrypt --mechanism AES-CTR` ; the
  integration test skips it transparently with an explanatory message.
* `ML-DSA` / `SLH-DSA` sign+verify wire is in place but `pkcs11-tool` of
  Debian 13 doesn't recognize these mechanism names. Tested via
  `tests/mlkem_e2e.c` for ML-KEM ; ML-DSA can be exercised similarly
  through a custom harness.


## [1.0.0-FIPS] --- 2025-12

Initial C reimplementation of the original Python proof-of-concept.
Scope : minimal PKCS#11 v3.0 with AES-GCM and SHA-2, FIPS 140-3 §7.10
self-tests, integrity boot check, token store scaffold.

* 6 smoke KATs
* Slot 0 hard-coded
* `C_Initialize` / `C_Finalize` / `C_GetInfo`
* Audit chain HMAC
* Reproducible build infrastructure (Dockerfile.build)
* Documentation : `AGD_PRE` and `AGD_OPE` skeletons in EN+FR
