Skip to content

SPKI and PKCS8 for ML-KEM and ML-DSA tests #184

Open

Description

@davidben

Filing this to remember to make a note of it. Unless someone beats me to it, I'll probably put something together, at least for the seed-bases ones, as part of other work.

(I won't bother with expandedKey one as those just generally are bad ideas. The IETF fumbled the ball at the last minute and we ended up with three different PKCS8 encodings, two of which should never be used.)

Activity

  1. added a commit that references this issue on Oct 18, 2025
  2. added a commit that references this issue on Oct 23, 2025
  3. tarcieri commented on May 6, 2026

    @tarcieri

    Was this addressed by #186?

  4. cpu commented on May 6, 2026

    @cpu
    Member

    I thought there were still gaps for ML-KEM

  5. added a commit that references this issue on Jul 28, 2026
  6. Arpan0995 commented on Jul 31, 2026

    @Arpan0995

    Is the ML-KEM side of this still open? As of today none of the ML-KEM files carry a DER field: mlkem_*_encaps_test, mlkem_*_keygen_seed_test, mlkem_*_semi_expanded_decaps_test and mlkem_*_test have no publicKeyDer or privateKeyPkcs8 equivalent, so the gap @cpu mentioned looks like it is still there. #186 covered ML-DSA only.

    If nobody has it in flight I would be glad to put it together. One structural question first, because ML-KEM does not fit the #186 shape directly.

    In the ML-DSA files the key lives on the test group, so #186 could add publicKeyDer and privateKeyPkcs8 once per group. In the ML-KEM files ek, dk and seed are per test case, and there are 1701 of them across the twelve files: encaps is 261/265/269 for the three parameter sets, the main file and keygen_seed are 193 and 100 each, decaps is 9. So a per-case DER field is a much bigger change than the ML-DSA one was. Three ways to go:

    1. Add ekDer and dkPkcs8 per test case, consistent with where the raw keys already are, at the cost of a large size increase across the existing files.
    2. Add them only in keygen_seed, which is the file whose subject is the key material anyway, and leave encaps/decaps untouched.
    3. Keep them out of the existing files entirely and add a separate key-encoding file per parameter set.

    I would lean towards 2, but this is your call on file layout rather than mine.

    Two details worth settling at the same time:

    The ML-KEM seed is 64 bytes (d || z), not 32 as for ML-DSA, so "the seed form" means something different here and the schema description should probably say so explicitly rather than reuse the ML-DSA wording.

    On the private key CHOICE: I agree the seed form is the one to build vectors around, but the interop situation is worth knowing before deciding, because it is not what I expected. Building all three forms by hand for ML-KEM-512 and importing them on JDK 24, into SunJCE and BouncyCastle 1.85:

    PKCS#8 private key form SunJCE BouncyCastle
    seed [0] rejects accepts
    expandedKey accepts accepts
    both rejects accepts

    ML-DSA-44 behaves identically against SUN. So today the seed form, the one this issue is about and the one the drafts prefer, is the form the JDK will not parse, and expandedKey is the only one both accept.

    That is not an artifact of how I built the DER. Asking each provider what it emits for a freshly generated key, and decoding the CHOICE tag inside privateKey: SunJCE emits expandedKey (1660 bytes for ML-KEM-512), BouncyCastle emits both (1730). Round-tripping those native encodings, a BouncyCastle-generated ML-KEM private key fails to import into SunJCE, while a SunJCE-generated one imports into BouncyCastle fine. ML-DSA-44 shows the same asymmetry between SUN and BouncyCastle. Neither JDK provider emits or accepts the seed form at all.

    That is not an argument against seed-form vectors, and I am not suggesting Wycheproof bless the other encodings. It does suggest a vector set covering seed alone would pass on BouncyCastle and fail on a stock JDK for reasons that have nothing to do with the implementation being wrong, so it may be worth deciding deliberately whether the file should carry more than one form, or note which is expected to be parseable where. Happy to be told that is out of scope.

    If the shape above is agreed I will generate the vectors and open a PR.

  7. davidben commented on Jul 31, 2026

    @davidben
    ContributorAuthor

    That table is missing some other entries. Other implementations only take seed. That's where implementations have generally aligned.

    Unfortunately, this is the kind of mess that resulted from one particular implementation gumming up the standards process. To clear out this mess, it is actively desirable for Wycheproof to encourage seed in its test vectors. The standards process failed, so we're down to implementation pressures to get folks into an interoperable state. I.e. non-seed implementations failing test vectors is a feature, not a bug. Additionally, both and expandedKey add extra dimensions of testing complexity because you have to worry about values that are not self-consistent. (Part of why seed is just a better format.)

    Regarding SunJCE, that table doesn't match what https://bugs.openjdk.org/browse/JDK-8349163 says. Perhaps you tested against an older version?

  8. Arpan0995 commented on Aug 3, 2026

    @Arpan0995

    You are right, and thank you for the pointer. I tested on Corretto 24.0.2, which predates this. The implementation is JDK-8347938, fixed in 27 on 2026-02-03, and it has since been backported: 26.0.2 (JDK-8381386), 25.0.4 (JDK-8381844), and 21.0.13 and 17.0.21 on the Oracle side (JDK-8385394, JDK-8385800). It makes all three CHOICE forms decodable and switches the default encoding to seed. My table describes a JDK that has already been superseded, so please disregard it.

    Re-running the same probe on Temurin 25.0.4+7 (LTS) and again on 27-ea+32, with BouncyCastle 1.85. Both give identical results:

    SunJCE / SUN BouncyCastle 1.85
    accepts seed [0] yes yes
    accepts expandedKey yes yes
    accepts both yes yes
    emits by default seed both

    ML-DSA-44 private keys are 54 bytes from the JDK against 2626 from BouncyCastle, and ML-KEM-512 is 86 against 1730. Native encodings round-trip in both directions on both JDKs.

    You are also right that the table was missing entries. It covered the two Java providers and nothing else, so it was a poor guide to where implementations in general have landed. I did not measure anything outside the JVM, and I take your word that other implementations accept seed alone.

    So this is already in a shipping LTS release, and on current versions the JDK emits seed by default, which puts the Java side on the path you describe. The BouncyCastle column still holds: 1.85 is the current release, it decodes all three forms, and it still emits both by default. If the aim is implementation pressure toward seed, that is where it would land, and a seed-only vector set does that job. I withdraw the suggestion that the file should carry more than one form.

    On the point about self-consistency in both and expandedKey: that had not occurred to me and it is a good argument on its own. Seed-only avoids an entire class of vector where the two halves disagree.

    That leaves the layout question, which I do not think the above affects. ML-KEM keys are per test case rather than per test group, so the #186 pattern does not transfer directly. Options 1 to 3 in my earlier comment still stand, and I still lean toward adding the DER forms only in keygen_seed. Happy to build it that way if you agree, or to leave it with you if you would rather fold it into the other work you mentioned.

  9. cpu commented on Aug 6, 2026

    @cpu
    Member

    Add ekDer and dkPkcs8 per test case, consistent with where the raw keys already are, at the cost of a large size increase across the existing files.

    How big is the size increase in practice?

  10. Arpan0995 commented on Aug 11, 2026

    @Arpan0995

    @cpu I measured it against the current files. The twelve ML-KEM files are 8.3 MB together. Adding ekDer wherever ek is present and a seed-form dkPkcs8 wherever the seed is present, then reserialising:

    file type per-file increase
    encaps_test +61 to +64%
    test (main) +49 to +51%
    keygen_seed_test +35%
    semi_expanded_decaps_test about +25%

    Across all twelve that is +4.1 MB, so 8.3 MB becomes about 12.4 MB, roughly +50%.

    Almost all of that is ekDer, not the private key. The seed-form dkPkcs8 is 86 bytes (172 hex chars) for a valid 64-byte seed, the same for every parameter set, so it is noise. ekDer is the SPKI wrapping of ek, which is 822, 1206 and 1590 bytes for a well-formed key at 512, 768 and 1024, so it roughly doubles the largest field already in each case. That is why encaps, which is mostly ek and carries one in every case, grows the most.

    For contrast, option 2 (both fields in keygen_seed only) is +0.79 MB, about +9.5% of the corpus, roughly a fifth of option 1.

    One wrinkle: the two fields are not addable to every case, so option 1 would not be uniform. The encaps files are public-key only, and semi_expanded_decaps carries dk but no seed, so both get ekDer alone (those files have no seed, and it cannot be recovered from the expanded dk). The invalid cases in the main file are the reverse, a seed but no ek (the wrong-length private key cases), so they get dkPkcs8 but not ekDer. I added each field only where its raw input is already present, wrapping it as-is for the invalid-length cases, which is what the totals above reflect.

  11. cpu commented on Aug 12, 2026

    @cpu
    Member

    Across all twelve that is +4.1 MB, so 8.3 MB becomes about 12.4 MB, roughly +50%.

    Open to input from others but that doesn't seem too bad to me overall 🤷

  12. Arpan0995 commented on Aug 18, 2026

    @Arpan0995

    Thanks. If no one has started on this, I am glad to put the PR together.

    One thing to lock before I generate: the layout. Option 1 (per-case) adds ekDer and the seed-form dkPkcs8 wherever the raw key is already present across the twelve ML-KEM files; option 2 keeps it to the keygen_seed files. @cpu is fine with option 1's size, and I lean the same way since it exercises the DER paths everywhere the raw key already appears, but I will follow whichever you and @davidben prefer.

    Scope as discussed: ekDer (SPKI) plus the seed-form dkPkcs8, no expandedKey PKCS8 per @davidben's note, and each field added only where its raw input is already present.

    If option 1 works and no one else is on it, I will regenerate the twelve ML-KEM files and the generator and open a PR against this issue in a few days.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions