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

@@ -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<HTMLInputElement>(null);
const keyInputRef = useRef<HTMLInputElement>(null);
const caInputRef = useRef<HTMLInputElement>(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() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<FileField label="Certificate (PEM) *" inputRef={certInputRef} accept=".pem,.crt,.cer" />
<FileField label="Private Key (PEM) *" inputRef={keyInputRef} accept=".pem,.key" />
<div>
<label style={{ fontSize: 13, color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>
Key Password (if encrypted)
</label>
<Input
type="password"
placeholder="Leave empty if key is not encrypted"
value={keyPassword}
onChange={(e) => setKeyPassword(e.target.value)}
/>
</div>
<FileField label="CA Bundle (PEM, optional)" inputRef={caInputRef} accept=".pem,.crt,.cer" />
<Button
variant="primary"