mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 21:08:52 +00:00
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:
@@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
Reference in New Issue
Block a user