mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 22:21:30 +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();
|
||||
|
||||
Reference in New Issue
Block a user