http://docker:9090Sign arbitrary data with ML-DSA-44/65/87 via the SignData RPC. Keypair generated per request using Qudo provider.
ML-DSA-44 / ML-DSA-65 / ML-DSA-87Verify PQC signatures via the VerifySignature RPC. Public key and signature provided by the caller.
ML-DSA (FIPS 204)Generate ML-KEM keypairs via the ExchangeKeys RPC for quantum-safe key encapsulation.
ML-KEM-512 / ML-KEM-768 / ML-KEM-1024gRPC calls authenticated with ML-DSA-65 signed JWT passed via the 'authorization' metadata key.
ML-DSA-65 (FIPS 204)When accessed via NGINX gateway (port 8443), gRPC traffic is encrypted with X25519MLKEM768 hybrid key exchange.
X25519MLKEM768This service demonstrates PQC operations over gRPC — the same patterns you'll implement in your own gRPC services using the Qudo JNI provider.
Signing: provider.sign(data, privKey, "ML-DSA-65") inside your RPC handlers.
Key Exchange: provider.kemEncapsulate(pubKey, "ML-KEM-768") for quantum-safe shared secrets.
JWT Auth: Sign gRPC metadata tokens with provider.sign().
Transport: One NGINX line: ssl_ecdh_curve X25519MLKEM768:X25519:P-384.
Your .proto files stay the same — PQC changes are entirely in the implementation layer.
/api/grpc/sign
Sign data via gRPC SignData RPC
/api/grpc/verify
Verify signature via gRPC VerifySignature RPC
/api/grpc/key-exchange
ML-KEM key encapsulation via gRPC ExchangeKeys RPC
/api/grpc/auth/token
Generate ML-DSA-65 signed JWT for gRPC auth
/api/grpc/auth/jwks
Get ML-DSA-65 public key (JWKS)
/api/grpc/algorithms
List supported PQC algorithms and FIPS standards
/api/grpc/health
gRPC service health + PQC status
Migrate your gRPC services to quantum-safe cryptography. Use this playground to try PQC signing, key exchange, and JWT auth over gRPC, then apply the Qudo provider to your own gRPC services — your .proto files stay the same.
Three layers of PQC protection for your gRPC services, all powered by the Qudo provider:
Sign data with provider.sign(data, privKey, "ML-DSA-65") inside your RPC handlers. Verify with provider.verify(). Supports ML-DSA-44 (Level 2), ML-DSA-65 (Level 3), ML-DSA-87 (Level 5).
Establish shared secrets with provider.kemEncapsulate(pubKey, "ML-KEM-768"). Supports ML-KEM-512/768/1024. The 32-byte shared secret derives AES keys.
Sign JWTs with provider.sign() and pass via gRPC metadata (authorization: Bearer <token>). Verify in your interceptor with provider.verify().
One line in your NGINX/Envoy config: ssl_ecdh_curve X25519MLKEM768:X25519:P-384. All gRPC traffic gets hybrid PQC TLS automatically.
gRPC Client Your Reverse Proxy (NGINX) Your gRPC Server
| | |
|-- TLS 1.3 (X25519MLKEM768) -->| |
| | |
|-- gRPC SignData ------------->|-- HTTP/2 -------------------> |
| metadata: authorization | |
| = Bearer <ML-DSA-65 JWT> | provider.sign(data, privKey, |
| payload: {data, algorithm} | "ML-DSA-65") -> signature |
| | |
|<- {signature, publicKey} -----|<- HTTP/2 -------------------- |
| (encrypted AES-256-GCM) | |
| | |
|-- gRPC ExchangeKeys --------->|-- HTTP/2 -------------------> |
| payload: {algorithm} | provider.kemEncapsulate( |
| | pubKey, "ML-KEM-768") |
|<- {encapsulatedKey, secret} --|<- HTTP/2 -------------------- |
Your .proto stays the same. PQC changes are in the implementation layer only.
Four changes to make your existing gRPC service quantum-safe. Your .proto files, client stubs, and service interfaces stay the same.
What changes: Your NGINX/Envoy gRPC proxy config
What doesn't change: Your gRPC server code, your proto definitions
# NGINX gRPC proxy grpc_pass grpc://backend:9090; ssl_ecdh_curve auto;
# NGINX gRPC proxy — PQC hybrid grpc_pass grpc://backend:9090; ssl_protocols TLSv1.3; ssl_ecdh_curve X25519MLKEM768:X25519:P-384;
What changes: Your sign/verify implementation inside gRPC RPC handlers
What doesn't change: Your proto definitions, your RPC signatures
Signature signer = Signature.getInstance(
"SHA256withECDSA");
signer.initSign(ecPrivateKey);
signer.update(data);
byte[] sig = signer.sign();
// Generate keypair once at startup
QudoKeyPair keys = provider.generateKeyPair(
"ML-DSA-65");
// Sign in your RPC handler
byte[] sig = provider.sign(data,
keys.getPrivateKeyPem(), "ML-DSA-65");
// Verify
boolean ok = provider.verify(data, sig,
keys.getPublicKeyPem(), "ML-DSA-65");
What changes: Your key agreement implementation
What doesn't change: The shared secret size (still 32 bytes)
KeyAgreement ka = KeyAgreement.getInstance(
"ECDH");
ka.init(myPrivateKey);
ka.doPhase(peerPublicKey, true);
byte[] secret = ka.generateSecret();
// Encapsulate (sender side)
QudoKemResult encap = provider.kemEncapsulate(
recipientPubKey, "ML-KEM-768");
byte[] sharedSecret = encap.getSharedSecret();
byte[] ciphertext = encap.getCiphertext();
// Decapsulate (recipient side)
byte[] secret = provider.kemDecapsulate(
ciphertext, privKey, "ML-KEM-768");
// same 32-byte shared secret
What changes: JWT signing in your gRPC interceptor
What doesn't change: The metadata key (authorization) and Bearer token format
// gRPC metadata
headers.put("authorization",
"Bearer " + hmacJwt);
// JWT alg: HS256
// Sign JWT with Qudo provider
byte[] sig = provider.sign(headerPayload,
privKey, "ML-DSA-65");
String jwt = headerPayload + "." +
Base64.encode(sig);
// gRPC metadata (same key)
headers.put("authorization",
"Bearer " + jwt);
// JWT alg: ML-DSA-65
| Component | Changes? | What to do |
|---|---|---|
| .proto files | No | Same proto3 definitions — RPCs, messages unchanged |
| gRPC transport | Yes — 1 line | Add ssl_ecdh_curve X25519MLKEM768 to NGINX/Envoy |
| Signing logic | Yes — use Qudo | provider.sign(data, privKey, "ML-DSA-65") |
| Key exchange | Yes — use Qudo | provider.kemEncapsulate(pubKey, "ML-KEM-768") |
| Auth interceptor | Yes — use Qudo | Sign JWT with provider.sign() instead of HMAC |
| Client stubs | No | Regenerated from same .proto — no changes |
| Business logic | No | Your RPC implementations stay the same |
# Sign
SIGN=$(curl -sk -X POST https://localhost:8443/api/grpc/sign \
-H "Content-Type: application/json" \
-d '{"data":"SGVsbG8gUFFD","algorithm":"ML-DSA-65"}')
echo "$SIGN" | python3 -c "import sys,json; d=json.load(sys.stdin); print('status:', d['status'], '| algo:', d['algorithm'])"
# Extract and verify
SIG=$(echo "$SIGN" | python3 -c "import sys,json; print(json.load(sys.stdin)['signature'])")
PUB=$(echo "$SIGN" | python3 -c "import sys,json; print(json.load(sys.stdin)['publicKey'])")
curl -sk -X POST https://localhost:8443/api/grpc/verify \
-H "Content-Type: application/json" \
-d "{\"data\":\"SGVsbG8gUFFD\",\"signature\":\"$SIG\",\"publicKey\":\"$PUB\",\"algorithm\":\"ML-DSA-65\"}"
# Expected: {"status":"success","valid":true}
curl -sk -X POST https://localhost:8443/api/grpc/key-exchange \
-H "Content-Type: application/json" \
-d '{"algorithm":"ML-KEM-768"}'
# Expected: {"status":"success","algorithm":"ML-KEM-768","sharedSecretLength":32}
# Get ML-DSA-65 JWT
TOKEN=$(curl -sk -X POST https://localhost:8443/api/grpc/auth/token \
-H "Content-Type: application/json" \
-d '{"username":"test"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
# Decode JWT header to confirm ML-DSA-65
echo "$TOKEN" | cut -d. -f1 | base64 -d 2>/dev/null
# Expected: {"alg":"ML-DSA-65","typ":"JWT","use":"grpc"}
# Install: brew install grpcurl
grpcurl -plaintext localhost:9090 list
# Expected: pqc.PqcService
grpcurl -plaintext -d '{"category":"ALL"}' \
localhost:9090 pqc.PqcService/GetAlgorithmInfo
Measured on Apple M-series, OpenSSL 3.6.1, Qudo FIPS v1.0.0. All operations include keypair generation + crypto operation.
| Operation | Algorithm | Latency | Output Size | vs Classical |
|---|---|---|---|---|
| gRPC Sign | ML-DSA-65 | ~120ms | 3,309 bytes | ECDSA: ~5ms / 64 bytes |
| gRPC Sign | ML-DSA-44 | ~118ms | 2,420 bytes | Smaller, faster than ML-DSA-65 |
| gRPC Key Exchange | ML-KEM-768 | ~80ms | 32 bytes (shared secret) | ECDH: ~2ms / 32 bytes |
| JWT Token Generation | ML-DSA-65 | ~70ms | ~4,500 chars | HMAC JWT: ~1ms / ~300 chars |
| TLS Handshake | X25519MLKEM768 | ~65ms | N/A | X25519: ~5ms |
| Component | Minimum Version | Notes |
|---|---|---|
| OpenSSL | 3.6.0+ | Required for ML-KEM, ML-DSA, SLH-DSA support |
| Qudo Provider | 1.0.0+ | FIPS-validated PQC provider for OpenSSL |
| NGINX | 1.25+ (built with OpenSSL 3.6+) | For hybrid TLS key exchange (X25519MLKEM768) |
| Java | 17+ | gRPC server runtime. PQC via Qudo JNI provider |
| gRPC Java | 1.60+ | Standard gRPC — no PQC-specific version required |
| Proto / protoc | 3.25+ | Standard proto3 — no changes for PQC |
| grpcurl | Any | For testing native gRPC calls |
| Chrome | 124+ | Supports hybrid PQC key exchange in TLS |
| Firefox | 128+ | Supports hybrid PQC key exchange in TLS |
| macOS | 12+ | For openssl CLI. Qudo provider is arm64 and x86_64 |
| Linux | Ubuntu 22.04+ / RHEL 9+ | OpenSSL 3.x in default repos |
com.qudo:qudo-jni-crypto), which calls OpenSSL natively via JNI. Add the dependency to your build and set -Djava.library.path to the native library location.
openssl pkeyutl -sign fails with "input data length too long"Cause: ML-DSA requires the -rawin flag for data larger than 64 bytes.
Without it, OpenSSL treats the input as a pre-hashed digest.
Fix: Always use openssl pkeyutl -sign -rawin -inkey key.pem -in data.bin -out sig.bin
pkeyutlCause: The Qudo provider currently supports SLH-DSA for certificate signing (openssl req)
but not for arbitrary data signing via pkeyutl.
Workaround: Use ML-DSA-87 (highest security level) for data signing. Use SLH-DSA only for certificate issuance.
Cause: ML-DSA-65 signatures are 3,309 bytes vs 64 bytes for ECDSA. The base64-encoded JWT is ~4.5KB.
Fix: Increase NGINX large_client_header_buffers to 4 16k.
For gRPC, increase max metadata size: grpc.max_metadata_size=16384.
Cause: OpenSSL can't find the Qudo provider dylib or the FIPS config isn't loaded.
Fix: Run openssl list -providers and verify qudoprovider is active.
If not, check openssl.cnf includes the FIPS module config and the provider section references qudo_sect.
Cause: The client doesn't support X25519MLKEM768, or NGINX's OpenSSL wasn't built with the Qudo provider.
Check: Run openssl s_client -connect host:port -groups X25519MLKEM768 -brief and look for
Negotiated TLS1.3 group: X25519MLKEM768. If it shows X25519, the client fell back.
Cause: Each PQC sign operation takes ~120ms (vs ~5ms for ECDSA). If you sign per-request on the gRPC thread, it blocks.
Fix: Generate the keypair once at startup (not per-request). Cache signed JWTs. Use async stubs for non-blocking calls.
A: No. Your proto definitions, message types, and RPC signatures stay exactly the same. PQC is applied at the implementation layer (how you sign/verify/exchange keys inside your RPC handlers) and the transport layer (TLS config). Existing client stubs work without regeneration.
A: Yes. The gRPC protocol is language-agnostic. Any gRPC client can call the PQC service. For the transport layer, the client just needs TLS 1.3 support (all modern gRPC libraries have this). For application-layer PQC (signing in your client), you need OpenSSL 3.6+ with the Qudo provider on the client machine.
A: Signing adds ~120ms per operation. For a service doing 100 signs/sec, this requires a thread pool. The TLS overhead is only on the first handshake (~65ms) — subsequent requests on the same connection have zero PQC overhead thanks to session reuse. Key exchange (ML-KEM) adds ~80ms per operation.
A: Two options:
1. With Qudo provider: provider.verify(data, sig, pubKey, "ML-DSA-65") — runs locally, no network call
2. Via JWKS endpoint: Fetch the public key from the signing service's JWKS endpoint, then verify locally with provider.verify()
JWT format is standard (header.payload.signature) — only the algorithm changes from HS256 to ML-DSA-65.
A: Yes. PQC is applied per-RPC in your implementation, not globally. You can migrate one RPC at a time: update SignData to use ML-DSA while keeping other RPCs on ECDSA. The transport layer (TLS) is all-or-nothing per connection, but that's transparent to the application.
A: Rollback plan by layer:
Transport: Remove X25519MLKEM768 from NGINX ssl_ecdh_curve — falls back to X25519.
JWT: Switch provider.sign() back to HMAC — just change the token generation code.
Signing: Replace provider.sign(..., "ML-DSA-65") with your previous ECDSA code in the RPC handler.
Each layer is independent — roll back one without affecting the others.
Analyze any endpoint's cryptographic configuration. Enter a host:port to scan its TLS setup and identify what's quantum-safe vs what needs migration.