feat: support password-protected private keys
All checks were successful
CI / build (push) Successful in 1m8s
CI / docker (push) Successful in 42s

Encrypted PKCS#8 private keys are decrypted during staging using the
provided password. The decrypted key is stored for Traefik (which needs
cleartext PEM). Unencrypted keys continue to work without a password.

- CertificateManager.stage() accepts optional keyPassword
- DockerCertificateManager handles EncryptedPrivateKeyInfo decryption
- UI: password field in upload form (vendor CertificatesPage)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-10 19:44:09 +02:00
parent 82f62ca0ff
commit 3ae8fa18cd
8 changed files with 88 additions and 24 deletions

View File

@@ -77,6 +77,7 @@ public class CertificateController {
@RequestParam("cert") MultipartFile certFile,
@RequestParam("key") MultipartFile keyFile,
@RequestParam(value = "ca", required = false) MultipartFile caFile,
@RequestParam(value = "password", required = false) String keyPassword,
@AuthenticationPrincipal Jwt jwt) {
try {
byte[] certPem = certFile.getBytes();
@@ -84,7 +85,7 @@ public class CertificateController {
byte[] caPem = caFile != null ? caFile.getBytes() : null;
UUID actorId = resolveActorId(jwt);
CertValidationResult result = certificateService.stage(certPem, keyPem, caPem, actorId);
CertValidationResult result = certificateService.stage(certPem, keyPem, caPem, keyPassword, actorId);
if (!result.valid()) {
return ResponseEntity.badRequest().body(

View File

@@ -21,8 +21,9 @@ public interface CertificateManager {
/**
* Write cert+key+ca to staging area and validate.
* Does NOT activate — call {@link #activate()} to promote.
* @param keyPassword optional password for encrypted private keys (null if unencrypted)
*/
CertValidationResult stage(byte[] certPem, byte[] keyPem, byte[] caBundlePem);
CertValidationResult stage(byte[] certPem, byte[] keyPem, byte[] caBundlePem, String keyPassword);
/** Promote staged -> active. Moves current active to archive (deleting previous archive). */
void activate();

View File

@@ -42,7 +42,7 @@ public class CertificateService {
}
@Transactional
public CertValidationResult stage(byte[] certPem, byte[] keyPem, byte[] caBundlePem, UUID actorId) {
public CertValidationResult stage(byte[] certPem, byte[] keyPem, byte[] caBundlePem, String keyPassword, UUID actorId) {
if (!certManager.isAvailable()) {
return CertValidationResult.fail(List.of("Certificate management is not available"));
}
@@ -51,7 +51,7 @@ public class CertificateService {
certRepository.findByStatus(CertificateEntity.Status.STAGED).ifPresent(certRepository::delete);
// Stage files and validate
CertValidationResult result = certManager.stage(certPem, keyPem, caBundlePem);
CertValidationResult result = certManager.stage(certPem, keyPem, caBundlePem, keyPassword);
if (!result.valid()) {
return result;
}

View File

@@ -24,7 +24,7 @@ public class DisabledCertificateManager implements CertificateManager {
public CertificateInfo getArchived() { return null; }
@Override
public CertValidationResult stage(byte[] certPem, byte[] keyPem, byte[] caBundlePem) {
public CertValidationResult stage(byte[] certPem, byte[] keyPem, byte[] caBundlePem, String keyPassword) {
return CertValidationResult.fail(List.of("Certificate management is not available"));
}

View File

@@ -59,7 +59,7 @@ public class DockerCertificateManager implements CertificateManager {
}
@Override
public CertValidationResult stage(byte[] certPem, byte[] keyPem, byte[] caBundlePem) {
public CertValidationResult stage(byte[] certPem, byte[] keyPem, byte[] caBundlePem, String keyPassword) {
List<String> errors = new ArrayList<>();
// Parse certificate
@@ -71,9 +71,15 @@ public class DockerCertificateManager implements CertificateManager {
return CertValidationResult.fail(errors);
}
// Parse private key
// Parse private key (may be password-protected)
java.security.PrivateKey privateKey;
byte[] decryptedKeyPem = keyPem;
try {
var privateKey = parsePrivateKey(keyPem);
privateKey = parsePrivateKey(keyPem, keyPassword);
// Re-encode as unencrypted PKCS8 PEM for Traefik (which needs cleartext)
if (isEncryptedKey(keyPem)) {
decryptedKeyPem = encodePrivateKeyPem(privateKey);
}
// Verify key matches cert
if (cert.getPublicKey() instanceof RSAPublicKey rsaPub
&& privateKey instanceof RSAPrivateKey rsaPriv) {
@@ -107,7 +113,7 @@ public class DockerCertificateManager implements CertificateManager {
Files.createDirectories(stagedDir);
writeAtomic(stagedDir.resolve("cert.pem"), certPem);
writeAtomic(stagedDir.resolve("key.pem"), keyPem);
writeAtomic(stagedDir.resolve("key.pem"), decryptedKeyPem);
if (caBundlePem != null && caBundlePem.length > 0) {
writeAtomic(stagedDir.resolve("ca.pem"), caBundlePem);
} else {
@@ -286,9 +292,42 @@ public class DockerCertificateManager implements CertificateManager {
return certs.stream().map(c -> (X509Certificate) c).toList();
}
static java.security.PrivateKey parsePrivateKey(byte[] pem) throws Exception {
static boolean isEncryptedKey(byte[] pem) {
String s = new String(pem);
return s.contains("ENCRYPTED PRIVATE KEY");
}
static java.security.PrivateKey parsePrivateKey(byte[] pem, String password) throws Exception {
String pemStr = new String(pem);
// Extract base64 content between PEM markers (handles Bag Attributes etc.)
// Encrypted PKCS#8 key
if (pemStr.contains("BEGIN ENCRYPTED PRIVATE KEY")) {
if (password == null || password.isEmpty()) {
throw new IllegalArgumentException("Private key is encrypted but no password was provided");
}
var matcher = java.util.regex.Pattern
.compile("-----BEGIN ENCRYPTED PRIVATE KEY-----(.+?)-----END ENCRYPTED PRIVATE KEY-----",
java.util.regex.Pattern.DOTALL)
.matcher(pemStr);
if (!matcher.find()) {
throw new IllegalArgumentException("Malformed encrypted private key PEM");
}
String base64 = matcher.group(1).replaceAll("\\s+", "");
byte[] decoded = Base64.getDecoder().decode(base64);
var encryptedInfo = new javax.crypto.EncryptedPrivateKeyInfo(decoded);
var pbeKeySpec = new javax.crypto.spec.PBEKeySpec(password.toCharArray());
var keyFactory = javax.crypto.SecretKeyFactory.getInstance(encryptedInfo.getAlgName());
var secretKey = keyFactory.generateSecret(pbeKeySpec);
var cipher = javax.crypto.Cipher.getInstance(encryptedInfo.getAlgName());
cipher.init(javax.crypto.Cipher.DECRYPT_MODE, secretKey, encryptedInfo.getAlgParameters());
var pkcs8Spec = encryptedInfo.getKeySpec(cipher);
return KeyFactory.getInstance("RSA").generatePrivate(pkcs8Spec);
}
// Unencrypted key — extract base64 between PEM markers (handles Bag Attributes etc.)
var matcher = java.util.regex.Pattern
.compile("-----BEGIN (?:RSA )?PRIVATE KEY-----(.+?)-----END (?:RSA )?PRIVATE KEY-----",
java.util.regex.Pattern.DOTALL)
@@ -302,6 +341,14 @@ public class DockerCertificateManager implements CertificateManager {
return KeyFactory.getInstance("RSA").generatePrivate(spec);
}
static byte[] encodePrivateKeyPem(java.security.PrivateKey key) {
var sb = new StringBuilder();
sb.append("-----BEGIN PRIVATE KEY-----\n");
sb.append(Base64.getMimeEncoder(64, "\n".getBytes()).encodeToString(key.getEncoded()));
sb.append("\n-----END PRIVATE KEY-----\n");
return sb.toString().getBytes();
}
private void writeAtomic(Path target, byte[] data) throws IOException {
Path wip = target.resolveSibling(target.getFileName() + ".wip");
Files.write(wip, data);