Hello,

While looking at key lifecycle handling in the new ML-DSA provider I
ran into behavior that looks unintended. After calling destroy() on an
ML-DSA private key, the key is still accepted by Signature.initSign
and sign() completes normally. The resulting signature has the
spec-correct size but does not verify. Nothing throws and nothing is
logged, so the first visible symptom is a verification failure at the
relying party.

Minimal reproducer (identical results on Amazon Corretto 24.0.2 and
OpenJDK 27-ea; ML-DSA-44/65/87 produce 2420/3309/4627 byte signatures
respectively, all invalid):

    KeyPair kp = KeyPairGenerator.getInstance("ML-DSA-44").generateKeyPair();
    ((Destroyable) kp.getPrivate()).destroy();

    Signature s = Signature.getInstance("ML-DSA-44");
    s.initSign(kp.getPrivate());     // accepted
    s.update("m".getBytes());
    byte[] sig = s.sign();           // 2420 bytes

    Signature v = Signature.getInstance("ML-DSA-44");
    v.initVerify(kp.getPublic());
    v.update("m".getBytes());
    v.verify(sig);                   // false

The same thing happens when the Signature was initialized while the
key was still live and destroy() runs before sign(). engineInitSign
keeps a reference to the key bytes (getExpanded() on 27, getRawBytes()
on 24) and destroy() zeroes that array in place, so destroying a key
on one thread invalidates an already initialized Signature on another.
That variant seems like the one most likely to bite a real service,
for example cached Signature objects combined with key rotation.

>From reading the sources, NamedPKCS8Key.destroy() zeroes the material
and sets a private destroyed flag, but as far as I can tell nothing
ever reads that flag. The only isDestroyed() call site in java.base is
in PBEKey.equals(). ML_DSA.checkPrivateKey is a length-only check, and
an all-zero key has the correct length, so initSign passes. ML-KEM
decapsulation with a destroyed key does throw, but only incidentally:
the FIPS 203 section 7.3 hash check fails on zeroed material. It does
not consult the flag either.

To be clear about what this is not: the zeroization itself works. I
checked all six ML-DSA/ML-KEM parameter sets on both JDKs and no key
material survives in the post-destroy encoding, in either ordering of
getEncoded() and destroy(). The internal ML_DSA_PrivateKey.destroy()
also correctly wipes k/s1/s2/t0 after each signing operation. The PQC
providers are well ahead of the older ones on this front; this report
is only about the public Destroyable surface.

I am aware of the history on widening Destroyable coverage
(JDK-8158689, JDK-8160206, JDK-8008795, JDK-8228414, JDK-8303613), so
I am not proposing to add destroy() anywhere new. The question is
narrower: given that NamedPKCS8Key already implements destroy() and
tracks a destroyed flag, is it intended that a destroyed key is still
accepted for signing and silently produces an invalid signature?
JDK-8358451 treats the analogous shape for PBEKey (getEncoded()
succeeding after destroy) as a bug, and JDK-8389121 suggests this area
is getting attention right now, so it seemed worth asking what the
intended post-destroy semantics are before filing anything.

If the answer is that initSign (or the key check) should consult
isDestroyed() and throw, I am happy to file this at
bugreport.java.com, or a committer is welcome to file it directly.
Standalone reproducers for everything above are available on request.

Regards,
Arpan Sharma

Reply via email to