export as .p12
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Add Client CA Certificate</DialogTitle>
|
||||
<DialogContent>
|
||||
{true && (
|
||||
<>
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
||||
<Tab value="generate" label="Generate" />
|
||||
<Tab value="import" label="Import PEM" />
|
||||
</Tabs>
|
||||
<>
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
||||
<Tab value="generate" label="Generate" />
|
||||
<Tab value="import" label="Import PEM" />
|
||||
</Tabs>
|
||||
|
||||
{tab === "generate" && (
|
||||
<form ref={generateFormRef} onSubmit={handleGenerate}>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
name="name"
|
||||
label="Name"
|
||||
required
|
||||
fullWidth
|
||||
autoFocus
|
||||
placeholder="My Client CA"
|
||||
helperText="Display name in this UI"
|
||||
/>
|
||||
<TextField
|
||||
name="common_name"
|
||||
label="Common Name (CN)"
|
||||
fullWidth
|
||||
placeholder="My Client CA"
|
||||
helperText="CN field in the certificate. Defaults to the name above if left blank."
|
||||
/>
|
||||
<TextField
|
||||
name="validity_days"
|
||||
label="Validity"
|
||||
type="number"
|
||||
fullWidth
|
||||
defaultValue={3650}
|
||||
inputProps={{ min: 1, max: 3650 }}
|
||||
InputProps={{ endAdornment: <InputAdornment position="end">days</InputAdornment> }}
|
||||
/>
|
||||
<DialogActions sx={{ px: 0, pb: 0 }}>
|
||||
<Button onClick={handleClose} disabled={isPending}>Cancel</Button>
|
||||
<Button type="submit" variant="contained" disabled={isPending}>
|
||||
{isPending ? "Generating..." : "Generate CA Certificate"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Stack>
|
||||
</form>
|
||||
)}
|
||||
{tab === "generate" && (
|
||||
<form ref={generateFormRef} onSubmit={handleGenerate}>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
name="name"
|
||||
label="Name"
|
||||
required
|
||||
fullWidth
|
||||
autoFocus
|
||||
placeholder="My Client CA"
|
||||
helperText="Display name in this UI"
|
||||
/>
|
||||
<TextField
|
||||
name="common_name"
|
||||
label="Common Name (CN)"
|
||||
fullWidth
|
||||
placeholder="My Client CA"
|
||||
helperText="CN field in the certificate. Defaults to the name above if left blank."
|
||||
/>
|
||||
<TextField
|
||||
name="validity_days"
|
||||
label="Validity"
|
||||
type="number"
|
||||
fullWidth
|
||||
defaultValue={3650}
|
||||
inputProps={{ min: 1, max: 3650 }}
|
||||
InputProps={{ endAdornment: <InputAdornment position="end">days</InputAdornment> }}
|
||||
/>
|
||||
<DialogActions sx={{ px: 0, pb: 0 }}>
|
||||
<Button onClick={handleClose} disabled={isPending}>Cancel</Button>
|
||||
<Button type="submit" variant="contained" disabled={isPending}>
|
||||
{isPending ? "Generating..." : "Generate CA Certificate"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Stack>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{tab === "import" && (
|
||||
<form ref={importFormRef} onSubmit={handleImport}>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
name="name"
|
||||
label="Name"
|
||||
required
|
||||
fullWidth
|
||||
autoFocus
|
||||
placeholder="My Client CA"
|
||||
/>
|
||||
<TextField
|
||||
name="certificate_pem"
|
||||
label="Certificate PEM"
|
||||
required
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={6}
|
||||
placeholder="-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----"
|
||||
inputProps={{ style: { fontFamily: "monospace", fontSize: "0.8rem" } }}
|
||||
helperText="PEM-encoded X.509 CA certificate (no private key needed)"
|
||||
/>
|
||||
<DialogActions sx={{ px: 0, pb: 0 }}>
|
||||
<Button onClick={handleClose} disabled={isPending}>Cancel</Button>
|
||||
<Button type="submit" variant="contained" disabled={isPending}>
|
||||
{isPending ? "Adding..." : "Add CA Certificate"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Stack>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{tab === "import" && (
|
||||
<form ref={importFormRef} onSubmit={handleImport}>
|
||||
<Stack spacing={2}>
|
||||
<TextField
|
||||
name="name"
|
||||
label="Name"
|
||||
required
|
||||
fullWidth
|
||||
autoFocus
|
||||
placeholder="My Client CA"
|
||||
/>
|
||||
<TextField
|
||||
name="certificate_pem"
|
||||
label="Certificate PEM"
|
||||
required
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={6}
|
||||
placeholder="-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----"
|
||||
inputProps={{ style: { fontFamily: "monospace", fontSize: "0.8rem" } }}
|
||||
helperText="PEM-encoded X.509 CA certificate (no private key needed)"
|
||||
/>
|
||||
<DialogActions sx={{ px: 0, pb: 0 }}>
|
||||
<Button onClick={handleClose} disabled={isPending}>Cancel</Button>
|
||||
<Button type="submit" variant="contained" disabled={isPending}>
|
||||
{isPending ? "Adding..." : "Add CA Certificate"}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Stack>
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -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<string | null>(null);
|
||||
const formRef = useRef<HTMLFormElement>(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 ? (
|
||||
<Stack spacing={2} mt={1}>
|
||||
<Alert severity="success">
|
||||
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.
|
||||
</Alert>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Export format: {issued.exportAlgorithm === "3des" ? "Compatibility mode (3DES)" : "AES-256"}.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={() => downloadText(`${issued.name}.crt`, issued.certificatePem)}
|
||||
onClick={() =>
|
||||
downloadFile(
|
||||
`${issued.name}.p12`,
|
||||
new Blob([decodeBase64(issued.pkcs12Base64)], { type: "application/x-pkcs12" })
|
||||
)
|
||||
}
|
||||
>
|
||||
Download Certificate (.crt)
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<DownloadIcon />}
|
||||
onClick={() => downloadText(`${issued.name}.key`, issued.privateKeyPem)}
|
||||
>
|
||||
Download Private Key (.key)
|
||||
Download Client Certificate (.p12)
|
||||
</Button>
|
||||
{issued.passwordProtected && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Import it using the export password you entered during issuance.
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<form ref={formRef} onSubmit={handleSubmit}>
|
||||
@@ -296,6 +325,21 @@ export function IssueClientCertDialog({
|
||||
inputProps={{ min: 1, max: 3650 }}
|
||||
InputProps={{ endAdornment: <InputAdornment position="end">days</InputAdornment> }}
|
||||
/>
|
||||
<TextField
|
||||
name="export_password"
|
||||
label="Export Password"
|
||||
type="password"
|
||||
required
|
||||
fullWidth
|
||||
helperText="Used to protect the .p12 bundle when importing it into operating systems and browsers"
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={<Switch name="compatibility_mode" defaultChecked />}
|
||||
label="Compatibility mode"
|
||||
/>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: -1 }}>
|
||||
Enabled uses 3DES for broader OS/browser import compatibility. Disabled uses AES-256.
|
||||
</Typography>
|
||||
{error && <Typography color="error" variant="body2">{error}</Typography>}
|
||||
<DialogActions sx={{ px: 0, pb: 0 }}>
|
||||
<Button onClick={handleClose} disabled={isPending}>Cancel</Button>
|
||||
@@ -363,4 +407,3 @@ export function DeleteCaCertDialog({
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user