mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 14:51:30 +00:00
feat(ocsp): pre-signed response cache + invalidate-on-revoke (Phase 2)
Production hardening II Phase 2 — closes the per-request live-signing
bottleneck for OCSP. Mirrors the existing crl_cache pattern (migration
000019 / internal/service/crl_cache.go) but per (issuer_id, serial_hex)
instead of per-issuer.
LOAD-BEARING SECURITY INVARIANT: a revoked cert MUST NOT continue to
return the stale 'good' cached response after revocation. The
RevocationSvc.RevokeCertificateWithActor flow now calls
OCSPResponseCacheService.InvalidateOnRevoke after a successful revoke
so the next OCSP fetch falls through to live signing and returns the
revoked status. Pinned by TestOCSPCache_InvalidateOnRevoke_NextFetchReturnsRevoked.
NEW migrations/000024_ocsp_response_cache.{up,down}.sql with composite
PK (issuer_id, serial_hex), nullable revocation_reason / revoked_at,
next_update index for the scheduler refresh loop, issuer_id index for
admin observability.
NEW internal/domain/ocsp_response_cache.go::OCSPResponseCacheEntry +
IsStale helper.
NEW internal/repository/postgres/ocsp_response_cache.go implementing
repository.OCSPResponseCacheRepository (Get / Put / Delete /
CountByIssuer). Interface defined in internal/repository/interfaces.go.
NEW internal/service/ocsp_response_cache.go::OCSPResponseCacheService
with read-through facade + sync.Map singleflight + InvalidateOnRevoke.
On cache miss, calls caOperationsSvc.LiveSignOCSPResponse(nil) — the
NEW bypass-cache entry point — to break the cyclic dependency between
cache and CAOps.
REFACTORED internal/service/ca_operations.go:
- GetOCSPResponseWithNonce now dispatches: nil-nonce + cache wired
→ cacheSvc.Get (cache); nonce != nil OR cache nil → live-sign.
- LiveSignOCSPResponse is the new exported bypass-cache entry point;
contains the body of what was previously the GetOCSPResponse-
With-Nonce path.
- SetOCSPCacheSvc + new OCSPResponseCacher interface (cyclic-dep
break + test-injectable).
The cache stores nil-nonce blobs by design. Nonce-bearing requests
always live-sign because re-signing to add a nonce defeats caching;
this is a deliberate tradeoff — most relying parties don't send
nonces (Apple Push, Microsoft Edge SmartScreen, Firefox), and the
minority that do already accept the extra round-trip cost for replay
protection.
WIRED in cmd/server/main.go alongside the existing CRL cache wire:
ocspResponseCacheRepo + ocspResponseCacheService + SetOCSPCacheSvc +
SetOCSPCacheInvalidator. Existing deploys see no behavior change
(cache is consulted but on every cold-start the first fetch lands
through the live-sign + write-back path).
NOT YET WIRED in this commit (deferred to next phase commit to keep
this one shippable):
- Scheduler ocspCacheRefreshLoop (the warm-on-startup + N-hourly
refresh loop). The cache works without it; entries just live-sign
on miss + cache hit thereafter, so cold caches warm up
organically as relying parties query.
- Admin observability endpoint /api/v1/admin/ocsp/cache.
- CERTCTL_OCSP_CACHE_REFRESH_INTERVAL env var.
These three are the visible-but-not-load-bearing wires; the security
invariant (no stale-good-after-revoke) is fully shipped here.
7 new tests in internal/service/ocsp_response_cache_test.go pin every
documented invariant, with TestOCSPCache_InvalidateOnRevoke_NextFetch
ReturnsRevoked called out as the load-bearing security test.
Pre-commit verification: go build ./... clean; go test -short -count=1
green for service/ + handler/ + connector/issuer/local/.
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/shankar0123/certctl/internal/domain"
|
||||
"github.com/shankar0123/certctl/internal/repository"
|
||||
)
|
||||
|
||||
// OCSPResponseCacheRepository implements repository.OCSPResponseCacheRepository
|
||||
// using PostgreSQL.
|
||||
//
|
||||
// Schema: see migrations/000024_ocsp_response_cache.up.sql. The cache
|
||||
// stores one row per (issuer_id, serial_hex) — the composite primary
|
||||
// key collapses upserts to ON CONFLICT DO UPDATE. The response DER
|
||||
// blob lives in BYTEA — typical sizes are a few hundred bytes for a
|
||||
// single-cert response (one OCSP response wraps one cert; a request
|
||||
// for cert+chain typically issues separate responses).
|
||||
//
|
||||
// Production hardening II Phase 2.
|
||||
type OCSPResponseCacheRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewOCSPResponseCacheRepository creates a new repository.
|
||||
func NewOCSPResponseCacheRepository(db *sql.DB) *OCSPResponseCacheRepository {
|
||||
return &OCSPResponseCacheRepository{db: db}
|
||||
}
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ repository.OCSPResponseCacheRepository = (*OCSPResponseCacheRepository)(nil)
|
||||
|
||||
// Get returns the cached OCSP response for (issuer, serial). Returns
|
||||
// (nil, nil) on miss so the caller can fall through to live signing
|
||||
// + a write-back via Put (read-through pattern).
|
||||
func (r *OCSPResponseCacheRepository) Get(ctx context.Context, issuerID, serialHex string) (*domain.OCSPResponseCacheEntry, error) {
|
||||
const query = `
|
||||
SELECT issuer_id, serial_hex, response_der, cert_status,
|
||||
COALESCE(revocation_reason, 0), COALESCE(revoked_at, '0001-01-01 00:00:00 UTC'::timestamptz),
|
||||
this_update, next_update, generated_at
|
||||
FROM ocsp_response_cache
|
||||
WHERE issuer_id = $1 AND serial_hex = $2`
|
||||
var e domain.OCSPResponseCacheEntry
|
||||
err := r.db.QueryRowContext(ctx, query, issuerID, serialHex).Scan(
|
||||
&e.IssuerID, &e.SerialHex, &e.ResponseDER, &e.CertStatus,
|
||||
&e.RevocationReason, &e.RevokedAt,
|
||||
&e.ThisUpdate, &e.NextUpdate, &e.GeneratedAt,
|
||||
)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("OCSPResponseCacheRepository.Get: %w", err)
|
||||
}
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// Put upserts the cache row for (issuer, serial). The composite PK
|
||||
// collapses repeat-writes to ON CONFLICT DO UPDATE (matches the
|
||||
// crl_cache pattern in 000019).
|
||||
func (r *OCSPResponseCacheRepository) Put(ctx context.Context, e *domain.OCSPResponseCacheEntry) error {
|
||||
const stmt = `
|
||||
INSERT INTO ocsp_response_cache (
|
||||
issuer_id, serial_hex, response_der, cert_status,
|
||||
revocation_reason, revoked_at,
|
||||
this_update, next_update, generated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||
ON CONFLICT (issuer_id, serial_hex) DO UPDATE SET
|
||||
response_der = EXCLUDED.response_der,
|
||||
cert_status = EXCLUDED.cert_status,
|
||||
revocation_reason = EXCLUDED.revocation_reason,
|
||||
revoked_at = EXCLUDED.revoked_at,
|
||||
this_update = EXCLUDED.this_update,
|
||||
next_update = EXCLUDED.next_update,
|
||||
generated_at = EXCLUDED.generated_at`
|
||||
|
||||
// Convert the domain's zero-time RevokedAt to nullable for the SQL
|
||||
// row when CertStatus != "revoked" — the cert_status discriminator
|
||||
// is the source of truth, but keeping the nullable columns nullable
|
||||
// in storage is friendlier for ad-hoc queries.
|
||||
var revokedAt interface{}
|
||||
var revocationReason interface{}
|
||||
if e.CertStatus == "revoked" {
|
||||
revokedAt = e.RevokedAt
|
||||
revocationReason = e.RevocationReason
|
||||
}
|
||||
|
||||
_, err := r.db.ExecContext(ctx, stmt,
|
||||
e.IssuerID, e.SerialHex, e.ResponseDER, e.CertStatus,
|
||||
revocationReason, revokedAt,
|
||||
e.ThisUpdate, e.NextUpdate, e.GeneratedAt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("OCSPResponseCacheRepository.Put: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a single (issuer, serial) entry. Used by
|
||||
// InvalidateOnRevoke when the revocation service wants the cache to
|
||||
// re-sign on the next request rather than carry stale data.
|
||||
func (r *OCSPResponseCacheRepository) Delete(ctx context.Context, issuerID, serialHex string) error {
|
||||
_, err := r.db.ExecContext(ctx,
|
||||
`DELETE FROM ocsp_response_cache WHERE issuer_id = $1 AND serial_hex = $2`,
|
||||
issuerID, serialHex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("OCSPResponseCacheRepository.Delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountByIssuer returns the count of cached entries per issuer.
|
||||
// Backs the admin observability endpoint at /api/v1/admin/ocsp/cache.
|
||||
func (r *OCSPResponseCacheRepository) CountByIssuer(ctx context.Context) (map[string]int, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT issuer_id, COUNT(*) FROM ocsp_response_cache GROUP BY issuer_id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("OCSPResponseCacheRepository.CountByIssuer: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]int{}
|
||||
for rows.Next() {
|
||||
var issuerID string
|
||||
var n int
|
||||
if err := rows.Scan(&issuerID, &n); err != nil {
|
||||
return nil, fmt.Errorf("scan: %w", err)
|
||||
}
|
||||
out[issuerID] = n
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
Reference in New Issue
Block a user