Chore(Feat): Refractor CA Code + CA host Sign via web

This commit is contained in:
2026-03-03 18:02:21 +05:45
parent b97937f080
commit 7348ba916d
8 changed files with 1481 additions and 595 deletions
+236
View File
@@ -0,0 +1,236 @@
import {
MoreHorizontal,
RefreshCw,
Server,
ServerCog,
Settings,
ShieldOff,
Terminal,
User,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Textarea } from "@/components/ui/textarea";
import { OrgCA } from "@/lib/api";
import { formatDate } from "./utils";
import { CopyButton } from "./CopyButton";
interface CADetailCardProps {
ca: OrgCA;
onEdit: (ca: OrgCA) => void;
onRotate: (ca: OrgCA) => void;
onDelete: (ca: OrgCA) => void;
}
export function CADetailCard({ ca, onEdit, onRotate, onDelete }: CADetailCardProps) {
const isUser = ca.ca_type === "user";
const isSystem = !!ca.is_system;
// ── User CA: server trusts this public key so it accepts user certs ──────
const userCaServerSnippet = `# On each SSH server — trust Gatehouse-issued user certificates:
echo '${ca.public_key.trim()}' >> /etc/ssh/trusted_user_ca_keys
# /etc/ssh/sshd_config (add once, then reload sshd):
TrustedUserCAKeys /etc/ssh/trusted_user_ca_keys
AuthorizedPrincipalsFile /etc/ssh/auth_principals/%u
# Create /etc/ssh/auth_principals/<unix-user> containing one principal per line.`;
// ── Host CA: clients trust this public key so they can verify server certs ─
const hostCaClientSnippet = `# On SSH clients — trust host certificates signed by this CA:
# Add to ~/.ssh/known_hosts (or /etc/ssh/ssh_known_hosts for system-wide):
@cert-authority * ${ca.public_key.trim()}
# ─── Server side (separate step) ────────────────────────────────────────────
# 1. Collect the server's HOST public key:
# cat /etc/ssh/ssh_host_ed25519_key.pub
# 2. Submit it to Gatehouse → "Issue Host Certificate" to get a signed cert.
# 3. Install the cert on the server:
# /etc/ssh/sshd_config:
# HostKey /etc/ssh/ssh_host_ed25519_key
# HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub
# 4. Verify the cert (NOT this CA key):
# ssh-keygen -L -f /etc/ssh/ssh_host_ed25519_key-cert.pub
# ↳ Type must be: ssh-ed25519-cert-v01@openssh.com host certificate`;
const sshConfig = isUser ? userCaServerSnippet : hostCaClientSnippet;
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<CardTitle className="text-base flex items-center gap-2 flex-wrap">
{isSystem ? (
<ServerCog className="w-4 h-4 flex-shrink-0" />
) : isUser ? (
<User className="w-4 h-4 flex-shrink-0" />
) : (
<Server className="w-4 h-4 flex-shrink-0" />
)}
<span className="truncate">{ca.name}</span>
{isSystem ? (
<Badge variant="secondary" className="text-xs flex items-center gap-1">
<ServerCog className="w-3 h-3" />
System
</Badge>
) : ca.is_active ? (
<Badge className="bg-green-500/10 text-green-600 border-0 text-xs">Active</Badge>
) : (
<Badge variant="secondary" className="text-xs">Inactive</Badge>
)}
</CardTitle>
{ca.description && (
<CardDescription className="mt-1">{ca.description}</CardDescription>
)}
</div>
{/* Right side: key-type badge + actions menu */}
<div className="flex items-center gap-1 flex-shrink-0">
<Badge variant="outline" className="text-xs font-mono">{ca.key_type}</Badge>
{/* ⋯ actions — only for non-system CAs */}
{!isSystem && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<MoreHorizontal className="w-4 h-4" />
<span className="sr-only">CA actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuItem onClick={() => onEdit(ca)}>
<Settings className="w-3.5 h-3.5 mr-2" />
Edit configuration
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onRotate(ca)}>
<RefreshCw className="w-3.5 h-3.5 mr-2" />
Rotate key
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => onDelete(ca)}
className="text-destructive focus:text-destructive"
>
<ShieldOff className="w-3.5 h-3.5 mr-2" />
Delete CA
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats row — hidden for system CAs */}
{!isSystem && (
<div className="grid grid-cols-4 gap-3 text-center">
<div className="p-2 bg-muted rounded-lg">
<p className="text-lg font-semibold">{ca.active_certs}</p>
<p className="text-xs text-muted-foreground">Active certs</p>
</div>
<div className="p-2 bg-muted rounded-lg">
<p className="text-lg font-semibold">{ca.total_certs}</p>
<p className="text-xs text-muted-foreground">Total issued</p>
</div>
<div className="p-2 bg-muted rounded-lg">
<p className="text-lg font-semibold">{ca.default_cert_validity_hours}h</p>
<p className="text-xs text-muted-foreground">Default validity</p>
</div>
<div className="p-2 bg-muted rounded-lg">
<p className="text-lg font-semibold">{ca.next_serial_number ?? "—"}</p>
<p className="text-xs text-muted-foreground">Next serial</p>
</div>
</div>
)}
{/* Fingerprint — with copy button */}
<div>
<p className="text-xs text-muted-foreground mb-1">Fingerprint</p>
<div className="flex items-center gap-1">
<code className="text-xs font-mono bg-muted px-2 py-1 rounded break-all flex-1">
{ca.fingerprint}
</code>
<CopyButton text={ca.fingerprint} />
</div>
</div>
{/* Public key */}
<div>
<div className="flex items-center justify-between mb-1">
<div>
<p className="text-xs font-medium">
{isUser ? "User CA public key" : "Host CA public key"}
</p>
<p className="text-xs text-muted-foreground">
{isUser
? "Distribute to SSH servers → TrustedUserCAKeys"
: "Distribute to SSH clients → known_hosts @cert-authority (NOT HostCertificate)"}
</p>
</div>
<CopyButton text={ca.public_key} />
</div>
<Textarea readOnly value={ca.public_key} className="font-mono text-xs min-h-[60px]" />
</div>
{/* Setup instructions — collapsible */}
<Accordion type="single" collapsible className="border rounded-lg px-1">
<AccordionItem value="setup" className="border-none">
<AccordionTrigger className="py-2 text-xs font-semibold hover:no-underline">
<span className="flex items-center gap-1.5">
<Terminal className="w-3.5 h-3.5" />
{isUser
? "Server setup — trust Gatehouse user certificates"
: "Client setup — trust Gatehouse host certificates"}
</span>
</AccordionTrigger>
<AccordionContent className="pb-3">
{!isUser && (
<div className="mb-2 rounded border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-950/40 px-2 py-1.5 text-xs text-amber-800 dark:text-amber-300">
<strong>Two separate steps:</strong> (1) Put this CA public key in client{" "}
<code className="font-mono">known_hosts</code>. (2) Issue a host certificate
for each server via Gatehouse and install it as{" "}
<code className="font-mono">HostCertificate</code>.
</div>
)}
<pre className="text-xs font-mono whitespace-pre-wrap break-all bg-muted rounded p-3">
{sshConfig}
</pre>
</AccordionContent>
</AccordionItem>
</Accordion>
{/* Metadata */}
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-muted-foreground">
{ca.created_at && <span>Created {formatDate(ca.created_at)}</span>}
{ca.rotated_at && (
<span>
Key rotated {formatDate(ca.rotated_at)}
{ca.rotation_reason && <> {ca.rotation_reason}</>}
</span>
)}
</div>
</CardContent>
</Card>
);
}
+468
View File
@@ -0,0 +1,468 @@
import { Loader2, AlertCircle, Shield, User, Server, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { OrgCA } from "@/lib/api";
// ─────────────────────────────────────────────────────────────────────────────
// Create CA Dialog
// ─────────────────────────────────────────────────────────────────────────────
export interface CreateCAForm {
name: string;
description: string;
key_type: "ed25519" | "rsa" | "ecdsa";
default_cert_validity_hours: number;
max_cert_validity_hours: number;
}
interface CreateCADialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
caType: "user" | "host";
form: CreateCAForm;
onFormChange: (form: CreateCAForm) => void;
error: string | null;
isLoading: boolean;
onSubmit: () => void;
}
export function CreateCADialog({
open,
onOpenChange,
caType,
form,
onFormChange,
error,
isLoading,
onSubmit,
}: CreateCADialogProps) {
return (
<Dialog open={open} onOpenChange={(o) => { onOpenChange(o); }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{caType === "user" ? <User className="w-5 h-5" /> : <Server className="w-5 h-5" />}
Generate {caType === "user" ? "User" : "Host"} Signing Key
</DialogTitle>
<DialogDescription>
{caType === "user"
? "Creates a key pair for signing SSH user certificates. The private key is stored securely and never exposed."
: "Creates a key pair for signing SSH host certificates, allowing clients to verify server identity."}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm flex items-start gap-2">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{error}</span>
</div>
)}
<div className="space-y-2">
<Label htmlFor="ca-name">
Name <span className="text-destructive">*</span>
</Label>
<Input
id="ca-name"
placeholder={caType === "user" ? "User CA" : "Host CA"}
value={form.name}
onChange={(e) => onFormChange({ ...form, name: e.target.value })}
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ca-description">Description</Label>
<Input
id="ca-description"
placeholder="Optional description"
value={form.description}
onChange={(e) => onFormChange({ ...form, description: e.target.value })}
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ca-key-type">Key Algorithm</Label>
<Select
value={form.key_type}
onValueChange={(v) =>
onFormChange({ ...form, key_type: v as "ed25519" | "rsa" | "ecdsa" })
}
disabled={isLoading}
>
<SelectTrigger id="ca-key-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ed25519">Ed25519 (recommended)</SelectItem>
<SelectItem value="ecdsa">ECDSA (P-521)</SelectItem>
<SelectItem value="rsa">RSA-4096</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="ca-default-validity">Default validity (hours)</Label>
<Input
id="ca-default-validity"
type="number"
min="1"
value={form.default_cert_validity_hours}
onChange={(e) =>
onFormChange({
...form,
default_cert_validity_hours: parseInt(e.target.value) || 1,
})
}
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="ca-max-validity">Max validity (hours)</Label>
<Input
id="ca-max-validity"
type="number"
min="1"
value={form.max_cert_validity_hours}
onChange={(e) =>
onFormChange({
...form,
max_cert_validity_hours: parseInt(e.target.value) || 1,
})
}
disabled={isLoading}
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
Cancel
</Button>
<Button onClick={onSubmit} disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Generating key
</>
) : (
<>
<Shield className="w-4 h-4 mr-2" />
Generate Key
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Edit CA Dialog
// ─────────────────────────────────────────────────────────────────────────────
export interface EditCAForm {
default_cert_validity_hours: number;
max_cert_validity_hours: number;
}
interface EditCADialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
ca: OrgCA | null;
form: EditCAForm;
onFormChange: (form: EditCAForm) => void;
error: string | null;
isLoading: boolean;
onSubmit: () => void;
}
export function EditCADialog({
open,
onOpenChange,
ca,
form,
onFormChange,
error,
isLoading,
onSubmit,
}: EditCADialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit CA Configuration</DialogTitle>
<DialogDescription>
Update certificate validity settings for <strong>{ca?.name}</strong>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm flex items-start gap-2">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="default-validity">Default Certificate Validity (hours)</Label>
<Input
id="default-validity"
type="number"
min="1"
value={form.default_cert_validity_hours}
onChange={(e) =>
onFormChange({
...form,
default_cert_validity_hours: parseInt(e.target.value) || 1,
})
}
disabled={isLoading}
/>
<p className="text-xs text-muted-foreground">
Default validity period when issuing new certificates
</p>
</div>
<div className="space-y-2">
<Label htmlFor="max-validity">Maximum Certificate Validity (hours)</Label>
<Input
id="max-validity"
type="number"
min="1"
value={form.max_cert_validity_hours}
onChange={(e) =>
onFormChange({
...form,
max_cert_validity_hours: parseInt(e.target.value) || 1,
})
}
disabled={isLoading}
/>
<p className="text-xs text-muted-foreground">
Maximum allowed validity period for any certificate from this CA
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
Cancel
</Button>
<Button onClick={onSubmit} disabled={isLoading}>
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Rotate CA Dialog
// ─────────────────────────────────────────────────────────────────────────────
interface RotateCADialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
ca: OrgCA | null;
keyType: "ed25519" | "rsa" | "ecdsa";
onKeyTypeChange: (kt: "ed25519" | "rsa" | "ecdsa") => void;
reason: string;
onReasonChange: (r: string) => void;
error: string | null;
isLoading: boolean;
onSubmit: () => void;
}
export function RotateCADialog({
open,
onOpenChange,
ca,
keyType,
onKeyTypeChange,
reason,
onReasonChange,
error,
isLoading,
onSubmit,
}: RotateCADialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<RefreshCw className="w-5 h-5" />
Rotate CA Key
</DialogTitle>
<DialogDescription>
Generate a new key pair for <strong>{ca?.name}</strong>. Previously-issued
certificates remain valid until they expire, but all new certificates will be signed
with the new key. You must update{" "}
{ca?.ca_type === "user"
? "TrustedUserCAKeys on your SSH servers"
: "@cert-authority in client known_hosts files"}{" "}
after rotation.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{error && (
<div className="p-3 rounded-lg bg-destructive/10 text-destructive text-sm flex items-start gap-2">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{error}</span>
</div>
)}
{ca && (
<div className="rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-900 p-3 text-xs text-amber-800 dark:text-amber-300">
<p className="font-semibold mb-1"> Important</p>
<p>
Current fingerprint:{" "}
<code className="font-mono">{ca.fingerprint}</code>
</p>
<p className="mt-1">
After rotation, you <strong>must</strong> replace this fingerprint on every
server / client that trusts this CA. Until updated, new certificates won't be
accepted.
</p>
</div>
)}
<div className="space-y-2">
<Label htmlFor="rotate-key-type">New Key Algorithm</Label>
<Select
value={keyType}
onValueChange={(v) => onKeyTypeChange(v as "ed25519" | "rsa" | "ecdsa")}
disabled={isLoading}
>
<SelectTrigger id="rotate-key-type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ed25519">Ed25519 (recommended)</SelectItem>
<SelectItem value="ecdsa">ECDSA (P-521)</SelectItem>
<SelectItem value="rsa">RSA-4096</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="rotate-reason">Reason (optional)</Label>
<Input
id="rotate-reason"
placeholder="e.g. Suspected key compromise, Scheduled rotation"
value={reason}
onChange={(e) => onReasonChange(e.target.value)}
disabled={isLoading}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
Cancel
</Button>
<Button onClick={onSubmit} disabled={isLoading} variant="destructive">
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Rotating
</>
) : (
<>
<RefreshCw className="w-4 h-4 mr-2" />
Rotate Key
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Delete CA Dialog
// ─────────────────────────────────────────────────────────────────────────────
interface DeleteCADialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
ca: OrgCA | null;
isLoading: boolean;
onConfirm: () => void;
}
export function DeleteCADialog({
open,
onOpenChange,
ca,
isLoading,
onConfirm,
}: DeleteCADialogProps) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Certificate Authority?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently deactivate <strong>{ca?.name}</strong>. No new certificates
can be signed with this CA after deletion. Existing certificates remain valid until
they expire.
{ca?.active_certs ? (
<span className="block mt-2 font-semibold text-amber-600 dark:text-amber-400">
This CA has {ca.active_certs} active certificate
{ca.active_certs !== 1 ? "s" : ""}.
</span>
) : null}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isLoading}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={onConfirm}
disabled={isLoading}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
Delete CA
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
+161
View File
@@ -0,0 +1,161 @@
import { AlertCircle, Plus, Server, ServerCog, ShieldAlert, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { OrgCA } from "@/lib/api";
import { CADetailCard } from "./CADetailCard";
import { IssueHostCertPanel } from "./IssueHostCertPanel";
interface CASectionProps {
caType: "user" | "host";
ca: OrgCA | null;
onCreateClick: (caType: "user" | "host") => void;
onEdit: (ca: OrgCA) => void;
onRotate: (ca: OrgCA) => void;
onDelete: (ca: OrgCA) => void;
}
const SECTION_META = {
user: {
title: "User CA",
subtitle:
"Signs SSH user certificates. Servers trust users who present a valid cert by adding this CA's public key to TrustedUserCAKeys.",
emptyDescription:
"No User CA configured. Generate a key pair to start issuing SSH user certificates.",
},
host: {
title: "Host CA",
subtitle:
"Signs SSH host certificates. Clients trust servers whose cert is signed by this CA. The CA public key goes in the client's known_hosts — not HostCertificate (that is issued per-server separately).",
emptyDescription:
"No Host CA configured. Generate a key pair to start issuing SSH host certificates.",
},
} as const;
// ── Tiny numbered step label used in the Host CA flow ────────────────────────
function StepLabel({ n, label }: { n: number; label: string }) {
return (
<div className="flex items-center gap-2">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-primary text-[10px] font-bold flex-shrink-0">
{n}
</span>
<span className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{label}
</span>
</div>
);
}
export function CASection({
caType,
ca,
onCreateClick,
onEdit,
onRotate,
onDelete,
}: CASectionProps) {
const isUser = caType === "user";
const { title, subtitle, emptyDescription } = SECTION_META[caType];
const Icon = isUser ? User : Server;
const isSystem = !!ca?.is_system;
return (
<div className="space-y-4">
{/* Section header */}
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
<Icon className="w-4 h-4 text-muted-foreground flex-shrink-0" />
<div>
<h2 className="text-sm font-semibold leading-tight">{title}</h2>
{/* Only show the verbose subtitle when there's no CA yet */}
{!ca && (
<p className="text-xs text-muted-foreground max-w-prose">{subtitle}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{ca ? (
isSystem ? (
<Badge variant="secondary" className="text-xs flex items-center gap-1">
<ServerCog className="w-3 h-3" />
System (read-only)
</Badge>
) : (
<Badge className="bg-green-500/10 text-green-600 border-0 text-xs">Configured</Badge>
)
) : (
<Badge variant="secondary" className="text-xs flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Not configured
</Badge>
)}
</div>
</div>
{/* Content */}
{ca ? (
<div className="space-y-6">
{isUser ? (
/* ── User CA: single card, no numbered steps needed ─────────── */
<CADetailCard ca={ca} onEdit={onEdit} onRotate={onRotate} onDelete={onDelete} />
) : (
/* ── Host CA: two explicit numbered steps ────────────────────── */
<>
{/* Step 1 — CA key → clients' known_hosts */}
<div className="space-y-2">
<StepLabel n={1} label="Distribute CA key to clients" />
<CADetailCard ca={ca} onEdit={onEdit} onRotate={onRotate} onDelete={onDelete} />
</div>
{/* Step 2 — sign each server's host public key */}
{!isSystem && (
<div className="space-y-2">
<StepLabel n={2} label="Sign each server's host key" />
<IssueHostCertPanel ca={ca} />
</div>
)}
</>
)}
{/* System CA upgrade prompt */}
{isSystem && (
<div className="flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30 p-3 text-xs text-amber-800 dark:text-amber-300">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="font-semibold mb-1">Using server-configured CA</p>
<p>
Certificates are being signed by a CA key loaded from the server
configuration, not managed through this UI. Generate a managed key below to
take full control of certificate issuance from Gatehouse.
</p>
</div>
<Button
onClick={() => onCreateClick(caType)}
size="sm"
variant="outline"
className="flex-shrink-0"
>
<Plus className="w-3 h-3 mr-1" />
Generate managed key
</Button>
</div>
)}
</div>
) : (
/* Empty state */
<Card className="border-dashed">
<CardContent className="flex flex-col items-center py-10 text-muted-foreground">
<ShieldAlert className="w-10 h-10 mb-3 opacity-30" />
<p className="text-sm font-medium mb-1">No {title} configured</p>
<p className="text-xs text-center mb-4 max-w-sm">{emptyDescription}</p>
<Button onClick={() => onCreateClick(caType)} size="sm" variant="outline">
<Plus className="w-4 h-4 mr-2" />
Generate {title}
</Button>
</CardContent>
</Card>
)}
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
import { useState } from "react";
import { Copy, CheckCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useToast } from "@/hooks/use-toast";
interface CopyButtonProps {
text: string;
}
export function CopyButton({ text }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const { toast } = useToast();
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
toast({ title: "Copied to clipboard" });
setTimeout(() => setCopied(false), 2000);
} catch {
toast({ variant: "destructive", title: "Copy failed" });
}
};
return (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 flex-shrink-0"
onClick={handleCopy}
title="Copy to clipboard"
>
{copied ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
);
}
+344
View File
@@ -0,0 +1,344 @@
import { useState } from "react";
import {
AlertCircle,
CheckCircle,
ChevronDown,
ChevronRight,
FileKey,
HelpCircle,
Loader2,
Terminal,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { api, ApiError, OrgCA } from "@/lib/api";
import { classifySshKeyMaterial } from "./utils";
import { CopyButton } from "./CopyButton";
interface IssueHostCertPanelProps {
ca: OrgCA;
}
// Preset validity options: [label, hours]
const VALIDITY_PRESETS: [string, number][] = [
["1d", 24],
["7d", 168],
["30d", 720],
["1y", 8760],
];
export function IssueHostCertPanel({ ca }: IssueHostCertPanelProps) {
const { toast } = useToast();
const [isExpanded, setIsExpanded] = useState(false);
const [showHowItWorks, setShowHowItWorks] = useState(false);
const [hostPubKey, setHostPubKey] = useState("");
const [principals, setPrincipals] = useState("");
const [validityHours, setValidityHours] = useState("720");
const [isIssuing, setIsIssuing] = useState(false);
const [issueError, setIssueError] = useState<string | null>(null);
const [hostCert, setHostCert] = useState<string | null>(null);
const [keyWarning, setKeyWarning] = useState<string | null>(null);
const handlePubKeyChange = (value: string) => {
setHostPubKey(value);
setIssueError(null);
setKeyWarning(null);
if (!value.trim()) return;
const kind = classifySshKeyMaterial(value.trim());
if (kind === "certificate") {
setKeyWarning(
"⚠ This looks like a certificate (ssh-…-cert-v01@openssh.com), not a public key. " +
"Paste the server's host PUBLIC key (from /etc/ssh/ssh_host_ed25519_key.pub), not an existing certificate.",
);
} else if (kind === "private_key") {
setKeyWarning("⚠ This looks like a PRIVATE key. Never paste private keys here. Use the .pub file.");
} else if (kind === "unknown") {
setKeyWarning("⚠ Unrecognised key format. Expected: ssh-ed25519 AAAA… or ecdsa-sha2-nistp256 AAAA…");
}
};
const handleIssue = async () => {
setIssueError(null);
setHostCert(null);
const kind = classifySshKeyMaterial(hostPubKey.trim());
if (kind === "certificate") {
setIssueError(
"You pasted a certificate, not a host public key. " +
"Get the server's host public key: cat /etc/ssh/ssh_host_ed25519_key.pub",
);
return;
}
if (kind === "private_key") {
setIssueError("Private keys must never be pasted here. Use the .pub file.");
return;
}
const principalList = principals
.split(/[\s,]+/)
.map((p) => p.trim())
.filter(Boolean);
if (principalList.length === 0) {
setIssueError("At least one principal (hostname/FQDN) is required.");
return;
}
const hours = parseInt(validityHours, 10);
if (!hours || hours < 1) {
setIssueError("Validity must be a positive number of hours.");
return;
}
setIsIssuing(true);
try {
const result = await api.ssh.signHostCert(hostPubKey.trim(), principalList, hours, ca.id);
setHostCert(result.certificate);
toast({ title: "Host certificate issued", description: `Serial #${result.serial}` });
} catch (err) {
setIssueError(
err instanceof ApiError ? err.message : "Failed to issue host certificate",
);
} finally {
setIsIssuing(false);
}
};
const serverInstallSnippet = hostCert
? `# 1. Copy the certificate to the server:
cat > /etc/ssh/ssh_host_ed25519_key-cert.pub << 'CERT'
${hostCert.trim()}
CERT
# 2. /etc/ssh/sshd_config (ensure these two lines exist):
HostKey /etc/ssh/ssh_host_ed25519_key
HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub
# 3. Reload sshd:
systemctl reload sshd
# 4. Verify the cert (must show type = host certificate):
ssh-keygen -L -f /etc/ssh/ssh_host_ed25519_key-cert.pub`
: "";
return (
<Card className="border-dashed border-blue-300 dark:border-blue-700">
<CardHeader
className="py-3 cursor-pointer select-none"
onClick={() => setIsExpanded((v) => !v)}
>
<CardTitle className="text-sm flex items-center gap-2">
<FileKey className="w-4 h-4 text-blue-600 dark:text-blue-400" />
Issue Host Certificate
<span className="ml-auto">
{isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</span>
</CardTitle>
<CardDescription className="text-xs">
Paste a server's host public key to receive a signed certificate to install as{" "}
<code>HostCertificate</code> in <code>sshd_config</code>.
</CardDescription>
</CardHeader>
{isExpanded && (
<CardContent className="space-y-4 pt-0">
{/* ── "How it works" — collapsed by default ──────────────── */}
<div>
<button
type="button"
onClick={() => setShowHowItWorks((v) => !v)}
className="flex items-center gap-1.5 text-xs text-blue-600 dark:text-blue-400 hover:underline focus:outline-none"
>
<HelpCircle className="w-3.5 h-3.5" />
How host certificates work
{showHowItWorks ? (
<ChevronDown className="w-3 h-3" />
) : (
<ChevronRight className="w-3 h-3" />
)}
</button>
{showHowItWorks && (
<div className="mt-2 rounded-lg border border-blue-200 dark:border-blue-800 bg-blue-50 dark:bg-blue-950/30 p-3 text-xs text-blue-800 dark:text-blue-300 space-y-1.5">
<p>
<strong>Step 1 (done above):</strong> Distribute the Host CA public key to SSH
clients via <code className="font-mono">@cert-authority</code> in{" "}
<code className="font-mono">known_hosts</code>.
</p>
<p>
<strong>Step 2 (here):</strong> For each server, collect its host public key,
paste it below, and Gatehouse will sign it. Install the resulting certificate
as <code className="font-mono">HostCertificate</code> in{" "}
<code className="font-mono">sshd_config</code>.
</p>
<p className="text-amber-700 dark:text-amber-400">
Do <strong>not</strong> put the CA public key from Step 1 into{" "}
<code className="font-mono">HostCertificate</code> that directive requires a
signed certificate, not a CA public key.
</p>
</div>
)}
</div>
{issueError && (
<div className="rounded-md bg-destructive/10 text-destructive text-sm p-3 flex items-start gap-2">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
{issueError}
</div>
)}
{!hostCert ? (
<>
{/* Host public key */}
<div className="space-y-1.5">
<Label className="text-sm">
Server host public key{" "}
<span className="ml-1 font-normal text-muted-foreground">
(<code>/etc/ssh/ssh_host_ed25519_key.pub</code>)
</span>
</Label>
<p className="text-xs text-muted-foreground">
Run{" "}
<code className="font-mono bg-muted px-1 rounded">
cat /etc/ssh/ssh_host_ed25519_key.pub
</code>{" "}
on the server and paste the result.
</p>
<Textarea
placeholder="ssh-ed25519 AAAA... root@server"
value={hostPubKey}
onChange={(e) => handlePubKeyChange(e.target.value)}
className="font-mono text-xs min-h-[80px]"
disabled={isIssuing}
/>
{keyWarning && (
<p className="text-xs text-amber-700 dark:text-amber-400">{keyWarning}</p>
)}
</div>
{/* Principals */}
<div className="space-y-1.5">
<Label className="text-sm">
Principals{" "}
<span className="ml-1 font-normal text-muted-foreground">
(hostnames/FQDNs, space or comma separated)
</span>
</Label>
<p className="text-xs text-muted-foreground">
Must match what clients type in{" "}
<code className="font-mono">ssh user@&lt;principal&gt;</code>.
</p>
<Input
placeholder="prod.example.com web01.internal"
value={principals}
onChange={(e) => setPrincipals(e.target.value)}
disabled={isIssuing}
/>
</div>
{/* Validity */}
<div className="space-y-1.5">
<Label className="text-sm">Validity (hours)</Label>
<div className="flex items-center gap-2 flex-wrap">
<Input
type="number"
min={1}
value={validityHours}
onChange={(e) => setValidityHours(e.target.value)}
className="w-28"
disabled={isIssuing}
/>
{/* Quick preset buttons */}
<div className="flex items-center gap-1">
{VALIDITY_PRESETS.map(([label, hours]) => (
<Button
key={label}
type="button"
size="sm"
variant={validityHours === String(hours) ? "default" : "outline"}
className="h-8 px-2 text-xs"
onClick={() => setValidityHours(String(hours))}
disabled={isIssuing}
>
{label}
</Button>
))}
</div>
</div>
<p className="text-xs text-muted-foreground">
Host certs are typically longer-lived than user certs.
</p>
</div>
<Button
onClick={handleIssue}
disabled={isIssuing || !hostPubKey.trim() || !principals.trim() || !!keyWarning}
size="sm"
>
{isIssuing && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
<FileKey className="w-4 h-4 mr-2" />
Issue host certificate
</Button>
</>
) : (
/* ── Success view ─────────────────────────────────────── */
<div className="space-y-3">
<p className="text-sm font-medium text-green-700 dark:text-green-400 flex items-center gap-1.5">
<CheckCircle className="w-4 h-4" />
Host certificate issued
</p>
{/* Certificate text */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground">Certificate</Label>
<CopyButton text={hostCert} />
</div>
<Textarea
readOnly
value={hostCert}
className="font-mono text-xs min-h-[80px]"
/>
</div>
{/* Install snippet */}
<div className="rounded-lg bg-muted p-3 space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold flex items-center gap-1">
<Terminal className="w-3 h-3" />
Install on the server
</p>
<CopyButton text={serverInstallSnippet} />
</div>
<pre className="text-xs font-mono whitespace-pre-wrap break-all">
{serverInstallSnippet}
</pre>
</div>
<Button
variant="outline"
size="sm"
onClick={() => {
setHostCert(null);
setHostPubKey("");
setPrincipals("");
}}
>
Issue another
</Button>
</div>
)}
</CardContent>
)}
</Card>
);
}
+32
View File
@@ -0,0 +1,32 @@
// ─── Shared utilities for the Certificate Authorities page ───────────────────
export function formatDate(d: string | null): string {
if (!d) return "—";
return new Date(d).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
export type SshKeyMaterialKind = "certificate" | "public_key" | "private_key" | "unknown";
/**
* Inspect the first token of a raw SSH key/cert string and classify it.
* Used to warn the user before they accidentally paste a certificate where
* a public key is expected, or vice-versa.
*/
export function classifySshKeyMaterial(raw: string): SshKeyMaterialKind {
const line = raw.trim().split(/\s+/)[0] ?? "";
if (/-cert-v01@openssh\.com$/.test(line)) return "certificate";
if (
/^(ssh-ed25519|ssh-rsa|ssh-dss|ecdsa-sha2-nistp\d+|sk-ssh-ed25519@openssh\.com)$/.test(line)
)
return "public_key";
if (
raw.trim().startsWith("-----BEGIN OPENSSH PRIVATE KEY-----") ||
raw.trim().startsWith("-----BEGIN RSA PRIVATE KEY-----")
)
return "private_key";
return "unknown";
}