feat(signature): ML-DSA-44/65/87 and the AKP key type via OpenSSL 3.5 (RFC 9964) - #735
Merged
Conversation
… (RFC 9964)
RFC 9964 registers ML-DSA (FIPS 204) for JOSE: the "ML-DSA-44",
"ML-DSA-65" and "ML-DSA-87" algorithms, the "AKP" key type and its
"pub" and "priv" parameters. It is the first post-quantum signature in
the JOSE registries and the library had nothing for it.
AKPKey carries the plumbing, as OKPKey does for the Edwards curves: the
sizes of FIPS 204, the checks of RFC 9964 section 7.3 run before OpenSSL
sees a key ("alg" present and equal, "pub" of the parameter set, "priv"
the 32-byte seed and nothing else, "pub" the key the seed expands to),
key generation from a fresh or a stored seed, the PEM forms of RFC 9881
(seed-only PrivateKeyInfo, SubjectPublicKeyInfo) and their loading, the
digest-less openssl_sign()/openssl_verify(). MLDSA and its three
subclasses implement SignatureAlgorithm on top of it; a signature of
the wrong length is false before any computation.
The computation is OpenSSL's and needs two things, both checked at
runtime: PHP 8.4, the first version whose openssl_sign() accepts a null
digest, and an OpenSSL library that provides ML-DSA (3.5). The second
cannot be read from OPENSSL_VERSION_TEXT, which reports the headers PHP
was built against and not the library it loaded - this very machine
reports 3.0.13 while running 3.5.5 - so the gate loads the all-zero-seed
key of RFC 9964 appendix A once per process. isSupported() tells, the
constructor throws a MissingDependencyException naming the missing
piece, and the bundle registers the algorithms only when they can run.
JWK::thumbprint() hashes "alg", "kty" and "pub" for an AKP key, which
the RFC 7638 member list would have silently dropped, and refuses a key
without "alg"; toPublic() strips "priv". JWKFactory::mldsa() generates
or rebuilds a key from its seed. KeyConverter intercepts an ML-DSA PEM
before OpenSSL, so that a public key loads on any platform, and reads
the SubjectPublicKeyInfo of a certificate whose key OpenSSL refuses.
MLDSAKeyAnalyzer checks the key structure; key:generate:mldsa and
keyset:generate:mldsa generate keys.
The fixtures of RFC 9964 appendix A.1, of the NIST ACVP FIPS 204 files
and of the OpenSSL command line come from web-auth/cose-lib, where the
COSE side of the same RFC was implemented. The CI matrix says which
side of the gate each job expects through JOSE_ML_DSA_EXPECTED, and a
new job runs PHP 8.4 on the OpenSSL 3.0 of ubuntu-24.04 for the other
side.
Backward compatibility was verified: the reflection dump of the public
API only shows additions, and the behavioural probes run against 4.3.x
are identical, except that toPublic() now strips a "priv" member on any
key type. Verified on PHP 8.3 (no ML-DSA) and 8.5.
Closes #723
Spomky
added a commit
to web-token/jwt-doc
that referenced
this pull request
Sep 13, 2026
web-token/jwt-framework#735 adds the ML-DSA-44/65/87 algorithms and the AKP key type. The key type and algorithm tables gain the entries, the signature algorithms page gets a section with the key shape (seed as "priv", "alg" required), the checks and the runtime platform requirement behind isSupported(), the key factory page the AKP generation, the seed form and the PEM forms the loader reads, the console page the two generator commands, the prerequisites and the README the requirement, and the 4.2 → 4.3 guide the feature entry.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #723
What
RFC 9964 registers ML-DSA (FIPS 204) for JOSE —
ML-DSA-44,ML-DSA-65,ML-DSA-87, theAKPkey type and itspub/privparameters. First post-quantum signature in the registries; the library had nothing for it. The design mirrors the COSE side implemented in web-auth/cose-lib#223.Plumbing —
Jose\Component\Core\Util\AKPKey(@internal)checkKey()— RFC 9964 §7.3 before OpenSSL sees the key:kty,algpresent and equal,pubof the parameter set (1312/1952/2592),privexactly the 32-byte seed (the expanded key is rejected), andpub= expansion of the seed (hash_equals, §7.4).generate($alg, ?$seed),publicKeyFromSeed(),sign()/verify()with digest0.PrivateKeyInfo(seed [0]),SubjectPublicKeyInfo.loadFromPEM()also reads theboth [2]choice and refusesexpandedKey [1]alone.supportsOpenSSL()= PHP ≥ 8.4 and a runtime probe (loading the all-zero-seed key of appendix A, cached for the process).OPENSSL_VERSION_TEXTis the compile-time header — the machine this was developed on reports 3.0.13 while running 3.5.5, so the probe is not theoretical.Algorithms —
MLDSA+MLDSA44/65/87isSupported(); the constructor throwsMissingDependencyExceptionnaming the missing piece (PHP version or OpenSSL library); key check before OpenSSL; signature length checked before any computation (false, never an exception);sign()refuses a key withoutpriv.Keys, converter, analyzer, console
JWK::thumbprint(): AKP case overalg,kty,pub(§6); an AKP key withoutalgthrows.toPublic()stripspriv.JWKFactory::mldsa($alg, $values)(+JWKFactoryInterface, unreleased): random seed, or rebuilt fromvalues['priv']. NocreateMLDSAKey()static: the static creators are all deprecated in 4.3.KeyConverter: an ML-DSA PEM is intercepted before OpenSSL (a public key loads on any platform); a certificate whose key OpenSSL refuses has its SPKI read by ASN.1. A certificate signed with ML-DSA is not loadable (pki-framework 1.6), documented.MLDSAKeyAnalyzer;key:generate:mldsa,keyset:generate:mldsa.Bundle
signature_mldsa.phploaded whenMLDSA44::isSupported()(aliasesML-DSA-44/65/87); analyzer and commands wired. Container compiles on both sides.CI
ml-dsa: yes/noper matrix entry →JOSE_ML_DSA_EXPECTED, asserted byMLDSASignatureTest; newtests_without_ml_dsajob (pinnedubuntu-24.04+ setup-php 8.4 on OpenSSL 3.0, expectsno) so a runner drifting to 3.5 fails instead of silently losing the coverage.Acceptance criteria
pub; eachkidisthumbprint('sha256')✔keyGenseed →pkcases, 8 pure-mode empty-contextsigGencases ✔priv≠ 32, wrongpublength, missing/otheralg,pub≠ seed) rejected before OpenSSL; 2419-byte or tampered signature isfalse✔isSupported()false, constructor throws, bundle compiles, suite green withJOSE_ML_DSA_EXPECTED=no(112 skips) ✔ — the OpenSSL 3.0 side runs in the new job.Fixtures vendored from cose-lib with provenance (
tests/fixtures/rfc9964/,tests/fixtures/nist-acvp/ml-dsa/);appendix-a.jsontrimmed to its JOSE half.Backward compatibility
Reflection dump of the public API vs 4.3.x: 44 additions, no removal or change. 48 behavioural probes (thumbprints of the four existing key types,
toPublic(), every key and certificate of the test fixtures, error paths): identical, except thattoPublic()now strips aprivmember on any key type — deliberate (privis a registered private parameter), flagged for review.Also in this PR: RFC citations removed from runtime messages (exception/deprecation/analyzer/console texts), including three merged in #732/#734; references stay in the docblocks.
QA
PHPUnit (8.3, 8.5), ECS, Rector, Deptrac, PHPStan green; PHPStan baseline unchanged.
Out of scope
HashML-DSA, non-empty
ctx, the expanded private key aspriv, ML-KEM/HPKE, SLH-DSA.Documentation: web-token/jwt-doc.