Java Post-Quantum Cryptography with Bouncy Castle

Published · Updated

Java developers preparing for the post-quantum transition have a clear path forward: Bouncy Castle. As of version 1.79 (released early 2025), Bouncy Castle includes full implementations of NIST’s finalized PQC standards: ML-KEM (FIPS 203) for key encapsulation and ML-DSA (FIPS 204) for digital signatures. This guide walks through the practical steps to add PQC support to your Java applications today.

Before committing to a provider, compare Bouncy Castle’s scope, FIPS boundary, and protocol integration with the alternatives in the post-quantum cryptography library guide.

Unlike OpenSSL or BoringSSL, which are C-based libraries primarily used for TLS, Bouncy Castle gives Java developers direct access to PQC primitives at the application layer. This makes it the right choice for encrypting data at rest, signing JWTs or documents, building custom protocols, or simply experimenting with PQC algorithms before your TLS stack supports them natively.

Why Bouncy Castle for PQC in Java

The standard JCA (Java Cryptography Architecture) in OpenJDK does not yet include ML-KEM or ML-DSA providers. Oracle has indicated PQC support is on the roadmap for a future JDK release, but as of JDK 22 (March 2024) and JDK 23 (September 2024), no built-in PQC algorithms ship with the JDK.

Bouncy Castle fills this gap by registering as a JCE provider, giving you access to PQC algorithms through familiar Java cryptography APIs. It also offers a lower-level API for cases where you need more control.

Key advantages of Bouncy Castle for PQC work:

  • Implements all three NIST PQC standards: ML-KEM (FIPS 203), ML-DSA (FIPS 204), SLH-DSA (FIPS 205)
  • Works with any JDK 8+ runtime
  • Dual API: high-level JCE provider or low-level Bouncy Castle API
  • Active maintenance and rapid updates following NIST spec changes
  • MIT license allows unrestricted use in commercial applications
  • Certified FIPS module available separately (BC-FJA)

Maven Setup

Add the following dependencies to your pom.xml:

<dependencies>
    <!-- Core Bouncy Castle provider -->
    <dependency>
        <groupId>org.bouncycastle</groupId>
        <artifactId>bcprov-jdk18on</artifactId>
        <version>1.79</version>
    </dependency>
    <!-- PQC-specific algorithms -->
    <dependency>
        <groupId>org.bouncycastle</groupId>
        <artifactId>bcpqc-jdk18on</artifactId>
        <version>1.79</version>
    </dependency>
</dependencies>

For Gradle users:

implementation 'org.bouncycastle:bcprov-jdk18on:1.79'
implementation 'org.bouncycastle:bcpqc-jdk18on:1.79'

The bcprov-jdk18on artifact contains the core provider and works on JDK 18 and later. For JDK 8 through 17, use bcprov-jdk15to18 and bcpqc-jdk15to18 instead.

Registering the Provider

Before using Bouncy Castle algorithms, register the provider:

import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider;
import java.security.Security;

public class PQCSetup {
    static {
        Security.addProvider(new BouncyCastleProvider());
        Security.addProvider(new BouncyCastlePQCProvider());
    }
}

Alternatively, register it globally in your java.security file:

security.provider.N=org.bouncycastle.jce.provider.BouncyCastleProvider
security.provider.N+1=org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider

ML-KEM Key Encapsulation (FIPS 203)

ML-KEM (formerly CRYSTALS-Kyber) is a key encapsulation mechanism (KEM). Unlike traditional key exchange (like ECDH), a KEM works differently: one party generates a keypair, the other party uses the public key to encapsulate a shared secret, and the first party decapsulates it with their private key. The result is a shared secret that both parties can use for symmetric encryption.

ML-KEM comes in three parameter sets:

  • ML-KEM-512: NIST Security Level 1 (roughly equivalent to AES-128)
  • ML-KEM-768: NIST Security Level 3 (roughly equivalent to AES-192)
  • ML-KEM-1024: NIST Security Level 5 (roughly equivalent to AES-256)

For most applications, ML-KEM-768 provides a good balance of security and performance. Use ML-KEM-1024 for data that must remain confidential for decades.

Generating an ML-KEM Keypair

import org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider;
import org.bouncycastle.pqc.jcajce.spec.KyberParameterSpec;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.Security;

public class MLKEMKeyGen {
    public static void main(String[] args) throws Exception {
        Security.addProvider(new BouncyCastlePQCProvider());

        KeyPairGenerator kpg = KeyPairGenerator.getInstance("KYBER", "BCPQC");
        kpg.initialize(KyberParameterSpec.kyber768);

        KeyPair keyPair = kpg.generateKeyPair();

        System.out.println("Public key algorithm: " + keyPair.getPublic().getAlgorithm());
        System.out.println("Public key size: " + keyPair.getPublic().getEncoded().length + " bytes");
        System.out.println("Private key size: " + keyPair.getPrivate().getEncoded().length + " bytes");
    }
}

Encapsulation and Decapsulation

import org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider;
import org.bouncycastle.pqc.jcajce.spec.KyberParameterSpec;
import javax.crypto.KEM;
import javax.crypto.KEM.Encapsulated;
import javax.crypto.KEM.Encapsulator;
import javax.crypto.KEM.Decapsulator;
import javax.crypto.SecretKey;
import java.security.*;

public class MLKEMExample {
    public static void main(String[] args) throws Exception {
        Security.addProvider(new BouncyCastlePQCProvider());

        // Generate keypair (done once by the receiver)
        KeyPairGenerator kpg = KeyPairGenerator.getInstance("KYBER", "BCPQC");
        kpg.initialize(KyberParameterSpec.kyber768);
        KeyPair keyPair = kpg.generateKeyPair();

        // Sender: encapsulate a shared secret using receiver's public key
        KEM kemSender = KEM.getInstance("KYBER", "BCPQC");
        Encapsulator encapsulator = kemSender.newEncapsulator(keyPair.getPublic());
        Encapsulated encapsulated = encapsulator.encapsulate();

        byte[] ciphertext = encapsulated.encapsulation();
        SecretKey sharedSecretSender = encapsulated.key();

        // Receiver: decapsulate the shared secret using their private key
        KEM kemReceiver = KEM.getInstance("KYBER", "BCPQC");
        Decapsulator decapsulator = kemReceiver.newDecapsulator(keyPair.getPrivate());
        SecretKey sharedSecretReceiver = decapsulator.decapsulate(ciphertext);

        // Both shared secrets are identical
        boolean match = MessageDigest.isEqual(
            sharedSecretSender.getEncoded(),
            sharedSecretReceiver.getEncoded()
        );
        System.out.println("Shared secrets match: " + match);
        System.out.println("Shared secret length: " + sharedSecretSender.getEncoded().length + " bytes");
    }
}

Note: The javax.crypto.KEM API was introduced in JDK 21 (JEP 452). If you are on an older JDK, use the lower-level Bouncy Castle API directly with KyberKEMGenerator and KyberKEMExtractor.

ML-DSA Digital Signatures (FIPS 204)

ML-DSA (formerly CRYSTALS-Dilithium) is a lattice-based digital signature scheme. It replaces RSA and ECDSA for use cases where you need to sign and verify data: code signing, document signing, JWT tokens, API request authentication, and certificate issuance.

ML-DSA parameter sets:

  • ML-DSA-44: NIST Security Level 2 (roughly equivalent to SHA-256/Ed25519)
  • ML-DSA-65: NIST Security Level 3 (roughly equivalent to AES-192)
  • ML-DSA-87: NIST Security Level 5 (roughly equivalent to AES-256)

Generating an ML-DSA Keypair and Signing

import org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider;
import org.bouncycastle.pqc.jcajce.spec.DilithiumParameterSpec;
import java.security.*;

public class MLDSAExample {
    public static void main(String[] args) throws Exception {
        Security.addProvider(new BouncyCastlePQCProvider());

        // Generate ML-DSA keypair
        KeyPairGenerator kpg = KeyPairGenerator.getInstance("DILITHIUM", "BCPQC");
        kpg.initialize(DilithiumParameterSpec.dilithium3);  // ML-DSA-65
        KeyPair keyPair = kpg.generateKeyPair();

        System.out.println("Public key size: " + keyPair.getPublic().getEncoded().length + " bytes");
        System.out.println("Private key size: " + keyPair.getPrivate().getEncoded().length + " bytes");

        // Sign a message
        byte[] message = "This message is quantum-safe signed.".getBytes();

        Signature signer = Signature.getInstance("DILITHIUM", "BCPQC");
        signer.initSign(keyPair.getPrivate());
        signer.update(message);
        byte[] signature = signer.sign();

        System.out.println("Signature size: " + signature.length + " bytes");

        // Verify the signature
        Signature verifier = Signature.getInstance("DILITHIUM", "BCPQC");
        verifier.initVerify(keyPair.getPublic());
        verifier.update(message);
        boolean valid = verifier.verify(signature);

        System.out.println("Signature valid: " + valid);
    }
}

Key and Signature Sizes

One practical consideration with PQC algorithms is the larger key and signature sizes compared to classical algorithms:

AlgorithmPublic KeyPrivate KeySignature/Ciphertext
ML-DSA-441,312 bytes2,560 bytes2,420 bytes
ML-DSA-651,952 bytes4,032 bytes3,309 bytes
ML-DSA-872,592 bytes4,896 bytes4,627 bytes
ML-KEM-512800 bytes1,632 bytes768 bytes
ML-KEM-7681,184 bytes2,400 bytes1,088 bytes
ML-KEM-10241,568 bytes3,168 bytes1,568 bytes
Ed25519 (classical)32 bytes64 bytes64 bytes
RSA-2048 (classical)256 bytes~1,200 bytes256 bytes

These larger sizes matter for bandwidth-constrained environments, embedded devices, and protocols with size limits (like DNS or Bluetooth). For typical server-side Java applications, the size increase is manageable.

Hybrid Approach: Combining Classical and PQC

During the migration period, a hybrid approach provides protection against both classical and quantum attacks. If the PQC algorithm is later found to have a weakness, the classical algorithm still provides security. If a quantum computer breaks the classical algorithm, the PQC algorithm protects you.

Here is a simple hybrid signing pattern that produces both an ECDSA and ML-DSA signature:

import org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider;
import org.bouncycastle.pqc.jcajce.spec.DilithiumParameterSpec;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import java.security.*;

public class HybridSignature {
    public static void main(String[] args) throws Exception {
        Security.addProvider(new BouncyCastleProvider());
        Security.addProvider(new BouncyCastlePQCProvider());

        byte[] message = "Hybrid-signed payload".getBytes();

        // Classical ECDSA signature
        KeyPairGenerator ecKpg = KeyPairGenerator.getInstance("EC", "BC");
        ecKpg.initialize(256);
        KeyPair ecKeyPair = ecKpg.generateKeyPair();

        Signature ecSigner = Signature.getInstance("SHA256withECDSA", "BC");
        ecSigner.initSign(ecKeyPair.getPrivate());
        ecSigner.update(message);
        byte[] ecSignature = ecSigner.sign();

        // PQC ML-DSA signature
        KeyPairGenerator pqcKpg = KeyPairGenerator.getInstance("DILITHIUM", "BCPQC");
        pqcKpg.initialize(DilithiumParameterSpec.dilithium3);
        KeyPair pqcKeyPair = pqcKpg.generateKeyPair();

        Signature pqcSigner = Signature.getInstance("DILITHIUM", "BCPQC");
        pqcSigner.initSign(pqcKeyPair.getPrivate());
        pqcSigner.update(message);
        byte[] pqcSignature = pqcSigner.sign();

        System.out.println("ECDSA signature: " + ecSignature.length + " bytes");
        System.out.println("ML-DSA signature: " + pqcSignature.length + " bytes");

        // Verification requires BOTH signatures to be valid
        Signature ecVerifier = Signature.getInstance("SHA256withECDSA", "BC");
        ecVerifier.initVerify(ecKeyPair.getPublic());
        ecVerifier.update(message);
        boolean ecValid = ecVerifier.verify(ecSignature);

        Signature pqcVerifier = Signature.getInstance("DILITHIUM", "BCPQC");
        pqcVerifier.initVerify(pqcKeyPair.getPublic());
        pqcVerifier.update(message);
        boolean pqcValid = pqcVerifier.verify(pqcSignature);

        System.out.println("Hybrid verification: " + (ecValid && pqcValid));
    }
}

Serializing and Storing PQC Keys

For persistence, use the standard Java encoded key format:

import java.util.Base64;

// Serialize
byte[] publicKeyBytes = keyPair.getPublic().getEncoded();
byte[] privateKeyBytes = keyPair.getPrivate().getEncoded();

String publicKeyPem = Base64.getEncoder().encodeToString(publicKeyBytes);
String privateKeyPem = Base64.getEncoder().encodeToString(privateKeyBytes);

// Deserialize
import org.bouncycastle.pqc.jcajce.provider.BouncyCastlePQCProvider;
import java.security.KeyFactory;
import java.security.spec.X509EncodedKeySpec;
import java.security.spec.PKCS8EncodedKeySpec;

KeyFactory kf = KeyFactory.getInstance("DILITHIUM", "BCPQC");
PublicKey restoredPublic = kf.generatePublic(
    new X509EncodedKeySpec(Base64.getDecoder().decode(publicKeyPem))
);
PrivateKey restoredPrivate = kf.generatePrivate(
    new PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKeyPem))
);

Performance Considerations

ML-KEM and ML-DSA are significantly faster than their classical counterparts for most operations:

  • ML-KEM-768 key generation: approximately 0.1 ms (faster than RSA-2048 key generation at 100+ ms)
  • ML-KEM-768 encapsulation: approximately 0.1 ms
  • ML-DSA-65 signing: approximately 0.3 ms (comparable to ECDSA)
  • ML-DSA-65 verification: approximately 0.3 ms

The speed advantage comes from the underlying lattice math, which maps well to modern CPUs. The tradeoff is larger key and signature sizes, not slower execution.

Common Pitfalls

  1. Wrong artifact: The PQC algorithms live in bcpqc-jdk18on, not the base bcprov-jdk18on. You need both.

  2. Provider not registered: If you get NoSuchAlgorithmException, ensure BouncyCastlePQCProvider is registered, not just BouncyCastleProvider.

  3. Algorithm naming: Bouncy Castle uses the pre-NIST names internally (KYBER, DILITHIUM) rather than ML-KEM/ML-DSA. This may change in a future release.

  4. JDK KEM API: The javax.crypto.KEM interface requires JDK 21+. On older JDKs, use BC’s native API classes directly.

  5. Thread safety: KeyPairGenerator and Signature instances are not thread-safe. Create new instances per thread or use synchronization.

Testing Your Implementation

A minimal test to verify everything works:

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class PQCTest {
    @Test
    void mlKemRoundTrip() throws Exception {
        Security.addProvider(new BouncyCastlePQCProvider());

        KeyPairGenerator kpg = KeyPairGenerator.getInstance("KYBER", "BCPQC");
        kpg.initialize(KyberParameterSpec.kyber768);
        KeyPair kp = kpg.generateKeyPair();

        KEM kem = KEM.getInstance("KYBER", "BCPQC");
        KEM.Encapsulated enc = kem.newEncapsulator(kp.getPublic()).encapsulate();
        SecretKey dec = kem.newDecapsulator(kp.getPrivate()).decapsulate(enc.encapsulation());

        assertArrayEquals(enc.key().getEncoded(), dec.getEncoded());
    }

    @Test
    void mlDsaSignVerify() throws Exception {
        Security.addProvider(new BouncyCastlePQCProvider());

        KeyPairGenerator kpg = KeyPairGenerator.getInstance("DILITHIUM", "BCPQC");
        kpg.initialize(DilithiumParameterSpec.dilithium3);
        KeyPair kp = kpg.generateKeyPair();

        Signature sig = Signature.getInstance("DILITHIUM", "BCPQC");
        sig.initSign(kp.getPrivate());
        sig.update("test".getBytes());
        byte[] signed = sig.sign();

        sig.initVerify(kp.getPublic());
        sig.update("test".getBytes());
        assertTrue(sig.verify(signed));
    }
}

Next Steps

Once you have PQC working in your Java application:

  1. Profile key and signature sizes in your specific use case. If you are embedding signatures in JWTs, check that your token middleware can handle 3+ KB signatures.
  2. Benchmark in your environment. Bouncy Castle’s pure-Java implementation is fast, but measure it under your actual load.
  3. Plan for key rotation. PQC keys are larger, which affects key storage and distribution systems.
  4. Monitor Bouncy Castle releases. The API names may shift from KYBER/DILITHIUM to ML-KEM/ML-DSA in a future version.
  5. Evaluate the BC-FJA FIPS module separately if you need FIPS 140-3 validation. Active certificate #4943 covers BC-FJA 2.1.1, but its approved algorithm list does not include ML-KEM, ML-DSA or SLH-DSA.

Frequently Asked Questions

Is Bouncy Castle’s PQC implementation production-ready?

For application-level cryptography (signing, key encapsulation), Bouncy Castle provides implementations of the final NIST algorithms. That is separate from CMVP validation. BC-FJA has Active FIPS 140-3 certificate #4943, but PQC algorithms are not in its approved algorithm list. If validation is required, match the exact package, module version, certificate and approved operation using the FIPS 140-2 to FIPS 140-3 transition guide.

Can I use ML-KEM for TLS in Java?

Not directly through Bouncy Castle alone. TLS libraries like Java’s built-in SSLEngine use the JDK’s security providers. Until the JDK natively supports ML-KEM in its TLS implementation, you would need to use a different TLS termination layer (like a reverse proxy with OpenSSL 3.5+) or wait for JDK PQC TLS support.

What JDK version do I need?

Bouncy Castle’s PQC library works on JDK 8+. However, the javax.crypto.KEM API (used in the encapsulation examples above) requires JDK 21. On older JDKs, use BC’s internal KyberKEMGenerator and KyberKEMExtractor classes.

How does performance compare to classical algorithms?

ML-KEM and ML-DSA operations are fast, often faster than RSA. The main tradeoff is larger key and signature sizes, not speed. For typical server workloads, you will not notice a performance difference.

Should I use ML-KEM-512 or ML-KEM-768?

ML-KEM-768 is recommended for most applications. It provides NIST Security Level 3, which offers a comfortable margin. ML-KEM-512 is appropriate for constrained environments where bandwidth or storage is limited and Level 1 security is acceptable.

Can I combine Bouncy Castle PQC with Spring Boot?

Yes. Register the Bouncy Castle providers in a @Configuration class or application startup, and use the standard JCE APIs in your service classes. Spring Security does not interfere with Bouncy Castle’s provider registration.

Sources