ML-DSA¶
RFC 9964 registers ML-DSA, the module-lattice signature scheme of
FIPS 204, for COSE, the first post-quantum signature in the registry, together with the key type it is carried
in, AKP (see Key types). The three parameter sets live in the Cose\Algorithm\Signature\MLDSA
namespace.
| Algorithm | Identifier | Description | Reference |
|---|---|---|---|
| ML-DSA-44 | -48 | ML-DSA with the FIPS 204 parameter set of security category 2; 1312-byte public key, 2420-byte signature | RFC 9964 §5 |
| ML-DSA-65 | -49 | ML-DSA with the parameter set of security category 3; 1952-byte public key, 3309-byte signature | RFC 9964 §5 |
| ML-DSA-87 | -50 | ML-DSA with the parameter set of security category 5; 2592-byte public key, 4627-byte signature | RFC 9964 §5 |
The three are pure ML-DSA (FIPS 204 algorithm 2) with the empty context string, which is all RFC 9964 allows:
HashML-DSA is not registered (§7.2 explains why), and a non-empty ctx is forbidden (§5). The private key is the
32-byte seed of FIPS 204 (§4) and nothing else: the expanded private key of FIPS 204 is not a representation the RFC
allows, and AkpKey refuses a priv of that size.
Keys¶
An ML-DSA key is an AkpKey: kty 7, the required alg naming the parameter set, pub (-1) holding the
encoded public key of FIPS 204 §7.2, and, on the signing side, priv (-2) holding the seed. The algorithm expands
a seed into the key pair, which is how a key is generated, from random_bytes(32), and how a stored seed is
turned back into a key:
use Cose\Algorithm\Signature\MLDSA\MLDSA65;
use Cose\Key\AkpKey;
use Cose\Key\Key;
$algorithm = MLDSA65::create();
$key = $algorithm->keyPairFromSeed(random_bytes(32)); // AkpKey: alg -49, pub (1952 bytes), priv (the seed)
$key->pub(); // FIPS 204 pkEncode() output
$key->priv(); // the 32-byte seed
$key->toPublic(); // the same key without "priv"
// The key as it travels, or as a stored credential is rebuilt:
$key = AkpKey::create([
Key::TYPE => Key::TYPE_AKP,
Key::ALG => MLDSA65::ID,
AkpKey::DATA_PUB => $pub,
AkpKey::DATA_PRIV => $seed, // omitted on the verifying side
]);
Key::createFromData() dispatches kty 7, as the integer, the '7' string cbor-php decodes it to, or the name
AKP, to AkpKey. asPEM() writes the RFC 9881 forms OpenSSL reads: a seed-only PrivateKeyInfo (the seed [0]
choice of ML-DSA-PrivateKey) for a private key, a SubjectPublicKeyInfo for a public one. PublicKeyLoader reads
the SubjectPublicKeyInfo back, from a bare structure or from the certificate a classical CA issued for the key, into
an AkpKey carrying the alg the OID names. A certificate signed with ML-DSA cannot be read yet:
spomky-labs/pki-framework does not know the ML-DSA signature algorithm identifiers.
Signing and verifying¶
$signature = $algorithm->sign((string) $toBeSigned, $key); // 3309 bytes for ML-DSA-65
$isValid = $algorithm->verify((string) $toBeSigned, $key->toPublic(), $signature); // bool
Signing is randomised (the hedged variant of FIPS 204, OpenSSL's default): two signatures over the same input differ, and both verify.
What is checked before OpenSSL is called¶
AkpKeyrejects, when the key is built, apubwhose length is not the one of the parameter set named byalgand aprivthat is not 32 bytes (RFC 9964 §4, §5, §7.3: "the seed length check MUST be performed"). A malformed key is therefore refused when it is first seen, as the verification contract promises for every key type.- The algorithm refuses an AKP key without
alg: the type says nothing about the algorithm, and §3 makes the parameter REQUIRED. It also refuses a key whosealgis another parameter set, whether or not the key restrictions are enforced: for this key type,algis whatcrvis to an EC2 key, not a usage restriction laid over it. With the restrictions enforced,key_opsis checked as for any algorithm. - When the key carries both halves, the public key is recomputed from the seed and compared in constant time: a
mismatched pair (§7.4, whose consequences "can range from operations failing to private key compromise") is
rejected on both
sign()andverify(). - A signature of any length other than the table's is invalid (FIPS 204 algorithm 3, step 1):
verify()returnsfalsewithout loading the key.
The platform gate¶
ML-DSA is computed by OpenSSL, which ships it in its default provider as of 3.5, and needs the digest-less
openssl_sign() that PHP only offers as of 8.4. OPENSSL_VERSION_TEXT reports the headers PHP was compiled
against, not the library it loaded (a PHP built against 3.0 and running on 3.5 is common), so the OpenSSL check is
a runtime probe: an ML-DSA key is loaded once per process. MLDSA44::isSupported(), which the three classes share,
answers for both conditions; create() throws a RuntimeException naming the missing piece. Register the
algorithms conditionally when the platform is not known in advance:
use Cose\Algorithm\Signature\MLDSA\MLDSA44;
use Cose\Algorithm\Signature\MLDSA\MLDSA65;
use Cose\Algorithm\Signature\MLDSA\MLDSA87;
if (MLDSA44::isSupported()) {
$manager->add(MLDSA44::create(), MLDSA65::create(), MLDSA87::create());
}
The thumbprint of an AKP key is computed over kty, alg and pub (RFC 9964 §6), see
Key thumbprints. examples/16-ml-dsa.php reproduces the
COSE example of RFC 9964 Appendix A, thumbprint included, and signs a COSE_Sign1 with a fresh key.