mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 18:51:32 +00:00
ec88a61274
First slice of the RFC 8555 ACME server endpoint (master plan at cowork/acme-server-endpoint-prompt.md, per-phase prompts at cowork/acme-server-prompts/). This commit lands the smallest viable end-to-end deployable slice: an ACME client running curl -sk https://certctl/acme/profile/<id>/directory curl -sk -I https://certctl/acme/profile/<id>/new-nonce successfully fetches the directory document and a Replay-Nonce. Account creation, JWS verification, orders, challenges, and revocation are all out of scope for this phase and arrive in Phases 1b–4. Closes the Rank 1 LHF from the 2026-05-03 Infisical deep-research (cowork/infisical-deep-research-results.md). Pre-fix, certctl was an ACME consumer only — no /acme/directory endpoint, no JWS verifier, no challenge validators. K8s customers running cert-manager could not point at certctl as an ACME issuer; they had to deploy a certctl agent on every node. What ships: - internal/api/acme/{directory,nonce,errors}.go (+ tests). - internal/api/handler/acme.go + acme_handler_test.go. - internal/repository/postgres/acme.go (nonce ops only — Phase 1b extends with account CRUD; Phases 2-4 extend with order / authz / challenge CRUD). - internal/service/acme.go (BuildDirectory + IssueNonce stubs; Phase 1b adds VerifyJWS / NewAccount / etc.). - migrations/000025_acme_server.{up,down}.sql ships the full 5-table ACME schema (acme_accounts / acme_orders / acme_authorizations / acme_challenges / acme_nonces) PLUS the per-profile certificate_profiles.acme_auth_mode column. Phase 1a actively uses only acme_nonces; remaining tables are empty until Phases 1b-4 plug in. - internal/config/config.go: ACMEServerConfig struct + ACMEServer field on Config. Env vars use CERTCTL_ACME_SERVER_* prefix to avoid colliding with the existing consumer-side ACMEConfig at config.go:1746 (CERTCTL_ACME_DIRECTORY_URL / PROFILE / CHALLENGE_TYPE etc.). Phase 1a wires Enabled + DefaultAuthMode + DefaultProfileID + NonceTTL + DirectoryMeta; Order/Authz TTLs + per-challenge-type concurrency caps + DNS01 resolver are reserved fields parsed in 1a so operators can set them ahead of Phases 2/3. - cmd/server/main.go: wire ACMEHandler into the HandlerRegistry literal alongside the existing certificate / EST / SCEP / etc. handlers. - internal/api/router/router.go: HandlerRegistry.ACME field + 6 Register calls (3 per-profile + 3 shorthand). - internal/api/router/openapi_parity_test.go: 6 new entries in SpecParityExceptions. ACME is a wire-protocol surface (JWS-signed JSON over HTTPS per RFC 7515) whose semantics are dictated by RFC 8555 + RFC 9773 rather than by an OpenAPI document, same precedent as SCEP/EST. The canonical reference is docs/acme-server.md. - docs/acme-server.md: Phase-1a-shaped reference. Configuration table for every CERTCTL_ACME_SERVER_* env var. Per-profile auth-mode decision tree skeleton. TLS trust bootstrap section flagging cert-manager's ClusterIssuer.spec.acme.caBundle requirement (the single biggest first-time-deploy footgun; the full cert-manager walkthrough lands in Phase 6 but the requirement is documented up front). Architecture decisions baked in: - URL family is /acme/profile/<id>/* (per-profile, canonical) with /acme/* shorthand active when CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID is set. Path matches existing per-profile precedent in EST + SCEP. - Auth mode is per-profile (acme_auth_mode column on certificate_profiles), NOT server-wide. One certctl-server can serve trust_authenticated for an internal-PKI profile and challenge for a public-trust-style profile simultaneously. The column is read at request time, not cached at server start — operators flipping a profile's mode via SQL take effect on the next order without restart. - Nonces are DB-backed (acme_nonces table). Survive server restart. The RFC 8555 §6.5 replay defense requires the store to outlast the client's nonce caching window; an in-memory-only nonce store would lose every in-flight order on restart. - Per-op atomic counters on service.ACMEService.Metrics() — certctl_acme_directory_total, certctl_acme_directory_failures_total, certctl_acme_new_nonce_total, certctl_acme_new_nonce_failures_total. Naming follows certctl frozen decision 0.10 cardinality discipline. Phase 1b will extend with new_account counters; Phase 2 with order / finalize / cert; Phase 3 with per-challenge-type counters. Audit fixes #11 + #12 (cowork/acme-server-prompts/audit-additions.md) applied: - #11: CERTCTL_ACME_SERVER_* prefix avoids the consumer-side CERTCTL_ACME_* namespace collision. - #12: prior-attempt WIP from two failed Phase-1 dispatches was discarded at phase start; this commit starts from a clean tree. Tests: - 14 unit tests in internal/api/acme/ (directory, nonce, errors). - 7 handler-level tests via httptest.NewServer + mockACMEService (mirrors the mockSCEPService pattern at scep_handler_test.go). - 7 service-layer tests with mocked repo + injected profileLookup. - All pass under -race -count=1 -short. Deferred to Phase 1b: - JWS verification (go-jose v4 — see master-prompt §8a for the API surface and audit doc for the speculation pitfalls). - new-account / account/<id> endpoints + AccountService. - Nonce *consumption* path (issue path is in this commit; consume is only invoked by JWS-verified POSTs which Phase 1b adds). Engineering history: cowork/WORKSPACE-CHANGELOG.md "ACME-Server-1a". Per-phase implementation plan: cowork/acme-server-prompts/. Master plan + audit fixes: cowork/acme-server-endpoint-prompt.md + cowork/acme-server-prompt-audit.md + cowork/acme-server-prompts/audit-additions.md.
136 lines
6.1 KiB
SQL
136 lines
6.1 KiB
SQL
-- ACME Server (RFC 8555 + RFC 9773 ARI) — Phase 1a foundation.
|
|
--
|
|
-- Adds the per-profile auth-mode column on certificate_profiles plus
|
|
-- the 5 ACME state tables (accounts, orders, authorizations, challenges,
|
|
-- nonces). Phase 1a actively uses only `acme_nonces`; Phase 1b consumes
|
|
-- `acme_accounts`; Phases 2-4 consume the rest. All five tables ship
|
|
-- in this migration so the schema is stable from day one.
|
|
--
|
|
-- Per the architecture decision documented in docs/acme-server.md,
|
|
-- auth_mode is per-profile (NOT a server-wide env var). One certctl-server
|
|
-- can serve `trust_authenticated` for an internal-PKI profile AND
|
|
-- `challenge` for a public-trust-style profile simultaneously.
|
|
--
|
|
-- Idempotent (IF NOT EXISTS / IF EXISTS) per certctl architecture
|
|
-- decision; safe to re-run.
|
|
|
|
-- 1. Add per-profile auth_mode to certificate_profiles.
|
|
-- 'trust_authenticated' (default) — JWS-authenticated client trusted
|
|
-- to issue for any identifier the profile policy allows; no per-
|
|
-- identifier ownership proof. The most common certctl use case.
|
|
-- 'challenge' — full HTTP-01 + DNS-01 + TLS-ALPN-01 validation per
|
|
-- RFC 8555 §8. For public-trust-style PKI.
|
|
ALTER TABLE certificate_profiles
|
|
ADD COLUMN IF NOT EXISTS acme_auth_mode TEXT NOT NULL DEFAULT 'trust_authenticated';
|
|
|
|
-- Constraint name pinned so the .down.sql can drop it deterministically.
|
|
-- Wrapped in DO block so re-running the migration on a database that
|
|
-- already has the constraint doesn't fail.
|
|
DO $$
|
|
BEGIN
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM pg_constraint
|
|
WHERE conname = 'certificate_profiles_acme_auth_mode_chk'
|
|
) THEN
|
|
ALTER TABLE certificate_profiles
|
|
ADD CONSTRAINT certificate_profiles_acme_auth_mode_chk
|
|
CHECK (acme_auth_mode IN ('trust_authenticated', 'challenge'));
|
|
END IF;
|
|
END $$;
|
|
|
|
-- 2. acme_accounts — RFC 8555 §7.1.2.
|
|
-- account_id is 'acme-acc-' + base32-encoded random per certctl's
|
|
-- human-readable-prefix convention. jwk_thumbprint is RFC 7638
|
|
-- SHA-256 thumbprint of the canonicalized JWK; the (profile_id,
|
|
-- jwk_thumbprint) UNIQUE constraint enforces "one account per
|
|
-- keypair per profile" — RFC 8555 §7.3.1 idempotent semantics.
|
|
--
|
|
-- Phase 1a creates the table; Phase 1b adds CRUD methods.
|
|
CREATE TABLE IF NOT EXISTS acme_accounts (
|
|
account_id TEXT PRIMARY KEY,
|
|
jwk_thumbprint TEXT NOT NULL,
|
|
jwk_pem TEXT NOT NULL,
|
|
contact TEXT[],
|
|
status TEXT NOT NULL,
|
|
profile_id TEXT NOT NULL,
|
|
owner_id TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
FOREIGN KEY (profile_id) REFERENCES certificate_profiles(id),
|
|
FOREIGN KEY (owner_id) REFERENCES owners(id),
|
|
UNIQUE (profile_id, jwk_thumbprint)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_acme_accounts_jwk_thumb ON acme_accounts(profile_id, jwk_thumbprint);
|
|
CREATE INDEX IF NOT EXISTS idx_acme_accounts_status ON acme_accounts(status) WHERE status = 'valid';
|
|
|
|
-- 3. acme_orders — RFC 8555 §7.1.3.
|
|
-- identifiers stored as JSONB to keep the DNS-name list simple
|
|
-- (ACME currently has only the dns identifier type in scope; future
|
|
-- types like ip can extend without schema migration).
|
|
-- error stored as JSONB (RFC 7807 Problem+JSON shape on failure).
|
|
-- certificate_id FKs into managed_certificates so the existing cert
|
|
-- pipeline owns the leaf data.
|
|
CREATE TABLE IF NOT EXISTS acme_orders (
|
|
order_id TEXT PRIMARY KEY,
|
|
account_id TEXT NOT NULL,
|
|
identifiers JSONB NOT NULL,
|
|
status TEXT NOT NULL,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
not_before TIMESTAMPTZ,
|
|
not_after TIMESTAMPTZ,
|
|
error JSONB,
|
|
csr_pem TEXT,
|
|
certificate_id TEXT,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
FOREIGN KEY (account_id) REFERENCES acme_accounts(account_id),
|
|
FOREIGN KEY (certificate_id) REFERENCES managed_certificates(id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_acme_orders_account ON acme_orders(account_id);
|
|
CREATE INDEX IF NOT EXISTS idx_acme_orders_status ON acme_orders(status) WHERE status IN ('pending', 'ready', 'processing');
|
|
CREATE INDEX IF NOT EXISTS idx_acme_orders_expires ON acme_orders(expires_at);
|
|
|
|
-- 4. acme_authorizations — RFC 8555 §7.1.4.
|
|
CREATE TABLE IF NOT EXISTS acme_authorizations (
|
|
authz_id TEXT PRIMARY KEY,
|
|
order_id TEXT NOT NULL,
|
|
identifier JSONB NOT NULL,
|
|
status TEXT NOT NULL,
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
wildcard BOOLEAN NOT NULL DEFAULT FALSE,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
FOREIGN KEY (order_id) REFERENCES acme_orders(order_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_acme_authz_order ON acme_authorizations(order_id);
|
|
CREATE INDEX IF NOT EXISTS idx_acme_authz_status ON acme_authorizations(status) WHERE status IN ('pending', 'processing');
|
|
|
|
-- 5. acme_challenges — RFC 8555 §8.
|
|
CREATE TABLE IF NOT EXISTS acme_challenges (
|
|
challenge_id TEXT PRIMARY KEY,
|
|
authz_id TEXT NOT NULL,
|
|
type TEXT NOT NULL,
|
|
status TEXT NOT NULL,
|
|
token TEXT NOT NULL,
|
|
validated_at TIMESTAMPTZ,
|
|
error JSONB,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
FOREIGN KEY (authz_id) REFERENCES acme_authorizations(authz_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_acme_challenges_authz ON acme_challenges(authz_id);
|
|
|
|
-- 6. acme_nonces — RFC 8555 §6.5.
|
|
-- Nonces are short-lived (TTL default 5m, configurable via
|
|
-- CERTCTL_ACME_SERVER_NONCE_TTL). DB-backed (NOT in-memory) so
|
|
-- they survive server restart — replay protection only works if the
|
|
-- server-side store outlasts the client's nonce caching window.
|
|
-- Phase 5 adds a scheduler-loop GC sweep; Phase 1a inserts but does
|
|
-- not yet GC.
|
|
CREATE TABLE IF NOT EXISTS acme_nonces (
|
|
nonce TEXT PRIMARY KEY,
|
|
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
expires_at TIMESTAMPTZ NOT NULL,
|
|
used BOOLEAN NOT NULL DEFAULT FALSE
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_acme_nonces_expires ON acme_nonces(expires_at) WHERE used = FALSE;
|