Skip to content

Add RFC 9964 AKP JWK support for ML-DSA keys - #1072

Open
Arpan0995 wants to merge 2 commits into
jwtk:mainfrom
Arpan0995:rfc9964-akp-jwk
Open

Arpan0995 wants to merge 2 commits into
jwtk:mainfrom
Arpan0995:rfc9964-akp-jwk

Conversation

@Arpan0995

@Arpan0995 Arpan0995 commented Aug 5, 2026 •

Copy link
Copy Markdown

This implements the JWK layer for RFC 9964 that I described in #1042: the AKP (Algorithm Key Pair) key type with its pub and priv parameters, and the factory, builder and parser wiring for the ML-DSA parameter sets.

What's included

  • AkpPublicJwk / AkpPrivateJwk API interfaces plus their builder interfaces, mirroring the Octet (OKP) family
  • MlDsaAlgorithm (impl): parameter set identification for keys that have no java.security.interfaces type. Lookup is by JCA algorithm name when the provider reports a specific one (BouncyCastle reports ML-DSA-65), falling back to the algorithm OID in the key's ASN.1 encoding, since the JDK SUN provider reports the generic ML-DSA name for all three sets. This follows the EdwardsCurve.findByKey approach.
  • AkpPublicJwkFactory / AkpPrivateJwkFactory registered in DispatchingJwkFactory, new akpKey / akpKeyPair builder methods, and dispatch from the generic key(PublicKey) / key(PrivateKey) methods
  • JWK thumbprints computed over the RFC 9964 canonical member set (alg, kty, pub)
  • Works natively on JDK 24+ (JEP 497) and via BouncyCastle on earlier JDKs, consistent with how Edwards keys are handled

Design notes

A few decisions worth calling out for review:

  1. priv is the 32-byte seed. RFC 9964 Section 4 requires it ("the priv parameter MUST be the seed and MUST have a length of 32 bytes"). Not every provider retains the seed: JDK 24 through 26 encode ML-DSA private keys as an expanded key only, and the seed cannot be recovered from the expanded form. Creating an AkpPrivateJwk from such a key fails with a clear InvalidKeyException explaining why. Keys parsed from an existing AKP JWK, BouncyCastle keys, and JDK 27+ keys (which switched to the seed encoding) all work. The PKCS#8 parser handles all three ML-DSA-PrivateKey CHOICE alternatives (seed, expandedKey, both); BouncyCastle emits both, JDK 27 emits seed.

  2. Seed import needs an explicit BouncyCastle retry on JDK 24 through 26. Those JDKs have ML-DSA, so the existing NoSuchAlgorithmException-triggered fallback in JcaTemplate never fires, yet their KeyFactory rejects the RFC 9964 seed encoding (InvalidKeySpecException: Cannot parse input). MlDsaAlgorithm retries with BouncyCastle in that case, only when the caller did not specify a provider. I verified the JDK behavior empirically on 24.0.2 and 27-ea, and the BC path on 1.84 (the version this project pins).

  3. Building a private AKP JWK requires the public key. There is no JCA API to derive an ML-DSA public key from a private key, unlike RSA, EC and Edwards keys, so Jwks.builder().key(privateKey).build() alone cannot work. The factory throws an InvalidKeyException directing the caller to Jwks.builder().key(privateKey).publicKey(publicKey).build(), and mismatched parameter sets between the two are rejected.

  4. Strict length validation. Public key material must be exactly 1312, 1952 or 2592 bytes for ML-DSA-44, -65 and -87 respectively, and seeds must be exactly 32 bytes, so malformed JWKs are rejected at parse time rather than surfacing later as provider errors.

Testing

45 new tests across AkpJwksTest and MlDsaAlgorithmTest: round trips for all three parameter sets (build, serialize, parse, and sign with a parsed key), RFC 9964 thumbprint member order, rejection paths (missing or mismatched alg, wrong-length pub and priv, unrecognized kty), and byte-level tests for every PKCS#8 CHOICE alternative and malformed-encoding branch using handcrafted encodings, so they run identically on every JDK. The full suite passes on JDK 17, 21 and 24 locally, and the new and modified files are at 100% method, statement and conditional coverage under the Clover gate.

Scope: this PR is the JWK layer only. Registering ML-DSA-44/65/87 as standard Jwts.SIG algorithms would be the natural follow-up, and I am happy to do that as a separate PR if you want it.

Adds the AKP (Algorithm Key Pair) JWK key type defined by RFC 9964 for
the ML-DSA parameter sets (FIPS 204): API interfaces and builders
mirroring the Octet (OKP) family, family factories registered in
DispatchingJwkFactory, dynamic builder dispatch, and RFC 9964 JWK
thumbprint support (alg, kty, pub).

ML-DSA keys have no java.security.interfaces type, so parameter sets
are identified by JCA algorithm name when the provider reports a
specific one, falling back to the algorithm OID in the key's ASN.1
encoding, following the EdwardsCurve.findByKey approach.

Per RFC 9964, an AKP private JWK's priv value is the 32-byte seed.
PKCS#8 parsing handles all three ML-DSA-PrivateKey CHOICE alternatives
(seed, expandedKey, both).  Seed import falls back to BouncyCastle on
JDK 24 through 26, whose SUN provider accepts only the expandedKey
form.

See issue 1042.
@lhazlewood

Copy link
Copy Markdown
Contributor

@Arpan0995 wow, I didn't see this, this is great, thanks! I'll do a review as soon as feasible.

@Arpan0995

Copy link
Copy Markdown
Author

Thanks again @lhazlewood, no rush at all. A couple of notes in case they help whenever you get to it.

CI is green across the full JDK matrix (8 through 25, plus the license and coverage checks). And if it would be easier to review in smaller pieces, I am happy to split the commit or rebase it however suits you, and glad to answer questions or make changes as you go.

On Eclipse OpenJ9, IBM's OpenJCEPlus provider supplies ML-DSA on JDK 17
and 21 and encodes private keys without a recoverable RFC 9964 seed. The
AkpJwksTest private-key round-trip tests built the key through the default
provider and then could not extract the seed, so they errored on OpenJ9
17 and 21 while passing on HotSpot. Build those keys through BouncyCastle
explicitly so the seed round-trips regardless of the default provider, and
note the OpenJCEPlus limitation alongside the existing JDK 24 to 26 note.
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