Skip to content

Add the ML-DSA-44, ML-DSA-65 and ML-DSA-87 signature algorithms and the AKP key type (RFC 9964) - #198

Merged
TheStormN merged 2 commits into
cisco:mainfrom
OpenIDC:jws-ml-dsa
Sep 15, 2026
Merged

TheStormN merged 2 commits into
cisco:mainfrom
OpenIDC:jws-ml-dsa

Conversation

@zandbelt

Copy link
Copy Markdown
Contributor

Summary

Adds the ML-DSA signature algorithms ML-DSA-44, ML-DSA-65 and ML-DSA-87 of RFC 9964, and the AKP key type they sign with. Two commits: the key type, then the algorithms.

This is the first thing I have proposed here that is not RFC 7518, so the first question is whether you want it at all. RFC 9964 is a Proposed Standard and all four registrations are live in the IANA JOSE registries, but with an implementation requirement of Optional, and the underlying FIPS 204 needs OpenSSL 3.5 where this project's floor is 3.0. So it is off by default and costs nothing to anyone who does not ask for it. If you would rather cjose stayed on RFC 7518 for now, say so and I will close this without argument.

  • Build gate. CJOSE_ENABLE_ML_DSA, OFF by default, and configuration fails with a clear message on OpenSSL below 3.5 rather than leaving it to a link error. With the option off the code is compiled out and the three new functions refuse with CJOSE_ERR_INVALID_ARG, which is how CJOSE_ENABLE_RSA1_5 already behaves. They keep their place in the ABI either way, so the export lists do not depend on the option.
  • The AKP key type. kty: "AKP" with the key in pub and priv, and alg REQUIRED — unlike every other key type, since the key type alone does not say which algorithm the key belongs to (RFC 9964 section 3). For ML-DSA the priv member is the 32 octet seed, not the expanded private key (section 4), and the 32 octet length check is a MUST (section 7.3). OpenSSL grows the expanded key from the seed, so a JWK round trips through the seed and the expanded form never leaves the EVP_PKEY.
  • Mismatched keys. Section 7.4 warns that a pub which does not belong to the private key is tampered or mismatched. A supplied seed therefore has its public key derived and compared in constant time rather than being taken on the caller's word, which is what the OKP import already does for x and d. With only a seed, pub is derived, as cjose_jwk_create_OKP_spec derives x from d.
  • Signing. Pure ML-DSA, Algorithm 2 of FIPS 204, over the JWS signing input with the empty context string the RFC requires — which is what OpenSSL does when no signature parameter is set. HashML-DSA is outside RFC 9964 and is not offered. Like PureEdDSA there is no pre-hash, so the two share the step that prepares the signing input; it is renamed from _cjose_jws_build_dig_eddsa now that it serves more than one family.
  • API. CJOSE_JWK_KTY_AKP, cjose_jwk_akp_alg, cjose_jwk_akp_keyspec, cjose_jwk_create_AKP_random, cjose_jwk_create_AKP_spec, cjose_jwk_AKP_get_alg, and the macro-only CJOSE_HDR_ALG_ML_DSA_44/65/87. CJOSE_JWK_KTY_AKP is appended to cjose_jwk_kty_t, which is an ABI change and so belongs in the 1.0.x cycle rather than a patch release.

Things worth a second opinion

  • The key keeps its own copy of the 32 octet seed, wiped on release. I would normally avoid a second copy of private material, but an OpenSSL configured with ml-dsa.retain_seed=no will not hand the seed back, and a key that can sign but can never be serialised is worse. rsa_keydata already keeps its BIGNUMs for the same round-tripping reason. cjose_jwk_create_AKP_random generates the seed itself rather than asking the provider for it afterwards, for the same reason.
  • The AKP import deliberately does not use _cjose_jwk_decode_private_attribute. That helper refuses a private member whose octets are all zero, which is right for RSA and EC where the member is an integer and zero is not a key. An ML-DSA priv is 32 opaque octets of seed, and the all-zeros seed is a good one — it is what RFC 9964 Appendix A.1 uses for all three of its examples. The import does its own presence and length check instead, with a comment saying why, since the obvious tidy-up would break the RFC's own vectors.

Testing

The tests are the three JOSE examples of RFC 9964 Appendix A.1. Their seed is all zeros, so the public key is reproducible: each JWK must import and export back to exactly the public key the RFC prints, and each of the RFC's JWSs must verify and yield the RFC's payload. Those are known-answer tests over signatures this library did not produce, not round trips.

Beside them: a random round trip per algorithm; a seed-only spec that must derive the RFC's public key; and the negative cases — a missing, unknown or foreign alg, a missing, empty or wrongly sized pub including a well formed one of another ML-DSA algorithm, a seed of 31 or 33 octets, a seed that does not match pub, a public-only signing key, an alg naming another ML-DSA algorithm, an ML-DSA header over a key of another type, a JWS verified under the wrong algorithm's key, and a signature of the wrong length or with a byte changed.

-pedantic -Wall -Werror clean and the full check_cjose run in three configurations: ML-DSA on (145 checks), off (139) and on together with CJOSE_ENABLE_RSA1_5 (144). valgrind over the whole suite: no leaks, no errors. The clang-format target produces no diff. Each commit builds and passes on its own.

openssl.yml turns the option on for the three matrix entries that have ML-DSA (3.5.8, 3.6.4, 4.0.2) and leaves it off below that, so CI actually compiles and exercises the new code. Without that it would not: ubuntu-latest is on OpenSSL 3.0.13, so build.yml cannot enable it and a green run would only prove the OFF path still works. Move it elsewhere if you would rather it lived in another workflow.

🤖 Generated with Claude Code

zandbelt and others added 2 commits September 15, 2026 11:25
RFC 9964 registers "AKP" with the key itself in "pub" and "priv" and the
algorithm in "alg", which unlike every other key type is REQUIRED there: the
key type alone does not say which algorithm the key belongs to. For the ML-DSA
algorithms of US NIST FIPS 204 "priv" is the 32 octet seed rather than the
expanded private key, and section 7.3 makes that length check a MUST. OpenSSL
grows the expanded key from the seed itself, so a JWK round trips through the
seed and the expanded form never leaves the EVP_PKEY.

Section 7.4 warns that a "pub" which does not belong to the private key is a
tampered or mismatched key. A supplied seed therefore has its public key
derived and compared, in constant time, rather than being accepted on the
caller's word, which is what the OKP import already does for "x" and "d".

The algorithms arrived in OpenSSL 3.5, well above the 3.0 the rest of cjose
builds on, and RFC 9964 registers them as OPTIONAL. So the implementation sits
behind the CJOSE_ENABLE_ML_DSA build option, off by default and refused at
configure time on an older OpenSSL, and cjose_jwk_create_AKP_random,
cjose_jwk_create_AKP_spec and cjose_jwk_AKP_get_alg keep their place in the
ABI either way, failing with CJOSE_ERR_INVALID_ARG when the option is off.
That is the treatment RSA1_5 already gets.

The tests are the three JOSE examples of RFC 9964 Appendix A.1, whose seed is
all zeros and whose public key is therefore reproducible: each must import,
export back to exactly the public key the RFC prints, and keep "priv" out of a
public export. Beside them a random round trip per algorithm and the negative
cases: a missing, unknown or foreign "alg", a missing, empty or wrongly sized
"pub" including a well formed one of another ML-DSA algorithm, a seed of 31 or
33 octets, and a seed that does not match "pub".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Hans Zandbelt <hans.zandbelt@openidc.com>
…9964)

RFC 9964 registers the three ML-DSA algorithms of US NIST FIPS 204 for JOSE,
signing with the AKP key type the previous commit added. They are pure ML-DSA,
Algorithm 2 of FIPS 204, over the JWS signing input with the empty context
string the RFC requires, which is what OpenSSL does when no signature
parameter is set; HashML-DSA is outside the specification and is not offered.

Like PureEdDSA there is no pre-hash, so the two share the step that prepares
the signing input, renamed from _cjose_jws_build_dig_eddsa now that it serves
more than one algorithm family. The signature is the fixed size of the
algorithm, which is what EVP_PKEY_get_size reports for ML-DSA rather than an
upper bound, so it is also what an attacker-supplied signature is measured
against before OpenSSL sees it.

An AKP key carries its own algorithm, so the "alg" header has to name that
same one: signing an ML-DSA-44 key under an ML-DSA-65 header is refused, in
_cjose_jws_validate_verify_key with every other family and again at the point
of use. A public-only key is refused before OpenSSL because EVP_DigestSignInit
succeeds on one and only the signing itself fails, which is the shape of the
EdDSA crash this library has already had once.

The tests are the three JOSE examples of RFC 9964 Appendix A.1: each JWS must
verify under the public key the RFC prints and yield the RFC's payload, which
makes them known-answer tests over signatures this library did not produce.
Beside them a sign and verify round trip per algorithm and the negative cases:
a public-only signing key, an "alg" naming another ML-DSA algorithm, an
ML-DSA header over a key of another type, a JWS verified under the wrong
algorithm's key, and a signature of the wrong length or with a byte changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Hans Zandbelt <hans.zandbelt@openidc.com>
@TheStormN

Copy link
Copy Markdown
Contributor

Do we have anything else already implemented here that is part of a 'proposed' RFC? In general I would like to stick to already approved RFCs than implementing drafts, unless there is some real business need for it.

@zandbelt

Copy link
Copy Markdown
Contributor Author

that's just IETF wording, RFC 7518 is also a "proposed standard", see also https://www.ietf.org/process/rfcs/#statuses

@TheStormN
TheStormN merged commit 8d1afeb into cisco:main Sep 15, 2026
27 checks passed
@TheStormN

Copy link
Copy Markdown
Contributor

@zandbelt After I merged the PR I noticed that you haven't updated the README file to mention the new algorithms and their OpenSSL requirements. Also an update on the CHANGELOG would be nice. Could you please submit another PR to update them?

@zandbelt

Copy link
Copy Markdown
Contributor Author

Done in #199.

On the README: #198 did update it — the JWS alg table, the JWK kty table and the build options table all gained a row naming CJOSE_ENABLE_ML_DSA and OpenSSL >= 3.5, with a paragraph on pure ML-DSA and why HashML-DSA is not offered. The one spot it missed is Prerequisites → Libraries, which said OpenSSL >= 3.0.0 and nothing else; #199 fixes that line and leaves the rest alone.

The CHANGELOG turned out to need more than ML-DSA. The 1.0.0 (unreleased) section had only the Breaking list from #187, so nothing merged after it was recorded — #185, #186, #188, #189, #190, #191, #192, #193 and #196 as well as #198. All of them now have entries.

I also noticed main had no 0.8.1 section at all: you released it from the 0.8.x branch, where its section still lives, so this file went straight from the unreleased 1.0.0 to 0.8.0. #199 copies that section over unchanged. Drop either half if you would rather keep them separate.

@zandbelt
zandbelt deleted the jws-ml-dsa branch September 15, 2026 14:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants