Chore(Feat): Refractor CA Code + CA host Sign via web
This commit is contained in:
@@ -1098,6 +1098,25 @@ export const api = {
|
||||
body: JSON.stringify({ key_id, principals, cert_type, expiry_hours }),
|
||||
}, true, requestConfig),
|
||||
|
||||
// Issue a host certificate by submitting a raw server host public key
|
||||
// (admin-only; does not require a pre-registered SSHKey record)
|
||||
signHostCert: (
|
||||
hostPublicKey: string,
|
||||
principals: string[],
|
||||
validityHours: number,
|
||||
caId: string,
|
||||
requestConfig?: RequestConfig,
|
||||
) =>
|
||||
request<SSHSignResponse>('/ssh/sign/host', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
host_public_key: hostPublicKey,
|
||||
principals,
|
||||
validity_hours: validityHours,
|
||||
ca_id: caId,
|
||||
}),
|
||||
}, true, requestConfig),
|
||||
|
||||
// Get the merged department certificate policy for the current user (used in sign dialog)
|
||||
getMyDeptCertPolicy: (requestConfig?: RequestConfig) =>
|
||||
request<{ policy: DeptCertPolicy }>('/ssh/dept-cert-policy', {}, true, requestConfig),
|
||||
|
||||
+178
-592
@@ -1,298 +1,23 @@
|
||||
// ─── THIS FILE IS THE LEAN ORCHESTRATOR ──────────────────────────────────────
|
||||
// Heavy sub-components live in ./ca/ — edit them there for isolated changes.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
Copy,
|
||||
CheckCircle,
|
||||
Loader2,
|
||||
Terminal,
|
||||
Plus,
|
||||
User,
|
||||
Server,
|
||||
Settings,
|
||||
AlertCircle,
|
||||
ServerCog,
|
||||
RefreshCw,
|
||||
ShieldOff,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Loader2, Server, Shield, User } from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
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 { useToast } from "@/hooks/use-toast";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useCurrentOrganizationId } from "@/hooks/useCurrentOrganization";
|
||||
import { api, OrgCA, ApiError } from "@/lib/api";
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
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}>
|
||||
{copied ? <CheckCircle className="w-4 h-4 text-green-500" /> : <Copy className="w-4 h-4" />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(d: string | null) {
|
||||
if (!d) return "—";
|
||||
return new Date(d).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
// ─── CA Detail Card ───────────────────────────────────────────────────────────
|
||||
|
||||
interface CADetailCardProps {
|
||||
ca: OrgCA;
|
||||
onEdit: (ca: OrgCA) => void;
|
||||
onRotate: (ca: OrgCA) => void;
|
||||
onDelete: (ca: OrgCA) => void;
|
||||
}
|
||||
|
||||
function CADetailCard({ ca, onEdit, onRotate, onDelete }: CADetailCardProps) {
|
||||
const isUser = ca.ca_type === "user";
|
||||
const isSystem = !!ca.is_system;
|
||||
const sshConfig = isUser
|
||||
? `# /etc/ssh/sshd_config:\nTrustedUserCAKeys /etc/ssh/trusted_user_ca_keys\n\n# Add public key:\necho '${ca.public_key.trim()}' \\\n >> /etc/ssh/trusted_user_ca_keys`
|
||||
: `# /etc/ssh/sshd_config:\nHostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub\n\n# Add to known_hosts (clients):\n@cert-authority * ${ca.public_key.trim()}`;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
{isSystem ? <ServerCog className="w-4 h-4" /> : isUser ? <User className="w-4 h-4" /> : <Server className="w-4 h-4" />}
|
||||
{ca.name}
|
||||
{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>
|
||||
<Badge variant="outline" className="text-xs font-mono">{ca.key_type}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Stats — hidden for system CAs (we have no cert records for them) */}
|
||||
{!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 */}
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">Fingerprint</p>
|
||||
<code className="text-xs font-mono bg-muted px-2 py-1 rounded break-all">{ca.fingerprint}</code>
|
||||
</div>
|
||||
|
||||
{/* Public key */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="text-xs text-muted-foreground">Public key</p>
|
||||
<CopyButton text={ca.public_key} />
|
||||
</div>
|
||||
<Textarea readOnly value={ca.public_key} className="font-mono text-xs min-h-[60px]" />
|
||||
</div>
|
||||
|
||||
{/* Setup instructions */}
|
||||
<div className="rounded-lg bg-muted p-3">
|
||||
<p className="text-xs font-semibold flex items-center gap-1 mb-1">
|
||||
<Terminal className="w-3 h-3" />
|
||||
{isUser ? "Add to SSH servers (sshd_config)" : "Host certificate setup"}
|
||||
</p>
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-all">{sshConfig}</pre>
|
||||
</div>
|
||||
|
||||
{ca.created_at && (
|
||||
<p className="text-xs text-muted-foreground">Created {formatDate(ca.created_at)}</p>
|
||||
)}
|
||||
{ca.rotated_at && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Key rotated {formatDate(ca.rotated_at)}
|
||||
{ca.rotation_reason && <> — {ca.rotation_reason}</>}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!isSystem && (
|
||||
<div className="pt-2 border-t space-y-2">
|
||||
<Button variant="outline" size="sm" onClick={() => onEdit(ca)} className="w-full">
|
||||
<Settings className="w-3 h-3 mr-2" />
|
||||
Edit Configuration
|
||||
</Button>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => onRotate(ca)} className="w-full">
|
||||
<RefreshCw className="w-3 h-3 mr-2" />
|
||||
Rotate Key
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onDelete(ca)}
|
||||
className="w-full text-destructive hover:text-destructive border-destructive/30 hover:border-destructive/60"
|
||||
>
|
||||
<ShieldOff className="w-3 h-3 mr-2" />
|
||||
Delete CA
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CA Section (one per type) ────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function CASection({ caType, ca, onCreateClick, onEdit, onRotate, onDelete }: CASectionProps) {
|
||||
const isUser = caType === "user";
|
||||
const title = isUser ? "User Signing Key" : "Host Signing Key";
|
||||
const subtitle = isUser
|
||||
? "Signs SSH user certificates so users can authenticate to servers."
|
||||
: "Signs SSH host certificates so clients can verify server identity.";
|
||||
const Icon = isUser ? User : Server;
|
||||
const isSystem = !!ca?.is_system;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="w-4 h-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
{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>
|
||||
|
||||
{ca ? (
|
||||
<>
|
||||
<CADetailCard ca={ca} onEdit={onEdit} onRotate={onRotate} onDelete={onDelete} />
|
||||
{/* When only a system CA is present, offer to generate a managed replacement */}
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<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">{subtitle}</p>
|
||||
<Button onClick={() => onCreateClick(caType)} size="sm" variant="outline">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Generate {title}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Page ────────────────────────────────────────────────────────────────
|
||||
import { CASection } from "./ca/CASection";
|
||||
import {
|
||||
CreateCADialog,
|
||||
CreateCAForm,
|
||||
EditCADialog,
|
||||
EditCAForm,
|
||||
RotateCADialog,
|
||||
DeleteCADialog,
|
||||
} from "./ca/CADialogs";
|
||||
|
||||
export default function CAsPage() {
|
||||
const params = useParams<{ orgId?: string }>();
|
||||
@@ -303,44 +28,48 @@ export default function CAsPage() {
|
||||
const [cas, setCAs] = useState<OrgCA[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Create CA dialog
|
||||
// ── Create dialog ──────────────────────────────────────────────────────────
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [createCaType, setCreateCaType] = useState<"user" | "host">("user");
|
||||
const [createForm, setCreateForm] = useState({
|
||||
const [createForm, setCreateForm] = useState<CreateCAForm>({
|
||||
name: "",
|
||||
description: "",
|
||||
key_type: "ed25519" as "ed25519" | "rsa" | "ecdsa",
|
||||
key_type: "ed25519",
|
||||
default_cert_validity_hours: 8,
|
||||
max_cert_validity_hours: 720,
|
||||
});
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
// Edit CA dialog
|
||||
// ── Edit dialog ────────────────────────────────────────────────────────────
|
||||
const [editingCA, setEditingCA] = useState<OrgCA | null>(null);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [editFormData, setEditFormData] = useState({
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [editForm, setEditForm] = useState<EditCAForm>({
|
||||
default_cert_validity_hours: 1,
|
||||
max_cert_validity_hours: 24,
|
||||
});
|
||||
const [isEditSaving, setIsEditSaving] = useState(false);
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
|
||||
// Rotate CA dialog
|
||||
// ── Rotate dialog ──────────────────────────────────────────────────────────
|
||||
const [rotatingCA, setRotatingCA] = useState<OrgCA | null>(null);
|
||||
const [isRotateDialogOpen, setIsRotateDialogOpen] = useState(false);
|
||||
const [isRotateOpen, setIsRotateOpen] = useState(false);
|
||||
const [rotateKeyType, setRotateKeyType] = useState<"ed25519" | "rsa" | "ecdsa">("ed25519");
|
||||
const [rotateReason, setRotateReason] = useState("");
|
||||
const [isRotating, setIsRotating] = useState(false);
|
||||
const [rotateError, setRotateError] = useState<string | null>(null);
|
||||
|
||||
// Delete CA dialog
|
||||
// ── Delete dialog ──────────────────────────────────────────────────────────
|
||||
const [deletingCA, setDeletingCA] = useState<OrgCA | null>(null);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// ── Load CAs ───────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!orgId) { setIsLoading(false); return; }
|
||||
if (!orgId) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -348,7 +77,11 @@ export default function CAsPage() {
|
||||
setCAs(data.cas);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.code === 403) {
|
||||
toast({ variant: "destructive", title: "Access denied", description: "Admin or owner role required." });
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Access denied",
|
||||
description: "Admin or owner role required.",
|
||||
});
|
||||
} else {
|
||||
toast({ variant: "destructive", title: "Failed to load CAs" });
|
||||
}
|
||||
@@ -361,6 +94,7 @@ export default function CAsPage() {
|
||||
const userCA = cas.find((c) => c.ca_type === "user") ?? null;
|
||||
const hostCA = cas.find((c) => c.ca_type === "host") ?? null;
|
||||
|
||||
// ── Handlers: Create ───────────────────────────────────────────────────────
|
||||
const handleOpenCreate = (caType: "user" | "host") => {
|
||||
setCreateCaType(caType);
|
||||
setCreateForm({
|
||||
@@ -376,12 +110,17 @@ export default function CAsPage() {
|
||||
|
||||
const handleCreateCA = async () => {
|
||||
if (!orgId) return;
|
||||
if (!createForm.name.trim()) { setCreateError("Name is required"); return; }
|
||||
if (!createForm.name.trim()) {
|
||||
setCreateError("Name is required");
|
||||
return;
|
||||
}
|
||||
if (createForm.default_cert_validity_hours <= 0 || createForm.max_cert_validity_hours <= 0) {
|
||||
setCreateError("Validity hours must be greater than 0"); return;
|
||||
setCreateError("Validity hours must be greater than 0");
|
||||
return;
|
||||
}
|
||||
if (createForm.default_cert_validity_hours > createForm.max_cert_validity_hours) {
|
||||
setCreateError("Default validity must be ≤ maximum validity"); return;
|
||||
setCreateError("Default validity must be ≤ maximum validity");
|
||||
return;
|
||||
}
|
||||
setIsCreating(true);
|
||||
setCreateError(null);
|
||||
@@ -401,39 +140,40 @@ export default function CAsPage() {
|
||||
description: result.ca.name,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) {
|
||||
setCreateError(err.message);
|
||||
} else {
|
||||
setCreateError("Failed to create CA — please try again");
|
||||
}
|
||||
setCreateError(
|
||||
err instanceof ApiError ? err.message : "Failed to create CA — please try again",
|
||||
);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Handlers: Edit ─────────────────────────────────────────────────────────
|
||||
const handleEditCA = (ca: OrgCA) => {
|
||||
setEditingCA(ca);
|
||||
setEditFormData({
|
||||
setEditForm({
|
||||
default_cert_validity_hours: ca.default_cert_validity_hours,
|
||||
max_cert_validity_hours: ca.max_cert_validity_hours,
|
||||
});
|
||||
setEditError(null);
|
||||
setIsEditDialogOpen(true);
|
||||
setIsEditOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveCA = async () => {
|
||||
if (!orgId || !editingCA) return;
|
||||
if (editFormData.default_cert_validity_hours <= 0 || editFormData.max_cert_validity_hours <= 0) {
|
||||
setEditError("Validity hours must be greater than 0"); return;
|
||||
if (editForm.default_cert_validity_hours <= 0 || editForm.max_cert_validity_hours <= 0) {
|
||||
setEditError("Validity hours must be greater than 0");
|
||||
return;
|
||||
}
|
||||
if (editFormData.default_cert_validity_hours > editFormData.max_cert_validity_hours) {
|
||||
setEditError("Default validity must be less than or equal to maximum validity"); return;
|
||||
if (editForm.default_cert_validity_hours > editForm.max_cert_validity_hours) {
|
||||
setEditError("Default validity must be less than or equal to maximum validity");
|
||||
return;
|
||||
}
|
||||
setIsEditSaving(true);
|
||||
try {
|
||||
const updated = await api.organizations.updateCA(orgId, editingCA.id, editFormData);
|
||||
const updated = await api.organizations.updateCA(orgId, editingCA.id, editForm);
|
||||
setCAs(cas.map((ca) => (ca.id === editingCA.id ? updated.ca : ca)));
|
||||
setIsEditDialogOpen(false);
|
||||
setIsEditOpen(false);
|
||||
setEditingCA(null);
|
||||
toast({ title: "CA configuration updated" });
|
||||
} catch (err) {
|
||||
@@ -443,13 +183,13 @@ export default function CAsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ── Rotate handlers ──
|
||||
// ── Handlers: Rotate ───────────────────────────────────────────────────────
|
||||
const handleRotateCA = (ca: OrgCA) => {
|
||||
setRotatingCA(ca);
|
||||
setRotateKeyType((ca.key_type as "ed25519" | "rsa" | "ecdsa") || "ed25519");
|
||||
setRotateReason("");
|
||||
setRotateError(null);
|
||||
setIsRotateDialogOpen(true);
|
||||
setIsRotateOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmRotate = async () => {
|
||||
@@ -462,7 +202,7 @@ export default function CAsPage() {
|
||||
reason: rotateReason.trim() || undefined,
|
||||
});
|
||||
setCAs(cas.map((ca) => (ca.id === rotatingCA.id ? result.ca : ca)));
|
||||
setIsRotateDialogOpen(false);
|
||||
setIsRotateOpen(false);
|
||||
setRotatingCA(null);
|
||||
toast({
|
||||
title: "CA key rotated successfully",
|
||||
@@ -475,10 +215,10 @@ export default function CAsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// ── Delete handlers ──
|
||||
// ── Handlers: Delete ───────────────────────────────────────────────────────
|
||||
const handleDeleteCA = (ca: OrgCA) => {
|
||||
setDeletingCA(ca);
|
||||
setIsDeleteDialogOpen(true);
|
||||
setIsDeleteOpen(true);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async () => {
|
||||
@@ -487,299 +227,145 @@ export default function CAsPage() {
|
||||
try {
|
||||
await api.organizations.deleteCA(orgId, deletingCA.id);
|
||||
setCAs(cas.filter((ca) => ca.id !== deletingCA.id));
|
||||
setIsDeleteDialogOpen(false);
|
||||
setIsDeleteOpen(false);
|
||||
setDeletingCA(null);
|
||||
toast({ title: "CA deleted", description: "Existing certificates remain valid until they expire." });
|
||||
toast({
|
||||
title: "CA deleted",
|
||||
description: "Existing certificates remain valid until they expire.",
|
||||
});
|
||||
} catch (err) {
|
||||
toast({ variant: "destructive", title: "Failed to delete CA", description: err instanceof ApiError ? err.message : "" });
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Failed to delete CA",
|
||||
description: err instanceof ApiError ? err.message : "",
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}; return (
|
||||
};
|
||||
|
||||
// Shared section event props
|
||||
const sectionProps = {
|
||||
onCreateClick: handleOpenCreate,
|
||||
onEdit: handleEditCA,
|
||||
onRotate: handleRotateCA,
|
||||
onDelete: handleDeleteCA,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
{/* Page header */}
|
||||
<div className="page-header">
|
||||
<div className="flex items-start gap-3">
|
||||
<Shield className="w-6 h-6 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<h1 className="page-title">Certificate Authorities</h1>
|
||||
<p className="page-description">
|
||||
Manage your organization's SSH certificate authorities and access controls
|
||||
Manage your organization's SSH CAs — sign user and host certificates to eliminate
|
||||
static <code>authorized_keys</code> files.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
<CASection caType="user" ca={userCA} onCreateClick={handleOpenCreate} onEdit={handleEditCA} onRotate={handleRotateCA} onDelete={handleDeleteCA} />
|
||||
<div className="border-t" />
|
||||
<CASection caType="host" ca={hostCA} onCreateClick={handleOpenCreate} onEdit={handleEditCA} onRotate={handleRotateCA} onDelete={handleDeleteCA} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Edit CA Dialog ── */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={(open) => { setIsEditDialogOpen(open); if (!open) setEditError(null); }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit CA Configuration</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update certificate validity settings for <strong>{editingCA?.name}</strong>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
{editError && (
|
||||
<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" />
|
||||
{editError}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default-validity">Default Certificate Validity (hours)</Label>
|
||||
<Input
|
||||
id="default-validity"
|
||||
type="number"
|
||||
min="1"
|
||||
value={editFormData.default_cert_validity_hours}
|
||||
onChange={(e) => setEditFormData({ ...editFormData, default_cert_validity_hours: parseInt(e.target.value) || 1 })}
|
||||
disabled={isEditSaving}
|
||||
/>
|
||||
<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={editFormData.max_cert_validity_hours}
|
||||
onChange={(e) => setEditFormData({ ...editFormData, max_cert_validity_hours: parseInt(e.target.value) || 1 })}
|
||||
disabled={isEditSaving}
|
||||
/>
|
||||
<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={() => setIsEditDialogOpen(false)} disabled={isEditSaving}>Cancel</Button>
|
||||
<Button onClick={handleSaveCA} disabled={isEditSaving}>
|
||||
{isEditSaving && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
Save Changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Create CA Dialog ── */}
|
||||
<Dialog open={isCreateOpen} onOpenChange={(open) => { setIsCreateOpen(open); if (!open) setCreateError(null); }}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{createCaType === "user" ? <User className="w-5 h-5" /> : <Server className="w-5 h-5" />}
|
||||
Generate {createCaType === "user" ? "User" : "Host"} Signing Key
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{createCaType === "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">
|
||||
{createError && (
|
||||
<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>{createError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ca-name">Name <span className="text-destructive">*</span></Label>
|
||||
<Input
|
||||
id="ca-name"
|
||||
placeholder={createCaType === "user" ? "User CA" : "Host CA"}
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ca-description">Description</Label>
|
||||
<Input
|
||||
id="ca-description"
|
||||
placeholder="Optional description"
|
||||
value={createForm.description}
|
||||
onChange={(e) => setCreateForm({ ...createForm, description: e.target.value })}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ca-key-type">Key Algorithm</Label>
|
||||
<Select
|
||||
value={createForm.key_type}
|
||||
onValueChange={(v) => setCreateForm({ ...createForm, key_type: v as "ed25519" | "rsa" | "ecdsa" })}
|
||||
disabled={isCreating}
|
||||
>
|
||||
<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={createForm.default_cert_validity_hours}
|
||||
onChange={(e) => setCreateForm({ ...createForm, default_cert_validity_hours: parseInt(e.target.value) || 1 })}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</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={createForm.max_cert_validity_hours}
|
||||
onChange={(e) => setCreateForm({ ...createForm, max_cert_validity_hours: parseInt(e.target.value) || 1 })}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCreateOpen(false)} disabled={isCreating}>Cancel</Button>
|
||||
<Button onClick={handleCreateCA} disabled={isCreating}>
|
||||
{isCreating ? (
|
||||
<><Loader2 className="w-4 h-4 mr-2 animate-spin" />Generating key…</>
|
||||
<Tabs defaultValue="user" className="space-y-4">
|
||||
<TabsList className="h-auto gap-1">
|
||||
<TabsTrigger value="user" className="flex items-center gap-2 px-4 py-2">
|
||||
<User className="w-4 h-4" />
|
||||
<span>User CA</span>
|
||||
{userCA ? (
|
||||
userCA.is_system ? (
|
||||
<Badge variant="secondary" className="text-xs ml-1">System</Badge>
|
||||
) : (
|
||||
<><Shield className="w-4 h-4 mr-2" />Generate Key</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ── Rotate CA Dialog ── */}
|
||||
<Dialog open={isRotateDialogOpen} onOpenChange={(open) => { setIsRotateDialogOpen(open); if (!open) setRotateError(null); }}>
|
||||
<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>{rotatingCA?.name}</strong>.
|
||||
Previously-issued certificates remain valid until they expire, but all new
|
||||
certificates will be signed with the new key. You must update
|
||||
{rotatingCA?.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">
|
||||
{rotateError && (
|
||||
<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>{rotateError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rotatingCA && (
|
||||
<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">{rotatingCA.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={rotateKeyType}
|
||||
onValueChange={(v) => setRotateKeyType(v as "ed25519" | "rsa" | "ecdsa")}
|
||||
disabled={isRotating}
|
||||
>
|
||||
<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={rotateReason}
|
||||
onChange={(e) => setRotateReason(e.target.value)}
|
||||
disabled={isRotating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsRotateDialogOpen(false)} disabled={isRotating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleConfirmRotate} disabled={isRotating} variant="destructive">
|
||||
{isRotating ? (
|
||||
<><Loader2 className="w-4 h-4 mr-2 animate-spin" />Rotating…</>
|
||||
<Badge className="bg-green-500/10 text-green-700 border-0 text-xs ml-1">
|
||||
Active
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
<><RefreshCw className="w-4 h-4 mr-2" />Rotate Key</>
|
||||
<Badge variant="outline" className="text-xs ml-1 text-muted-foreground">
|
||||
Not set
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TabsTrigger>
|
||||
|
||||
{/* ── Delete CA Confirmation ── */}
|
||||
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Certificate Authority?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently deactivate <strong>{deletingCA?.name}</strong>.
|
||||
No new certificates can be signed with this CA after deletion.
|
||||
Existing certificates remain valid until they expire.
|
||||
{deletingCA?.active_certs ? (
|
||||
<span className="block mt-2 font-semibold text-amber-600 dark:text-amber-400">
|
||||
⚠ This CA has {deletingCA.active_certs} active certificate{deletingCA.active_certs !== 1 ? "s" : ""}.
|
||||
</span>
|
||||
) : null}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
Delete CA
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<TabsTrigger value="host" className="flex items-center gap-2 px-4 py-2">
|
||||
<Server className="w-4 h-4" />
|
||||
<span>Host CA</span>
|
||||
{hostCA ? (
|
||||
hostCA.is_system ? (
|
||||
<Badge variant="secondary" className="text-xs ml-1">System</Badge>
|
||||
) : (
|
||||
<Badge className="bg-green-500/10 text-green-700 border-0 text-xs ml-1">
|
||||
Active
|
||||
</Badge>
|
||||
)
|
||||
) : (
|
||||
<Badge variant="outline" className="text-xs ml-1 text-muted-foreground">
|
||||
Not set
|
||||
</Badge>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="user" className="mt-0">
|
||||
<CASection caType="user" ca={userCA} {...sectionProps} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="host" className="mt-0">
|
||||
<CASection caType="host" ca={hostCA} {...sectionProps} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
{/* ── Dialogs ─────────────────────────────────────────────────────────── */}
|
||||
<CreateCADialog
|
||||
open={isCreateOpen}
|
||||
onOpenChange={(o) => { setIsCreateOpen(o); if (!o) setCreateError(null); }}
|
||||
caType={createCaType}
|
||||
form={createForm}
|
||||
onFormChange={setCreateForm}
|
||||
error={createError}
|
||||
isLoading={isCreating}
|
||||
onSubmit={handleCreateCA}
|
||||
/>
|
||||
|
||||
<EditCADialog
|
||||
open={isEditOpen}
|
||||
onOpenChange={(o) => { setIsEditOpen(o); if (!o) setEditError(null); }}
|
||||
ca={editingCA}
|
||||
form={editForm}
|
||||
onFormChange={setEditForm}
|
||||
error={editError}
|
||||
isLoading={isEditSaving}
|
||||
onSubmit={handleSaveCA}
|
||||
/>
|
||||
|
||||
<RotateCADialog
|
||||
open={isRotateOpen}
|
||||
onOpenChange={(o) => { setIsRotateOpen(o); if (!o) setRotateError(null); }}
|
||||
ca={rotatingCA}
|
||||
keyType={rotateKeyType}
|
||||
onKeyTypeChange={setRotateKeyType}
|
||||
reason={rotateReason}
|
||||
onReasonChange={setRotateReason}
|
||||
error={rotateError}
|
||||
isLoading={isRotating}
|
||||
onSubmit={handleConfirmRotate}
|
||||
/>
|
||||
|
||||
<DeleteCADialog
|
||||
open={isDeleteOpen}
|
||||
onOpenChange={setIsDeleteOpen}
|
||||
ca={deletingCA}
|
||||
isLoading={isDeleting}
|
||||
onConfirm={handleConfirmDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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@<principal></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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user