An open-source PKCS#11 v3.2 cryptographic module under Simorgh Labs
CKM_HYBRID_ED25519_ML_DSA_65 is not Composite ML-DSAEstablished 2026-08-03, while scoping #112.
Reference read: draft-ietf-lamps-pq-composite-sigs-19, 21 April 2026, IESG state RFC Editor Queue.
Specification status, re-checked 2026-09-02. Still
-19; no newer revision. The state moved on 26 August 2026 — RFC Editor status is now In Progress (First Edit), IANA is atRFC-Ed-Ack, action holders none. No RFC number assigned. The draft’s own expiry is 23 October 2026, which is a property of Internet-Drafts and not a deadline on the queue. So: advancing, not published, and nothing in it has changed under us since this document was written.On FIPS, §10.2 of the draft is the authority and it disclaims itself in its own second sentence — “This guidance is not authoritative and has not been endorsed by US NIST” — before setting out the design goal that a composite “be able to be considered FIPS-approved”. That goal is about certification, which does not apply here (see Certification in
README.md), soCKM_COMPOSITE_MLDSA65_ED25519is all-mechanisms-only as a settled choice and not as a state awaiting the RFC. Publication will lift the interoperability limit — third-party CAs accepting these requests — and will not change the profile question, because a mechanism entersnist-approved-onlyby being approved, not by being argued for.One line of §10.2 bears on #168 and is worth quoting where it will be found: the ML-DSA seed “MUST be the direct output of a FIPS-approved Deterministic Random Bit Generator (DRBG)”.
fhsm_drbgis not one and will not become one. That is the specification saying, independently, whatdocs/ROADMAP.mdconcluded from measurement on 2026-09-01.
The mechanism works and does something defensible. What is wrong is the label on it: three places in this repository cite the LAMPS composite draft as the mechanism’s reference, and the mechanism does not implement that draft. Citing a specification one does not implement is the kind of error that costs a reader their time and the project its credibility, whoever the reader is.
Composite ML-DSA does not sign the message. It signs a message representative built by a signature combiner (§2.2, §3.2):
M' := Prefix || Label || len(ctx) || ctx || PH( M )
Prefix = "CompositeAlgorithmSignatures2025" (32 bytes, ASCII)
Label = a value unique to each algorithm combination (§6)
ctx = application context, 0-255 bytes
PH = the pre-hash function for that combination
mldsaSig = ML-DSA.Sign( mldsaSK, M', mldsa_ctx = Label )
tradSig = Trad.Sign( tradSK, M' )
s = SerializeSignatureValue( mldsaSig, tradSig )
Note mldsa_ctx = Label: the per-algorithm label is also passed down as the
FIPS 204 context string, so the ML-DSA component is bound twice.
dispatch_hybrid_ed25519_ml_dsa_65 (src/dispatch/fhsm_dispatch_hybrid.c):
sig_ed = Ed25519.Sign( sk_ed, M ) /* the raw message */
sig_pq = ML-DSA-65.Sign( sk_pq, M ) /* the raw message, no ctx */
out = sig_ed || sig_pq
1. No signature combiner, therefore no non-separability. Both components sign the bare message. The Ed25519 output is consequently a valid standalone Ed25519 signature over that same message: anyone can lift it out of the concatenation and present it as a plain Ed25519 signature. §2.2 states the Label exists precisely to “protect against component signature values being removed from the composite and used out of context”, and §9.2.3 is devoted to non-separability. This is a missing security property, not a formatting difference.
2. No mldsa_ctx = Label. The ML-DSA component is not bound to the
combination it belongs to.
3. No pre-hashing. The draft signs PH(M); we sign M. Beyond conformance,
this is the operational point of §2.1 — a composite otherwise has to stream the
whole message to both primitives.
4. Component order is reversed. The draft serializes
(mldsaSig, tradSig); we emit Ed25519 first. Trivial on its own, fatal for
interoperability.
5. No composite AlgorithmIdentifier. §6 registers an OID per combination.
Without one the signature cannot appear in an X.509 certificate, a CSR, or a CMS
structure at all — which is the entire point of the specification, and the
entire point of #112.
It is a reasonable PQ/T hybrid of local design: two independent signatures, both
required to verify, giving defence in depth against a break in either primitive.
That is worth having and is what the mechanism name says —
CKM_HYBRID_ED25519_ML_DSA_65, a vendor mechanism at 0x80004201, “hybrid”,
not “composite”.
The error is entirely in the citations. They are now corrected: the mechanism is described as what it is, and the draft is referenced as the thing #112 will implement rather than as the thing this code follows.
The KEM combiner in the same file does it correctly:
ss = SHA3-256( ss_x25519 || ss_pqkem || ct_x25519 || ct_pqkem
|| "HYBRID-X25519-ML-KEM-768" )
A domain-separation label, hashed in, exactly as it should be. The technique was understood and applied on one path and not the other, fifty lines apart. That is the same defect shape this project has now found seven times in its own code — a control wired to some of the paths that reach a state and not the rest — and it turns out to be sitting in the flagship feature.
It survived because nothing tested it against anything external. The KAT vectors are self-generated: they check that our verify accepts our sign, which is true of any self-consistent construction, including a wrong one.
The PKI tooling cannot issue draft-conforming composite certificates on top of this mechanism. Implementing Composite ML-DSA properly therefore becomes task zero of #112, ahead of any CA ergonomics — a CA that issues certificates nothing else can verify is worth less than no CA.
The work is well-bounded, because the draft is finished and generous:
MLDSA65-Ed25519 is the natural first target since both primitives
are already in the module.MLDSA65-Ed25519-SHA512Collected 2026-08-03 from the working group’s repository — Composite-MLDSA-2025.asn
and src/algParams.md, the file {::include}-ed into §6, which is why the labels
are not in the draft’s own markdown.
The target combination, and it is the draft’s own recommendation: §10.4 says
that for applications requiring the signature primitive to provide SUF-CMA,
id-MLDSA65-Ed25519-SHA512 is the one to focus implementation effort on. Both
primitives are already in this module.
| OID | 1.3.6.1.5.5.7.6.48 — { pkix(7) alg(6) 48 } |
| Label | COMPSIG-MLDSA65-Ed25519-SHA512 (ASCII, 30 bytes) |
Pre-hash PH |
SHA-512 |
| ML-DSA variant | ML-DSA-65 |
| Traditional | Ed25519 (id-Ed25519) |
| Key usage permitted | digitalSignature, nonRepudiation, keyCertSign, cRLSign |
§6 is explicit that labels are written as ASCII in the document and implementations MUST convert them to their ASCII byte values before concatenation.
Prefix = "CompositeAlgorithmSignatures2025" (32 bytes)
436F6D706F73697465416C676F726974686D5369676E61747572657332303235
Label = "COMPSIG-MLDSA65-Ed25519-SHA512" (30 bytes)
434F4D505349472D4D4C44534136352D456432353531392D534841353132
M' = Prefix || Label || len(ctx) || ctx || SHA-512(M)
with an empty ctx: 32 + 30 + 1 + 0 + 64 = 127 bytes
mldsaSig = ML-DSA-65.Sign( mldsaSK, M', mldsa_ctx = Label )
edSig = Ed25519.Sign( edSK, M' )
sig = mldsaSig || edSig /* that order; no ASN.1 wrapping */
Note the Label appears twice and in two roles: inside M', and as the FIPS 204
context string handed to the ML-DSA primitive. Both are required.
Neither the public key nor the signature value carries ASN.1 wrapping
(-- KEY no ASN.1 wrapping --, -- VALUE no ASN.1 wrapping -- in the module),
consistent with §4 serializing by plain concatenation of the component encodings.
So concatenating was never the error — the order was, and everything upstream of
it.
Appendix D publishes two fully worked M' constructions. Both were reproduced
byte for byte before any code was written, and both are now in the repository as
kat/composite/mprime_appendix_d.txt:
M' length |
reproduced | |
|---|---|---|
| empty ctx | 130 bytes | yes |
| 8-byte ctx | 138 bytes | yes |
They use id-MLDSA65-ECDSA-P256-SHA512, because that is the combination the
draft chose to work through, but the combiner is identical apart from the label,
so they validate the construction our implementation has to share. The 130 and
138 differ from our 127 only because that label is 33 bytes against our 30.
These vectors are in the tree before the implementation on purpose. The reason the existing hybrid could sign the wrong thing for months is that its KATs were self-generated: they establish that our verify accepts our sign, which is true of any self-consistent construction, a wrong one included. This time the check exists first and it is somebody else’s arithmetic.
Signature-level vectors for our own combination, from Appendix E — in the
working group’s repository as src/testvectors_wrapped.json. Those exercise the
component signatures and the serialization; the combiner is already covered
above. Everything needed to start writing the code is now in hand.
fhsm_composite_csr produces a PKCS#10 request carrying a composite key.
Handed to the openssl command-line tool — a program sharing no code with this
project — the result splits cleanly in two, and the distinction matters more
than either half.
The structure is interoperable. openssl asn1parse walks the whole request:
24 elements, zero errors. openssl req -text reports the version, the subject
C=FR, O=Simorgh Labs, CN=composite.example, both algorithm identifiers, and
the signature value. The subjectPublicKey BIT STRING is 1985 bytes (one
unused-bits octet plus the 1984-byte composite key) and the signature BIT STRING
is 3374. This is the “protocol backwards compatibility” the draft claims in
§1.3 — a composite fits existing PKIX structures without the parser needing to
be hybrid-aware — and it now has a measurement behind it rather than a citation.
The signature cannot be verified by OpenSSL, and this is not a defect in the
request. openssl req -text reports:
Warning: error while verifying CSR self-signature
Public Key Algorithm: 1.3.6.1.5.5.7.6.48
Unable to load Public Key
...X509_PUBKEY_get0:decode error
...int_ctx_new:unsupported algorithm
OpenSSL 3.5.6 has no implementation of Composite ML-DSA — it prints the OID in dotted form rather than by name, which is the same thing said another way: no NID, no decoder, no provider. It cannot verify a signature for an algorithm it does not have. A CA that implements composite would; nothing shipping today does, because the RFC has not published.
The signature is nevertheless over the right bytes, established
independently. tests/test_composite_csr re-derives the to-be-signed region
from OpenSSL’s own parse of the finished request with i2d_re_X509_REQ_tbs
— 2080 bytes, matching what the signing callback was handed — and verifies
against that rather than against our own idea of what was signed. It also
checks the negative: the same signature must not verify over the whole request.
Without that control, a verifier that accepted anything would pass the first
check.
The practical consequence, stated plainly. A composite CSR can be produced, transported, parsed and archived today. It cannot be validated by any generally available tool. Anyone told otherwise will discover it the first time they submit one. That is a limitation of the ecosystem rather than of this code, and it will lift when the RFC publishes and implementations follow — but until then it belongs in any documentation that mentions composite CSRs, and in any answer given to a user who asks whether they can use one.
src/fhsm_drbg.c exists because the module wanted more than RAND_bytes:
multi-source seeding, a SHA-384 conditioner, SP 800-90B health tests (RCT, APT,
CRNGT), auto-reseed every 1 MiB or hour, and — the point — a latching
failure. An alarm puts the module in FHSM_STATE_ERROR and every subsequent
C_* call returns CKR_DEVICE_ERROR, so key material stops being produced the
moment the entropy source is suspect. fhsm_rng_bytes is the way in.
Two places in the composite path go around it.
Key generation. gen_one() calls EVP_PKEY_keygen with a NULL library
context, so both components — the ML-DSA-65 seed and the Ed25519 scalar — come
from OpenSSL’s default RAND. The health tests never see that draw, and an alarm
that would latch the module does not prevent a composite key pair from being
generated.
Certificate serial numbers. fhsm_composite_issue fills its 20 random
octets with RAND_bytes directly. Random serials are load-bearing here: they
are what makes the to-be-signed bytes unpredictable, which is the defence
against a chosen-prefix attack on the signature hash. That defence rests on
entropy the module’s own audited path never inspected.
Neither is exploitable on its own — OpenSSL’s DRBG is not a weak generator, and that is why this is a gap rather than a defect. What is lost is the property the DRBG was built for: that every byte of key material passed the health tests, and that a failing source stops the module instead of quietly producing keys.
The fix has two halves of very different sizes. Serials are easy:
fhsm_rng_bytes in place of RAND_bytes, one line. Key generation is not,
because the seed is drawn inside OpenSSL’s provider — it needs a library
context whose RAND is backed by fhsm_drbg, which is plumbing the module does
not have yet and which would also cover every other EVP_PKEY_keygen call.
Worth stating plainly, since this was carried in conversation as “§10.2
requires the seed to come from fhsm_drbg”: no such requirement was found.
The only §10.2 in these documents is RNG.md citing SP 800-90A §10.2.1.5, on
reseed intervals, which says nothing about this. The gap is real and worth
closing on its own merits — consistency with the module’s stated RNG
guarantees — not because a specification demands it.
Nothing. Until a conforming implementation ships, no announcement, README or
release note may describe this module as implementing composite signatures —
PRIMACY_AUDIT_PQC_COMPOSITE.md §3 says the same thing from the other side.
The mechanism that exists today is ML-DSA and a classical signature produced
side by side, which is a reasonable thing to have and is not what the draft
specifies.