Integrating EJBCA (Community) with Scala & Play Framework using CMP

October 28, 2025 (1y ago)

Keywords: EJBCA CMP integration with Scala, CMP Play Framework, CMP client Scala, EJBCA community enrollment

TL;DR

This guide shows how to enable CMP on an EJBCA Community server and how to perform certificate enrollment (Init + Confirm), renewal, and revocation from a Scala backend. Two client examples are included: one using Play’s WSClient and another using Java 11 HttpClient. Production tips include using AWS S3 + KMS to back up and protect private keys.

1 — Quick prerequisites (what you need)

  • EJBCA Community Edition (latest Community release you can get; this guide uses HTTP CMP endpoints).
  • Java 11+ (server & client).
  • Scala 2.13+ or 3.x and Play Framework 2.8+ if using Play example.
  • BouncyCastle (bcprov, bcpkix) for CMP and ASN.1 helpers.

build.sbt (minimal):

libraryDependencies ++= Seq(
"com.typesafe.play" %% "play-ahc-ws" % "2.8.20", // Play WSClient
"org.bouncycastle" % "bcprov-jdk15on" % "1.74",
"org.bouncycastle" % "bcpkix-jdk15on" % "1.74"
)
Note: adjust versions to match your project.

2 — EJBCA: enable CMP (HTTP) endpoint — quick steps

  1. Log into EJBCA Admin GUI.
  2. Create/identify the CA you want to enroll certificates against.
  3. In System Configuration -> CMP (or similar, depending on version):
  • Add/enable an alias for HTTP CMP (e.g. cmpiam) and set a shared secret for password/mac (this is used with PKMAC in CMP).
  • Ensure the HTTP alias listens on the public web endpoint, usually at:
http://<ejbca-host>/ejbca/publicweb/cmp/<alias>

(Your EJBCA version may use a slightly different base path; check your admin UI.)

  • Ensure the CA has CMP enabled and the alias maps to it. You may need to trust the server certificate chain on your client.

3 — Config (application.conf sample)

Minimal application.conf:

webpki {
endpoint = "http://ejbca.local/ejbca/publicweb/cmp/cmpiam"
protocolEncrKeyPem = """-----BEGIN PUBLIC KEY----- ... -----END PUBLIC KEY-----"""
issuer = "CN=MyCA,O=MyOrg,C=HK"
certificateStorePath = "/var/data/webpki"
domain = "example.com"
timeoutMs = 10000
}
Production: do NOT put secrets in plaintext. Use your secret manager. In production we used AWS KMS + S3 to encrypt and backup PKCS#12 files. I removed that from the runnable example to keep the guide focused and reproducible.

4 — CMP helpers (simplified CmpMessage)

This class builds Init and Confirm CMP messages and parses PKI messages returned by the CA. It is intentionally minimal: it produces the INIT request bytes and can parse the PKIMessage returned by EJBCA.

package com.example.cmp

import org.bouncycastle.asn1.ASN1InputStream
import org.bouncycastle.asn1.cmp.{PKIBody, PKIMessage, ErrorMsgContent}
import org.bouncycastle.cert.cmp.{ProtectedPKIMessage, ProtectedPKIMessageBuilder}
import org.bouncycastle.cert.crmf.{CertificateRequestMessageBuilder, PKMACBuilder}
import org.bouncycastle.cert.crmf.jcajce.JcePKMACValuesCalculator
import org.bouncycastle.asn1.x500.X500Name
import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo
import org.bouncycastle.asn1.{DEROctetString}
import org.bouncycastle.asn1.cmp.PKIBody.{TYPE_INIT_REQ, TYPE_CERT_CONFIRM}
import org.bouncycastle.asn1.crmf.CertReqMessages
import org.bouncycastle.asn1.cmp.PKIBody
import org.bouncycastle.asn1.cmp.PKIHeaderBuilder
import java.util.{Date, UUID}
import java.math.BigInteger
import java.security.SecureRandom
import java.io.ByteArrayInputStream
import play.api.libs.ws.WSResponse

class CmpMessage(val issuer: String, val protocolEncrPem: String, val macSecret: String) {
private val jcePkmacCalc = new JcePKMACValuesCalculator()
// SHA1 + HMAC/SHA1 config used for PKMACBuilder (EJBCA commonly supports PKMAC)
// (You may customize algorithms here.)
/** Build a minimal enrollment (INIT) CMP message as bytes */
def createInitRequest(subject: String): Array[Byte] = {
val issuerDN = new X500Name(issuer)
val subjectDN = new X500Name(subject)
val certReqId = BigInteger.valueOf(new SecureRandom().nextLong.abs)
val sender = issuerDN // simple choice for demo
val recipient = issuerDN
val macCalculator = new PKMACBuilder(jcePkmacCalc).build(macSecret.toCharArray)
val msg = new CertificateRequestMessageBuilder(certReqId)
.setIssuer(issuerDN)
.setSubject(subjectDN)
.setPublicKey(null) // server will generate key; this is CRMF with ProtocolEncrKey control in real deployments
.setAuthInfoSender(sender)
.setProofOfPossessionRaVerified()
.build()
val msgs = new CertReqMessages(msg.toASN1Structure)
val pkibody = new PKIBody(TYPE_INIT_REQ, msgs)
val senderName = new org.bouncycastle.asn1.x509.GeneralName(issuerDN)
val recipientName = new org.bouncycastle.asn1.x509.GeneralName(issuerDN)
val protectedMsg = new ProtectedPKIMessageBuilder(senderName, recipientName)
.setMessageTime(new Date())
.setSenderNonce(UUID.randomUUID().toString.getBytes)
.setTransactionID(UUID.randomUUID().toString.getBytes)
.setBody(pkibody)
.build(macCalculator)
protectedMsg.toASN1Structure.getEncoded
}
/** Build a confirm request given the CA response PKIMessage */
def createConfirmRequest(pkiResponse: PKIMessage): Array[Byte] = {
val recipient = pkiResponse.getHeader.getSender
val sender = pkiResponse.getHeader.getRecipient
val nonce = pkiResponse.getHeader.getSenderNonce.getOctets
val txId = pkiResponse.getHeader.getTransactionID.getOctets
val certReqId = {
val rep = pkiResponse.getBody.getContent.asInstanceOf[org.bouncycastle.asn1.cmp.CertRepMessage]
rep.getResponse.head.getCertReqId.getValue
}
val pkiHeader = new PKIHeaderBuilder(2, sender, recipient)
.setMessageTime(new org.bouncycastle.asn1.ASN1GeneralizedTime(new Date()))
.setSenderNonce(new DEROctetString(nonce))
.setTransactionID(new DEROctetString(txId))
.build()
val cs = new org.bouncycastle.asn1.cmp.CertStatus(UUID.randomUUID().toString.getBytes, certReqId)
val cc = org.bouncycastle.asn1.cmp.CertConfirmContent.getInstance(new org.bouncycastle.asn1.DERSequence(cs))
val myPKIBody = new PKIBody(TYPE_CERT_CONFIRM, cc)
new PKIMessage(pkiHeader, myPKIBody).getEncoded
}
/** Parse an incoming PKI message from a WSResponse (bytes) */
def parsePkiMessage(resp: WSResponse): PKIMessage = {
val in = new ASN1InputStream(new ByteArrayInputStream(resp.body[Array[Byte]]))
try {
val obj = in.readObject()
val pkiMessage = PKIMessage.getInstance(obj)
pkiMessage.getBody.getType match {
case PKIBody.TYPE_ERROR =>
val err = ErrorMsgContent.getInstance(pkiMessage.getBody.getContent)
throw new RuntimeException("CA returned error: " + err.getPKIStatusInfo.getStatusString.getStringAtUTF8(0).getString)
case _ => pkiMessage
}
} finally in.close()
}
}
Note: The snippet intentionally omits ProtocolEncrKeyControl details and private-key decryption logic to keep the example compact. In production use, if the CA returns an encrypted private key, you must unwrap it (using your protocol private key) and assemble a PKCS#12 (see lower note).

5 — Play WSClient: WebPkiService (enroll, confirm, renew, revoke)

A compact service using Play WSClient. This demonstrates sending bytes to the EJBCA CMP HTTP endpoint and handling responses.

package com.example.webpki
import play.api.libs.ws.WSClient
import play.api.libs.ws.DefaultBodyWritables._
import scala.concurrent.{ExecutionContext, Future}
import java.nio.file.{Paths, Files}
import java.nio.charset.StandardCharsets
class WebPkiService(ws: WSClient, cfg: play.api.Configuration, cmpMessage: com.example.cmp.CmpMessage)(implicit ec: ExecutionContext) {
private val endpoint = cfg.get[String]("webpki.endpoint")
private val storePath = cfg.get[String]("webpki.certificateStorePath")
private val timeoutMs = cfg.get[Int]("webpki.timeoutMs")
private def saveBytesAsFile(bytes: Array[Byte], filename: String): Unit = {
Files.createDirectories(Paths.get(storePath))
Files.write(Paths.get(storePath, filename), bytes)
}
/** Enroll: send INIT; parse response; send CONFIRM; store cert bytes (demo). */
def enroll(subject: String, machineId: String): Future[Array[Byte]] = {
val initBytes = cmpMessage.createInitRequest(subject)
ws.url(endpoint)
.withRequestTimeout(timeoutMs)
.post(initBytes)
.flatMap { resp =>
val pkiResp = cmpMessage.parsePkiMessage(resp)
// For demo, createConfirmRequest needs PKIResponse
val confirmBytes = cmpMessage.createConfirmRequest(pkiResp)
ws.url(endpoint).post(confirmBytes).map { confResp =>
// In a real flow, confResp will confirm; the INIT response has the certificate.
// For demonstration: save INIT response bytes as raw PKIMessage
saveBytesAsFile(resp.body[Array[Byte]], s"$machineId-init.pki")
saveBytesAsFile(confResp.body[Array[Byte]], s"$machineId-confirm.pki")
// Optionally convert to PKCS12 if you implement toPkcs12.
resp.body[Array[Byte]]
}
}
}
/** Renewal example - send a new INIT with 'renew' semantics (some CAs require special controls) */
def renew(subject: String, machineId: String): Future[Array[Byte]] = {
// For many CMP servers, renewal is similar to init but with appropriate control flags.
// Here we reuse createInitRequest for simplicity.
val initBytes = cmpMessage.createInitRequest(subject)
ws.url(endpoint).post(initBytes).map { resp =>
saveBytesAsFile(resp.body[Array[Byte]], s"$machineId-renew.pki")
resp.body[Array[Byte]]
}
}
/** Revoke (simple HTTP call to an EJBCA RPC or REST endpoint would be typical).
* CMP-based revocation exists, but often systems use RA/REST to revoke. We'll show a stub.
*/
def revoke(certSerialNumber: String, reason: Int): Future[Unit] = {
// EJBCA typically has REST endpoints for revocation (or CMP revocation message).
// Example: POST to /ejbca/ejbca-rest-api/v1/certificate/revoke/{serial}
val revokeUrl = cfg.get[String]("webpki.revokeEndpoint") // configure accordingly
ws.url(revokeUrl)
.withRequestTimeout(timeoutMs)
.post(Map("serial" -> certSerialNumber, "reason" -> reason.toString))
.map(_ => ())
}
}

Notes

  • The code saves raw PKI bytes locally for inspection. In production you should parse/unwrap and build a PKCS#12 file; see next section.
  • If the CA returns an encrypted private key, you must decrypt with your protocol private key and assemble a PKCS#12 keystore. That step was intentionally left as a comment/placeholder.

6 — Java 11 HttpClient version (no Play)

If you prefer not to depend on Play, here’s a minimal Java-HttpClient-based client for the same flow.

package com.example.webpki

import java.net.http.{HttpClient, HttpRequest, HttpResponse}
import java.net.URI
import java.time.Duration
import java.nio.file.{Files, Paths}
import scala.concurrent.{Future, ExecutionContext}
import scala.jdk.FutureConverters._
import java.net.http.HttpRequest.BodyPublishers
import java.net.http.HttpResponse.BodyHandlers
class WebPkiServiceJavaHttp(cfg: play.api.Configuration, cmpMessage: com.example.cmp.CmpMessage)(implicit ec: ExecutionContext) {
private val endpoint = cfg.get[String]("webpki.endpoint")
private val storePath = cfg.get[String]("webpki.certificateStorePath")
private val timeoutMs = cfg.get[Int]("webpki.timeoutMs")
private val client = HttpClient.newBuilder().connectTimeout(Duration.ofMillis(timeoutMs)).build()
private def save(name: String, data: Array[Byte]): Unit = {
Files.createDirectories(Paths.get(storePath))
Files.write(Paths.get(storePath, name), data)
}
def enroll(subject: String, machineId: String): Future[Array[Byte]] = {
val initBytes = cmpMessage.createInitRequest(subject)
val req = HttpRequest.newBuilder(URI.create(endpoint))
.timeout(Duration.ofMillis(timeoutMs))
.header("Content-Type", "application/pkixcmp")
.POST(BodyPublishers.ofByteArray(initBytes))
.build()
client.sendAsync(req, BodyHandlers.ofByteArray()).thenCompose { resp =>
val body = resp.body()
val pkiResp = /* you need parse bytes into PKIMessage; wrap in WSResponse-like helper */ {
// For simplicity reuse a small adapter: parse bytes into PKIMessage (left as exercise)
null
}
// In practice you'd convert bytes to PKIMessage and call createConfirmRequest
// For demo we simply persist the response bytes and return them
save(s"$machineId-init.pki", body)
java.util.concurrent.CompletableFuture.completedFuture(body)
}.asScala
}
}
The Java client version intentionally leaves the PKIMessage adaptation minimal — the key idea is: you can use any HTTP client that posts application/pkixcmp bytes and reads the raw bytes back. Use BouncyCastle to parse the PKI message.

7 — Building a PKCS#12 (production note)

Often EJBCA will return:

  • certificate chain, and optionally
  • an encrypted private key that must be unwrapped with your Protocol Private Key.

Production flow:

  1. Use your protocol private key to unwrap the symmetric key (as shown in your original decryptPrivateKey logic).
  2. Decrypt private key bytes and combine with certificate chain to create a KeyStore of type PKCS12, then store() it to disk (or S3).
  3. Protect the PKCS#12 password with your secret manager (KMS). In production we used KMS for password encryption and S3 for backup of PKCS#12 files. This ensures you can recover certificates and keep them encrypted at rest.

8 — Renewal & Revocation: when & how

  • Renewal: Request a new certificate when expiry is near. CMP renewal usually looks like an init request with renewal controls or re-using existing CMP request flow. Some CMP servers accept ExplicitConfirm or specific controls. Test against your EJBCA configuration.
  • Revocation: You can revoke via CMP (CMP supports revocation messages) or via EJBCA REST/CLI/admin endpoints. In many enterprise setups, RA systems call EJBCA’s REST API for revocation since it’s simpler to authenticate.

9 — Common pitfalls & troubleshooting

  • Wrong shared secret → PKMAC validation fails; EJBCA returns ErrorMsgContent. Check alias configuration and secret.
  • Certificates not present → Ensure CA is allowed for CMP alias and the CA has the proper issuing certificate.
  • Clock skew → CMP messages include timestamps. Sync servers via NTP.
  • Missing protocol keys → If CA expects ProtocolEncrKeyControl, ensure you provide the CA public key in your CRMF control.
  • Content-Type: CMP HTTP expects application/pkixcmp for many deployments; ensure client sets that header.

10 — Security & production checklist

  • Use HTTPS endpoints for CMP (TLS). If using plain HTTP for testing, never do that in production.
  • Protect macSecret, protocol private key, and PKCS#12 passwords using a secret manager. In production, we used AWS KMS to encrypt passwords and S3 to store PKCS#12 blobs — keep them encrypted at rest and only decrypt in memory when needed.
  • Rotate shared secrets and protocol keys according to policy.
  • Log only non-sensitive metadata. Do not log private key material or PKCS#12 password.

11 — Conclusion

Integrating EJBCA Community Edition with Scala is straightforward once CMP is enabled on the CA and you have the right cryptographic controls. This guide focused on core flows (Init + Confirm), renewal and revocation examples, and two client implementations (Play WSClient and Java 11 HttpClient). For production-grade deployments, combine CMP with secure key storage to keep certificate material safe and recoverable (e.g., AWS KMS for protection and S3 for encrypted backups).

Ali Farooqi

About the Writer

Ali is a software engineer based in Hong Kong who builds cloud-powered, high-performance web apps. He writes about React, Next.js, DevOps, SEO, and building modern portfolios that scale. When not coding, he’s probably hiking mountains or testing new cloud infra ideas.

Originally posted on Medium →