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
- 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.
- After destruction, have
getEncoded() (and other secret-bearing accessors) throw IllegalStateException, per the Destroyable contract.
- 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).
Summary
The JCE private key classes for the NIST PQC algorithms (
BCMLDSAPrivateKey,BCMLKEMPrivateKey,BCSLHDSAPrivateKey) do not overrideDestroyable.destroy(). Callingdestroy()therefore hits thejavax.security.auth.Destroyabledefault method, which unconditionally throwsDestroyFailedExceptionand erases nothing:isDestroyed()staysfalse, the underlying secret arrays are untouched, andgetEncoded()continues to return the complete private key afterwards.The
Destroyabledefault 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
bcprov-jdk18on1.85 and 1.86-SNAPSHOT (2026-07-19)Reproduction
Output (identical on 1.85 and 1.86-SNAPSHOT):
Root cause
None of the PQC JCE key classes define
destroy()/isDestroyed()(verified viajavapon 1.86-SNAPSHOT), so theDestroyabledefault methods apply. The secrets live in the low-level parameter objects — e.g.org.bouncycastle.crypto.params.MLDSAPrivateKeyParametersstores the key across eightfinal byte[]fields, of whichk,s1,s2,t0, andseedare secret (rho,tr, andt1are public components) — and nothing ever zeroizes them. The references arefinal, 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.hproffor 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-residencydestroy()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
destroy()on the PQC JCE key classes (and ideally adestroy()/zeroize()on the corresponding*PrivateKeyParameters):Arrays.filleach secret array in place, clear any cached encoding, set a destroyed flag.getEncoded()(and other secret-bearing accessors) throwIllegalStateException, per theDestroyablecontract.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).