Smallstep Certificates is a private certificate authority (CA) that automates certificate lifecycle management. It provides APIs for certificate issuance, renewal, and revocation with ACME protocol support.
Architecture Overview
Key Components:
- Step CLI & CA: Certificate authority server and command-line interface
- ACME Protocol: Automated Certificate Management Environment
- OAuth OIDC: Identity integration for authentication
- CRL & OCSP: Certificate revocation mechanisms
- TLS/SSL: Secure communication protocols
Java Application → Smallstep CA → Certificate Management
- Applications request certificates via Smallstep APIs
- Smallstep handles certificate issuance, renewal, and revocation
- Support for various certificate types and formats
Dependencies and Setup
Maven Dependencies
<properties>
<spring-boot.version>3.1.0</spring-boot.version>
<bouncycastle.version>1.75</bouncycastle.version>
<apache-httpclient.version>5.2.1</apache-httpclient.version>
<jackson.version>2.15.2</jackson.version>
</properties>
<dependencies>
<!-- Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- Cryptography -->
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcpkix-jdk18on</artifactId>
<version>${bouncycastle.version}</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>${bouncycastle.version}</version>
</dependency>
<!-- HTTP Client -->
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
<version>${apache-httpclient.version}</version>
</dependency>
<!-- JSON Processing -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>${jackson.version}</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-core</artifactId>
<version>${jackson.version}</version>
</dependency>
<!-- SSL/TLS -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-ssl</artifactId>
<version>${spring-boot.version}</version>
</dependency>
<!-- Utilities -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.13.0</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.13.0</version>
</dependency>
</dependencies>
Smallstep CA Setup (Docker)
# docker-compose.yml version: '3.8' services: step-ca: image: smallstep/step-ca:0.24.2 ports: - "9000:9000" environment: - STEP_CA_NAME=Smallstep CA - STEP_CA_ADDRESS=:9000 - STEP_CA_DNS=localhost,step-ca - STEP_CA_DEFAULT_PROVISIONER=admin - STEP_CA_DEFAULT_SSH=false volumes: - ./step-ca:/home/step - ./config/ca.json:/home/step/config/ca.json:ro command: | /bin/sh -c " if [ ! -f /home/step/ca.crt ]; then step ca init --name='Smallstep CA' --dns=localhost,step-ca --address=':9000' --provisioner=admin --password-file=/dev/null fi && step-ca /home/step/config/ca.json " networks: - step-network step-cli: image: smallstep/step-cli:0.24.2 volumes: - ./step-ca:/home/step working_dir: /home/step networks: - step-network networks: step-network: driver: bridge
CA Configuration
{
"root": "/home/step/ca.crt",
"crt": "/home/step/certs/intermediate_ca.crt",
"key": "/home/step/secrets/intermediate_ca_key",
"address": ":9000",
"dnsNames": ["localhost", "step-ca"],
"logger": {
"format": "text"
},
"db": {
"type": "badger",
"dataSource": "/home/step/db"
},
"authority": {
"provisioners": [
{
"type": "JWK",
"name": "admin",
"key": {
"use": "sig",
"kty": "EC",
"kid": "admin",
"crv": "P-256",
"alg": "ES256",
"x": "public-key-x",
"y": "public-key-y"
},
"encryptedKey": "encrypted-private-key"
}
],
"templates": {
"ssh": {
"template": {
"type": "",
"keyId": "{{.Subject}}",
"principals": ["{{.Subject}}"],
"extensions": {
"permit-X11-forwarding": "",
"permit-agent-forwarding": "",
"permit-port-forwarding": "",
"permit-pty": "",
"permit-user-rc": ""
}
}
}
}
},
"tls": {
"cipherSuites": [
"TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"
],
"minVersion": 1.2,
"maxVersion": 1.3,
"renegotiation": false
}
}
Configuration and Properties
1. Smallstep Configuration Properties
@ConfigurationProperties(prefix = "smallstep")
@Data
public class SmallstepProperties {
private String caUrl = "https://localhost:9000";
private String caFingerprint;
private String provisionerName = "admin";
private String provisionerPassword;
private String rootCertificatePath;
private AuthConfig auth = new AuthConfig();
private CertificateConfig certificate = new CertificateConfig();
private AcmeConfig acme = new AcmeConfig();
@Data
public static class AuthConfig {
private boolean enabled = true;
private String type = "JWK";
private String jwkPath;
private String oidcIssuer;
private String oidcClientId;
private String oidcClientSecret;
}
@Data
public static class CertificateConfig {
private String template;
private List<String> dnsNames = new ArrayList<>();
private List<String> ipAddresses = new ArrayList<>();
private Duration validity = Duration.ofDays(90);
private Duration renewalWindow = Duration.ofDays(30);
private String keyType = "RSA";
private int keySize = 2048;
private String keyAlgorithm = "SHA256withRSA";
}
@Data
public static class AcmeConfig {
private boolean enabled = true;
private String directoryUrl;
private String accountKeyPath;
private String contactEmail;
}
}
2. Application Configuration
# application.yaml
smallstep:
ca-url: "https://localhost:9000"
ca-fingerprint: "${CA_FINGERPRINT:}"
provisioner-name: "admin"
provisioner-password: "${PROVISIONER_PASSWORD:}"
root-certificate-path: "./certs/root_ca.crt"
auth:
enabled: true
type: "JWK"
jwk-path: "./config/provisioner.jwk"
certificate:
template: "server"
dns-names:
- "localhost"
- "*.example.com"
validity: "90d"
renewal-window: "30d"
key-type: "RSA"
key-size: 2048
key-algorithm: "SHA256withRSA"
acme:
enabled: true
directory-url: "https://localhost:9000/acme/acme/directory"
account-key-path: "./acme-account.key"
contact-email: "[email protected]"
app:
certificates:
storage:
type: "file" # file, database, memory
path: "./certs"
auto-renewal:
enabled: true
check-interval: "24h"
threshold: "30d"
monitoring:
enabled: true
metrics-prefix: "certificates"
management:
endpoints:
web:
exposure:
include: health,info,metrics,certificates
endpoint:
health:
show-details: always
certificates:
enabled: true
logging:
level:
com.smallstep: DEBUG
com.example.smallstep: DEBUG
Core Smallstep Client Implementation
1. Smallstep HTTP Client
@Component
@Slf4j
public class SmallstepHttpClient {
private final SmallstepProperties properties;
private final ObjectMapper objectMapper;
private final CloseableHttpClient httpClient;
public SmallstepHttpClient(SmallstepProperties properties, ObjectMapper objectMapper) {
this.properties = properties;
this.objectMapper = objectMapper;
this.httpClient = createHttpClient();
}
private CloseableHttpClient createHttpClient() {
try {
SSLContext sslContext = createSSLContext();
return HttpClients.custom()
.setSSLContext(sslContext)
.setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE)
.setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create()
.setSSLSocketFactory(SSLConnectionSocketFactoryBuilder.create()
.setSslContext(sslContext)
.build())
.build())
.setDefaultRequestConfig(RequestConfig.custom()
.setConnectTimeout(30000)
.setConnectionRequestTimeout(30000)
.setSocketTimeout(60000)
.build())
.build();
} catch (Exception e) {
throw new SmallstepClientException("Failed to create HTTP client", e);
}
}
private SSLContext createSSLContext() throws Exception {
// Load root CA certificate for TLS verification
if (properties.getRootCertificatePath() != null) {
File rootCertFile = new File(properties.getRootCertificatePath());
if (rootCertFile.exists()) {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
Certificate rootCert;
try (FileInputStream fis = new FileInputStream(rootCertFile)) {
rootCert = cf.generateCertificate(fis);
}
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
keyStore.load(null, null);
keyStore.setCertificateEntry("root-ca", rootCert);
TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
tmf.init(keyStore);
SSLContext sslContext = SSLContext.getInstance("TLS");
sslContext.init(null, tmf.getTrustManagers(), new SecureRandom());
return sslContext;
}
}
// Fallback to default SSL context
return SSLContext.getDefault();
}
public <T> T executeGet(String path, Class<T> responseType) {
return executeRequest(HttpGet.METHOD_NAME, path, null, responseType);
}
public <T> T executePost(String path, Object requestBody, Class<T> responseType) {
return executeRequest(HttpPost.METHOD_NAME, path, requestBody, responseType);
}
public <T> T executeRequest(String method, String path, Object requestBody, Class<T> responseType) {
String url = properties.getCaUrl() + path;
try {
HttpRequestBase request = createRequest(method, url, requestBody);
addAuthHeaders(request);
log.debug("Executing {} request to: {}", method, url);
try (CloseableHttpResponse response = httpClient.execute(request)) {
int statusCode = response.getCode();
String responseBody = EntityUtils.toString(response.getEntity());
if (statusCode >= 200 && statusCode < 300) {
if (responseType == String.class) {
return responseType.cast(responseBody);
} else {
return objectMapper.readValue(responseBody, responseType);
}
} else {
handleErrorResponse(statusCode, responseBody);
return null;
}
}
} catch (Exception e) {
throw new SmallstepClientException("HTTP request failed: " + method + " " + url, e);
}
}
private HttpRequestBase createRequest(String method, String url, Object requestBody) throws Exception {
switch (method.toUpperCase()) {
case HttpGet.METHOD_NAME:
return new HttpGet(url);
case HttpPost.METHOD_NAME:
HttpPost post = new HttpPost(url);
if (requestBody != null) {
String jsonBody = objectMapper.writeValueAsString(requestBody);
post.setEntity(new StringEntity(jsonBody, ContentType.APPLICATION_JSON));
}
return post;
default:
throw new IllegalArgumentException("Unsupported HTTP method: " + method);
}
}
private void addAuthHeaders(HttpRequestBase request) {
// Add JWT token or other authentication headers
if (properties.getAuth().isEnabled()) {
String token = generateAuthToken();
request.setHeader("Authorization", "Bearer " + token);
}
}
private String generateAuthToken() {
// Generate JWT token for Smallstep authentication
// This would use the provisioner credentials
try {
return JwtTokenGenerator.generateToken(properties);
} catch (Exception e) {
throw new SmallstepAuthException("Failed to generate auth token", e);
}
}
private void handleErrorResponse(int statusCode, String responseBody) {
log.error("Smallstep API error: {} - {}", statusCode, responseBody);
switch (statusCode) {
case 400:
throw new SmallstepBadRequestException("Bad request: " + responseBody);
case 401:
throw new SmallstepAuthException("Authentication failed");
case 403:
throw new SmallstepAuthException("Authorization failed");
case 404:
throw new SmallstepNotFoundException("Resource not found");
case 500:
throw new SmallstepServerException("Server error: " + responseBody);
default:
throw new SmallstepClientException("HTTP error " + statusCode + ": " + responseBody);
}
}
@PreDestroy
public void close() {
try {
httpClient.close();
} catch (Exception e) {
log.error("Failed to close HTTP client", e);
}
}
}
2. JWT Token Generator
@Component
@Slf4j
public class JwtTokenGenerator {
private static final String JWT_HEADER = "{\"alg\":\"ES256\",\"typ\":\"JWT\"}";
public static String generateToken(SmallstepProperties properties) throws Exception {
String provisionerName = properties.getProvisionerName();
Instant now = Instant.now();
Instant expiry = now.plusSeconds(300); // 5 minutes
// Create JWT claims
Map<String, Object> claims = new HashMap<>();
claims.put("iss", provisionerName);
claims.put("aud", properties.getCaUrl() + "/1.0/sign");
claims.put("sub", provisionerName);
claims.put("nbf", now.getEpochSecond());
claims.put("iat", now.getEpochSecond());
claims.put("exp", expiry.getEpochSecond());
// Load provisioner private key
PrivateKey privateKey = loadProvisionerPrivateKey(properties);
// Sign JWT
return signJwt(claims, privateKey);
}
private static PrivateKey loadProvisionerPrivateKey(SmallstepProperties properties) throws Exception {
// Load private key from JWK file or other source
String jwkPath = properties.getAuth().getJwkPath();
if (jwkPath != null) {
File jwkFile = new File(jwkPath);
if (jwkFile.exists()) {
return loadPrivateKeyFromJwk(jwkFile);
}
}
throw new SmallstepAuthException("Provisioner private key not found");
}
private static PrivateKey loadPrivateKeyFromJwk(File jwkFile) throws Exception {
ObjectMapper mapper = new ObjectMapper();
Map<String, Object> jwk = mapper.readValue(jwkFile, new TypeReference<Map<String, Object>>() {});
String kty = (String) jwk.get("kty");
if ("EC".equals(kty)) {
return loadECPrivateKey(jwk);
} else if ("RSA".equals(kty)) {
return loadRSAPrivateKey(jwk);
} else {
throw new SmallstepAuthException("Unsupported key type: " + kty);
}
}
private static PrivateKey loadECPrivateKey(Map<String, Object> jwk) throws Exception {
String crv = (String) jwk.get("crv");
String d = (String) jwk.get("d"); // Private key parameter
ECParameterSpec ecSpec = getECParameterSpec(crv);
BigInteger privateKeyValue = base64UrlDecodeToBigInteger(d);
KeyFactory keyFactory = KeyFactory.getInstance("EC");
ECPrivateKeySpec privateKeySpec = new ECPrivateKeySpec(privateKeyValue, ecSpec);
return keyFactory.generatePrivate(privateKeySpec);
}
private static ECParameterSpec getECParameterSpec(String crv) {
switch (crv) {
case "P-256":
return new ECParameterSpec(
new EllipticCurve(
new ECFieldFp(new BigInteger("FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFF", 16)),
new BigInteger("FFFFFFFF00000001000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFC", 16),
new BigInteger("5AC635D8AA3A93E7B3EBBD55769886BC651D06B0CC53B0F63BCE3C3E27D2604B", 16)
),
new ECPoint(
new BigInteger("6B17D1F2E12C4247F8BCE6E563A440F277037D812DEB33A0F4A13945D898C296", 16),
new BigInteger("4FE342E2FE1A7F9B8EE7EB4A7C0F9E162BCE33576B315ECECBB6406837BF51F5", 16)
),
new BigInteger("FFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551", 16),
1
);
default:
throw new SmallstepAuthException("Unsupported EC curve: " + crv);
}
}
private static String signJwt(Map<String, Object> claims, PrivateKey privateKey) throws Exception {
String headerBase64 = Base64.getUrlEncoder().withoutPadding()
.encodeToString(JWT_HEADER.getBytes(StandardCharsets.UTF_8));
String claimsJson = new ObjectMapper().writeValueAsString(claims);
String claimsBase64 = Base64.getUrlEncoder().withoutPadding()
.encodeToString(claimsJson.getBytes(StandardCharsets.UTF_8));
String signingInput = headerBase64 + "." + claimsBase64;
Signature signature = Signature.getInstance("SHA256withECDSA");
signature.initSign(privateKey);
signature.update(signingInput.getBytes(StandardCharsets.UTF_8));
byte[] signatureBytes = signature.sign();
String signatureBase64 = Base64.getUrlEncoder().withoutPadding()
.encodeToString(signatureBytes);
return signingInput + "." + signatureBase64;
}
private static BigInteger base64UrlDecodeToBigInteger(String base64Url) {
byte[] bytes = Base64.getUrlDecoder().decode(base64Url);
return new BigInteger(1, bytes);
}
// RSA private key loading would be implemented similarly
private static PrivateKey loadRSAPrivateKey(Map<String, Object> jwk) throws Exception {
// Implementation for RSA private key loading
throw new SmallstepAuthException("RSA private key loading not implemented");
}
}
Certificate Service Implementation
1. Certificate Service
@Service
@Slf4j
public class CertificateService {
private final SmallstepHttpClient httpClient;
private final SmallstepProperties properties;
private final KeyPairGenerator keyPairGenerator;
private final CertificateStorageService storageService;
public CertificateService(SmallstepHttpClient httpClient, SmallstepProperties properties,
CertificateStorageService storageService) throws NoSuchAlgorithmException {
this.httpClient = httpClient;
this.properties = properties;
this.storageService = storageService;
this.keyPairGenerator = KeyPairGenerator.getInstance(properties.getCertificate().getKeyType());
this.keyPairGenerator.initialize(properties.getCertificate().getKeySize());
}
/**
* Request a new certificate
*/
public CertificateResponse requestCertificate(CertificateRequest request) {
try {
log.info("Requesting certificate for CN: {}", request.getCommonName());
// Generate key pair
KeyPair keyPair = generateKeyPair();
// Create CSR
String csr = createCertificateSigningRequest(keyPair, request);
// Submit to Smallstep CA
SignRequest signRequest = createSignRequest(csr, request);
SignResponse signResponse = httpClient.executePost("/1.0/sign", signRequest, SignResponse.class);
// Parse certificate chain
X509Certificate certificate = parseCertificate(signResponse.getServerPem().getCert());
List<X509Certificate> chain = parseCertificateChain(signResponse.getServerPem().getCerts());
CertificateResponse response = CertificateResponse.builder()
.certificate(certificate)
.certificateChain(chain)
.privateKey(keyPair.getPrivate())
.publicKey(keyPair.getPublic())
.csr(csr)
.serialNumber(signResponse.getServerPem().getSerialNumber())
.validFrom(certificate.getNotBefore().toInstant())
.validTo(certificate.getNotAfter().toInstant())
.build();
// Store certificate
storageService.storeCertificate(request.getCommonName(), response);
log.info("Successfully issued certificate for CN: {} (serial: {})",
request.getCommonName(), signResponse.getServerPem().getSerialNumber());
return response;
} catch (Exception e) {
log.error("Failed to request certificate for CN: {}", request.getCommonName(), e);
throw new CertificateRequestException("Certificate request failed", e);
}
}
/**
* Renew an existing certificate
*/
public CertificateResponse renewCertificate(String commonName) {
try {
log.info("Renewing certificate for CN: {}", commonName);
// Load existing certificate
CertificateResponse existingCert = storageService.loadCertificate(commonName);
if (existingCert == null) {
throw new CertificateNotFoundException("Certificate not found: " + commonName);
}
// Create renewal request
CertificateRequest renewalRequest = CertificateRequest.builder()
.commonName(commonName)
.dnsNames(List.of(commonName))
.keyType(properties.getCertificate().getKeyType())
.keySize(properties.getCertificate().getKeySize())
.validity(properties.getCertificate().getValidity())
.build();
return requestCertificate(renewalRequest);
} catch (Exception e) {
log.error("Failed to renew certificate for CN: {}", commonName, e);
throw new CertificateRenewalException("Certificate renewal failed", e);
}
}
/**
* Revoke a certificate
*/
public void revokeCertificate(String serialNumber, RevocationReason reason) {
try {
log.info("Revoking certificate with serial: {} (reason: {})", serialNumber, reason);
RevokeRequest revokeRequest = RevokeRequest.builder()
.serial(serialNumber)
.reason(reason.getCode())
.build();
httpClient.executePost("/1.0/revoke", revokeRequest, Void.class);
// Remove from storage
storageService.removeCertificate(serialNumber);
log.info("Successfully revoked certificate with serial: {}", serialNumber);
} catch (Exception e) {
log.error("Failed to revoke certificate with serial: {}", serialNumber, e);
throw new CertificateRevocationException("Certificate revocation failed", e);
}
}
/**
* Get certificate status
*/
public CertificateStatus getCertificateStatus(String serialNumber) {
try {
CertificateResponse cert = storageService.loadCertificateBySerial(serialNumber);
if (cert == null) {
return CertificateStatus.NOT_FOUND;
}
Instant now = Instant.now();
if (now.isBefore(cert.getValidFrom())) {
return CertificateStatus.NOT_YET_VALID;
} else if (now.isAfter(cert.getValidTo())) {
return CertificateStatus.EXPIRED;
} else {
return CertificateStatus.VALID;
}
} catch (Exception e) {
log.error("Failed to get certificate status for serial: {}", serialNumber, e);
return CertificateStatus.UNKNOWN;
}
}
/**
* Check if certificate needs renewal
*/
public boolean needsRenewal(String commonName) {
try {
CertificateResponse cert = storageService.loadCertificate(commonName);
if (cert == null) {
return true; // No certificate exists
}
Instant renewalTime = cert.getValidTo()
.minus(properties.getCertificate().getRenewalWindow());
return Instant.now().isAfter(renewalTime);
} catch (Exception e) {
log.error("Failed to check renewal for CN: {}", commonName, e);
return true;
}
}
private KeyPair generateKeyPair() {
return keyPairGenerator.generateKeyPair();
}
private String createCertificateSigningRequest(KeyPair keyPair, CertificateRequest request) throws Exception {
StringWriter writer = new StringWriter();
JcaPEMWriter pemWriter = new JcaPEMWriter(writer);
PKCS10CertificationRequestBuilder p10Builder = new JcaPKCS10CertificationRequestBuilder(
new X500Principal(buildDistinguishedName(request)),
keyPair.getPublic()
);
// Add extensions
ExtensionsGenerator extensionsGenerator = new ExtensionsGenerator();
// Subject Alternative Names
if (!request.getDnsNames().isEmpty() || !request.getIpAddresses().isEmpty()) {
GeneralNames generalNames = new GeneralNames();
for (String dnsName : request.getDnsNames()) {
generalNames.add(new GeneralName(GeneralName.dNSName, dnsName));
}
for (String ipAddress : request.getIpAddresses()) {
generalNames.add(new GeneralName(GeneralName.iPAddress, ipAddress));
}
extensionsGenerator.addExtension(Extension.subjectAlternativeName, false, generalNames);
}
if (!extensionsGenerator.isEmpty()) {
p10Builder.addAttribute(
PKCSObjectIdentifiers.pkcs_9_at_extensionRequest,
extensionsGenerator.generate()
);
}
ContentSigner signer = new JcaContentSignerBuilder(properties.getCertificate().getKeyAlgorithm())
.build(keyPair.getPrivate());
PKCS10CertificationRequest csr = p10Builder.build(signer);
pemWriter.writeObject(csr);
pemWriter.close();
return writer.toString();
}
private String buildDistinguishedName(CertificateRequest request) {
List<String> components = new ArrayList<>();
if (request.getCommonName() != null) {
components.add("CN=" + request.getCommonName());
}
if (request.getOrganization() != null) {
components.add("O=" + request.getOrganization());
}
if (request.getOrganizationalUnit() != null) {
components.add("OU=" + request.getOrganizationalUnit());
}
if (request.getCountry() != null) {
components.add("C=" + request.getCountry());
}
if (request.getState() != null) {
components.add("ST=" + request.getState());
}
if (request.getLocality() != null) {
components.add("L=" + request.getLocality());
}
return String.join(",", components);
}
private SignRequest createSignRequest(String csr, CertificateRequest request) {
return SignRequest.builder()
.csr(csr)
.ott(generateOneTimeToken())
.notBefore(Instant.now())
.notAfter(Instant.now().plus(request.getValidity()))
.build();
}
private String generateOneTimeToken() {
// Generate OTT for certificate signing
// This would typically come from Smallstep provisioner
try {
return JwtTokenGenerator.generateToken(properties);
} catch (Exception e) {
throw new SmallstepAuthException("Failed to generate OTT", e);
}
}
private X509Certificate parseCertificate(String pem) throws Exception {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
try (ByteArrayInputStream bis = new ByteArrayInputStream(pem.getBytes(StandardCharsets.UTF_8))) {
return (X509Certificate) cf.generateCertificate(bis);
}
}
private List<X509Certificate> parseCertificateChain(String pemChain) throws Exception {
List<X509Certificate> chain = new ArrayList<>();
CertificateFactory cf = CertificateFactory.getInstance("X.509");
try (ByteArrayInputStream bis = new ByteArrayInputStream(pemChain.getBytes(StandardCharsets.UTF_8))) {
Collection<? extends Certificate> certificates = cf.generateCertificates(bis);
for (Certificate cert : certificates) {
chain.add((X509Certificate) cert);
}
}
return chain;
}
}
2. Certificate Models
@Data
@Builder
@Jacksonized
public class CertificateRequest {
@NotBlank
private String commonName;
private List<String> dnsNames = new ArrayList<>();
private List<String> ipAddresses = new ArrayList<>();
private String organization;
private String organizationalUnit;
private String country;
private String state;
private String locality;
private String emailAddress;
@Builder.Default
private String keyType = "RSA";
@Builder.Default
private int keySize = 2048;
@Builder.Default
private Duration validity = Duration.ofDays(90);
private Map<String, String> extensions;
}
@Data
@Builder
@Jacksonized
public class CertificateResponse {
private X509Certificate certificate;
private List<X509Certificate> certificateChain;
private PrivateKey privateKey;
private PublicKey publicKey;
private String csr;
private String serialNumber;
private Instant validFrom;
private Instant validTo;
private Instant issuedAt;
public String getCertificatePem() {
try {
StringWriter writer = new StringWriter();
JcaPEMWriter pemWriter = new JcaPEMWriter(writer);
pemWriter.writeObject(certificate);
pemWriter.close();
return writer.toString();
} catch (Exception e) {
throw new CertificateSerializationException("Failed to serialize certificate to PEM", e);
}
}
public String getPrivateKeyPem() {
try {
StringWriter writer = new StringWriter();
JcaPEMWriter pemWriter = new JcaPEMWriter(writer);
pemWriter.writeObject(privateKey);
pemWriter.close();
return writer.toString();
} catch (Exception e) {
throw new CertificateSerializationException("Failed to serialize private key to PEM", e);
}
}
}
@Data
@Builder
@Jacksonized
public class SignRequest {
private String csr;
private String ott;
private Instant notBefore;
private Instant notAfter;
}
@Data
@Builder
@Jacksonized
public class SignResponse {
private ServerPem serverPem;
private String caPem;
private String certChainPem;
@Data
@Builder
@Jacksonized
public static class ServerPem {
private String cert;
private String certs;
private String serialNumber;
}
}
@Data
@Builder
@Jacksonized
public class RevokeRequest {
private String serial;
private Integer reason;
private String reasonString;
}
public enum RevocationReason {
UNSPECIFIED(0),
KEY_COMPROMISE(1),
CA_COMPROMISE(2),
AFFILIATION_CHANGED(3),
SUPERSEDED(4),
CESSATION_OF_OPERATION(5),
CERTIFICATE_HOLD(6),
REMOVE_FROM_CRL(8),
PRIVILEGE_WITHDRAWN(9),
AA_COMPROMISE(10);
private final int code;
RevocationReason(int code) {
this.code = code;
}
public int getCode() {
return code;
}
}
public enum CertificateStatus {
VALID, EXPIRED, REVOKED, NOT_FOUND, NOT_YET_VALID, UNKNOWN
}
3. Certificate Storage Service
@Service
@Slf4j
public class CertificateStorageService {
private final ObjectMapper objectMapper;
private final String storagePath;
public CertificateStorageService(ObjectMapper objectMapper,
@Value("${app.certificates.storage.path:./certs}") String storagePath) {
this.objectMapper = objectMapper;
this.storagePath = storagePath;
createStorageDirectory();
}
public void storeCertificate(String commonName, CertificateResponse certificate) {
try {
String filename = commonName + ".json";
File file = new File(storagePath, filename);
CertificateMetadata metadata = CertificateMetadata.fromResponse(commonName, certificate);
String json = objectMapper.writeValueAsString(metadata);
Files.writeString(file.toPath(), json, StandardCharsets.UTF_8);
log.debug("Stored certificate for CN: {} at {}", commonName, file.getAbsolutePath());
} catch (Exception e) {
throw new CertificateStorageException("Failed to store certificate", e);
}
}
public CertificateResponse loadCertificate(String commonName) {
try {
String filename = commonName + ".json";
File file = new File(storagePath, filename);
if (!file.exists()) {
return null;
}
String json = Files.readString(file.toPath(), StandardCharsets.UTF_8);
CertificateMetadata metadata = objectMapper.readValue(json, CertificateMetadata.class);
return metadata.toResponse();
} catch (Exception e) {
log.error("Failed to load certificate for CN: {}", commonName, e);
return null;
}
}
public CertificateResponse loadCertificateBySerial(String serialNumber) {
try {
File storageDir = new File(storagePath);
File[] files = storageDir.listFiles((dir, name) -> name.endsWith(".json"));
if (files != null) {
for (File file : files) {
String json = Files.readString(file.toPath(), StandardCharsets.UTF_8);
CertificateMetadata metadata = objectMapper.readValue(json, CertificateMetadata.class);
if (serialNumber.equals(metadata.getSerialNumber())) {
return metadata.toResponse();
}
}
}
return null;
} catch (Exception e) {
log.error("Failed to load certificate by serial: {}", serialNumber, e);
return null;
}
}
public void removeCertificate(String commonName) {
try {
String filename = commonName + ".json";
File file = new File(storagePath, filename);
if (file.exists()) {
Files.delete(file.toPath());
log.debug("Removed certificate for CN: {}", commonName);
}
} catch (Exception e) {
log.error("Failed to remove certificate for CN: {}", commonName, e);
}
}
public List<CertificateMetadata> listCertificates() {
List<CertificateMetadata> certificates = new ArrayList<>();
try {
File storageDir = new File(storagePath);
File[] files = storageDir.listFiles((dir, name) -> name.endsWith(".json"));
if (files != null) {
for (File file : files) {
try {
String json = Files.readString(file.toPath(), StandardCharsets.UTF_8);
CertificateMetadata metadata = objectMapper.readValue(json, CertificateMetadata.class);
certificates.add(metadata);
} catch (Exception e) {
log.error("Failed to read certificate file: {}", file.getName(), e);
}
}
}
} catch (Exception e) {
log.error("Failed to list certificates", e);
}
return certificates;
}
private void createStorageDirectory() {
try {
File dir = new File(storagePath);
if (!dir.exists()) {
Files.createDirectories(dir.toPath());
log.info("Created certificate storage directory: {}", dir.getAbsolutePath());
}
} catch (Exception e) {
throw new CertificateStorageException("Failed to create storage directory", e);
}
}
}
@Data
@Builder
@Jacksonized
class CertificateMetadata {
private String commonName;
private String serialNumber;
private Instant validFrom;
private Instant validTo;
private Instant issuedAt;
private String certificatePem;
private String privateKeyPem;
private String csrPem;
public static CertificateMetadata fromResponse(String commonName, CertificateResponse response) {
return CertificateMetadata.builder()
.commonName(commonName)
.serialNumber(response.getSerialNumber())
.validFrom(response.getValidFrom())
.validTo(response.getValidTo())
.issuedAt(Instant.now())
.certificatePem(response.getCertificatePem())
.privateKeyPem(response.getPrivateKeyPem())
.csrPem(response.getCsr())
.build();
}
public CertificateResponse toResponse() throws Exception {
X509Certificate certificate = parseCertificate(certificatePem);
PrivateKey privateKey = parsePrivateKey(privateKeyPem);
PublicKey publicKey = certificate.getPublicKey();
return CertificateResponse.builder()
.certificate(certificate)
.certificateChain(List.of(certificate)) // Simplified
.privateKey(privateKey)
.publicKey(publicKey)
.csr(csrPem)
.serialNumber(serialNumber)
.validFrom(validFrom)
.validTo(validTo)
.issuedAt(issuedAt)
.build();
}
private X509Certificate parseCertificate(String pem) throws Exception {
CertificateFactory cf = CertificateFactory.getInstance("X.509");
try (ByteArrayInputStream bis = new ByteArrayInputStream(pem.getBytes(StandardCharsets.UTF_8))) {
return (X509Certificate) cf.generateCertificate(bis);
}
}
private PrivateKey parsePrivateKey(String pem) throws Exception {
PEMParser parser = new PEMParser(new StringReader(pem));
Object object = parser.readObject();
parser.close();
if (object instanceof PrivateKeyInfo) {
JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
return converter.getPrivateKey((PrivateKeyInfo) object);
} else if (object instanceof org.bouncycastle.openssl.PKCS8Generator) {
// Handle PKCS8 format
return null; // Implementation would parse PKCS8
} else {
throw new CertificateSerializationException("Unsupported private key format");
}
}
}
ACME Client Implementation
1. ACME Client Service
@Service
@Slf4j
public class AcmeClientService {
private final SmallstepProperties properties;
private final ObjectMapper objectMapper;
private final CloseableHttpClient httpClient;
public AcmeClientService(SmallstepProperties properties, ObjectMapper objectMapper) {
this.properties = properties;
this.objectMapper = objectMapper;
this.httpClient = createHttpClient();
}
/**
* Request certificate via ACME protocol
*/
public CertificateResponse requestAcmeCertificate(AcmeCertificateRequest request) {
try {
log.info("Requesting ACME certificate for domains: {}", request.getDomains());
// ACME directory discovery
AcmeDirectory directory = discoverAcmeDirectory();
// Create ACME account
AcmeAccount account = createOrLoadAccount(directory);
// Create order
AcmeOrder order = createOrder(account, request.getDomains());
// Perform challenges
performChallenges(order);
// Finalize order
CertificateResponse certificate = finalizeOrder(order, request);
log.info("Successfully issued ACME certificate for domains: {}", request.getDomains());
return certificate;
} catch (Exception e) {
log.error("Failed to request ACME certificate for domains: {}", request.getDomains(), e);
throw new AcmeException("ACME certificate request failed", e);
}
}
private AcmeDirectory discoverAcmeDirectory() {
String directoryUrl = properties.getAcme().getDirectoryUrl();
return httpClient.executeGet(directoryUrl, AcmeDirectory.class);
}
private AcmeAccount createOrLoadAccount(AcmeDirectory directory) {
// Try to load existing account
AcmeAccount existingAccount = loadAccount();
if (existingAccount != null) {
return existingAccount;
}
// Create new account
return createNewAccount(directory);
}
private AcmeAccount createNewAccount(AcmeDirectory directory) {
AcmeAccountRequest accountRequest = AcmeAccountRequest.builder()
.contact(List.of("mailto:" + properties.getAcme().getContactEmail()))
.termsOfServiceAgreed(true)
.build();
// Create account key pair if not exists
KeyPair accountKeyPair = createOrLoadAccountKeyPair();
// Sign request with account key
String signedRequest = signAcmeRequest(accountRequest, accountKeyPair, directory.getNewAccount());
// Submit account creation request
AcmeAccount account = submitAcmeRequest(directory.getNewAccount(), signedRequest, AcmeAccount.class);
// Save account
saveAccount(account);
return account;
}
private AcmeOrder createOrder(AcmeAccount account, List<String> domains) {
AcmeOrderRequest orderRequest = AcmeOrderRequest.builder()
.identifiers(domains.stream()
.map(domain -> new Identifier("dns", domain))
.collect(Collectors.toList()))
.build();
String signedRequest = signAcmeRequest(orderRequest, getAccountKeyPair(), account.getOrders());
return submitAcmeRequest(account.getOrders(), signedRequest, AcmeOrder.class);
}
private void performChallenges(AcmeOrder order) {
for (String authUrl : order.getAuthorizations()) {
AcmeAuthorization auth = fetchAuthorization(authUrl);
for (AcmeChallenge challenge : auth.getChallenges()) {
if ("http-01".equals(challenge.getType())) {
performHttpChallenge(challenge);
break;
} else if ("dns-01".equals(challenge.getType())) {
performDnsChallenge(challenge);
break;
}
}
}
}
private void performHttpChallenge(AcmeChallenge challenge) {
// Implement HTTP-01 challenge
// This would involve creating a file at the specified path
log.info("Performing HTTP challenge: {}", challenge.getUrl());
// Notify ACME server that challenge is ready
triggerChallengeValidation(challenge);
// Wait for challenge validation
waitForChallengeValidation(challenge);
}
private void performDnsChallenge(AcmeChallenge challenge) {
// Implement DNS-01 challenge
// This would involve creating a DNS TXT record
log.info("Performing DNS challenge: {}", challenge.getUrl());
// Notify ACME server that challenge is ready
triggerChallengeValidation(challenge);
// Wait for challenge validation
waitForChallengeValidation(challenge);
}
private CertificateResponse finalizeOrder(AcmeOrder order, AcmeCertificateRequest request) {
// Generate key pair for certificate
KeyPair keyPair = generateKeyPair(request);
// Create CSR
String csr = createCsr(keyPair, request);
// Finalize order
AcmeFinalizeRequest finalizeRequest = AcmeFinalizeRequest.builder()
.csr(csr)
.build();
String signedRequest = signAcmeRequest(finalizeRequest, getAccountKeyPair(), order.getFinalize());
AcmeOrder finalizedOrder = submitAcmeRequest(order.getFinalize(), signedRequest, AcmeOrder.class);
// Download certificate
return downloadCertificate(finalizedOrder.getCertificate(), keyPair);
}
// Helper methods for ACME protocol implementation
private KeyPair createOrLoadAccountKeyPair() {
// Implementation to create or load ACME account key pair
return null;
}
private String signAcmeRequest(Object request, KeyPair keyPair, String url) {
// Implementation for ACME request signing
return null;
}
private <T> T submitAcmeRequest(String url, String signedRequest, Class<T> responseType) {
// Implementation for ACME request submission
return null;
}
private CloseableHttpClient createHttpClient() {
// Similar to SmallstepHttpClient implementation
return null;
}
}
@Data
@Builder
@Jacksonized
class AcmeCertificateRequest {
@NotEmpty
private List<String> domains;
@Builder.Default
private String keyType = "RSA";
@Builder.Default
private int keySize = 2048;
@Builder.Default
private Duration validity = Duration.ofDays(90);
private String organization;
private String organizationalUnit;
private String country;
private String state;
private String locality;
}
@Data
@Builder
@Jacksonized
class AcmeDirectory {
private String newNonce;
private String newAccount;
private String newOrder;
private String revokeCert;
private String keyChange;
private Map<String, String> meta;
}
@Data
@Builder
@Jacksonized
class AcmeAccount {
private String status;
private String contact;
private String orders;
private String initialIp;
private String createdAt;
private String key;
}
@Data
@Builder
@Jacksonized
class AcmeAccountRequest {
private List<String> contact;
private Boolean termsOfServiceAgreed;
private Boolean onlyReturnExisting;
}
@Data
@Builder
@Jacksonized
class AcmeOrder {
private String status;
private String expires;
private List<String> identifiers;
private List<String> authorizations;
private String finalize;
private String certificate;
}
@Data
@Builder
@Jacksonized
class AcmeOrderRequest {
private List<Identifier> identifiers;
private String notBefore;
private String notAfter;
}
@Data
@Builder
@Jacksonized
class Identifier {
private String type;
private String value;
public Identifier(String type, String value) {
this.type = type;
this.value = value;
}
}
@Data
@Builder
@Jacksonized
class AcmeAuthorization {
private String status;
private String expires;
private List<AcmeChallenge> challenges;
}
@Data
@Builder
@Jacksonized
class AcmeChallenge {
private String type;
private String url;
private String token;
private String status;
}
@Data
@Builder
@Jacksonized
class AcmeFinalizeRequest {
private String csr;
}
Certificate Monitoring and Auto-Renewal
1. Certificate Monitor Service
@Service
@Slf4j
public class CertificateMonitorService {
private final CertificateService certificateService;
private final CertificateStorageService storageService;
private final SmallstepProperties properties;
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
public CertificateMonitorService(CertificateService certificateService,
CertificateStorageService storageService,
SmallstepProperties properties) {
this.certificateService = certificateService;
this.storageService = storageService;
this.properties = properties;
if (properties.getCertificate().getRenewalWindow() != null) {
startMonitoring();
}
}
@PostConstruct
public void startMonitoring() {
Duration checkInterval = Duration.ofHours(24); // Check daily
scheduler.scheduleAtFixedRate(this::checkCertificates, 0, checkInterval.toMinutes(), TimeUnit.MINUTES);
log.info("Started certificate monitoring with {} interval", checkInterval);
}
/**
* Check all certificates for renewal
*/
public void checkCertificates() {
try {
log.info("Starting certificate renewal check");
List<CertificateMetadata> certificates = storageService.listCertificates();
int renewedCount = 0;
int failedCount = 0;
for (CertificateMetadata cert : certificates) {
if (certificateService.needsRenewal(cert.getCommonName())) {
try {
log.info("Certificate needs renewal: {}", cert.getCommonName());
certificateService.renewCertificate(cert.getCommonName());
renewedCount++;
} catch (Exception e) {
log.error("Failed to renew certificate: {}", cert.getCommonName(), e);
failedCount++;
}
}
}
log.info("Certificate renewal check completed: {} renewed, {} failed", renewedCount, failedCount);
} catch (Exception e) {
log.error("Certificate renewal check failed", e);
}
}
/**
* Get certificate expiration summary
*/
public CertificateSummary getCertificateSummary() {
List<CertificateMetadata> certificates = storageService.listCertificates();
Instant now = Instant.now();
long expired = certificates.stream()
.filter(cert -> cert.getValidTo().isBefore(now))
.count();
long expiringSoon = certificates.stream()
.filter(cert -> {
Instant warningTime = cert.getValidTo()
.minus(properties.getCertificate().getRenewalWindow());
return now.isAfter(warningTime) && cert.getValidTo().isAfter(now);
})
.count();
long valid = certificates.stream()
.filter(cert -> cert.getValidTo().isAfter(now))
.count() - expiringSoon;
return CertificateSummary.builder()
.total(certificates.size())
.valid(valid)
.expiringSoon(expiringSoon)
.expired(expired)
.lastCheck(Instant.now())
.build();
}
@PreDestroy
public void shutdown() {
scheduler.shutdown();
try {
if (!scheduler.awaitTermination(10, TimeUnit.SECONDS)) {
scheduler.shutdownNow();
}
} catch (InterruptedException e) {
scheduler.shutdownNow();
Thread.currentThread().interrupt();
}
}
}
@Data
@Builder
class CertificateSummary {
private int total;
private long valid;
private long expiringSoon;
private long expired;
private Instant lastCheck;
}
REST API Controllers
1. Certificate Management Controller
@RestController
@RequestMapping("/api/certificates")
@Slf4j
@Validated
public class CertificateController {
private final CertificateService certificateService;
private final AcmeClientService acmeClientService;
private final CertificateMonitorService monitorService;
public CertificateController(CertificateService certificateService,
AcmeClientService acmeClientService,
CertificateMonitorService monitorService) {
this.certificateService = certificateService;
this.acmeClientService = acmeClientService;
this.monitorService = monitorService;
}
@PostMapping
public ResponseEntity<ApiResponse<CertificateResponse>> requestCertificate(
@Valid @RequestBody CertificateRequest request) {
try {
CertificateResponse response = certificateService.requestCertificate(request);
return ResponseEntity.ok(ApiResponse.success(response));
} catch (Exception e) {
log.error("Failed to request certificate for CN: {}", request.getCommonName(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("Certificate request failed: " + e.getMessage()));
}
}
@PostMapping("/acme")
public ResponseEntity<ApiResponse<CertificateResponse>> requestAcmeCertificate(
@Valid @RequestBody AcmeCertificateRequest request) {
try {
CertificateResponse response = acmeClientService.requestAcmeCertificate(request);
return ResponseEntity.ok(ApiResponse.success(response));
} catch (Exception e) {
log.error("Failed to request ACME certificate for domains: {}", request.getDomains(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("ACME certificate request failed: " + e.getMessage()));
}
}
@PostMapping("/{commonName}/renew")
public ResponseEntity<ApiResponse<CertificateResponse>> renewCertificate(
@PathVariable String commonName) {
try {
CertificateResponse response = certificateService.renewCertificate(commonName);
return ResponseEntity.ok(ApiResponse.success(response));
} catch (Exception e) {
log.error("Failed to renew certificate for CN: {}", commonName, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("Certificate renewal failed: " + e.getMessage()));
}
}
@DeleteMapping("/{serialNumber}")
public ResponseEntity<ApiResponse<String>> revokeCertificate(
@PathVariable String serialNumber,
@RequestParam(defaultValue = "UNSPECIFIED") RevocationReason reason) {
try {
certificateService.revokeCertificate(serialNumber, reason);
return ResponseEntity.ok(ApiResponse.success("Certificate revoked successfully"));
} catch (Exception e) {
log.error("Failed to revoke certificate with serial: {}", serialNumber, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("Certificate revocation failed: " + e.getMessage()));
}
}
@GetMapping("/{commonName}/status")
public ResponseEntity<ApiResponse<CertificateStatus>> getCertificateStatus(
@PathVariable String commonName) {
try {
// For simplicity, using common name instead of serial number
CertificateStatus status = certificateService.getCertificateStatus(commonName);
return ResponseEntity.ok(ApiResponse.success(status));
} catch (Exception e) {
log.error("Failed to get certificate status for CN: {}", commonName, e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("Failed to get certificate status: " + e.getMessage()));
}
}
@GetMapping("/summary")
public ResponseEntity<ApiResponse<CertificateSummary>> getCertificateSummary() {
try {
CertificateSummary summary = monitorService.getCertificateSummary();
return ResponseEntity.ok(ApiResponse.success(summary));
} catch (Exception e) {
log.error("Failed to get certificate summary", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("Failed to get certificate summary: " + e.getMessage()));
}
}
@PostMapping("/monitor/check")
public ResponseEntity<ApiResponse<String>> triggerCertificateCheck() {
try {
monitorService.checkCertificates();
return ResponseEntity.ok(ApiResponse.success("Certificate check completed"));
} catch (Exception e) {
log.error("Failed to trigger certificate check", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("Certificate check failed: " + e.getMessage()));
}
}
}
2. Health Check Controller
@RestController
@RequestMapping("/api/health")
@Slf4j
public class HealthController {
private final SmallstepHttpClient httpClient;
private final CertificateMonitorService monitorService;
public HealthController(SmallstepHttpClient httpClient, CertificateMonitorService monitorService) {
this.httpClient = httpClient;
this.monitorService = monitorService;
}
@GetMapping("/smallstep")
public ResponseEntity<SmallstepHealth> checkSmallstepHealth() {
SmallstepHealth health = new SmallstepHealth();
try {
// Test Smallstep CA connectivity
httpClient.executeGet("/health", String.class);
health.setStatus(HealthStatus.UP);
health.setMessage("Smallstep CA is accessible");
} catch (Exception e) {
health.setStatus(HealthStatus.DOWN);
health.setMessage("Smallstep CA is not accessible: " + e.getMessage());
log.error("Smallstep health check failed", e);
}
// Add certificate summary
CertificateSummary summary = monitorService.getCertificateSummary();
health.setCertificateSummary(summary);
HttpStatus status = health.getStatus() == HealthStatus.UP ?
HttpStatus.OK : HttpStatus.SERVICE_UNAVAILABLE;
return ResponseEntity.status(status).body(health);
}
}
@Data
class SmallstepHealth {
private HealthStatus status;
private String message;
private CertificateSummary certificateSummary;
private Instant timestamp = Instant.now();
}
Custom Exceptions
public class SmallstepClientException extends RuntimeException {
public SmallstepClientException(String message) {
super(message);
}
public SmallstepClientException(String message, Throwable cause) {
super(message, cause);
}
}
public class SmallstepAuthException extends RuntimeException {
public SmallstepAuthException(String message) {
super(message);
}
public SmallstepAuthException(String message, Throwable cause) {
super(message, cause);
}
}
public class CertificateRequestException extends RuntimeException {
public CertificateRequestException(String message) {
super(message);
}
public CertificateRequestException(String message, Throwable cause) {
super(message, cause);
}
}
public class CertificateRenewalException extends RuntimeException {
public CertificateRenewalException(String message) {
super(message);
}
public CertificateRenewalException(String message, Throwable cause) {
super(message, cause);
}
}
public class CertificateRevocationException extends RuntimeException {
public CertificateRevocationException(String message) {
super(message);
}
public CertificateRevocationException(String message, Throwable cause) {
super(message, cause);
}
}
public class CertificateStorageException extends RuntimeException {
public CertificateStorageException(String message) {
super(message);
}
public CertificateStorageException(String message, Throwable cause) {
super(message, cause);
}
}
public class AcmeException extends RuntimeException {
public AcmeException(String message) {
super(message);
}
public AcmeException(String message, Throwable cause) {
super(message, cause);
}
}
@ControllerAdvice
public class CertificateExceptionHandler {
@ExceptionHandler(CertificateRequestException.class)
public ResponseEntity<ApiResponse<?>> handleCertificateRequestException(CertificateRequestException e) {
log.error("Certificate request failed", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("Certificate request failed: " + e.getMessage()));
}
@ExceptionHandler(SmallstepAuthException.class)
public ResponseEntity<ApiResponse<?>> handleSmallstepAuthException(SmallstepAuthException e) {
log.error("Smallstep authentication failed", e);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ApiResponse.error("Authentication failed: " + e.getMessage()));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<?>> handleGenericException(Exception e) {
log.error("Unexpected error in certificate management", e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("An unexpected error occurred"));
}
}
Testing
1. Unit Tests
@ExtendWith(MockitoExtension.class)
class CertificateServiceTest {
@Mock
private SmallstepHttpClient httpClient;
@Mock
private CertificateStorageService storageService;
@InjectMocks
private CertificateService certificateService;
@Test
void shouldRequestCertificate() throws Exception {
// Given
CertificateRequest request = CertificateRequest.builder()
.commonName("test.example.com")
.dnsNames(List.of("test.example.com"))
.build();
SignResponse mockResponse = SignResponse.builder()
.serverPem(SignResponse.ServerPem.builder()
.cert("test-cert")
.certs("test-chain")
.serialNumber("12345")
.build())
.build();
when(httpClient.executePost(anyString(), any(SignRequest.class), eq(SignResponse.class)))
.thenReturn(mockResponse);
// When
CertificateResponse response = certificateService.requestCertificate(request);
// Then
assertThat(response).isNotNull();
assertThat(response.getSerialNumber()).isEqualTo("12345");
verify(storageService).storeCertificate(eq("test.example.com"), any(CertificateResponse.class));
}
}
@SpringBootTest
@Testcontainers
class SmallstepIntegrationTest {
@Container
private static final GenericContainer<?> stepCaContainer =
new GenericContainer<>("smallstep/step-ca:0.24.2")
.withExposedPorts(9000)
.withCommand("step-ca --config /home/step/config/ca.json");
@DynamicPropertySource
static void configureProperties(DynamicPropertyRegistry registry) {
registry.add("smallstep.ca-url",
() -> "https://localhost:" + stepCaContainer.getMappedPort(9000));
}
@Autowired
private CertificateService certificateService;
@Test
void shouldIssueCertificate() {
// Integration test with real Smallstep CA
// This would require proper CA setup and configuration
}
}
Best Practices
- Security: Store private keys securely, use HSM where possible
- Monitoring: Monitor certificate expiration and renewal status
- Backup: Regularly backup CA and certificate data
- Access Control: Implement proper authentication and authorization
- Audit Logging: Log all certificate operations
- Certificate Policies: Define and enforce certificate policies
// Example of secure key storage
@Component
@Slf4j
public class SecureKeyStorageService {
public void storePrivateKey(String keyId, PrivateKey privateKey, char[] password) throws Exception {
// Encrypt private key before storage
byte[] encryptedKey = encryptPrivateKey(privateKey, password);
// Store encrypted key in secure storage
// This could be a secure database, HSM, or encrypted file system
log.info("Stored encrypted private key: {}", keyId);
}
public PrivateKey loadPrivateKey(String keyId, char[] password) throws Exception {
// Load encrypted key from secure storage
byte[] encryptedKey = loadEncryptedKey(keyId);
// Decrypt private key
return decryptPrivateKey(encryptedKey, password);
}
private byte[] encryptPrivateKey(PrivateKey privateKey, char[] password) throws Exception {
// Implementation for private key encryption
// Using AES-GCM or similar secure algorithm
return new byte[0];
}
private PrivateKey decryptPrivateKey(byte[] encryptedKey, char[] password) throws Exception {
// Implementation for private key decryption
return null;
}
}
Conclusion
Smallstep Certificates integration in Java provides:
- Automated Certificate Management: Streamlined certificate lifecycle
- ACME Protocol Support: Standard-based certificate automation
- Private CA: Internal certificate authority with full control
- Security: Strong cryptographic foundations and secure key management
- Monitoring: Comprehensive certificate expiration and renewal tracking
- Integration: Seamless integration with Spring applications and microservices
By implementing the patterns shown above, you can build robust certificate management solutions that automate the entire certificate lifecycle while maintaining security best practices and operational reliability.