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
- Log into EJBCA Admin GUI.
- Create/identify the CA you want to enroll certificates against.
- 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:
- Use your protocol private key to unwrap the symmetric key (as shown in your original decryptPrivateKey logic).
- Decrypt private key bytes and combine with certificate chain to create a KeyStore of type PKCS12, then store() it to disk (or S3).
- 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).