diff --git a/app/(dashboard)/certificates/ca-actions.ts b/app/(dashboard)/certificates/ca-actions.ts index 48ef7c8e..be080443 100644 --- a/app/(dashboard)/certificates/ca-actions.ts +++ b/app/(dashboard)/certificates/ca-actions.ts @@ -92,8 +92,9 @@ export async function generateCaCertificateAction(formData: FormData): Promise<{ } export type IssuedClientCert = { - certificatePem: string; - privateKeyPem: string; + pkcs12Base64: string; + passwordProtected: boolean; + exportAlgorithm: "3des" | "aes256"; }; export async function issueClientCertificateAction( @@ -103,8 +104,12 @@ export async function issueClientCertificateAction( await requireAdmin(); const commonName = String(formData.get("common_name") ?? "").trim(); const validityDays = Math.min(3650, Math.max(1, parseInt(String(formData.get("validity_days") ?? "365"), 10) || 365)); + const exportPassword = String(formData.get("export_password") ?? ""); + const compatibilityMode = formData.get("compatibility_mode") === "on"; + const exportAlgorithm: IssuedClientCert["exportAlgorithm"] = compatibilityMode ? "3des" : "aes256"; if (!commonName) throw new Error("Common name is required"); + if (!exportPassword) throw new Error("Export password is required"); const caPrivateKeyPem = await getCaCertificatePrivateKey(caCertId); if (!caPrivateKeyPem) throw new Error("This CA has no stored private key — cannot issue client certificates"); @@ -133,8 +138,20 @@ export async function issueClientCertificateAction( cert.sign(caKey, forge.md.sha256.create()); + const pkcs12Asn1 = forge.pkcs12.toPkcs12Asn1( + keypair.privateKey, + [cert, caCert], + exportPassword, + { + algorithm: exportAlgorithm, + friendlyName: commonName, + } + ); + const pkcs12Der = forge.asn1.toDer(pkcs12Asn1).getBytes(); + return { - certificatePem: forge.pki.certificateToPem(cert), - privateKeyPem: forge.pki.privateKeyToPem(keypair.privateKey), + pkcs12Base64: forge.util.encode64(pkcs12Der), + passwordProtected: true, + exportAlgorithm, }; } diff --git a/src/components/ca-certificates/CaCertDialogs.tsx b/src/components/ca-certificates/CaCertDialogs.tsx index 149585b3..e713791e 100644 --- a/src/components/ca-certificates/CaCertDialogs.tsx +++ b/src/components/ca-certificates/CaCertDialogs.tsx @@ -9,8 +9,10 @@ import { DialogContent, DialogContentText, DialogTitle, + FormControlLabel, InputAdornment, Stack, + Switch, Tab, Tabs, TextField, @@ -27,8 +29,7 @@ import { updateCaCertificateAction, } from "@/app/(dashboard)/certificates/ca-actions"; -function downloadText(filename: string, content: string) { - const blob = new Blob([content], { type: "text/plain" }); +function downloadFile(filename: string, blob: Blob) { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; @@ -37,6 +38,21 @@ function downloadText(filename: string, content: string) { URL.revokeObjectURL(url); } +function decodeBase64(base64: string): ArrayBuffer { + const binary = atob(base64); + const bytes = new Uint8Array(new ArrayBuffer(binary.length)); + + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + + return bytes.buffer; +} + +function sanitizeFilenameSegment(value: string): string { + return value.trim().replace(/[^a-z0-9._-]+/gi, "_").replace(/^_+|_+$/g, "") || "client"; +} + export function CreateCaCertDialog({ open, onClose, @@ -76,84 +92,82 @@ export function CreateCaCertDialog({ Add Client CA Certificate - {true && ( - <> - setTab(v)} sx={{ mb: 2 }}> - - - + <> + setTab(v)} sx={{ mb: 2 }}> + + + - {tab === "generate" && ( -
- - - - days }} - /> - - - - - -
- )} + {tab === "generate" && ( +
+ + + + days }} + /> + + + + + +
+ )} - {tab === "import" && ( -
- - - - - - - - -
- )} - - )} + {tab === "import" && ( +
+ + + + + + + + +
+ )} +
); @@ -227,7 +241,12 @@ export function IssueClientCertDialog({ onClose: () => void; }) { const [isPending, startTransition] = useTransition(); - const [issued, setIssued] = useState<{ certificatePem: string; privateKeyPem: string; name: string } | null>(null); + const [issued, setIssued] = useState<{ + pkcs12Base64: string; + name: string; + passwordProtected: boolean; + exportAlgorithm: "3des" | "aes256"; + } | null>(null); const [error, setError] = useState(null); const formRef = useRef(null); @@ -244,7 +263,10 @@ export function IssueClientCertDialog({ startTransition(async () => { try { const result = await issueClientCertificateAction(cert.id, formData); - setIssued({ ...result, name: String(formData.get("common_name") ?? "client") }); + setIssued({ + ...result, + name: sanitizeFilenameSegment(String(formData.get("common_name") ?? "client")), + }); } catch (e) { setError(e instanceof Error ? e.message : "Failed to issue certificate"); } @@ -258,22 +280,29 @@ export function IssueClientCertDialog({ {issued ? ( - Client certificate issued. Download the certificate and key — the private key will not be stored. + Client certificate issued. Download the .p12 bundle now. It contains the client certificate, + private key, and CA chain, and the private key will not be stored. + + Export format: {issued.exportAlgorithm === "3des" ? "Compatibility mode (3DES)" : "AES-256"}. + - + {issued.passwordProtected && ( + + Import it using the export password you entered during issuance. + + )} ) : (
@@ -296,6 +325,21 @@ export function IssueClientCertDialog({ inputProps={{ min: 1, max: 3650 }} InputProps={{ endAdornment: days }} /> + + } + label="Compatibility mode" + /> + + Enabled uses 3DES for broader OS/browser import compatibility. Disabled uses AES-256. + {error && {error}} @@ -363,4 +407,3 @@ export function DeleteCaCertDialog({ ); } -