Skip to content

feat(operatorca): check operator certificates with the CA's OCSP responder - #151

Merged
Bugs5382 merged 1 commit into
mainfrom
feat/142-operatorca-ocsp
Oct 1, 2026
Merged

Bugs5382 merged 1 commit into
mainfrom
feat/142-operatorca-ocsp

Conversation

@Bugs5382

@Bugs5382 Bugs5382 commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

What and why

The manager now asks each operator CA's OCSP responder about operator certificates, next to the denylist and the CRL. OCSP can only add a refusal; it never removes one. This finishes the operator CA trust and revocation work that #146 started.

Closes #142
Refs #78

Merge order: independent of the other open manager PRs. Operator CA rotation (SetOperatorCAOCSP, phase 5) builds on this one and should merge after it.

Docs: companion PR CryptOS-PKI/website#72, to merge together with this one. The manager's docs/operator-ca.md, docs/mcp.md and the README are updated here, and the "OCSP isn't checked yet" note is gone.

What changes

  • Responder source per operator CA: aia (default) uses the leaf's authorityInfoAccess OCSP URI, read only after the leaf verified against the anchor; url uses the anchor's URL (operatorOCSP.url for the file source, the row's ocsp_url when registered); off sends nothing. A leaf without an AIA URI in aia mode gets no OCSP check.
  • Request: built with encoding/asn1, a SHA-1 CertID and a 32-byte nonce (RFC 8954), sent by POST. GET (RFC 6960 appendix A.1) is used only after a 405, and only when the base64 request is under 255 bytes. Fetches keep the CRL limits (http or https, 10 s, 3 redirects, explicit transport, no body in errors) with a 64 KiB cap.
  • Validation: golang.org/x/crypto/ocsp parses the response and the manager adds what it leaves out. The signer is the anchor, or a delegated responder issued directly by the anchor with id-kp-OCSPSigning, valid now and named by the ResponderID; noCheck is honoured, and without it the delegated cert is checked against the anchor's denylist and CRL (never OCSP). The CertID hash algorithm, issuer name hash, issuer key hash and serial must match. thisUpdate at most 5 minutes ahead, nextUpdate not passed. An echoed nonce must match; no nonce is accepted. A non-successful status, a non-basic type or an unknown critical extension (single or response) is OCSP_INVALID.
  • Decision (web): denylist or CRL first (1610 REVOKED); then OCSP: revoked gives 1610 REVOKED_OCSP, unknown gives 1610 OCSP_UNKNOWN and is audited operator-ocsp-unknown once per cache period, a fresh good allows. With no fresh response: a CRL within nextUpdate allows; otherwise soft allows with the OCSP responder unreachable banner (AnchorStatus.OCSPBanner), an hourly log line per anchor and one operator-ocsp-unavailable audit row per outage, and hard refuses with 1608 STALE_OCSP. Under hard with a stale CRL, a fresh OCSP good still allows.
  • Decision (MCP): a fresh CRL is still required (NO_CRL, STALE_CRL). When OCSP is configured and a fresh response is available it must be good; an OCSP failure is allowed because the fresh CRL covers it.
  • Cache: per replica, an LRU keyed on (anchor, serial). Lifetime min(nextUpdate, 1 h), 5 minutes without nextUpdate. An entry in use is refreshed in the background at half its lifetime; idle entries expire. A miss is one synchronous fetch, single-flighted per certificate. A failed fetch is remembered for 30 s. The denylist is checked first on every request, whatever the cache holds.
  • Probe: OCSPClient.Probe asks about a random serial and needs a validly signed response of any status, else 1605 OCSP_UNREACHABLE or OCSP_INVALID. The registration and SetOperatorCAOCSP RPCs that call it come with bootstrap and rotation. OCSPClient.ClearAnchor is there for SetOperatorCAOCSP.
  • Wiring: PeerAuthorizer and AdmitMCP pass the whole certificate (CheckWebCert, CheckMCPCert). mcpauth.Resolver uses CheckMCPCert when the checker offers it, so the MCP path sees the AIA URI too; the serial-only CheckWeb and CheckMCP keep their signatures and apply url-mode OCSP.

Design readings

  • A background refresh that fails keeps the still-valid cached response and tries again after 30 s, rather than replacing it with a failure. A synchronous miss that fails is cached as "no fresh response" for 30 s.
  • "Under 255 bytes" for the GET fallback is read as the URL-escaped base64 request.
  • A delegated responder must be embedded in the response; one named by the ResponderID but not included can't be checked, so the response is OCSP_INVALID.

Dependencies

  • golang.org/x/crypto v0.55.0 becomes a direct dependency for ocsp (the Go project's own subrepository, at the version cryptos uses). The other module versions are main's.
  • golang.org/x/sync moves from indirect to direct for singleflight.

The error code table needs no change: the 16xx sub-reasons were already registered.

Verification

  • Lint clean
  • Tests pass
  • Build succeeds
  • Documentation updated (if behavior or API changed)

How this was verified

  • task ci (gofmt, vet, golangci-lint, tests) passes with MANAGER_TEST_DATABASE_URL pointing at a throwaway Postgres 18 container: 840 tests and subtests pass, none skipped (rebased onto main after build: build with Go 1.26.8 and bump grpc and x/text #149 and feat(operators): credential requests, recording and denylist revoke #152). 18 top-level tests are new, each written first and seen failing against stubs.
  • task license exits 0.
  • An in-test responder (httptest) signs with golang.org/x/crypto/ocsp; responses that need responseExtensions or several embedded certificates are re-encoded and re-signed in the test. The tests cover each refusal (wrong signer, delegated cert without OCSPSigning, from another CA or expired, ResponderID not the signer, each CertID field, future thisUpdate, stale response, non-successful status, unknown critical extensions, a body over 64 KiB), noCheck, the nonce, POST and the GET fallback, the transport limits, the cache lifetimes and half-life refresh on a fake clock, single-flight with 50 concurrent checks, the CRL fallback, soft and hard, the MCP rules, and that an AIA URI is never read from a leaf that fails the chain check.
  • Not tested on the lab here; the lab OCSP drill belongs to the integration run.

@Bugs5382 Bugs5382 self-assigned this Sep 30, 2026
@Bugs5382
Bugs5382 marked this pull request as ready for review September 30, 2026 23:27
@github-actions github-actions Bot added the enhancement New feature (feat). Minor version bump. label Sep 30, 2026
…onder

Each operator CA now has an OCSP responder source: off, aia (the leaf's
authorityInfoAccess URI, read only after the chain check) or url. The
file source takes it from operatorOCSP and a registered CA from its row.

- Requests carry a 32-byte nonce (RFC 8954) and a SHA-1 CertID, built
  with encoding/asn1. They go by POST, with a GET fallback only after a
  405 and for a base64 request under 255 bytes. Fetches keep the CRL
  limits with a 64 KiB body cap.
- Responses are parsed with golang.org/x/crypto/ocsp, and the manager
  adds what it leaves out: the signer is the anchor or a delegated
  responder issued directly by it with id-kp-OCSPSigning, valid now and
  named by the ResponderID (noCheck honoured, otherwise the denylist and
  CRL apply); the CertID hashes and serial match; thisUpdate is at most
  5 minutes ahead and nextUpdate hasn't passed; an echoed nonce matches;
  no unknown critical extension.
- revoked and unknown refuse (1610 REVOKED_OCSP, OCSP_UNKNOWN; unknown is
  audited once per cache period). good never overrides the denylist or
  the CRL. With no fresh response the web path falls back to a fresh CRL,
  then to operatorRevocationPolicy: soft allows with a banner, an hourly
  log line and one operator-ocsp-unavailable audit row per outage; hard
  refuses with 1608 STALE_OCSP. A fresh good still allows under hard
  with a stale CRL.
- MCP still needs a fresh CRL; a fresh OCSP revoked or unknown refuses,
  an OCSP failure is covered by the CRL.
- Per-replica LRU for min(nextUpdate, 1 h), 5 minutes without nextUpdate,
  refreshed in the background at half life while in use, single-flighted
  per certificate, with failures remembered for 30 s.
- OCSPClient.Probe asks about a random serial and needs a validly signed
  answer (1605 OCSP_UNREACHABLE or OCSP_INVALID), for registration.

golang.org/x/crypto v0.55.0 becomes a direct dependency for the OCSP
parser (the Go project's own subrepository, at the version cryptos
uses). golang.org/x/sync moves from indirect to direct for
singleflight.

Closes #142
Refs #78

Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
@Bugs5382
Bugs5382 force-pushed the feat/142-operatorca-ocsp branch from 9edb766 to 9882c47 Compare October 1, 2026 13:31
@Bugs5382

Bugs5382 commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Merging for the v0.1.0 test run: CI green and mergeable at merge time.

@Bugs5382
Bugs5382 merged commit 5a0318a into main Oct 1, 2026
17 checks passed
@Bugs5382
Bugs5382 deleted the feat/142-operatorca-ocsp branch October 1, 2026 13:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature (feat). Minor version bump.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(operatorca): external operator CA trust and revocation foundation

1 participant