diff --git a/app/(dashboard)/certificates/CertificatesClient.tsx b/app/(dashboard)/certificates/CertificatesClient.tsx index cf0506b0..25cee9ca 100644 --- a/app/(dashboard)/certificates/CertificatesClient.tsx +++ b/app/(dashboard)/certificates/CertificatesClient.tsx @@ -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(null); const [deleteCaCert, setDeleteCaCert] = useState(null); + const [issueCaCert, setIssueCaCert] = useState(null); const acmeColumns = [ { id: 'name', @@ -482,6 +483,13 @@ export default function CertificatesClient({ acmeHosts, importedCerts, managedCe label: "", render: (ca: CaCertificate) => ( + {ca.has_private_key && ( + + + + )} setEditCaCert(ca)}> @@ -521,6 +529,13 @@ export default function CertificatesClient({ acmeHosts, importedCerts, managedCe onClose={() => setDeleteCaCert(null)} /> )} + {issueCaCert && ( + setIssueCaCert(null)} + /> + )} ); } diff --git a/app/(dashboard)/certificates/ca-actions.ts b/app/(dashboard)/certificates/ca-actions.ts index 2c0fc4f6..48ef7c8e 100644 --- a/app/(dashboard)/certificates/ca-actions.ts +++ b/app/(dashboard)/certificates/ca-actions.ts @@ -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 { + 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), + }; +} diff --git a/drizzle/0012_ca_private_key.sql b/drizzle/0012_ca_private_key.sql new file mode 100644 index 00000000..82ff5187 --- /dev/null +++ b/drizzle/0012_ca_private_key.sql @@ -0,0 +1 @@ +ALTER TABLE `ca_certificates` ADD `private_key_pem` text; diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 9a3dad7b..587e1db6 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1772300000000, "tag": "0011_mtls", "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1772400000000, + "tag": "0012_ca_private_key", + "breakpoints": true } ] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 5a0cd9bc..dddd29aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index ac5b8599..21d1a991 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/ca-certificates/CaCertDialogs.tsx b/src/components/ca-certificates/CaCertDialogs.tsx index f37043fd..149585b3 100644 --- a/src/components/ca-certificates/CaCertDialogs.tsx +++ b/src/components/ca-certificates/CaCertDialogs.tsx @@ -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(null); + const importFormRef = useRef(null); + const generateFormRef = useRef(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 ( - + Add Client CA Certificate -
- - - - - - - - - - -
+ + {true && ( + <> + setTab(v)} sx={{ mb: 2 }}> + + + + + {tab === "generate" && ( +
+ + + + days }} + /> + + + + + +
+ )} + + {tab === "import" && ( +
+ + + + + + + + +
+ )} + + )} +
); } @@ -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(null); + const formRef = useRef(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 ( + + Issue Client Certificate + + {issued ? ( + + + Client certificate issued. Download the certificate and key — the private key will not be stored. + + + + + ) : ( +
+ + + days }} + /> + {error && {error}} + + + + + +
+ )} +
+ {issued && ( + + + + )} +
+ ); +} + export function DeleteCaCertDialog({ open, cert, diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 79fac497..0f38c3da 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -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() diff --git a/src/lib/models/ca-certificates.ts b/src/lib/models/ca-certificates.ts index 6ec5d4aa..3e0c81a6 100644 --- a/src/lib/models/ca-certificates.ts +++ b/src/lib/models/ca-certificates.ts @@ -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 { return rows.map(parseCaCertificate); } +export async function getCaCertificatePrivateKey(id: number): Promise { + 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 { 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