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({
);
@@ -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"}.
+
}
- onClick={() => downloadText(`${issued.name}.crt`, issued.certificatePem)}
+ onClick={() =>
+ downloadFile(
+ `${issued.name}.p12`,
+ new Blob([decodeBase64(issued.pkcs12Base64)], { type: "application/x-pkcs12" })
+ )
+ }
>
- Download Certificate (.crt)
-
- }
- onClick={() => downloadText(`${issued.name}.key`, issued.privateKeyPem)}
- >
- Download Private Key (.key)
+ Download Client Certificate (.p12)
+ {issued.passwordProtected && (
+
+ Import it using the export password you entered during issuance.
+
+ )}
) : (