better pki

This commit is contained in:
fuomag9
2026-03-06 00:22:30 +01:00
parent 94eba03595
commit c76004f8ac
9 changed files with 366 additions and 38 deletions

View File

@@ -29,7 +29,7 @@ import {
} from "./actions";
import type { AcmeHost, CaCertificate, CertExpiryStatus, ImportedCertView, ManagedCertView } from "./page";
import { useState } from "react";
import { CreateCaCertDialog, EditCaCertDialog, DeleteCaCertDialog } from "@/src/components/ca-certificates/CaCertDialogs";
import { CreateCaCertDialog, EditCaCertDialog, DeleteCaCertDialog, IssueClientCertDialog } from "@/src/components/ca-certificates/CaCertDialogs";
type Props = {
acmeHosts: AcmeHost[];
@@ -67,6 +67,7 @@ export default function CertificatesClient({ acmeHosts, importedCerts, managedCe
const [createCaOpen, setCreateCaOpen] = useState(false);
const [editCaCert, setEditCaCert] = useState<CaCertificate | null>(null);
const [deleteCaCert, setDeleteCaCert] = useState<CaCertificate | null>(null);
const [issueCaCert, setIssueCaCert] = useState<CaCertificate | null>(null);
const acmeColumns = [
{
id: 'name',
@@ -482,6 +483,13 @@ export default function CertificatesClient({ acmeHosts, importedCerts, managedCe
label: "",
render: (ca: CaCertificate) => (
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
{ca.has_private_key && (
<Tooltip title="Issue Client Certificate">
<Button size="small" variant="outlined" onClick={() => setIssueCaCert(ca)}>
Issue Cert
</Button>
</Tooltip>
)}
<Tooltip title="Edit">
<IconButton size="small" onClick={() => setEditCaCert(ca)}>
<EditIcon fontSize="small" />
@@ -521,6 +529,13 @@ export default function CertificatesClient({ acmeHosts, importedCerts, managedCe
onClose={() => setDeleteCaCert(null)}
/>
)}
{issueCaCert && (
<IssueClientCertDialog
open={!!issueCaCert}
cert={issueCaCert}
onClose={() => setIssueCaCert(null)}
/>
)}
</Stack>
);
}

View File

@@ -2,8 +2,9 @@
import { revalidatePath } from "next/cache";
import { requireAdmin } from "@/src/lib/auth";
import { createCaCertificate, deleteCaCertificate, updateCaCertificate } from "@/src/lib/models/ca-certificates";
import { createCaCertificate, deleteCaCertificate, updateCaCertificate, getCaCertificatePrivateKey } from "@/src/lib/models/ca-certificates";
import { X509Certificate } from "node:crypto";
import forge from "node-forge";
function validatePem(pem: string): void {
try {
@@ -50,3 +51,90 @@ export async function deleteCaCertificateAction(id: number) {
await deleteCaCertificate(id, userId);
revalidatePath("/certificates");
}
export async function generateCaCertificateAction(formData: FormData): Promise<{ id: number }> {
const session = await requireAdmin();
const userId = Number(session.user.id);
const name = String(formData.get("name") ?? "").trim();
const commonName = String(formData.get("common_name") ?? name).trim() || name;
const validityDays = Math.min(3650, Math.max(1, parseInt(String(formData.get("validity_days") ?? "3650"), 10) || 3650));
if (!name) throw new Error("Name is required");
const keypair = forge.pki.rsa.generateKeyPair({ bits: 4096 });
const cert = forge.pki.createCertificate();
cert.publicKey = keypair.publicKey;
cert.serialNumber = "01";
cert.validity.notBefore = new Date();
cert.validity.notAfter = new Date();
cert.validity.notAfter.setDate(cert.validity.notBefore.getDate() + validityDays);
const attrs = [
{ name: "commonName", value: commonName },
{ name: "organizationName", value: "Caddy Proxy Manager" },
];
cert.setSubject(attrs);
cert.setIssuer(attrs);
cert.setExtensions([
{ name: "basicConstraints", cA: true, critical: true },
{ name: "keyUsage", keyCertSign: true, cRLSign: true, critical: true },
{ name: "subjectKeyIdentifier" },
]);
cert.sign(keypair.privateKey, forge.md.sha256.create());
const certificatePem = forge.pki.certificateToPem(cert);
const privateKeyPem = forge.pki.privateKeyToPem(keypair.privateKey);
const record = await createCaCertificate({ name, certificate_pem: certificatePem, private_key_pem: privateKeyPem }, userId);
revalidatePath("/certificates");
return { id: record.id };
}
export type IssuedClientCert = {
certificatePem: string;
privateKeyPem: string;
};
export async function issueClientCertificateAction(
caCertId: number,
formData: FormData
): Promise<IssuedClientCert> {
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));
if (!commonName) throw new Error("Common name is required");
const caPrivateKeyPem = await getCaCertificatePrivateKey(caCertId);
if (!caPrivateKeyPem) throw new Error("This CA has no stored private key — cannot issue client certificates");
const caCertRecord = await import("@/src/lib/models/ca-certificates").then(m => m.getCaCertificate(caCertId));
if (!caCertRecord) throw new Error("CA certificate not found");
const caKey = forge.pki.privateKeyFromPem(caPrivateKeyPem);
const caCert = forge.pki.certificateFromPem(caCertRecord.certificate_pem);
const keypair = forge.pki.rsa.generateKeyPair({ bits: 2048 });
const cert = forge.pki.createCertificate();
cert.publicKey = keypair.publicKey;
cert.serialNumber = Date.now().toString(16);
cert.validity.notBefore = new Date();
cert.validity.notAfter = new Date();
cert.validity.notAfter.setDate(cert.validity.notBefore.getDate() + validityDays);
cert.setSubject([{ name: "commonName", value: commonName }]);
cert.setIssuer(caCert.subject.attributes);
cert.setExtensions([
{ name: "basicConstraints", cA: false },
{ name: "keyUsage", digitalSignature: true, keyEncipherment: true },
{ name: "extKeyUsage", clientAuth: true },
]);
cert.sign(caKey, forge.md.sha256.create());
return {
certificatePem: forge.pki.certificateToPem(cert),
privateKeyPem: forge.pki.privateKeyToPem(keypair.privateKey),
};
}

View File

@@ -0,0 +1 @@
ALTER TABLE `ca_certificates` ADD `private_key_pem` text;

View File

@@ -85,6 +85,13 @@
"when": 1772300000000,
"tag": "0011_mtls",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1772400000000,
"tag": "0012_ca_private_key",
"breakpoints": true
}
]
}

21
package-lock.json generated
View File

@@ -24,6 +24,7 @@
"maxmind": "^5.0.5",
"next": "^16.1.6",
"next-auth": "^5.0.0-beta.30",
"node-forge": "^1.3.3",
"react": "^19.2.4",
"react-apexcharts": "^2.1.0",
"react-dom": "^19.2.4",
@@ -35,6 +36,7 @@
"@next/eslint-plugin-next": "^16.1.6",
"@types/d3-geo": "^3.1.0",
"@types/node": "^25.3.3",
"@types/node-forge": "^1.3.14",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/topojson-client": "^3.1.5",
@@ -2630,6 +2632,16 @@
"undici-types": "~7.18.0"
}
},
"node_modules/@types/node-forge": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz",
"integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/parse-json": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
@@ -4655,6 +4667,15 @@
"node": ">=10"
}
},
"node_modules/node-forge": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.3.tgz",
"integrity": "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==",
"license": "(BSD-3-Clause OR GPL-2.0)",
"engines": {
"node": ">= 6.13.0"
}
},
"node_modules/oauth4webapi": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.3.tgz",

View File

@@ -29,6 +29,7 @@
"maxmind": "^5.0.5",
"next": "^16.1.6",
"next-auth": "^5.0.0-beta.30",
"node-forge": "^1.3.3",
"react": "^19.2.4",
"react-apexcharts": "^2.1.0",
"react-dom": "^19.2.4",
@@ -40,6 +41,7 @@
"@next/eslint-plugin-next": "^16.1.6",
"@types/d3-geo": "^3.1.0",
"@types/node": "^25.3.3",
"@types/node-forge": "^1.3.14",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/topojson-client": "^3.1.5",

View File

@@ -1,6 +1,7 @@
"use client";
import {
Alert,
Box,
Button,
Dialog,
@@ -8,18 +9,34 @@ import {
DialogContent,
DialogContentText,
DialogTitle,
InputAdornment,
Stack,
Tab,
Tabs,
TextField,
Typography,
} from "@mui/material";
import DownloadIcon from "@mui/icons-material/Download";
import { useTransition, useRef, useState } from "react";
import type { CaCertificate } from "@/src/lib/models/ca-certificates";
import {
createCaCertificateAction,
deleteCaCertificateAction,
generateCaCertificateAction,
issueClientCertificateAction,
updateCaCertificateAction,
} from "@/app/(dashboard)/certificates/ca-actions";
function downloadText(filename: string, content: string) {
const blob = new Blob([content], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
export function CreateCaCertDialog({
open,
onClose,
@@ -27,52 +44,117 @@ export function CreateCaCertDialog({
open: boolean;
onClose: () => void;
}) {
const [tab, setTab] = useState<"import" | "generate">("generate");
const [isPending, startTransition] = useTransition();
const formRef = useRef<HTMLFormElement>(null);
const importFormRef = useRef<HTMLFormElement>(null);
const generateFormRef = useRef<HTMLFormElement>(null);
function handleSubmit(e: React.FormEvent) {
function handleClose() {
setTab("generate");
onClose();
}
function handleImport(e: React.FormEvent) {
e.preventDefault();
const formData = new FormData(formRef.current!);
const formData = new FormData(importFormRef.current!);
startTransition(async () => {
await createCaCertificateAction(formData);
onClose();
handleClose();
});
}
function handleGenerate(e: React.FormEvent) {
e.preventDefault();
const formData = new FormData(generateFormRef.current!);
startTransition(async () => {
await generateCaCertificateAction(formData);
handleClose();
});
}
return (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle>Add Client CA Certificate</DialogTitle>
<form ref={formRef} onSubmit={handleSubmit}>
<DialogContent>
<Stack spacing={2} mt={1}>
<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-----&#10;...&#10;-----END CERTIFICATE-----"
inputProps={{ style: { fontFamily: "monospace", fontSize: "0.8rem" } }}
helperText="PEM-encoded X.509 CA certificate (no private key needed)"
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={isPending}>Cancel</Button>
<Button type="submit" variant="contained" disabled={isPending}>
{isPending ? "Adding..." : "Add CA Certificate"}
</Button>
</DialogActions>
</form>
<DialogContent>
{true && (
<>
<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 === "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-----&#10;...&#10;-----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>
);
}
@@ -135,6 +217,105 @@ export function EditCaCertDialog({
);
}
export function IssueClientCertDialog({
open,
cert,
onClose,
}: {
open: boolean;
cert: CaCertificate;
onClose: () => void;
}) {
const [isPending, startTransition] = useTransition();
const [issued, setIssued] = useState<{ certificatePem: string; privateKeyPem: string; name: string } | null>(null);
const [error, setError] = useState<string | null>(null);
const formRef = useRef<HTMLFormElement>(null);
function handleClose() {
setIssued(null);
setError(null);
onClose();
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const formData = new FormData(formRef.current!);
setError(null);
startTransition(async () => {
try {
const result = await issueClientCertificateAction(cert.id, formData);
setIssued({ ...result, name: String(formData.get("common_name") ?? "client") });
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to issue certificate");
}
});
}
return (
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle>Issue Client Certificate</DialogTitle>
<DialogContent>
{issued ? (
<Stack spacing={2} mt={1}>
<Alert severity="success">
Client certificate issued. Download the certificate and key the private key will not be stored.
</Alert>
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={() => downloadText(`${issued.name}.crt`, issued.certificatePem)}
>
Download Certificate (.crt)
</Button>
<Button
variant="outlined"
startIcon={<DownloadIcon />}
onClick={() => downloadText(`${issued.name}.key`, issued.privateKeyPem)}
>
Download Private Key (.key)
</Button>
</Stack>
) : (
<form ref={formRef} onSubmit={handleSubmit}>
<Stack spacing={2} mt={1}>
<TextField
name="common_name"
label="Common Name (CN)"
required
fullWidth
autoFocus
placeholder="alice"
helperText="Identifies this client (e.g. a username or device name)"
/>
<TextField
name="validity_days"
label="Validity"
type="number"
fullWidth
defaultValue={365}
inputProps={{ min: 1, max: 3650 }}
InputProps={{ endAdornment: <InputAdornment position="end">days</InputAdornment> }}
/>
{error && <Typography color="error" variant="body2">{error}</Typography>}
<DialogActions sx={{ px: 0, pb: 0 }}>
<Button onClick={handleClose} disabled={isPending}>Cancel</Button>
<Button type="submit" variant="contained" disabled={isPending}>
{isPending ? "Issuing..." : "Issue Certificate"}
</Button>
</DialogActions>
</Stack>
</form>
)}
</DialogContent>
{issued && (
<DialogActions>
<Button variant="contained" onClick={handleClose}>Done</Button>
</DialogActions>
)}
</Dialog>
);
}
export function DeleteCaCertDialog({
open,
cert,

View File

@@ -132,6 +132,7 @@ export const caCertificates = sqliteTable("ca_certificates", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
certificatePem: text("certificate_pem").notNull(),
privateKeyPem: text("private_key_pem"),
createdBy: integer("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()

View File

@@ -17,6 +17,7 @@ export type CaCertificate = {
id: number;
name: string;
certificate_pem: string;
has_private_key: boolean;
created_at: string;
updated_at: string;
};
@@ -24,6 +25,7 @@ export type CaCertificate = {
export type CaCertificateInput = {
name: string;
certificate_pem: string;
private_key_pem?: string;
};
type CaCertificateRow = typeof caCertificates.$inferSelect;
@@ -33,6 +35,7 @@ function parseCaCertificate(row: CaCertificateRow): CaCertificate {
id: row.id,
name: row.name,
certificate_pem: row.certificatePem,
has_private_key: !!row.privateKeyPem,
created_at: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)!
};
@@ -43,6 +46,13 @@ export async function listCaCertificates(): Promise<CaCertificate[]> {
return rows.map(parseCaCertificate);
}
export async function getCaCertificatePrivateKey(id: number): Promise<string | null> {
const cert = await db.query.caCertificates.findFirst({
where: (table, { eq }) => eq(table.id, id)
});
return cert?.privateKeyPem ?? null;
}
export async function getCaCertificate(id: number): Promise<CaCertificate | null> {
const cert = await db.query.caCertificates.findFirst({
where: (table, { eq }) => eq(table.id, id)
@@ -57,6 +67,7 @@ export async function createCaCertificate(input: CaCertificateInput, actorUserId
.values({
name: input.name.trim(),
certificatePem: input.certificate_pem.trim(),
privateKeyPem: input.private_key_pem?.trim() ?? null,
createdBy: actorUserId,
createdAt: now,
updatedAt: now
@@ -90,6 +101,7 @@ export async function updateCaCertificate(id: number, input: Partial<CaCertifica
.set({
name: input.name?.trim() ?? existing.name,
certificatePem: input.certificate_pem?.trim() ?? existing.certificate_pem,
...(input.private_key_pem !== undefined ? { privateKeyPem: input.private_key_pem?.trim() ?? null } : {}),
updatedAt: now
})
.where(eq(caCertificates.id, id));