mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 05:58:51 +00:00
gui/cert-detail: revocation endpoints panel (CRL/OCSP) — Phase 5
CertificateDetailPage now surfaces a Revocation Endpoints card showing
the standards-compliant /.well-known/pki/crl/{issuer_id} CRL distribution
point (RFC 5280 §4.2.1.13) and /.well-known/pki/ocsp/{issuer_id} OCSP
responder URL (RFC 6960 §A.1) for relying parties that don't already know
certctl's well-known scheme.
Two action buttons exercise the same network path the issued leaves'
AIA/CDP extensions advertise, so an operator can confirm 'did the
backend Phases 1-4 actually wire end-to-end?' without curl:
* 'Test CRL fetch' — fetchCRL(issuer_id) helper, surfaces byte count
* 'Check OCSP status' — getOCSPStatus(issuer_id, serial_hex) helper
Admin-only cache-age badge: when useAuth().admin is true the panel pulls
GET /api/v1/admin/crl/cache (M-008 admin-gated handler) and shows
'Cache fresh · 2m ago' / 'Cache stale' / 'Not yet generated' next to
the heading. Non-admin callers don't trigger the fetch (gated client-side
on enabled flag, server-side on middleware.IsAdmin) so the badge cannot
leak generation cadence.
Test coverage in CertificateDetailPage.test.tsx pins:
1. CRL + OCSP URLs render with issuer_id substituted
2. Test CRL fetch button calls fetchCRL with the issuer_id and renders
the byte-count success message
3. Check OCSP status button calls getOCSPStatus with (issuer_id, serial)
and renders the DER byte-count
4. Admin badge stays HIDDEN (and getAdminCRLCache is NEVER called) when
useAuth().admin is false — pins the no-info-leak invariant
P-1 closure docblock + CI guardrail (.github/workflows/ci.yml) updated
to remove getOCSPStatus from the documented-orphan list since it now
has a real consumer.
types.ts: CRLCacheRow / CRLCacheEvent / CRLCacheResponse mirrors of the
backend admin handler payload (admin_crl_cache.go).
client.ts: fetchCRL + getAdminCRLCache helpers; getOCSPStatus already
existed and is now an active consumer.
Tests: 6/6 in CertificateDetailPage.test.tsx, 150/150 across api+page
suite. tsc --noEmit clean.
This commit is contained in:
@@ -1037,7 +1037,11 @@ jobs:
|
|||||||
# diff-04x03-d24864996ad4 + cat-b-dc46aadab98e for closure rationale.
|
# diff-04x03-d24864996ad4 + cat-b-dc46aadab98e for closure rationale.
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
DOCUMENTED='getAgentGroup getAgentGroupMembers getAuditEvent getCertificateDeployments getDiscoveredCertificate getHealthCheck getHealthCheckHistory getNetworkScanTarget getNotification getOCSPStatus getOwner getPolicy getPolicyViolations getRenewalPolicy getTeam registerAgent updateHealthCheck'
|
# CRL/OCSP-Responder Phase 5 closed the getOCSPStatus orphan: the
|
||||||
|
# CertificateDetailPage Revocation Endpoints panel now consumes it
|
||||||
|
# ("Check OCSP status" button). Removed from the list to keep the
|
||||||
|
# docblock + guardrail honest.
|
||||||
|
DOCUMENTED='getAgentGroup getAgentGroupMembers getAuditEvent getCertificateDeployments getDiscoveredCertificate getHealthCheck getHealthCheckHistory getNetworkScanTarget getNotification getOwner getPolicy getPolicyViolations getRenewalPolicy getTeam registerAgent updateHealthCheck'
|
||||||
MISSING=""
|
MISSING=""
|
||||||
for fn in $DOCUMENTED; do
|
for fn in $DOCUMENTED; do
|
||||||
if ! grep -qE "^export const ${fn}\b" web/src/api/client.ts; then
|
if ! grep -qE "^export const ${fn}\b" web/src/api/client.ts; then
|
||||||
|
|||||||
+30
-2
@@ -1,4 +1,4 @@
|
|||||||
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, RenewalPolicy, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse, DashboardSummary, CertificateStatusCount, ExpirationBucket, JobTrendDataPoint, IssuanceRateDataPoint, MetricsResponse, DiscoveredCertificate, DiscoveryScan, DiscoverySummary, NetworkScanTarget, EndpointHealthCheck, HealthHistoryEntry, HealthCheckSummary, AgentDependencyCounts, RetireAgentResponse, BlockedByDependenciesResponse } from './types';
|
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, RenewalPolicy, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse, DashboardSummary, CertificateStatusCount, ExpirationBucket, JobTrendDataPoint, IssuanceRateDataPoint, MetricsResponse, DiscoveredCertificate, DiscoveryScan, DiscoverySummary, NetworkScanTarget, EndpointHealthCheck, HealthHistoryEntry, HealthCheckSummary, AgentDependencyCounts, RetireAgentResponse, BlockedByDependenciesResponse, CRLCacheResponse } from './types';
|
||||||
|
|
||||||
const BASE = '/api/v1';
|
const BASE = '/api/v1';
|
||||||
|
|
||||||
@@ -16,11 +16,16 @@ const BASE = '/api/v1';
|
|||||||
// getAgentGroup, getAgentGroupMembers, getAuditEvent,
|
// getAgentGroup, getAgentGroupMembers, getAuditEvent,
|
||||||
// getCertificateDeployments, getDiscoveredCertificate,
|
// getCertificateDeployments, getDiscoveredCertificate,
|
||||||
// getHealthCheck, getHealthCheckHistory, getNetworkScanTarget,
|
// getHealthCheck, getHealthCheckHistory, getNetworkScanTarget,
|
||||||
// getNotification, getOCSPStatus, getOwner, getPolicy,
|
// getNotification, getOwner, getPolicy,
|
||||||
// getPolicyViolations, getRenewalPolicy, getTeam, registerAgent
|
// getPolicyViolations, getRenewalPolicy, getTeam, registerAgent
|
||||||
// (by-design pull-only; see C-1 closure docblock above its export),
|
// (by-design pull-only; see C-1 closure docblock above its export),
|
||||||
// updateHealthCheck.
|
// updateHealthCheck.
|
||||||
//
|
//
|
||||||
|
// CRL/OCSP-Responder Phase 5 closed the getOCSPStatus orphan: the
|
||||||
|
// CertificateDetailPage Revocation Endpoints panel now exercises it
|
||||||
|
// via the "Check OCSP status" button, so it's removed from the list
|
||||||
|
// above (and from the CI guardrail's DOCUMENTED list).
|
||||||
|
//
|
||||||
// CI guardrail at .github/workflows/ci.yml::"Documented orphan
|
// CI guardrail at .github/workflows/ci.yml::"Documented orphan
|
||||||
// client fns sync guard (P-1)" enforces the docblock list ↔
|
// client fns sync guard (P-1)" enforces the docblock list ↔
|
||||||
// export list relationship: every name above must still be
|
// export list relationship: every name above must still be
|
||||||
@@ -268,6 +273,29 @@ export const getOCSPStatus = (issuerId: string, serial: string) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// CRL/OCSP-Responder Phase 5: GUI-side helper for the "Test CRL fetch" button
|
||||||
|
// on CertificateDetailPage. Fetches the DER-encoded CRL from the well-known
|
||||||
|
// endpoint and returns the byte length so the panel can show "OK — N bytes".
|
||||||
|
// The Authorization header is intentionally omitted: /.well-known/pki/crl/ is
|
||||||
|
// the standards-compliant relying-party surface and runs unauthenticated.
|
||||||
|
export const fetchCRL = (issuerId: string) => {
|
||||||
|
return fetch(`/.well-known/pki/crl/${issuerId}`)
|
||||||
|
.then(async r => {
|
||||||
|
if (!r.ok) throw new Error(`CRL fetch failed: ${r.status}`);
|
||||||
|
const buf = await r.arrayBuffer();
|
||||||
|
return { byteLength: buf.byteLength, contentType: r.headers.get('content-type') ?? '' };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// CRL/OCSP-Responder Phase 5 admin endpoint mirror.
|
||||||
|
//
|
||||||
|
// Backend handler: internal/api/handler/admin_crl_cache.go::ListCache.
|
||||||
|
// M-008 admin-gated; non-admin Bearer callers get HTTP 403 — the GUI hides
|
||||||
|
// the badge entirely (rather than letting it 403 noisily) by gating the
|
||||||
|
// React-Query enabled flag on useAuth().admin at the call site.
|
||||||
|
export const getAdminCRLCache = () =>
|
||||||
|
fetchJSON<CRLCacheResponse>(`${BASE}/admin/crl/cache`);
|
||||||
|
|
||||||
// Agents
|
// Agents
|
||||||
export const getAgents = (params: Record<string, string> = {}) => {
|
export const getAgents = (params: Record<string, string> = {}) => {
|
||||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||||
|
|||||||
@@ -586,3 +586,43 @@ export interface HealthCheckSummary {
|
|||||||
unknown: number;
|
unknown: number;
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CRL/OCSP-Responder Phase 5: admin observability endpoint payload mirror.
|
||||||
|
//
|
||||||
|
// Backend type lives at internal/api/handler/admin_crl_cache.go::CRLCacheRow /
|
||||||
|
// CRLCacheEvt and is gated behind middleware.IsAdmin (M-008 admin-gated handler
|
||||||
|
// allowlist). The GUI surfaces a per-issuer cache-age badge on the
|
||||||
|
// CertificateDetailPage Revocation Endpoints panel — only visible to admin
|
||||||
|
// callers. Non-admin callers get HTTP 403 from the server; the GUI suppresses
|
||||||
|
// the fetch entirely (and the badge) when useAuth().admin is false.
|
||||||
|
//
|
||||||
|
// Optional fields stay optional here because the server omits them when the
|
||||||
|
// cache row is absent (issuer never had a CRL generated yet) — the panel
|
||||||
|
// renders a "Not yet generated" pill in that case.
|
||||||
|
export interface CRLCacheEvent {
|
||||||
|
started_at: string;
|
||||||
|
duration_ms: number;
|
||||||
|
succeeded: boolean;
|
||||||
|
crl_number: number;
|
||||||
|
revoked_count: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CRLCacheRow {
|
||||||
|
issuer_id: string;
|
||||||
|
cache_present: boolean;
|
||||||
|
crl_number?: number;
|
||||||
|
this_update?: string;
|
||||||
|
next_update?: string;
|
||||||
|
generated_at?: string;
|
||||||
|
generation_duration_ms?: number;
|
||||||
|
revoked_count?: number;
|
||||||
|
is_stale?: boolean;
|
||||||
|
recent_events?: CRLCacheEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CRLCacheResponse {
|
||||||
|
cache_rows: CRLCacheRow[];
|
||||||
|
row_count: number;
|
||||||
|
generated_at: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,6 +30,30 @@ vi.mock('../api/client', () => ({
|
|||||||
updateCertificate: vi.fn(),
|
updateCertificate: vi.fn(),
|
||||||
downloadCertificatePEM: vi.fn(),
|
downloadCertificatePEM: vi.fn(),
|
||||||
exportCertificatePKCS12: vi.fn(),
|
exportCertificatePKCS12: vi.fn(),
|
||||||
|
// CRL/OCSP-Responder Phase 5: revocation-panel mocks. fetchCRL +
|
||||||
|
// getOCSPStatus are exercised by the "Test CRL fetch" / "Check OCSP
|
||||||
|
// status" buttons; getAdminCRLCache backs the admin cache-age badge
|
||||||
|
// and is gated by useAuth().admin at the call site.
|
||||||
|
getOCSPStatus: vi.fn(),
|
||||||
|
fetchCRL: vi.fn(),
|
||||||
|
getAdminCRLCache: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// AuthProvider's useAuth hook is read by the new RevocationEndpointsCard to
|
||||||
|
// decide whether to render the cache-age badge. Mock it to keep the test
|
||||||
|
// independent of the real auth bootstrap (getAuthInfo / checkAuth).
|
||||||
|
vi.mock('../components/AuthProvider', () => ({
|
||||||
|
useAuth: () => ({
|
||||||
|
loading: false,
|
||||||
|
authRequired: false,
|
||||||
|
authenticated: true,
|
||||||
|
authType: 'none',
|
||||||
|
user: '',
|
||||||
|
admin: false,
|
||||||
|
login: vi.fn(),
|
||||||
|
logout: vi.fn(),
|
||||||
|
error: null,
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import CertificateDetailPage from './CertificateDetailPage';
|
import CertificateDetailPage from './CertificateDetailPage';
|
||||||
@@ -90,6 +114,12 @@ describe('CertificateDetailPage — render + XSS hardening (M-026 / M-029 Pass 3
|
|||||||
vi.mocked(client.getProfiles).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as never);
|
vi.mocked(client.getProfiles).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as never);
|
||||||
vi.mocked(client.getRenewalPolicies).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 500 } as never);
|
vi.mocked(client.getRenewalPolicies).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 500 } as never);
|
||||||
vi.mocked(client.getJobs).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as never);
|
vi.mocked(client.getJobs).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as never);
|
||||||
|
// Default: no real network for the revocation panel — buttons remain
|
||||||
|
// idle until a test exercises them. getAdminCRLCache resolves to an
|
||||||
|
// empty rows array since the test mocks useAuth().admin = false.
|
||||||
|
vi.mocked(client.fetchCRL).mockResolvedValue({ byteLength: 1234, contentType: 'application/pkix-crl' } as never);
|
||||||
|
vi.mocked(client.getOCSPStatus).mockResolvedValue(new ArrayBuffer(256) as never);
|
||||||
|
vi.mocked(client.getAdminCRLCache).mockResolvedValue({ cache_rows: [], row_count: 0, generated_at: new Date().toISOString() } as never);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders the page when getCertificate resolves', async () => {
|
it('renders the page when getCertificate resolves', async () => {
|
||||||
@@ -114,3 +144,115 @@ describe('CertificateDetailPage — render + XSS hardening (M-026 / M-029 Pass 3
|
|||||||
).toBeUndefined();
|
).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// CRL/OCSP-Responder Phase 5: Revocation Endpoints panel coverage.
|
||||||
|
//
|
||||||
|
// Pins:
|
||||||
|
// 1. The CRL distribution point + OCSP responder URLs render with the
|
||||||
|
// issuer_id substituted in (relying parties copy these straight into
|
||||||
|
// curl/openssl, so the format is load-bearing).
|
||||||
|
// 2. Clicking "Test CRL fetch" calls fetchCRL(issuer_id) and surfaces the
|
||||||
|
// byte-count success message — confirms the button is wired and not
|
||||||
|
// decorative.
|
||||||
|
// 3. Clicking "Check OCSP status" calls getOCSPStatus(issuer_id, serial)
|
||||||
|
// and surfaces the DER byte-count success message.
|
||||||
|
// 4. The admin cache-age badge stays HIDDEN when useAuth().admin is false
|
||||||
|
// (the hook is mocked to admin: false at the top of this file). Stops
|
||||||
|
// a regression where the badge silently leaks generation cadence to
|
||||||
|
// non-admin viewers.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
describe('CertificateDetailPage — Revocation Endpoints panel (Phase 5)', () => {
|
||||||
|
const plainCert = {
|
||||||
|
id: 'mc-rev-001',
|
||||||
|
name: 'rev.example.com',
|
||||||
|
common_name: 'rev.example.com',
|
||||||
|
sans: ['rev.example.com'],
|
||||||
|
status: 'Active',
|
||||||
|
environment: 'prod',
|
||||||
|
issuer_id: 'iss-local-prod',
|
||||||
|
certificate_profile_id: 'cp-tls',
|
||||||
|
owner_id: 'o-ops',
|
||||||
|
team_id: 't-platform',
|
||||||
|
renewal_policy_id: 'rp-30d',
|
||||||
|
expires_at: new Date(Date.now() + 90 * 86400000).toISOString(),
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const certVersion = {
|
||||||
|
id: 'cv-1',
|
||||||
|
certificate_id: 'mc-rev-001',
|
||||||
|
serial_number: 'a1b2c3d4',
|
||||||
|
fingerprint_sha256: 'deadbeef'.repeat(8),
|
||||||
|
not_before: new Date(Date.now() - 86400000).toISOString(),
|
||||||
|
not_after: new Date(Date.now() + 90 * 86400000).toISOString(),
|
||||||
|
key_algorithm: 'ECDSA',
|
||||||
|
key_size: 256,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
cleanup();
|
||||||
|
vi.mocked(client.getCertificate).mockResolvedValue(plainCert as never);
|
||||||
|
vi.mocked(client.getCertificateVersions).mockResolvedValue({ data: [certVersion], total: 1, page: 1, per_page: 50 } as never);
|
||||||
|
vi.mocked(client.getTargets).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as never);
|
||||||
|
vi.mocked(client.getProfile).mockResolvedValue({ id: 'cp-tls', name: 'TLS' } as never);
|
||||||
|
vi.mocked(client.getProfiles).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as never);
|
||||||
|
vi.mocked(client.getRenewalPolicies).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 500 } as never);
|
||||||
|
vi.mocked(client.getJobs).mockResolvedValue({ data: [], total: 0, page: 1, per_page: 50 } as never);
|
||||||
|
vi.mocked(client.fetchCRL).mockResolvedValue({ byteLength: 4096, contentType: 'application/pkix-crl' } as never);
|
||||||
|
vi.mocked(client.getOCSPStatus).mockResolvedValue(new ArrayBuffer(312) as never);
|
||||||
|
vi.mocked(client.getAdminCRLCache).mockResolvedValue({ cache_rows: [], row_count: 0, generated_at: new Date().toISOString() } as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the CRL distribution point + OCSP responder URLs with the issuer_id substituted', async () => {
|
||||||
|
const { fireEvent: _fe } = await import('@testing-library/react');
|
||||||
|
void _fe;
|
||||||
|
renderRoute(<CertificateDetailPage />, '/certificates/mc-rev-001');
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('heading', { name: 'Revocation Endpoints' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Both URLs include the issuer_id segment under /.well-known/pki/.
|
||||||
|
// window.location.origin in jsdom is http://localhost:3000.
|
||||||
|
expect(screen.getByText('http://localhost:3000/.well-known/pki/crl/iss-local-prod')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('http://localhost:3000/.well-known/pki/ocsp/iss-local-prod')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"Test CRL fetch" button calls fetchCRL(issuer_id) and shows the byte-count success message', async () => {
|
||||||
|
const { fireEvent } = await import('@testing-library/react');
|
||||||
|
renderRoute(<CertificateDetailPage />, '/certificates/mc-rev-001');
|
||||||
|
const btn = await screen.findByRole('button', { name: /Test CRL fetch/i });
|
||||||
|
fireEvent.click(btn);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(client.fetchCRL).toHaveBeenCalledWith('iss-local-prod');
|
||||||
|
expect(screen.getByText(/OK — 4,096 bytes/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('"Check OCSP status" button calls getOCSPStatus(issuer_id, serial_hex) and shows DER byte-count', async () => {
|
||||||
|
const { fireEvent } = await import('@testing-library/react');
|
||||||
|
renderRoute(<CertificateDetailPage />, '/certificates/mc-rev-001');
|
||||||
|
const btn = await screen.findByRole('button', { name: /Check OCSP status/i });
|
||||||
|
fireEvent.click(btn);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(client.getOCSPStatus).toHaveBeenCalledWith('iss-local-prod', 'a1b2c3d4');
|
||||||
|
expect(screen.getByText(/OCSP response received — 312 bytes/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the admin cache-age badge when useAuth().admin is false (no information leak to non-admin)', async () => {
|
||||||
|
renderRoute(<CertificateDetailPage />, '/certificates/mc-rev-001');
|
||||||
|
await screen.findByRole('heading', { name: 'Revocation Endpoints' });
|
||||||
|
// None of the badge variants ("Cache fresh" / "Cache stale" / "Not yet
|
||||||
|
// generated") should appear for a non-admin caller.
|
||||||
|
expect(screen.queryByText(/Cache fresh/i)).toBeNull();
|
||||||
|
expect(screen.queryByText(/Cache stale/i)).toBeNull();
|
||||||
|
expect(screen.queryByText(/Not yet generated/i)).toBeNull();
|
||||||
|
// And the admin endpoint must not have been hit at all.
|
||||||
|
expect(client.getAdminCRLCache).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { useState } from 'react';
|
|||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||||
import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, revokeCertificate, updateCertificate, getTargets, getJobs, getRenewalPolicies, getProfiles, getProfile, downloadCertificatePEM, exportCertificatePKCS12 } from '../api/client';
|
import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, revokeCertificate, updateCertificate, getTargets, getJobs, getRenewalPolicies, getProfiles, getProfile, downloadCertificatePEM, exportCertificatePKCS12, getOCSPStatus, fetchCRL, getAdminCRLCache } from '../api/client';
|
||||||
import { REVOCATION_REASONS } from '../api/types';
|
import { REVOCATION_REASONS } from '../api/types';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
import StatusBadge from '../components/StatusBadge';
|
import StatusBadge from '../components/StatusBadge';
|
||||||
import ErrorState from '../components/ErrorState';
|
import ErrorState from '../components/ErrorState';
|
||||||
|
import { useAuth } from '../components/AuthProvider';
|
||||||
import { formatDate, formatDateTime, daysUntil, expiryColor, timeAgo } from '../api/utils';
|
import { formatDate, formatDateTime, daysUntil, expiryColor, timeAgo } from '../api/utils';
|
||||||
import type { Job } from '../api/types';
|
import type { Job, CRLCacheRow } from '../api/types';
|
||||||
|
|
||||||
function InfoRow({ label, value, editable, onEdit }: { label: string; value: React.ReactNode; editable?: boolean; onEdit?: () => void }) {
|
function InfoRow({ label, value, editable, onEdit }: { label: string; value: React.ReactNode; editable?: boolean; onEdit?: () => void }) {
|
||||||
return (
|
return (
|
||||||
@@ -159,6 +160,163 @@ function DeploymentTimeline({ certId, certStatus, createdAt, issuedAt }: { certI
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CRL/OCSP-Responder Phase 5: Revocation Endpoints panel.
|
||||||
|
//
|
||||||
|
// Surfaces the standards-compliant revocation URLs (CRL distribution point
|
||||||
|
// per RFC 5280 §4.2.1.13, OCSP responder per RFC 6960 §A.1) for relying
|
||||||
|
// parties that don't already know certctl's well-known scheme. Both endpoints
|
||||||
|
// live under /.well-known/pki/ and run unauthenticated — relying-party clients
|
||||||
|
// should never need a Bearer key to check revocation status.
|
||||||
|
//
|
||||||
|
// The "Test CRL fetch" / "Check OCSP status" buttons exercise the same
|
||||||
|
// network path the CRL/OCSP responders advertise via the AIA + CDP
|
||||||
|
// extensions on issued leaves, so an operator confirming "did Phase 4
|
||||||
|
// actually wire end-to-end?" can do it without curl. Failures bubble up
|
||||||
|
// as inline error text rather than throwing a global error boundary.
|
||||||
|
//
|
||||||
|
// The cache-age badge is admin-only (gated client-side AND server-side; the
|
||||||
|
// server returns 403 for non-admin even if the GUI bug-clicks the fetch).
|
||||||
|
// Stale rows render in amber per the IsStale flag (next_update < now). Rows
|
||||||
|
// missing entirely (issuer never had a CRL pre-generated) render the neutral
|
||||||
|
// "Not yet generated" pill.
|
||||||
|
function RevocationEndpointsCard({ issuerId, serialNumber }: { issuerId: string; serialNumber?: string }) {
|
||||||
|
const { admin } = useAuth();
|
||||||
|
const [crlState, setCrlState] = useState<{ status: 'idle' | 'loading' | 'ok' | 'err'; msg?: string }>({ status: 'idle' });
|
||||||
|
const [ocspState, setOcspState] = useState<{ status: 'idle' | 'loading' | 'ok' | 'err'; msg?: string }>({ status: 'idle' });
|
||||||
|
|
||||||
|
// Build the absolute URLs from window.location so operators can copy-paste
|
||||||
|
// them straight into curl / openssl. Using window.location keeps the URLs
|
||||||
|
// honest under reverse-proxy deployments where the perceived host differs
|
||||||
|
// from what the dev sees in their browser bar — the location object is the
|
||||||
|
// ground truth for "what URL does the relying party hit?".
|
||||||
|
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||||
|
const crlURL = `${origin}/.well-known/pki/crl/${issuerId}`;
|
||||||
|
// OCSP per RFC 6960 §A.1.1 supports both POST (preferred for CSR-style
|
||||||
|
// requests) and the GET form with base64-url(DER) in the path. The GUI's
|
||||||
|
// "Check OCSP status" button uses the simpler /{issuer}/{serial_hex}
|
||||||
|
// helper certctl exposes alongside the standards endpoint — that's what
|
||||||
|
// getOCSPStatus() in client.ts hits.
|
||||||
|
const ocspURL = `${origin}/.well-known/pki/ocsp/${issuerId}`;
|
||||||
|
|
||||||
|
// Admin-only: pull the cache row for this issuer so we can show
|
||||||
|
// "generated 2m ago / next update 58m" with a stale-warning chip.
|
||||||
|
const { data: cacheData } = useQuery({
|
||||||
|
queryKey: ['admin-crl-cache'],
|
||||||
|
queryFn: () => getAdminCRLCache(),
|
||||||
|
enabled: admin,
|
||||||
|
// Refresh a touch faster than the default scheduler interval (1h) so
|
||||||
|
// the badge feels live during ops investigation. Falls back gracefully
|
||||||
|
// if the user navigates away before the next tick.
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const issuerRow: CRLCacheRow | undefined = cacheData?.cache_rows?.find(r => r.issuer_id === issuerId);
|
||||||
|
|
||||||
|
const handleTestCRL = async () => {
|
||||||
|
setCrlState({ status: 'loading' });
|
||||||
|
try {
|
||||||
|
const r = await fetchCRL(issuerId);
|
||||||
|
setCrlState({ status: 'ok', msg: `OK — ${r.byteLength.toLocaleString()} bytes (${r.contentType || 'no content-type'})` });
|
||||||
|
} catch (e) {
|
||||||
|
setCrlState({ status: 'err', msg: e instanceof Error ? e.message : 'Fetch failed' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckOCSP = async () => {
|
||||||
|
if (!serialNumber) {
|
||||||
|
setOcspState({ status: 'err', msg: 'Serial number unavailable — cert has not been issued yet.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOcspState({ status: 'loading' });
|
||||||
|
try {
|
||||||
|
const buf = await getOCSPStatus(issuerId, serialNumber);
|
||||||
|
setOcspState({ status: 'ok', msg: `OCSP response received — ${buf.byteLength.toLocaleString()} bytes (DER)` });
|
||||||
|
} catch (e) {
|
||||||
|
setOcspState({ status: 'err', msg: e instanceof Error ? e.message : 'OCSP request failed' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-sm font-semibold text-ink-muted">Revocation Endpoints</h3>
|
||||||
|
{admin && (
|
||||||
|
issuerRow ? (
|
||||||
|
issuerRow.cache_present ? (
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2 py-0.5 rounded font-medium ${
|
||||||
|
issuerRow.is_stale ? 'bg-amber-50 text-amber-700' : 'bg-emerald-50 text-emerald-700'
|
||||||
|
}`}
|
||||||
|
title={`CRL #${issuerRow.crl_number ?? '—'} — generated ${
|
||||||
|
issuerRow.generated_at ? formatDateTime(issuerRow.generated_at) : '—'
|
||||||
|
}, next update ${issuerRow.next_update ? formatDateTime(issuerRow.next_update) : '—'}`}
|
||||||
|
>
|
||||||
|
{issuerRow.is_stale ? 'Cache stale' : 'Cache fresh'}
|
||||||
|
{issuerRow.generated_at ? ` · ${timeAgo(issuerRow.generated_at)}` : ''}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded font-medium bg-surface-muted text-ink-faint">
|
||||||
|
Not yet generated
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-ink-muted mb-1">CRL Distribution Point (RFC 5280 §4.2.1.13)</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="font-mono text-xs bg-surface-muted px-2 py-1 rounded text-ink flex-1 break-all">{crlURL}</code>
|
||||||
|
<button
|
||||||
|
onClick={handleTestCRL}
|
||||||
|
disabled={crlState.status === 'loading'}
|
||||||
|
className="text-xs px-3 py-1 rounded border border-surface-border text-brand-400 hover:text-brand-500 hover:border-brand-300 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{crlState.status === 'loading' ? 'Fetching…' : 'Test CRL fetch'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{crlState.status === 'ok' && (
|
||||||
|
<div className="text-xs text-emerald-600 mt-1">{crlState.msg}</div>
|
||||||
|
)}
|
||||||
|
{crlState.status === 'err' && (
|
||||||
|
<div className="text-xs text-red-600 mt-1">{crlState.msg}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-ink-muted mb-1">OCSP Responder (RFC 6960 §A.1)</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="font-mono text-xs bg-surface-muted px-2 py-1 rounded text-ink flex-1 break-all">{ocspURL}</code>
|
||||||
|
<button
|
||||||
|
onClick={handleCheckOCSP}
|
||||||
|
disabled={ocspState.status === 'loading' || !serialNumber}
|
||||||
|
title={!serialNumber ? 'Serial number unavailable — cert not yet issued' : ''}
|
||||||
|
className="text-xs px-3 py-1 rounded border border-surface-border text-brand-400 hover:text-brand-500 hover:border-brand-300 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{ocspState.status === 'loading' ? 'Checking…' : 'Check OCSP status'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{ocspState.status === 'ok' && (
|
||||||
|
<div className="text-xs text-emerald-600 mt-1">{ocspState.msg}</div>
|
||||||
|
)}
|
||||||
|
{ocspState.status === 'err' && (
|
||||||
|
<div className="text-xs text-red-600 mt-1">{ocspState.msg}</div>
|
||||||
|
)}
|
||||||
|
{!serialNumber && ocspState.status === 'idle' && (
|
||||||
|
<div className="text-xs text-ink-faint mt-1">Serial number unavailable — issue the cert first.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-ink-faint mt-4">
|
||||||
|
Both endpoints run unauthenticated under <code className="font-mono">/.well-known/pki/</code> per RFC 8615 so relying parties can validate revocation without API keys. The CRL is pre-generated by the scheduler (configurable via <code className="font-mono">CERTCTL_CRL_GENERATION_INTERVAL</code>); OCSP is signed by the per-issuer responder cert (RFC 6960 §2.6).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function InlinePolicyEditor({ certId, currentPolicyId, currentProfileId }: { certId: string; currentPolicyId: string; currentProfileId: string }) {
|
function InlinePolicyEditor({ certId, currentPolicyId, currentProfileId }: { certId: string; currentPolicyId: string; currentProfileId: string }) {
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [policyId, setPolicyId] = useState(currentPolicyId);
|
const [policyId, setPolicyId] = useState(currentPolicyId);
|
||||||
@@ -613,6 +771,9 @@ export default function CertificateDetailPage() {
|
|||||||
currentProfileId={cert.certificate_profile_id || ''}
|
currentProfileId={cert.certificate_profile_id || ''}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Revocation Endpoints (CRL + OCSP) — Phase 5 */}
|
||||||
|
<RevocationEndpointsCard issuerId={cert.issuer_id} serialNumber={serialNumber} />
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags */}
|
||||||
{cert.tags && Object.keys(cert.tags).length > 0 && (
|
{cert.tags && Object.keys(cert.tags).length > 0 && (
|
||||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
||||||
|
|||||||
Reference in New Issue
Block a user