🚧 Add CA and server cert management

This commit is contained in:
Jonas Bengtsson
2026-09-19 13:35:49 +02:00
parent 45de68f0b7
commit 111d6cc390
20 changed files with 1317 additions and 34 deletions
+14 -5
View File
@@ -5,6 +5,7 @@ import { ClientsPage } from "./pages/Clients";
import { RolesPage } from "./pages/Roles";
import { GroupsPage } from "./pages/Groups";
import { BrokerConfigPage } from "./pages/BrokerConfig";
import { CertificatesPage } from "./pages/Certificates";
// Navigation structure per spec section 57. Entries with a `to` route are
// implemented; entries without one are planned pages, rendered greyed out
@@ -36,11 +37,11 @@ const NAV_SECTIONS: {
{
title: "Certificates",
items: [
{ label: "Authorities", hint: "PKI (Release 2)" },
{ label: "Server Certificates", hint: "PKI (Release 2)" },
{ label: "Client Certificates", hint: "PKI (Release 2)" },
{ label: "CSRs", hint: "CSR signing (Release 2)" },
{ label: "Revocations", hint: "CRL management (Release 2)" },
{ label: "Authorities", to: "/certificates" },
{ label: "Server Certificates", to: "/certificates" },
{ label: "Client Certificates", hint: "Client certs + one-time download (Release 2, later)" },
{ label: "CSRs", hint: "CSR signing (Release 2, later)" },
{ label: "Revocations", hint: "CRL management (Release 2, later)" },
],
},
{
@@ -149,6 +150,14 @@ export function App() {
</Protected>
}
/>
<Route
path="/certificates"
element={
<Protected>
<CertificatesPage />
</Protected>
}
/>
<Route path="/" element={<Navigate to="/clients" replace />} />
</Routes>
);
+35
View File
@@ -162,6 +162,41 @@ export interface PasswordReset {
password: string;
}
export interface Certificate {
id: string;
common_name: string;
is_ca: boolean;
parent_id: string | null;
key_algorithm: string;
serial_number: string;
dns_sans: string[];
ip_sans: string[];
fingerprint: string;
status: string;
expiry_date: string;
created_at: string;
}
export interface CaCreate {
common_name: string;
validity_days: number;
key_algorithm: "rsa3072" | "rsa4096" | "ecdsa-p256" | "ecdsa-p384";
organization?: string | null;
organizational_unit?: string | null;
country?: string | null;
}
export interface ServerCertCreate {
ca_id: string;
common_name: string;
validity_days: number;
dns_sans: string[];
ip_sans: string[];
organization?: string | null;
organizational_unit?: string | null;
country?: string | null;
}
export interface Listener {
id: string;
name: string;
+327
View File
@@ -0,0 +1,327 @@
import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, type Certificate } from "../api";
const KEY_ALGORITHMS = ["rsa3072", "rsa4096", "ecdsa-p256", "ecdsa-p384"] as const;
// --- CA creation form ---------------------------------------------------------
function CaForm({ pending }: { pending: boolean }) {
const queryClient = useQueryClient();
const [cn, setCn] = useState("");
const [days, setDays] = useState(3650);
const [algorithm, setAlgorithm] = useState<(typeof KEY_ALGORITHMS)[number]>("ecdsa-p256");
const [org, setOrg] = useState("");
const [error, setError] = useState<string | null>(null);
const create = useMutation({
mutationFn: () =>
api.post<Certificate>("/pki/ca", {
common_name: cn,
validity_days: days,
key_algorithm: algorithm,
organization: org || null,
}),
onSuccess: () => {
setCn("");
setError(null);
queryClient.invalidateQueries({ queryKey: ["cas"] });
queryClient.invalidateQueries({ queryKey: ["certificates"] });
},
onError: (e: Error) => setError(e.message),
});
const submit = (e: FormEvent) => {
e.preventDefault();
if (cn) create.mutate();
};
return (
<form className="stack" onSubmit={submit}>
{error && <div className="banner error">{error}</div>}
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "flex-end" }}>
<div>
<label>Common name</label>
<input
value={cn}
onChange={(e) => setCn(e.target.value)}
placeholder="MQTT Platform Root CA"
/>
</div>
<div>
<label>Validity (days)</label>
<input
type="number"
value={days}
min={1}
max={36500}
onChange={(e) => setDays(Number(e.target.value))}
/>
</div>
<div>
<label>Key algorithm</label>
<select value={algorithm} onChange={(e) => setAlgorithm(e.target.value as (typeof KEY_ALGORITHMS)[number])}>
{KEY_ALGORITHMS.map((a) => (
<option key={a} value={a}>
{a}
</option>
))}
</select>
</div>
<div>
<label>Organization</label>
<input value={org} onChange={(e) => setOrg(e.target.value)} placeholder="optional" />
</div>
<button className="primary" disabled={!cn || pending} type="submit">
{pending ? "Generating…" : "Create internal CA"}
</button>
</div>
</form>
);
}
// --- Server certificate issuance form ------------------------------------------
function ServerCertForm({
cas,
pending,
}: {
cas: Certificate[];
pending: boolean;
}) {
const queryClient = useQueryClient();
const [caId, setCaId] = useState("");
const [cn, setCn] = useState("");
const [days, setDays] = useState(365);
const [dns, setDns] = useState("");
const [ips, setIps] = useState("");
const [error, setError] = useState<string | null>(null);
const issue = useMutation({
mutationFn: () =>
api.post<Certificate>("/pki/certificates", {
ca_id: caId,
common_name: cn,
validity_days: days,
dns_sans: dns.split(",").map((s) => s.trim()).filter(Boolean),
ip_sans: ips.split(",").map((s) => s.trim()).filter(Boolean),
}),
onSuccess: () => {
setCn("");
setDns("");
setIps("");
setError(null);
queryClient.invalidateQueries({ queryKey: ["certificates"] });
},
onError: (e: Error) => setError(e.message),
});
const submit = (e: FormEvent) => {
e.preventDefault();
if (cn && caId) issue.mutate();
};
if (cas.length === 0) {
return (
<p style={{ color: "#52606d" }}>
No CA exists yet — create a certificate authority first; server
certificates are signed by one.
</p>
);
}
return (
<form className="stack" onSubmit={submit}>
{error && <div className="banner error">{error}</div>}
<div style={{ display: "flex", gap: "0.5rem", flexWrap: "wrap", alignItems: "flex-end" }}>
<div>
<label>Signing CA</label>
<select value={caId} onChange={(e) => setCaId(e.target.value)}>
<option value="">select CA…</option>
{cas.map((ca) => (
<option key={ca.id} value={ca.id}>
{ca.common_name} ({ca.key_algorithm})
</option>
))}
</select>
</div>
<div>
<label>Common name</label>
<input
value={cn}
onChange={(e) => setCn(e.target.value)}
placeholder="mqtt.example.local"
/>
</div>
<div>
<label>Validity (days)</label>
<input
type="number"
value={days}
min={1}
max={36500}
onChange={(e) => setDays(Number(e.target.value))}
/>
</div>
<div>
<label>DNS SANs (comma-separated)</label>
<input value={dns} onChange={(e) => setDns(e.target.value)} placeholder="mqtt, mqtt.local" />
</div>
<div>
<label>IP SANs (comma-separated)</label>
<input value={ips} onChange={(e) => setIps(e.target.value)} placeholder="10.20.30.40" />
</div>
<button className="primary" disabled={!cn || !caId || pending} type="submit">
{pending ? "Issuing…" : "Issue server certificate"}
</button>
</div>
</form>
);
}
// --- Inventory table -----------------------------------------------------------
function expiryClass(expiry: string): string | null {
const days = (new Date(expiry).getTime() - Date.now()) / 86400000;
if (days < 0) return "banner error";
if (days < 30) return "banner warn";
return null;
}
export function CertificatesPage() {
const queryClient = useQueryClient();
const [error, setError] = useState<string | null>(null);
const cas = useQuery({
queryKey: ["cas"],
queryFn: () => api.get<Certificate[]>("/pki/ca"),
});
const certificates = useQuery({
queryKey: ["certificates"],
queryFn: () => api.get<Certificate[]>("/pki/certificates"),
});
const remove = useMutation({
mutationFn: (id: string) => api.delete(`/pki/certificates/${id}`),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["cas"] });
queryClient.invalidateQueries({ queryKey: ["certificates"] });
},
onError: (e: Error) => setError(e.message),
});
const caList = cas.data ?? [];
return (
<div>
<h2>Certificate authorities</h2>
<p>
Internal CAs sign all platform-issued certificates. Private keys are
stored encrypted (AES-256-GCM) and never leave the backend; only the
public certificate is downloadable.
</p>
{(cas.error || certificates.error) && (
<div className="banner error">
{((cas.error ?? certificates.error) as Error).message}
</div>
)}
{error && <div className="banner error">{error}</div>}
<CaForm pending={cas.isFetching && false} />
{caList.length === 0 ? (
<p style={{ color: "#52606d" }}>No CA yet — create one above.</p>
) : (
<table style={{ marginTop: "1.5rem" }}>
<thead>
<tr>
<th>Common name</th>
<th>Algorithm</th>
<th>Serial</th>
<th>Expires</th>
<th></th>
</tr>
</thead>
<tbody>
{caList.map((ca) => (
<tr key={ca.id}>
<td>{ca.common_name}</td>
<td className="mono">{ca.key_algorithm}</td>
<td className="mono">{ca.serial_number.slice(0, 16)}…</td>
<td>{new Date(ca.expiry_date).toLocaleDateString()}</td>
<td>
<a href={`/api/v1/pki/certificates/${ca.id}/download`}>Download PEM</a>{" "}
<button
className="danger"
onClick={() => {
if (confirm(`Delete CA ${ca.common_name} and its stored key?`))
remove.mutate(ca.id);
}}
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
<h3 style={{ marginTop: "2rem" }}>Server certificates</h3>
<ServerCertForm cas={caList} pending={certificates.isFetching && false} />
<table style={{ marginTop: "1.5rem" }}>
<thead>
<tr>
<th>Common name</th>
<th>Signed by</th>
<th>SANs</th>
<th>Status</th>
<th>Expires</th>
<th></th>
</tr>
</thead>
<tbody>
{(certificates.data ?? [])
.filter((c) => !c.is_ca)
.map((c) => {
const signer = caList.find((ca) => ca.id === c.parent_id);
return (
<tr key={c.id}>
<td>{c.common_name}</td>
<td>{signer ? signer.common_name : "—"}</td>
<td className="mono">
{[...c.dns_sans, ...c.ip_sans].join(", ") || "—"}
</td>
<td>{c.status}</td>
<td className={expiryClass(c.expiry_date) ?? undefined}>
{new Date(c.expiry_date).toLocaleDateString()}
</td>
<td>
<a href={`/api/v1/pki/certificates/${c.id}/download`}>
Download PEM
</a>{" "}
<button
className="danger"
onClick={() => {
if (confirm(`Delete certificate ${c.common_name}?`))
remove.mutate(c.id);
}}
>
Delete
</button>
</td>
</tr>
);
})}
</tbody>
</table>
{(certificates.data ?? []).filter((c) => !c.is_ca).length === 0 && (
<p style={{ color: "#52606d" }}>
No server certificates yet. Issue one above, then bind it to a TLS
listener in Broker configuration.
</p>
)}
</div>
);
}