feat(m27): certificate export (PEM/PKCS#12) and S/MIME EKU support

Add certificate export in PEM (JSON or file download) and PKCS#12 formats.
Private keys are never included — they stay on agents. Add EKU-aware
issuance threading profile EKUs (serverAuth, clientAuth, codeSigning,
emailProtection, timeStamping) through the full issuance pipeline. Fix
agent CSR SAN splitting for email addresses, adaptive KeyUsage flags for
S/MIME vs TLS, and a pre-existing generateID collision bug in deployment
job creation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shankar0123
2026-03-28 16:16:19 -04:00
parent 78c7bc16b0
commit a00bb349c4
26 changed files with 1354 additions and 53 deletions
+80
View File
@@ -11,6 +11,9 @@ import {
updateCertificate,
archiveCertificate,
revokeCertificate,
exportCertificatePEM,
downloadCertificatePEM,
exportCertificatePKCS12,
getAgents,
getAgent,
registerAgent,
@@ -798,4 +801,81 @@ describe('API Client', () => {
expect(init.method).toBe('POST');
});
});
// ─── Certificate Export ────────────────────────────────
describe('Certificate Export', () => {
it('exportCertificatePEM fetches PEM data as JSON', async () => {
const pemResult = { cert_pem: 'CERT', chain_pem: 'CHAIN', full_pem: 'FULL' };
mockFetch.mockReturnValueOnce(mockJsonResponse(pemResult));
const result = await exportCertificatePEM('mc-1');
const [url] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/certificates/mc-1/export/pem');
expect(result.cert_pem).toBe('CERT');
expect(result.full_pem).toBe('FULL');
});
it('downloadCertificatePEM fetches blob with download=true', async () => {
const mockBlob = new Blob(['pem-data'], { type: 'application/x-pem-file' });
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 200,
blob: () => Promise.resolve(mockBlob),
} as Response)
);
const blob = await downloadCertificatePEM('mc-1');
const [url] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/certificates/mc-1/export/pem?download=true');
expect(blob).toBeInstanceOf(Blob);
});
it('downloadCertificatePEM includes auth header', async () => {
setApiKey('export-key');
const mockBlob = new Blob(['data']);
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 200,
blob: () => Promise.resolve(mockBlob),
} as Response)
);
await downloadCertificatePEM('mc-1');
const [, init] = mockFetch.mock.calls[0];
expect(init.headers['Authorization']).toBe('Bearer export-key');
});
it('exportCertificatePKCS12 sends POST with password', async () => {
const mockBlob = new Blob([new Uint8Array([0x30])], { type: 'application/x-pkcs12' });
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 200,
blob: () => Promise.resolve(mockBlob),
} as Response)
);
const blob = await exportCertificatePKCS12('mc-1', 'mypass');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/certificates/mc-1/export/pkcs12');
expect(init.method).toBe('POST');
const body = JSON.parse(init.body);
expect(body.password).toBe('mypass');
expect(blob).toBeInstanceOf(Blob);
});
it('exportCertificatePKCS12 uses empty password by default', async () => {
const mockBlob = new Blob([new Uint8Array([0x30])]);
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 200,
blob: () => Promise.resolve(mockBlob),
} as Response)
);
await exportCertificatePKCS12('mc-1');
const [, init] = mockFetch.mock.calls[0];
const body = JSON.parse(init.body);
expect(body.password).toBe('');
});
});
});
+27
View File
@@ -95,6 +95,33 @@ export const revokeCertificate = (id: string, reason: string) =>
body: JSON.stringify({ reason }),
});
// Certificate Export
export const exportCertificatePEM = (id: string) =>
fetchJSON<{ cert_pem: string; chain_pem: string; full_pem: string }>(`${BASE}/certificates/${id}/export/pem`);
export const downloadCertificatePEM = (id: string) => {
const headers: Record<string, string> = {};
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
return fetch(`${BASE}/certificates/${id}/export/pem?download=true`, { headers })
.then(r => {
if (!r.ok) throw new Error('Export failed');
return r.blob();
});
};
export const exportCertificatePKCS12 = (id: string, password: string = '') => {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
return fetch(`${BASE}/certificates/${id}/export/pkcs12`, {
method: 'POST',
headers,
body: JSON.stringify({ password }),
}).then(r => {
if (!r.ok) throw new Error('Export failed');
return r.blob();
});
};
// Agents
export const getAgents = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
+85 -1
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, revokeCertificate, updateCertificate, getTargets, getJobs, getPolicies, getProfiles } from '../api/client';
import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, revokeCertificate, updateCertificate, getTargets, getJobs, getPolicies, getProfiles, downloadCertificatePEM, exportCertificatePKCS12 } from '../api/client';
import { REVOCATION_REASONS } from '../api/types';
import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge';
@@ -226,6 +226,9 @@ export default function CertificateDetailPage() {
const [deployTargetId, setDeployTargetId] = useState('');
const [showRevoke, setShowRevoke] = useState(false);
const [revokeReason, setRevokeReason] = useState('unspecified');
const [showExport, setShowExport] = useState(false);
const [pkcs12Password, setPkcs12Password] = useState('');
const [exporting, setExporting] = useState(false);
const { data: cert, isLoading, error, refetch } = useQuery({
queryKey: ['certificate', id],
@@ -280,6 +283,42 @@ export default function CertificateDetailPage() {
},
});
const handleExportPEM = async () => {
setExporting(true);
try {
const blob = await downloadCertificatePEM(id!);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${cert?.common_name || id}.pem`;
a.click();
URL.revokeObjectURL(url);
} catch (err) {
alert(`Export failed: ${err instanceof Error ? err.message : err}`);
} finally {
setExporting(false);
}
};
const handleExportPKCS12 = async () => {
setExporting(true);
try {
const blob = await exportCertificatePKCS12(id!, pkcs12Password);
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${cert?.common_name || id}.p12`;
a.click();
URL.revokeObjectURL(url);
setShowExport(false);
setPkcs12Password('');
} catch (err) {
alert(`Export failed: ${err instanceof Error ? err.message : err}`);
} finally {
setExporting(false);
}
};
if (isLoading) {
return (
<>
@@ -313,6 +352,19 @@ export default function CertificateDetailPage() {
<button onClick={() => navigate('/certificates')} className="btn btn-ghost text-xs">
Back
</button>
<button
onClick={handleExportPEM}
disabled={exporting}
className="btn btn-ghost text-xs border border-surface-border disabled:opacity-50"
>
{exporting ? 'Exporting...' : 'Export PEM'}
</button>
<button
onClick={() => setShowExport(true)}
className="btn btn-ghost text-xs border border-surface-border"
>
Export PKCS#12
</button>
<button
onClick={() => setShowDeploy(true)}
disabled={isArchived || isRevoked}
@@ -546,6 +598,38 @@ export default function CertificateDetailPage() {
</div>
)}
{/* PKCS#12 Export Modal */}
{showExport && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setShowExport(false)}>
<div className="bg-surface border border-surface-border rounded p-6 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-ink mb-2">Export PKCS#12</h2>
<p className="text-sm text-ink-muted mb-4">
Downloads a .p12 file containing the certificate chain. Private keys are not included (they remain on the agent).
</p>
<label className="text-xs text-ink-muted block mb-2">Password (optional)</label>
<input
type="password"
value={pkcs12Password}
onChange={e => setPkcs12Password(e.target.value)}
placeholder="Leave empty for no encryption"
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink mb-4 focus:outline-none focus:border-brand-400"
/>
<div className="flex justify-end gap-3">
<button onClick={() => { setShowExport(false); setPkcs12Password(''); }} className="btn btn-ghost text-sm">
Cancel
</button>
<button
onClick={handleExportPKCS12}
disabled={exporting}
className="btn btn-primary text-sm disabled:opacity-50"
>
{exporting ? 'Exporting...' : 'Download .p12'}
</button>
</div>
</div>
</div>
)}
{/* Revoke Modal */}
{showRevoke && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setShowRevoke(false)}>