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();