The Case of the Frankenstein Key

One key, two secrets, and the checksum that isn't there.

OpenSSL does something unusual in its post-quantum implementations.

When you export an ML-KEM or ML-DSA private key, you get two distinct representations of the same secret—packaged together: a compact seed and an expanded private key.

This is not a parser quirk. It is a deliberate design choice. Exporting the same key in two forms introduces redundancy—and without consistency checks, confusion follows.

This post looks at a narrow discrepancy: importing a valid seed alongside a mismatched public key. ML-DSA rejects it. ML-KEM accepts it.

The Export Choice

ML-KEM and ML-DSA are both “seedable.” A small, fixed-size seed (32 bytes for ML-DSA, 64 bytes for ML-KEM) deterministically derives the rest of the keypair: the secret vector ss, the public matrix AA, and the public key tt.

This gives library authors a choice: export only the seed (32 or 64 bytes), compact but requiring re-derivation, or only the expanded key (2,400 bytes), fast to load but bulky.

OpenSSL chose a third path: Both.

OpenSSL’s default PQC export format (the seed-priv PKCS#8 format) packs both the seed and the expanded decapsulation key into the same object. The motivation appears pragmatic: retain the seed while also providing a ready-to-use expanded form.

Redundancy creates new failure modes. What if the caller provides a valid seed dd, but a public key pkpk' that belongs to a completely different seed?

The Strict ML-DSA

ML-DSA (the signature scheme) handles this strictly. In ml_dsa_kmgmt.c, import reconciles the pieces.

If you provide a seed, OpenSSL re-derives the expanded key, nullifying the faster-loading rationale. Crucially, it validates the result against any expanded public key you provided:

/* providers/implementations/keymgmt/ml_dsa_kmgmt.c */

/* Error if the supplied public key does not match the generated key */
if (pk_len == 0
    || seed_len + sk_len == 0
    || memcmp(ossl_ml_dsa_key_get_pub(key), pk, pk_len) == 0)
    return 1;

ERR_raise_data(ERR_LIB_PROV, PROV_R_INVALID_KEY,
    "explicit %s public key does not match private",
    key_params->alg);

If the re-derived public key doesn’t match, import fails. By re-deriving and checking every time, ML-DSA treats the redundant data as a checksum, not a shortcut.

The Lax ML-KEM

Now consider ML-KEM. The equivalent logic in ml_kem_kmgmt.c contains a striking admission:

/* providers/implementations/keymgmt/ml_kem_kmgmt.c */

if (p.seed != NULL && include_private) {
    /*
     * When a seed is provided, the private and public keys may be ignored,
     * after validating just their lengths.  Comparing encodings or hashes
     * when applicable is possible, but not currently implemented.
     */
    if (OSSL_PARAM_get_octet_string_ptr(p.seed, &seedenc, &seedlen) != 1)
        return 0;
    // ... length checks only ...
}

“Not currently implemented.”

Because this check is missing, you can import a “Frankenstein” key: a valid seed for Alice paired with Bob’s public key.

Decapsulation still works—the seed-derived secret unwraps Alice’s ciphertexts—but EVP_PKEY reports Bob.

The Self-Inflicted Cost of Redundancy

This is a problem OpenSSL created for itself by choosing to represent the same secret in two different ways at once. Most modern cryptographic libraries avoid this ambiguity by simply refusing to export redundant forms.

In Go 1.24, the crypto/mlkem package treats the private key as a singular object. The Bytes() method on a DecapsulationKey returns exactly one thing: the 64-byte seed. There is no expanded export. The seed is canonical.

Similarly, BoringSSL leans heavily toward the seed for long-term storage. Its mlkem.h header doesn’t even provide a function to marshal the expanded private key. You generate and restore from a 64-byte seed. The “expanded” form remains an internal detail and never leaves the address space.

But OpenSSL is the lingua franca of the cryptographic world. Its default export includes both, so other libraries must parse it.

In ML-DSA, the OpenSSL developers even noted the consequence of this choice: “if the seed is present, both the seed and the private key are exported. The recipient will have a choice.”

Providing that “choice” is exactly what creates the confusion.

Still untangling redundant secrets? Follow along by email.