diff --git a/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateController.java b/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateController.java index 5e7fbf0..879a3b5 100644 --- a/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateController.java +++ b/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateController.java @@ -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( diff --git a/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateManager.java b/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateManager.java index b13784a..1020640 100644 --- a/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateManager.java +++ b/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateManager.java @@ -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(); diff --git a/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateService.java b/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateService.java index d287e66..19d07eb 100644 --- a/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateService.java +++ b/src/main/java/net/siegeln/cameleer/saas/certificate/CertificateService.java @@ -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; } diff --git a/src/main/java/net/siegeln/cameleer/saas/provisioning/DisabledCertificateManager.java b/src/main/java/net/siegeln/cameleer/saas/provisioning/DisabledCertificateManager.java index 3db5b1c..f910568 100644 --- a/src/main/java/net/siegeln/cameleer/saas/provisioning/DisabledCertificateManager.java +++ b/src/main/java/net/siegeln/cameleer/saas/provisioning/DisabledCertificateManager.java @@ -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")); } diff --git a/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerCertificateManager.java b/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerCertificateManager.java index 781b28c..dab06bf 100644 --- a/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerCertificateManager.java +++ b/src/main/java/net/siegeln/cameleer/saas/provisioning/DockerCertificateManager.java @@ -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 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); diff --git a/src/test/java/net/siegeln/cameleer/saas/certificate/CertificateServiceTest.java b/src/test/java/net/siegeln/cameleer/saas/certificate/CertificateServiceTest.java index 33ed830..c7d7558 100644 --- a/src/test/java/net/siegeln/cameleer/saas/certificate/CertificateServiceTest.java +++ b/src/test/java/net/siegeln/cameleer/saas/certificate/CertificateServiceTest.java @@ -42,13 +42,13 @@ class CertificateServiceTest { var info = new CertificateInfo("CN=test", "CN=test", Instant.now(), Instant.now().plusSeconds(86400), false, true, "AA:BB"); when(certManager.isAvailable()).thenReturn(true); - when(certManager.stage(any(), any(), any())) + when(certManager.stage(any(), any(), any(), any())) .thenReturn(CertValidationResult.ok(info)); when(certRepository.findByStatus(CertificateEntity.Status.STAGED)) .thenReturn(Optional.empty()); when(certRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); - var result = service.stage("cert".getBytes(), "key".getBytes(), null, UUID.randomUUID()); + var result = service.stage("cert".getBytes(), "key".getBytes(), null, null, UUID.randomUUID()); assertThat(result.valid()).isTrue(); var captor = ArgumentCaptor.forClass(CertificateEntity.class); @@ -63,10 +63,10 @@ class CertificateServiceTest { when(certManager.isAvailable()).thenReturn(true); when(certRepository.findByStatus(CertificateEntity.Status.STAGED)) .thenReturn(Optional.of(existing)); - when(certManager.stage(any(), any(), any())) + when(certManager.stage(any(), any(), any(), any())) .thenReturn(CertValidationResult.fail(List.of("bad cert"))); - service.stage("cert".getBytes(), "key".getBytes(), null, UUID.randomUUID()); + service.stage("cert".getBytes(), "key".getBytes(), null, null, UUID.randomUUID()); verify(certRepository).delete(existing); } @@ -75,7 +75,7 @@ class CertificateServiceTest { void stage_returnsErrorWhenManagerUnavailable() { when(certManager.isAvailable()).thenReturn(false); - var result = service.stage("cert".getBytes(), "key".getBytes(), null, UUID.randomUUID()); + var result = service.stage("cert".getBytes(), "key".getBytes(), null, null, UUID.randomUUID()); assertThat(result.valid()).isFalse(); assertThat(result.errors()).contains("Certificate management is not available"); diff --git a/src/test/java/net/siegeln/cameleer/saas/certificate/DockerCertificateManagerTest.java b/src/test/java/net/siegeln/cameleer/saas/certificate/DockerCertificateManagerTest.java index 7639026..f6a9bbf 100644 --- a/src/test/java/net/siegeln/cameleer/saas/certificate/DockerCertificateManagerTest.java +++ b/src/test/java/net/siegeln/cameleer/saas/certificate/DockerCertificateManagerTest.java @@ -37,7 +37,7 @@ class DockerCertificateManagerTest { void stage_validatesAndWritesFiles() throws Exception { byte[][] pair = generateCertAndKey("CN=test.example.com"); - var result = manager.stage(pair[0], pair[1], null); + var result = manager.stage(pair[0], pair[1], null, null); assertThat(result.valid()).isTrue(); assertThat(result.info()).isNotNull(); @@ -49,7 +49,7 @@ class DockerCertificateManagerTest { @Test void stage_rejectsInvalidCertPem() { - var result = manager.stage("not a cert".getBytes(), "not a key".getBytes(), null); + var result = manager.stage("not a cert".getBytes(), "not a key".getBytes(), null, null); assertThat(result.valid()).isFalse(); assertThat(result.errors()).isNotEmpty(); } @@ -59,7 +59,7 @@ class DockerCertificateManagerTest { byte[][] pair1 = generateCertAndKey("CN=test.example.com"); byte[][] pair2 = generateCertAndKey("CN=other.example.com"); - var result = manager.stage(pair1[0], pair2[1], null); + var result = manager.stage(pair1[0], pair2[1], null, null); assertThat(result.valid()).isFalse(); assertThat(result.errors()).anyMatch(e -> e.contains("does not match")); } @@ -73,7 +73,7 @@ class DockerCertificateManagerTest { // Stage a second cert byte[][] pair2 = generateCertAndKey("CN=second.example.com"); - var stageResult = manager.stage(pair2[0], pair2[1], null); + var stageResult = manager.stage(pair2[0], pair2[1], null, null); assertThat(stageResult.valid()).isTrue(); // Activate: first -> archived, second -> active @@ -116,7 +116,7 @@ class DockerCertificateManagerTest { @Test void discardStaged_removesFiles() throws Exception { byte[][] pair = generateCertAndKey("CN=test.example.com"); - manager.stage(pair[0], pair[1], null); + manager.stage(pair[0], pair[1], null, null); assertThat(Files.exists(certsDir.resolve("staged/cert.pem"))).isTrue(); @@ -130,7 +130,7 @@ class DockerCertificateManagerTest { byte[][] pair = generateCertAndKey("CN=test.example.com"); // Use the cert itself as a "CA" for testing purposes - var result = manager.stage(pair[0], pair[1], pair[0]); + var result = manager.stage(pair[0], pair[1], pair[0], null); assertThat(result.valid()).isTrue(); assertThat(result.info().hasCaBundle()).isTrue(); @@ -165,7 +165,7 @@ class DockerCertificateManagerTest { // Stage new cert byte[][] pair2 = generateCertAndKey("CN=second.example.com"); - manager.stage(pair2[0], pair2[1], null); + manager.stage(pair2[0], pair2[1], null, null); // Activate: old archive should be deleted, first becomes archive manager.activate(); diff --git a/ui/src/pages/vendor/CertificatesPage.tsx b/ui/src/pages/vendor/CertificatesPage.tsx index a2c0dfb..81ad355 100644 --- a/ui/src/pages/vendor/CertificatesPage.tsx +++ b/ui/src/pages/vendor/CertificatesPage.tsx @@ -1,9 +1,10 @@ -import { useRef } from 'react'; +import { useRef, useState } from 'react'; import { Alert, Badge, Button, Card, + Input, Spinner, useToast, } from '@cameleer/design-system'; @@ -139,6 +140,7 @@ export function CertificatesPage() { const certInputRef = useRef(null); const keyInputRef = useRef(null); const caInputRef = useRef(null); + const [keyPassword, setKeyPassword] = useState(''); if (isLoading) { return ( @@ -172,6 +174,7 @@ export function CertificatesPage() { formData.append('cert', certFile); formData.append('key', keyFile); if (caFile) formData.append('ca', caFile); + if (keyPassword) formData.append('password', keyPassword); try { const result = await stageMutation.mutateAsync(formData); @@ -180,6 +183,7 @@ export function CertificatesPage() { if (certInputRef.current) certInputRef.current.value = ''; if (keyInputRef.current) keyInputRef.current.value = ''; if (caInputRef.current) caInputRef.current.value = ''; + setKeyPassword(''); } else { toast({ title: 'Validation failed', description: result.errors.join(', '), variant: 'error' }); } @@ -285,6 +289,17 @@ export function CertificatesPage() {
+
+ + setKeyPassword(e.target.value)} + /> +