Skip to content

destroy() on PQC private keys (ML-DSA, ML-KEM, SLH-DSA) is a throwing no-op — key material stays in heap and getEncoded() still returns the full secret after destroy #2366

Description

@Arpan0995

Summary

The JCE private key classes for the NIST PQC algorithms (BCMLDSAPrivateKey, BCMLKEMPrivateKey, BCSLHDSAPrivateKey) do not override Destroyable.destroy(). Calling destroy() therefore hits the javax.security.auth.Destroyable default method, which unconditionally throws DestroyFailedException and erases nothing: isDestroyed() stays false, the underlying secret arrays are untouched, and getEncoded() continues to return the complete private key afterwards.

The Destroyable default is technically permitted behaviour, so I'm filing this as an enhancement request rather than a bug report — but one with measurable security consequences. This affects the classical BC keys too, but the PQC migration raises the stakes considerably: the resident footprint is much larger (measurements below — ML-DSA-65 keeps 8,196 bytes of contiguous key material in heap for the life of the key object, 2.2× RSA-2048 and >100× EC/Ed25519), and these keys are exactly the ones long-lived services are now being migrated onto.

Reproduced on 1.85 (GA) and 1.86-SNAPSHOT (2026-07-19 beta).

Environment

  • BouncyCastle bcprov-jdk18on 1.85 and 1.86-SNAPSHOT (2026-07-19)
  • OpenJDK 21 and 24 (HotSpot), macOS arm64 — behaviour is not platform-specific

Reproduction

import java.security.KeyPairGenerator;
import java.security.PrivateKey;
import java.security.Security;
import javax.security.auth.DestroyFailedException;
import org.bouncycastle.jce.provider.BouncyCastleProvider;

public class DestroyRepro {
    public static void main(String[] args) throws Exception {
        Security.addProvider(new BouncyCastleProvider());
        String[][] algos = {
            {"ML-DSA", "ML-DSA-65"},
            {"ML-KEM", "ML-KEM-768"},
            {"SLH-DSA", "SLH-DSA-SHA2-128F"},
            {"RSA", null}
        };
        System.out.printf("%-22s %-10s %-14s %-14s%n",
                "algorithm", "threw", "isDestroyed()", "encodedAfter");
        for (String[] a : algos) {
            KeyPairGenerator g = KeyPairGenerator.getInstance(a[0], "BC");
            if (a[1] != null) {
                g.initialize(new java.security.spec.NamedParameterSpec(a[1]));
            } else {
                g.initialize(2048);
            }
            PrivateKey k = g.generateKeyPair().getPrivate();
            boolean threw = false;
            try {
                k.destroy();
            } catch (DestroyFailedException e) {
                threw = true;
            }
            int encLen;
            try {
                encLen = k.getEncoded().length;   // secret still fully available
            } catch (Exception e) {
                encLen = -1;
            }
            System.out.printf("%-22s %-10s %-14s %-14s%n",
                    (a[1] != null ? a[1] : "RSA-2048"), threw, k.isDestroyed(),
                    encLen + " bytes");
        }
    }
}

Output (identical on 1.85 and 1.86-SNAPSHOT):

algorithm              threw      isDestroyed()  encodedAfter
ML-DSA-65              true       false          4098 bytes
ML-KEM-768             true       false          2498 bytes
SLH-DSA-SHA2-128F      true       false          84 bytes
RSA-2048               true       false          1217 bytes

Root cause

None of the PQC JCE key classes define destroy()/isDestroyed() (verified via javap on 1.86-SNAPSHOT), so the Destroyable default methods apply. The secrets live in the low-level parameter objects — e.g. org.bouncycastle.crypto.params.MLDSAPrivateKeyParameters stores the key across eight final byte[] fields, of which k, s1, s2, t0, and seed are secret (rho, tr, and t1 are public components) — and nothing ever zeroizes them. The references are final, but the array contents can be filled in place, so erasure is implementable without any API change.

Measured heap impact

I measured resident key material with heap dumps (HotSpotDiagnosticMXBean.dumpHeap) and a byte-exact scan of the raw .hprof for the encoded key, per-algorithm worker JVMs, instrument self-validated (N live references → exactly N counted copies). Harness and raw CSVs: https://github.com/Arpan0995/pqc-memory-residency

algorithm encoded key contiguous copies while key object alive resident bytes copies removed by destroy()
ML-DSA-65 4,098 B 2 8,196 0
ML-KEM-768 2,498 B 0 (fragmented internal form) 0
SLH-DSA-128f 84 B 0 (fragmented internal form) 0
RSA-2048 ~1,217 B 3 ~3,651 0
EC P-256 67 B 1 67 0
Ed25519 48 B 1 48 0

Notes: ML-KEM/SLH-DSA hold their keys fragmented (no contiguous encoded copy while the object is alive), so they are not blob-recoverable by a naive scan — but they are equally unerasable, and any getEncoded() call materializes the full contiguous secret on demand.

Why it matters

A long-lived ML-DSA signing service holds ~8 KB of private-key material in heap that it has no way to clear, for the life of the process. Any core dump, container checkpoint/snapshot, or swap-out of that process captures the key. The standard guidance for handling cryptographic secrets (zeroize when no longer needed, e.g. SP 800-57 Part 1 key-destruction expectations) is currently unimplementable through the JCA with the BC provider, while the API surface (PrivateKey extends Destroyable) suggests it is supported.

Suggested direction

  1. Implement destroy() on the PQC JCE key classes (and ideally a destroy()/zeroize() on the corresponding *PrivateKeyParameters): Arrays.fill each secret array in place, clear any cached encoding, set a destroyed flag.
  2. After destruction, have getEncoded() (and other secret-bearing accessors) throw IllegalStateException, per the Destroyable contract.
  3. One design caveat to settle: the parameter objects can be shared (e.g. obtained by user code and passed to a signer), so zeroizing in place is observable by other holders of the same instance. Documenting "destroy() invalidates all users of this key's parameters" seems reasonable — it matches what destruction means — but that is a maintainer call.

I'm aware in-place zeroization on the JVM is best-effort — a copying GC may already have left older copies behind, and those are out of reach. But the measurements above show the parameter arrays are the only contiguous copies while the key is alive, so best-effort erasure genuinely removes the canonical instances and shrinks the exposure window from "process lifetime" to "until first GC of stale copies".

Happy to work on a PR for this if there's agreement on the semantics (particularly point 3).

Metadata

Metadata

Assignees

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