diff --git a/README.md b/README.md index 45132f7..45b8b3b 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Adopting a fresh node from its maintenance endpoint is safe to retry. The manage The node always checks the manager's admin credential, and the manager always checks the node's server certificate: against the node's recorded CA chain once the node signs its management certificate with its CA, or against a pinned `server.crt` before that. A node it can't verify is refused. `manager -check-node-trust` lists how each node is verified. See [`docs/node-trust.md`](docs/node-trust.md). -Operators sign in with a client certificate from an **external operator CA**, never a CryptOS node; the manager never signs an operator credential. It re-checks the certificate on every request and refuses revoked ones from its own denylist and the CA's CRL. See [`docs/operator-ca.md`](docs/operator-ca.md). +Operators sign in with a client certificate from an **external operator CA**, never a CryptOS node; the manager never signs an operator credential. It re-checks the certificate on every request and refuses revoked ones from its own denylist, the CA's CRL and, where configured, the CA's OCSP responder. See [`docs/operator-ca.md`](docs/operator-ca.md). Once linked, the node's embedded operator surface becomes read-only and FM owns day-to-day operations. Unlinking is a config change + reboot. A node that has never been linked is managed via [`cryptosctl`](https://github.com/CryptOS-PKI/cryptos) only โ€” no web UI in that case (by design โ€” there's no web frontend on the CA image). @@ -214,7 +214,7 @@ The MCP endpoint is off in the chart too. `mcp.enabled` and `mcp.publicURL` turn The manager can serve a [Model Context Protocol](https://modelcontextprotocol.io) endpoint at `/mcp` for AI agents. It is off by default. - ๐Ÿ”‘ **Logged in with your operator certificate.** An MCP client such as the `claude` CLI runs a one-time OAuth login; the consent page in the web UI needs your operator certificate, and the client receives a long-lived `fos_mcp_` key bound to that certificate's serial. Clients without OAuth use a key from the Agent keys page. -- ๐Ÿงฎ **Checked live on every call.** The key's certificate is re-validated against the operator CAs trusted now, the denylist and its CA's CRL each time, and its CA must have a fresh CRL, and the key never acts above the certificate's level or its own ceiling. +- ๐Ÿงฎ **Checked live on every call.** The key's certificate is re-validated against the operator CAs trusted now, the denylist, its CA's CRL and OCSP responder (where configured) each time, and its CA must have a fresh CRL, and the key never acts above the certificate's level or its own ceiling. - ๐Ÿ›‘ **Narrow by design.** Agents get read tools and non-CA leaf issuance on intermediate or issuing nodes. CA key material, node provisioning and operator credentials are not exposed. - โœ‹ **A person approves the risky calls.** Revocation, profile and adapter changes, and CA or root issuance only raise an approval; they run when the agent calls again after a person approves it in the web UI with their operator certificate. An approval covers one exact request, runs once and lapses after 15 minutes. - ๐Ÿงพ **Audited.** Every MCP call, reads included, is in the hash-chained audit log with the operator, key and tool. diff --git a/cmd/manager/trust.go b/cmd/manager/trust.go index d95e801..09de257 100644 --- a/cmd/manager/trust.go +++ b/cmd/manager/trust.go @@ -58,6 +58,9 @@ func setupOperatorTrust(ctx context.Context, cfg config.Config, st store.Store, Policy: src.Policy, Logf: logf, Audit: func(e store.AuditEvent) { auditlog.Record(context.Background(), st, e) }, + // OCSP per anchor (off, aia or url); the file source takes the mode + // from operatorOCSP, a registered CA from its row. + OCSPFetcher: operatorca.NewOCSPFetcher(), }) trust, err := operatorca.NewTrustStore(ctx, src, ot, rev, base, logf) if err != nil { diff --git a/docs/mcp.md b/docs/mcp.md index 65a94de..d7eaa16 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -61,7 +61,9 @@ because a key could not be re-validated on each request: A key also needs its operator certificate's CA to have a **CRL source with a fresh CRL**. That is checked when the key is created and on every call, not at start, so MCP can be enabled before the CRL is set up; until then keys are refused with -[1608](error-codes.md) `NO_CRL` or `STALE_CRL`. See [operator-ca.md](operator-ca.md#mcp-needs-a-fresh-crl). +[1608](error-codes.md) `NO_CRL` or `STALE_CRL`. Where OCSP is configured for the +certificate, a fresh `revoked` or `unknown` answer refuses the key as well. See +[operator-ca.md](operator-ca.md#mcp-needs-a-fresh-crl). Keys and login state live in Postgres, so every replica shares them and a login can start on one replica and finish on another. diff --git a/docs/operator-ca.md b/docs/operator-ca.md index f7f783d..ba09523 100644 --- a/docs/operator-ca.md +++ b/docs/operator-ca.md @@ -16,8 +16,8 @@ The source is chosen only by what is configured: | Source | When | Trust | Revocation | | --- | --- | --- | --- | -| Config file | `operatorCAPath` set | The certificates in that PEM file | The denylist (needs Postgres), plus the CRLs from `operatorCRL` | -| Registered | No `operatorCAPath`, Postgres configured, `firstRun` not `disabled` | The active and retiring operator CAs stored in Postgres | The denylist, plus each CA's own CRL source | +| Config file | `operatorCAPath` set | The certificates in that PEM file | The denylist (needs Postgres), plus the CRLs from `operatorCRL`, plus OCSP per `operatorOCSP` | +| Registered | No `operatorCAPath`, Postgres configured, `firstRun` not `disabled` | The active and retiring operator CAs stored in Postgres | The denylist, plus each CA's own CRL source, plus OCSP per the CA's OCSP mode | | None | No `operatorCAPath`, and no Postgres or `firstRun: disabled` | Nothing | Nothing; every API caller is refused | | Dev | `authBypass: true` | No certificates at all | n/a | @@ -53,6 +53,11 @@ so every source is keyed by the CA as well as the serial. library, and refuses a CRL older than the one it holds. It refreshes every 15 minutes and at the CRL's nextUpdate, and retries a failure after a minute, backing off to 15 minutes. +- **OCSP** is your CA's live answer about one certificate, optional per CA. It + can only add a refusal: `revoked` refuses with + [1610 REVOKED_OCSP](error-codes.md), and `unknown` counts as revoked + ([1610 OCSP_UNKNOWN](error-codes.md), audited `operator-ocsp-unknown`). A + `good` answer never overrides the denylist or the CRL. > [!WARNING] > The denylist stops a credential at the Fleet Manager only. Other systems that @@ -63,23 +68,78 @@ operator CA itself, with a nextUpdate. Delta CRLs, indirect CRLs, CRLs limited t CA certificates or to some reasons, and CRLs with an unknown critical extension are refused. Entries on hold count as revoked while they are listed. -### When no fresh CRL is available +### OCSP + +Each operator CA has an OCSP mode (`operatorOCSP.mode` for the config file, the +CA's own setting when registered): + +| Mode | Responder | +| --- | --- | +| `aia` (default) | The OCSP URI in the operator certificate's authorityInfoAccess. A certificate without one gets no OCSP check. The URI is read only after the certificate has been verified to chain to the CA, so only the CA can choose it. | +| `url` | The URL you set (`operatorOCSP.url`, or the CA's OCSP URL). The certificate's own URI is ignored. | +| `off` | No OCSP. | + +The manager asks with an HTTP POST carrying a 32-byte nonce, and falls back to +GET only when the responder answers POST with 405 and the base64 request is +under 255 bytes. Fetches follow the CRL limits: http or https only, a 10 second +timeout, at most 3 redirects, no proxy from the environment, and a 64 KiB +response cap. + +A response is used only if it is a successful basic response for exactly the +requested certificate (hash algorithm, CA name and key hashes, serial), its +thisUpdate is at most 5 minutes ahead and its nextUpdate hasn't passed, and it +is signed by one of: + +- the operator CA itself; +- a delegated responder certificate issued **directly** by the operator CA, with + the `OCSPSigning` extended key usage, valid now, and named by the response's + responder ID. With `noCheck` it isn't revocation-checked; without it, it must + not be on the CA's denylist or CRL. + +A nonce echoed in the response must match; a response without one is accepted, +because responders for offline CAs often pre-sign. Anything else counts as no +response, and is logged with the responder URL and `OCSP_INVALID` or +`OCSP_UNREACHABLE`, never with the response body. + +> [!CAUTION] +> The manager refuses responses from an expired delegated responder +> certificate. Renew it before it expires, or every check falls back to the CRL +> and then to `operatorRevocationPolicy`. + +Each replica caches answers in memory for the response's nextUpdate, at most 1 +hour, or 5 minutes when the response has no nextUpdate. A certificate in use is +re-checked in the background half way through that time; idle entries just +expire. Concurrent requests for the same certificate share one fetch, and a +failed fetch is remembered for 30 seconds so a dead responder doesn't slow every +request. A denylist entry still refuses a certificate at once, whatever the +cache holds. + +When OCSP is configured for a certificate but there is no fresh response, the +manager falls back to the CA's CRL: with a CRL within nextUpdate, the +certificate is allowed unless the CRL lists it. With no fresh CRL either, +`operatorRevocationPolicy` decides (below). + +### When no fresh revocation data is available `operatorRevocationPolicy` decides what the web UI and API do when a CA's CRL is -past its nextUpdate, or a configured CRL has never been fetched: +past its nextUpdate, a configured CRL has never been fetched, or OCSP is +configured for the certificate with no fresh response and no fresh CRL to fall +back on: | | `soft` (default) | `hard` | | --- | --- | --- | -| Web UI and API | Keep enforcing the last good CRL and the denylist. Log hourly and audit `operator-crl-expired` once. | Refuse certificates under that CA with [1608 STALE_CRL](error-codes.md) until a fresh CRL loads. | +| Web UI and API | Keep enforcing the last good CRL and the denylist. Log hourly and audit `operator-crl-expired` once per expiry; for OCSP, show the `OCSP responder unreachable` banner, log hourly and audit `operator-ocsp-unavailable` once per outage. | Refuse certificates under that CA with [1608 STALE_OCSP](error-codes.md) when OCSP was configured for the certificate, otherwise [1608 STALE_CRL](error-codes.md). A certificate with a fresh OCSP `good` is still allowed with a stale CRL. | | MCP | Refused ([1608 STALE_CRL](error-codes.md)) | Refused | > [!WARNING] -> With `hard`, an unreachable CRL URL locks every operator under that CA out of -> the web UI until the CRL publication point is fixed. Use it only with an -> automated, highly available CRL publication point. +> With `hard`, an unreachable CRL URL or OCSP responder locks every operator +> under that CA out of the web UI until the CRL publication point or the +> responder is fixed. Use it only with an automated, highly available CRL +> publication point and responder. -A CA with no CRL source is checked against the denylist only; revocations made -at the CA are not seen. +A CA with no CRL source and OCSP off (or `aia` and certificates without an OCSP +URI) is checked against the denylist only; revocations made at the CA are not +seen. With OCSP on, the CA's revocations are seen through OCSP only. ### MCP needs a fresh CRL @@ -87,7 +147,13 @@ MCP keys are long-lived, so they are held to a stricter rule. On every call, and before a key is created, the bound certificate's CA must have a CRL source with a CRL within nextUpdate, and this replica's last denylist poll must be under 5 minutes old. Otherwise the call is refused with -[1608](error-codes.md) `NO_CRL`, `STALE_CRL` or `STALE_DENYLIST`. +[1608](error-codes.md) `NO_CRL`, `STALE_CRL` or `STALE_DENYLIST`. A fresh OCSP +`good` doesn't replace the CRL here. + +When OCSP is configured for the bound certificate and a fresh response is +available, it must be `good`: `revoked` or `unknown` refuse with +[1610](error-codes.md) `REVOKED_OCSP` or `OCSP_UNKNOWN`. An OCSP failure is +allowed, because the fresh CRL covers it. ## โš™๏ธ Config keys @@ -122,10 +188,6 @@ firstRun: auto | `operatorOCSP.url` | unset | Required with mode `url`, http or https; refused with any other mode. | | `firstRun` | `auto` | `auto` or `disabled`. With `disabled` and no `operatorCAPath`, no operator CA is trusted. | -> [!NOTE] -> `operatorOCSP` is validated, but this version doesn't query OCSP responders -> yet. Revocation comes from the denylist and the CRL. - Unknown keys are refused at load, so a misspelt key fails loudly rather than being ignored. diff --git a/go.mod b/go.mod index 4381f91..9ea0480 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,8 @@ require ( github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.10.0 github.com/modelcontextprotocol/go-sdk v1.8.0 + golang.org/x/crypto v0.55.0 + golang.org/x/sync v0.22.0 golang.org/x/time v0.15.0 ) @@ -33,7 +35,6 @@ require ( go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sync v0.22.0 // indirect ) require ( diff --git a/go.sum b/go.sum index d7a094a..5990b96 100644 --- a/go.sum +++ b/go.sum @@ -72,6 +72,8 @@ go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRk go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= diff --git a/internal/mcpauth/resolver.go b/internal/mcpauth/resolver.go index 189d735..52f6bad 100644 --- a/internal/mcpauth/resolver.go +++ b/internal/mcpauth/resolver.go @@ -56,6 +56,13 @@ type MCPChecker interface { CheckMCP(anchorSHA256, serial string) error } +// CertMCPChecker is an MCPChecker that takes the whole certificate. The +// resolver prefers it: the OCSP check reads the responder URI from the +// certificate's authorityInfoAccess, which a serial alone doesn't carry. +type CertMCPChecker interface { + CheckMCPCert(anchorSHA256 string, cert *x509.Certificate) error +} + // Resolver turns a bearer key into the live identity of the operator // certificate it is bound to. Nothing is cached: every request re-checks the // key row and re-validates the certificate against the operator CAs trusted @@ -143,7 +150,13 @@ func (r *Resolver) validate(key store.McpKey, now time.Time) (authz.Identity, er anchor := chains[0][len(chains[0])-1] sum := sha256.Sum256(anchor.Raw) id.IssuerSHA256 = hex.EncodeToString(sum[:]) - if err := r.Revoked.CheckMCP(id.IssuerSHA256, fmt.Sprintf("%x", cert.SerialNumber)); err != nil { + var revErr error + if cc, ok := r.Revoked.(CertMCPChecker); ok { + revErr = cc.CheckMCPCert(id.IssuerSHA256, cert) + } else { + revErr = r.Revoked.CheckMCP(id.IssuerSHA256, fmt.Sprintf("%x", cert.SerialNumber)) + } + if err := revErr; err != nil { if code, _ := apperr.Code(err); code == apperr.CodeNoRevocationSource { return authz.Identity{}, fmt.Errorf("%w: %w", ErrRevocationStale, err) } diff --git a/internal/mcpauth/revocation_test.go b/internal/mcpauth/revocation_test.go index 802312f..acff9aa 100644 --- a/internal/mcpauth/revocation_test.go +++ b/internal/mcpauth/revocation_test.go @@ -129,3 +129,38 @@ func TestMint_RefusesACertificateTheAdmissionCheckRefuses(t *testing.T) { t.Fatal("a key was stored for a refused certificate") } } + +// certChecker also takes the whole certificate, which the OCSP check needs +// for the responder URI in the certificate's authorityInfoAccess. +type certChecker struct { + fakeChecker + gotCert *x509.Certificate +} + +func (c *certChecker) CheckMCPCert(anchor string, cert *x509.Certificate) error { + c.gotAnchor, c.gotCert = anchor, cert + c.callsCount++ + return c.errs[anchor] +} + +// A checker that takes the certificate gets the bound certificate itself. +func TestResolve_PassesTheCertificateToACertChecker(t *testing.T) { + ca := newTestCA(t, "Operator CA") + st := memory.New(nil) + cert := ca.validOperator(t, 0x0abd, authz.LevelOperator) + plain, _ := mintFor(t, st, cert, "") + revokedOCSP := apperr.Reasoned(apperr.CodeCertRejected, fleetv1.ErrorReason_ERROR_REASON_REVOKED_OCSP, errors.New("revoked")) + checker := &certChecker{fakeChecker: fakeChecker{errs: map[string]error{fp(ca.cert): revokedOCSP}}} + r := &Resolver{Store: st, Roots: ca.pool, Revoked: checker} + + _, err := r.Resolve(context.Background(), plain) + if !errors.Is(err, ErrCertRevoked) { + t.Fatalf("Resolve = %v, want ErrCertRevoked", err) + } + if checker.gotCert == nil || !checker.gotCert.Equal(cert) || checker.gotAnchor != fp(ca.cert) { + t.Fatalf("CheckMCPCert got anchor %q cert %v, want the bound certificate", checker.gotAnchor, checker.gotCert) + } + if checker.gotSerial != "" { + t.Fatal("the serial-only CheckMCP was called as well") + } +} diff --git a/internal/operatorca/doc.go b/internal/operatorca/doc.go index c03c47c..c41a20a 100644 --- a/internal/operatorca/doc.go +++ b/internal/operatorca/doc.go @@ -10,7 +10,9 @@ // over registered rows). TrustStore holds the current anchors as numbered // generations, serves them to the TLS handshake and re-verifies the peer on // every request. Revocations combines the manager's denylist with each -// anchor's CRL, keyed by anchor, because serials are unique per issuer only. +// anchor's CRL, keyed by anchor, because serials are unique per issuer only, +// and asks the anchor's OCSP responder about each certificate through +// OCSPClient, which can only add a refusal. package operatorca /* diff --git a/internal/operatorca/fetch.go b/internal/operatorca/fetch.go index b9d95c3..81f629e 100644 --- a/internal/operatorca/fetch.go +++ b/internal/operatorca/fetch.go @@ -112,30 +112,47 @@ func (f *Fetcher) Fetch(ctx context.Context, rawURL string) ([]byte, error) { } func (f *Fetcher) do(ctx context.Context, method, rawURL, contentType string, body io.Reader) ([]byte, error) { + b, err := f.request(ctx, method, rawURL, contentType, body) + if err != nil { + return nil, crlUnreachable("%s %s: %v", method, redact(rawURL), err) + } + return b, nil +} + +// httpStatusError is a response other than 200 OK. +type httpStatusError struct{ code int } + +func (e *httpStatusError) Error() string { return fmt.Sprintf("HTTP %d", e.code) } + +var errBodyTooLarge = errors.New("the response is over the size cap") + +// request does one fetch with the limits. Its errors carry no response +// body and no query string. +func (f *Fetcher) request(ctx context.Context, method, rawURL, contentType string, body io.Reader) ([]byte, error) { if err := ValidateFetchURL(rawURL); err != nil { - return nil, crlUnreachable("refused: %v", err) + return nil, fmt.Errorf("refused: %w", err) } req, err := http.NewRequestWithContext(ctx, method, rawURL, body) if err != nil { - return nil, crlUnreachable("refused: %v", err) + return nil, fmt.Errorf("refused: %w", err) } if contentType != "" { req.Header.Set("Content-Type", contentType) } resp, err := f.client.Do(req) if err != nil { - return nil, crlUnreachable("%s %s: %v", method, redact(rawURL), unwrapURLError(err)) + return nil, unwrapURLError(err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return nil, crlUnreachable("%s %s: HTTP %d", method, redact(rawURL), resp.StatusCode) + return nil, &httpStatusError{code: resp.StatusCode} } b, err := io.ReadAll(io.LimitReader(resp.Body, f.maxBytes+1)) if err != nil { - return nil, crlUnreachable("%s %s: read: %v", method, redact(rawURL), err) + return nil, fmt.Errorf("read: %w", err) } if int64(len(b)) > f.maxBytes { - return nil, crlUnreachable("%s %s: the response is more than %d bytes", method, redact(rawURL), f.maxBytes) + return nil, fmt.Errorf("%w (%d bytes)", errBodyTooLarge, f.maxBytes) } return b, nil } diff --git a/internal/operatorca/ocsp.go b/internal/operatorca/ocsp.go new file mode 100644 index 0000000..f48e85f --- /dev/null +++ b/internal/operatorca/ocsp.go @@ -0,0 +1,388 @@ +package operatorca + +/* +Copyright The CryptOS Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "bytes" + "crypto/rand" + "crypto/sha1" //nolint:gosec // RFC 6960 CertIDs and key-hash ResponderIDs use SHA-1 as names, not signatures. + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "errors" + "fmt" + "math/big" + "time" + + fleetv1 "github.com/CryptOS-PKI/api/go/cryptos/fleet/v1" + "github.com/CryptOS-PKI/manager/internal/apperr" + "golang.org/x/crypto/ocsp" +) + +// Limits and sizes for OCSP (RFC 6960, RFC 8954). +const ( + // MaxOCSPSize caps an OCSP response body. + MaxOCSPSize = 64 << 10 + ocspNonceSize = 32 +) + +var ( + oidOCSPNonce = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 48, 1, 2} + oidOCSPNoCheck = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 48, 1, 5} + oidOCSPBasic = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 48, 1, 1} + oidSHA1 = asn1.ObjectIdentifier{1, 3, 14, 3, 2, 26} +) + +// The ASN.1 of RFC 6960 section 4, as far as the manager reads or writes +// it. golang.org/x/crypto/ocsp can't add a request nonce and doesn't expose +// responseExtensions, the CertID issuer hashes or every embedded +// certificate, so those parts are read here with encoding/asn1. + +type ocspCertID struct { + HashAlgorithm pkix.AlgorithmIdentifier + NameHash []byte + IssuerKeyHash []byte + SerialNumber *big.Int +} + +type ocspSingleRequest struct { + Cert ocspCertID +} + +type ocspTBSRequest struct { + Version int `asn1:"explicit,tag:0,default:0,optional"` + RequestList []ocspSingleRequest + Extensions []pkix.Extension `asn1:"explicit,tag:2,optional"` +} + +type ocspRequestASN1 struct { + TBSRequest ocspTBSRequest +} + +type ocspResponseASN1 struct { + Status asn1.Enumerated + Response ocspResponseBytes `asn1:"explicit,tag:0,optional"` +} + +type ocspResponseBytes struct { + ResponseType asn1.ObjectIdentifier + Response []byte +} + +type ocspBasicResponse struct { + TBSResponseData ocspResponseData + SignatureAlgorithm pkix.AlgorithmIdentifier + Signature asn1.BitString + Certificates []asn1.RawValue `asn1:"explicit,tag:0,optional"` +} + +type ocspResponseData struct { + Raw asn1.RawContent + Version int `asn1:"optional,default:0,explicit,tag:0"` + RawResponderID asn1.RawValue + ProducedAt time.Time `asn1:"generalized"` + Responses []ocspSingleResponse + ResponseExtensions []pkix.Extension `asn1:"explicit,tag:1,optional"` +} + +type ocspSingleResponse struct { + CertID ocspCertID + CertStatus asn1.RawValue + ThisUpdate time.Time `asn1:"generalized"` + NextUpdate time.Time `asn1:"generalized,explicit,tag:0,optional"` + SingleExtensions []pkix.Extension `asn1:"explicit,tag:1,optional"` +} + +// OCSPStatus is a certificate status from a validated OCSP response. +type OCSPStatus int + +// Certificate statuses. +const ( + OCSPGood OCSPStatus = iota + OCSPRevoked + OCSPUnknown +) + +func (s OCSPStatus) String() string { + switch s { + case OCSPGood: + return "good" + case OCSPRevoked: + return "revoked" + default: + return "unknown" + } +} + +// OCSPResult is a validated response for one certificate. +type OCSPResult struct { + Status OCSPStatus + ThisUpdate, NextUpdate time.Time +} + +func ocspInvalid(format string, args ...any) error { + return apperr.Reasoned(apperr.CodeOperatorCARejected, fleetv1.ErrorReason_ERROR_REASON_OCSP_INVALID, + fmt.Errorf("operatorca: OCSP response refused: "+format, args...)) +} + +func ocspUnreachable(format string, args ...any) error { + return apperr.Reasoned(apperr.CodeOperatorCARejected, fleetv1.ErrorReason_ERROR_REASON_OCSP_UNREACHABLE, + fmt.Errorf("operatorca: OCSP responder "+format, args...)) +} + +// ocspQuery is one request for one certificate under an anchor: the DER +// request, carrying a fresh nonce, and what the response must match. +type ocspQuery struct { + anchor *x509.Certificate + serial *big.Int + nameHash []byte + keyHash []byte + // nonce is the nonce extension's extnValue as sent. + nonce []byte + der []byte +} + +func spkiKeyBytes(c *x509.Certificate) ([]byte, error) { + var spki struct { + Algorithm pkix.AlgorithmIdentifier + PublicKey asn1.BitString + } + if _, err := asn1.Unmarshal(c.RawSubjectPublicKeyInfo, &spki); err != nil { + return nil, fmt.Errorf("operatorca: parse the public key of %s: %w", c.Subject, err) + } + return spki.PublicKey.RightAlign(), nil +} + +// newOCSPQuery builds a request for serial under anchor with a SHA-1 +// CertID, the hash every responder supports, and a 32-byte nonce +// (RFC 8954). +func newOCSPQuery(anchor *x509.Certificate, serial *big.Int) (*ocspQuery, error) { + key, err := spkiKeyBytes(anchor) + if err != nil { + return nil, err + } + nameHash := sha1.Sum(anchor.RawSubject) //nolint:gosec // CertID name hash + keyHash := sha1.Sum(key) //nolint:gosec // CertID key hash + raw := make([]byte, ocspNonceSize) + if _, err := rand.Read(raw); err != nil { + return nil, fmt.Errorf("operatorca: OCSP nonce: %w", err) + } + nonce, err := asn1.Marshal(raw) + if err != nil { + return nil, fmt.Errorf("operatorca: OCSP nonce: %w", err) + } + q := &ocspQuery{anchor: anchor, serial: serial, nameHash: nameHash[:], keyHash: keyHash[:], nonce: nonce} + q.der, err = asn1.Marshal(ocspRequestASN1{TBSRequest: ocspTBSRequest{ + RequestList: []ocspSingleRequest{{Cert: ocspCertID{ + HashAlgorithm: pkix.AlgorithmIdentifier{Algorithm: oidSHA1, Parameters: asn1.NullRawValue}, + NameHash: q.nameHash, IssuerKeyHash: q.keyHash, SerialNumber: serial, + }}}, + Extensions: []pkix.Extension{{Id: oidOCSPNonce, Value: nonce}}, + }}) + if err != nil { + return nil, fmt.Errorf("operatorca: marshal OCSP request: %w", err) + } + return q, nil +} + +func (q *ocspQuery) matches(id ocspCertID) bool { + return id.HashAlgorithm.Algorithm.Equal(oidSHA1) && id.SerialNumber != nil && id.SerialNumber.Cmp(q.serial) == 0 && + bytes.Equal(id.NameHash, q.nameHash) && bytes.Equal(id.IssuerKeyHash, q.keyHash) +} + +// validate checks a response against the query (RFC 6960 sections 3.2 and +// 4.2.2.2): a successful basic response; a signer that is the anchor, or a +// delegated responder issued directly by the anchor with id-kp-OCSPSigning, +// valid now and named by the ResponderID; a single response whose CertID +// matches the request; a thisUpdate at most 5 minutes ahead and a +// nextUpdate not passed; a nonce, if echoed, equal to the one sent; no +// unknown critical extension. A delegated responder without +// id-pkix-ocsp-nocheck must not be revoked by isRevoked (the anchor's +// denylist and CRL; never OCSP, so there is no recursion). Errors never +// carry the response body. +func (q *ocspQuery) validate(der []byte, now time.Time, isRevoked func(serial string) bool) (OCSPResult, error) { + var outer ocspResponseASN1 + if rest, err := asn1.Unmarshal(der, &outer); err != nil || len(rest) > 0 { + return OCSPResult{}, ocspInvalid("the response isn't a DER OCSPResponse") + } + if st := ocsp.ResponseStatus(outer.Status); st != ocsp.Success { + return OCSPResult{}, ocspInvalid("response status %s", st) + } + if !outer.Response.ResponseType.Equal(oidOCSPBasic) { + return OCSPResult{}, ocspInvalid("response type %v isn't id-pkix-ocsp-basic", outer.Response.ResponseType) + } + var basic ocspBasicResponse + if rest, err := asn1.Unmarshal(outer.Response.Response, &basic); err != nil || len(rest) > 0 { + return OCSPResult{}, ocspInvalid("the BasicOCSPResponse doesn't parse") + } + tbs := basic.TBSResponseData + + var single *ocspSingleResponse + for i := range tbs.Responses { + if q.matches(tbs.Responses[i].CertID) { + single = &tbs.Responses[i] + break + } + } + if single == nil { + return OCSPResult{}, ocspInvalid("no single response matches the requested CertID (hash algorithm, issuer name and key hashes, serial)") + } + for _, e := range single.SingleExtensions { + if e.Critical { + return OCSPResult{}, ocspInvalid("unknown critical single extension %v", e.Id) + } + } + for _, e := range tbs.ResponseExtensions { + switch { + case e.Id.Equal(oidOCSPNonce): + if !bytes.Equal(e.Value, q.nonce) { + return OCSPResult{}, ocspInvalid("the echoed nonce doesn't match the request") + } + case e.Critical: + return OCSPResult{}, ocspInvalid("unknown critical response extension %v", e.Id) + } + } + + signer, err := q.signer(tbs.RawResponderID, basic.Certificates, now, isRevoked) + if err != nil { + return OCSPResult{}, err + } + resp, err := ocsp.ParseResponseForCert(der, &x509.Certificate{SerialNumber: q.serial}, nil) + if err != nil { + return OCSPResult{}, ocspInvalid("parse: %v", parseErrorClass(err)) + } + if err := resp.CheckSignatureFrom(signer); err != nil { + return OCSPResult{}, ocspInvalid("bad signature from %s", signer.Subject) + } + + if single.ThisUpdate.After(now.Add(maxClockSkew)) { + return OCSPResult{}, ocspInvalid("thisUpdate %s is more than 5 minutes ahead", single.ThisUpdate.UTC().Format(time.RFC3339)) + } + if !single.NextUpdate.IsZero() && !now.Before(single.NextUpdate) { + return OCSPResult{}, ocspInvalid("stale: nextUpdate %s has passed", single.NextUpdate.UTC().Format(time.RFC3339)) + } + res := OCSPResult{ThisUpdate: single.ThisUpdate, NextUpdate: single.NextUpdate} + switch resp.Status { + case ocsp.Good: + res.Status = OCSPGood + case ocsp.Revoked: + res.Status = OCSPRevoked + default: + res.Status = OCSPUnknown + } + return res, nil +} + +// parseErrorClass keeps an error from x/crypto/ocsp short: a ResponseError +// or ParseError says what was wrong without quoting the body. +func parseErrorClass(err error) error { + var pe ocsp.ParseError + var re ocsp.ResponseError + switch { + case errors.As(err, &pe), errors.As(err, &re): + return err + default: + return errors.New("malformed response") + } +} + +// signer picks the certificate that must have signed the response: the +// anchor when the ResponderID names it, otherwise an embedded delegated +// responder the ResponderID names. +func (q *ocspQuery) signer(rid asn1.RawValue, embedded []asn1.RawValue, now time.Time, isRevoked func(string) bool) (*x509.Certificate, error) { + names, err := responderIDMatcher(rid) + if err != nil { + return nil, err + } + if names(q.anchor) { + return q.anchor, nil + } + for _, raw := range embedded { + c, err := x509.ParseCertificate(raw.FullBytes) + if err != nil { + return nil, ocspInvalid("an embedded certificate doesn't parse") + } + if !names(c) { + continue + } + if err := q.checkDelegated(c, now, isRevoked); err != nil { + return nil, err + } + return c, nil + } + return nil, ocspInvalid("the ResponderID names neither the operator CA nor an embedded responder certificate") +} + +func (q *ocspQuery) checkDelegated(c *x509.Certificate, now time.Time, isRevoked func(string) bool) error { + if !bytes.Equal(c.RawIssuer, q.anchor.RawSubject) || c.CheckSignatureFrom(q.anchor) != nil { + return ocspInvalid("the responder certificate %s isn't issued directly by the operator CA", c.Subject) + } + ok := false + for _, u := range c.ExtKeyUsage { + if u == x509.ExtKeyUsageOCSPSigning { + ok = true + } + } + if !ok { + return ocspInvalid("the responder certificate %s lacks id-kp-OCSPSigning", c.Subject) + } + if now.Before(c.NotBefore) || now.After(c.NotAfter) { + return ocspInvalid("the responder certificate %s isn't valid now (%s to %s)", c.Subject, + c.NotBefore.UTC().Format(time.RFC3339), c.NotAfter.UTC().Format(time.RFC3339)) + } + for _, e := range c.Extensions { + if e.Id.Equal(oidOCSPNoCheck) { + return nil + } + } + if isRevoked != nil && isRevoked(SerialKey(c.SerialNumber)) { + return ocspInvalid("the responder certificate %s (serial %s) is revoked", c.Subject, SerialKey(c.SerialNumber)) + } + return nil +} + +// responderIDMatcher returns a test for whether a certificate is the one a +// ResponderID names: byName ([1]) compares the subject DER, byKey ([2]) the +// SHA-1 of the public key. +func responderIDMatcher(rid asn1.RawValue) (func(*x509.Certificate) bool, error) { + if rid.Class != asn1.ClassContextSpecific { + return nil, ocspInvalid("bad ResponderID") + } + switch rid.Tag { + case 1: + var name asn1.RawValue + if rest, err := asn1.Unmarshal(rid.Bytes, &name); err != nil || len(rest) > 0 { + return nil, ocspInvalid("bad ResponderID name") + } + return func(c *x509.Certificate) bool { return bytes.Equal(c.RawSubject, name.FullBytes) }, nil + case 2: + var hash []byte + if rest, err := asn1.Unmarshal(rid.Bytes, &hash); err != nil || len(rest) > 0 { + return nil, ocspInvalid("bad ResponderID key hash") + } + return func(c *x509.Certificate) bool { + key, err := spkiKeyBytes(c) + if err != nil { + return false + } + sum := sha1.Sum(key) //nolint:gosec // RFC 6960 KeyHash + return bytes.Equal(sum[:], hash) + }, nil + default: + return nil, ocspInvalid("bad ResponderID tag %d", rid.Tag) + } +} diff --git a/internal/operatorca/ocsp_revocation_test.go b/internal/operatorca/ocsp_revocation_test.go new file mode 100644 index 0000000..a5bc4fa --- /dev/null +++ b/internal/operatorca/ocsp_revocation_test.go @@ -0,0 +1,424 @@ +package operatorca + +/* +Copyright The CryptOS Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "context" + "math/big" + "sync" + "testing" + "time" + + fleetv1 "github.com/CryptOS-PKI/api/go/cryptos/fleet/v1" + "github.com/CryptOS-PKI/manager/internal/apperr" + "github.com/CryptOS-PKI/manager/internal/store" + "golang.org/x/crypto/ocsp" +) + +func allowed(t *testing.T, err error, what string) { + t.Helper() + if err != nil { + t.Fatalf("%s = %v, want allowed", what, err) + } +} + +// OCSP revoked and unknown refuse; unknown is audited once per cache +// period; good never overrides the denylist or the CRL. +func TestCheckWebCert_OCSPStatus(t *testing.T) { + ca := newCA(t, caOpts{}) + f := newOCSPFixture(t, PolicySoft, nil) + clock := f.clock + r := newTestResponder(t, ca, clock.Now) + a := ocspAnchor(ca, store.CRLSourceURL, store.OCSPModeURL, r.URL()) + f.rev.SetAnchors([]Anchor{a}) + mustStoreCRL(t, f.rev, a, ca.crl(t, crlOpts{revoked: []*big.Int{big.NewInt(0x66)}})) + + revokedLeaf := ca.leaf(t, leafOpts{serial: big.NewInt(0x100)}) + unknownLeaf := ca.leaf(t, leafOpts{serial: big.NewInt(0x101)}) + goodLeaf := ca.leaf(t, leafOpts{serial: big.NewInt(0x102)}) + crlListed := ca.leaf(t, leafOpts{serial: big.NewInt(0x66)}) + r.set(func(r *testResponder) { + r.status["100"] = ocsp.Revoked + r.status["101"] = ocsp.Unknown + }) + + wantReason(t, f.rev.CheckWebCert(a.SHA256, revokedLeaf), apperr.CodeCertRejected, fleetv1.ErrorReason_ERROR_REASON_REVOKED_OCSP) + wantReason(t, f.rev.CheckWebCert(a.SHA256, unknownLeaf), apperr.CodeCertRejected, fleetv1.ErrorReason_ERROR_REASON_OCSP_UNKNOWN) + wantReason(t, f.rev.CheckWebCert(a.SHA256, unknownLeaf), apperr.CodeCertRejected, fleetv1.ErrorReason_ERROR_REASON_OCSP_UNKNOWN) + if n := f.audit.kinds(KindOCSPUnknown); n != 1 { + t.Fatalf("%s audited %d times in one cache period, want 1", KindOCSPUnknown, n) + } + clock.Add(31 * time.Minute) + wantReason(t, f.rev.CheckWebCert(a.SHA256, unknownLeaf), apperr.CodeCertRejected, fleetv1.ErrorReason_ERROR_REASON_OCSP_UNKNOWN) + if n := f.audit.kinds(KindOCSPUnknown); n != 2 { + t.Fatalf("%s audited %d times over two cache periods, want 2", KindOCSPUnknown, n) + } + + allowed(t, f.rev.CheckWebCert(a.SHA256, goodLeaf), "good") + if err := f.rev.Deny(context.Background(), store.DenylistEntry{IssuerSHA256: a.SHA256, SerialHex: "102"}); err != nil { + t.Fatal(err) + } + wantReason(t, f.rev.CheckWebCert(a.SHA256, goodLeaf), apperr.CodeCertRejected, fleetv1.ErrorReason_ERROR_REASON_REVOKED) + wantReason(t, f.rev.CheckWebCert(a.SHA256, crlListed), apperr.CodeCertRejected, fleetv1.ErrorReason_ERROR_REASON_REVOKED) +} + +// The responder comes from the anchor's mode: aia reads the leaf, url the +// anchor, off nothing. +func TestCheckWebCert_ResponderSource(t *testing.T) { + ca := newCA(t, caOpts{}) + clock := &fakeClock{t: testNow} + aiaResp := newTestResponder(t, ca, clock.Now) + urlResp := newTestResponder(t, ca, clock.Now) + withAIA := ca.leaf(t, leafOpts{ocspServer: []string{aiaResp.URL()}}) + noAIA := ca.leaf(t, leafOpts{}) + + f := newOCSPFixture(t, PolicyHard, nil, ocspAnchor(ca, store.CRLSourceNone, store.OCSPModeAIA, "")) + sha := Fingerprint(ca.cert) + allowed(t, f.rev.CheckWebCert(sha, withAIA), "aia with a URI") + if aiaResp.count() != 1 { + t.Fatalf("aia: the leaf's responder got %d requests, want 1", aiaResp.count()) + } + allowed(t, f.rev.CheckWebCert(sha, noAIA), "aia leaf without a URI") + if aiaResp.count() != 1 || urlResp.count() != 0 { + t.Fatalf("aia leaf without a URI sent a request") + } + + f = newOCSPFixture(t, PolicyHard, nil, ocspAnchor(ca, store.CRLSourceNone, store.OCSPModeURL, urlResp.URL())) + allowed(t, f.rev.CheckWebCert(sha, withAIA), "url") + if urlResp.count() != 1 || aiaResp.count() != 1 { + t.Fatalf("url: anchor responder %d, leaf responder %d requests; want 1 and unchanged", urlResp.count(), aiaResp.count()) + } + + f = newOCSPFixture(t, PolicyHard, nil, ocspAnchor(ca, store.CRLSourceNone, store.OCSPModeOff, urlResp.URL())) + allowed(t, f.rev.CheckWebCert(sha, withAIA), "off") + if urlResp.count() != 1 || aiaResp.count() != 1 { + t.Fatal("off sent a request") + } +} + +// The AIA URI is read only after the leaf chains to a trusted anchor. +func TestPeerAuthorizer_NoAIAFromAnUnchainedLeaf(t *testing.T) { + st := newFakeTrust() + ca := liveCA(t, "Example Operator CA") + rogue := liveCA(t, "Example Rogue CA") + resp := newTestResponder(t, ca, time.Now) + row := registeredRow(ca, store.OperatorCAActive) + row.OCSPMode = store.OCSPModeAIA + if err := st.AddOperatorCA(t.Context(), row); err != nil { + t.Fatal(err) + } + rev := NewRevocations(RevocationOptions{Store: st, Policy: PolicySoft, OCSPFetcher: NewOCSPFetcher()}) + base, _ := serverTLS(t) + trust, err := NewTrustStore(t.Context(), registered(), st, rev, base, nil) + if err != nil { + t.Fatal(err) + } + auth := PeerAuthorizer{Trust: trust, Rev: rev} + + bad := liveLeaf(t, rogue, leafOpts{ocspServer: []string{resp.URL()}}) + if _, err := auth.AuthorizePeer(bad.Leaf, nil); err == nil { + t.Fatal("a leaf from an untrusted CA was authorized") + } + if resp.count() != 0 { + t.Fatalf("the AIA URI of an unchained leaf got %d requests", resp.count()) + } + good := liveLeaf(t, ca, leafOpts{ocspServer: []string{resp.URL()}}) + if _, err := auth.AuthorizePeer(good.Leaf, nil); err != nil { + t.Fatalf("AuthorizePeer = %v", err) + } + if resp.count() != 1 { + t.Fatalf("the trusted leaf's AIA responder got %d requests, want 1", resp.count()) + } +} + +// No fresh OCSP response falls back to a fresh CRL, whatever the policy. +func TestCheckWebCert_FallbackToTheCRL(t *testing.T) { + ca := newCA(t, caOpts{}) + other := newCA(t, caOpts{cn: "Example Other CA"}) + for name, broken := range map[string]func(r *testResponder){ + "unreachable": func(r *testResponder) { r.down = true }, + "invalid": func(r *testResponder) { r.wrongKey = &other }, + } { + t.Run(name, func(t *testing.T) { + r := newTestResponder(t, ca, func() time.Time { return testNow }) + r.set(broken) + a := ocspAnchor(ca, store.CRLSourceURL, store.OCSPModeURL, r.URL()) + f := newOCSPFixture(t, PolicyHard, nil, a) + mustStoreCRL(t, f.rev, a, ca.crl(t, crlOpts{revoked: []*big.Int{big.NewInt(0x66)}})) + allowed(t, f.rev.CheckWebCert(a.SHA256, ca.leaf(t, leafOpts{serial: big.NewInt(0x200)})), "fresh CRL, no OCSP") + wantReason(t, f.rev.CheckWebCert(a.SHA256, ca.leaf(t, leafOpts{serial: big.NewInt(0x66)})), apperr.CodeCertRejected, fleetv1.ErrorReason_ERROR_REASON_REVOKED) + }) + } +} + +// No fresh OCSP response and no CRL: soft allows with a banner, an hourly +// log line and one audit row per outage; hard refuses with STALE_OCSP. +func TestCheckWebCert_NoOCSPNoCRL(t *testing.T) { + ca := newCA(t, caOpts{}) + leaf := ca.leaf(t, leafOpts{serial: big.NewInt(0x300)}) + + t.Run("soft", func(t *testing.T) { + f := newOCSPFixture(t, PolicySoft, nil) + r := newTestResponder(t, ca, f.clock.Now) + a := ocspAnchor(ca, store.CRLSourceNone, store.OCSPModeURL, r.URL()) + f.rev.SetAnchors([]Anchor{a}) + r.set(func(r *testResponder) { r.down = true }) + + allowed(t, f.rev.CheckWebCert(a.SHA256, leaf), "soft, responder down") + if st := f.rev.Status(a.SHA256); st.OCSPBanner == "" { + t.Fatal("no OCSP banner during the outage") + } + for range 3 { + f.clock.Add(time.Minute) + allowed(t, f.rev.CheckWebCert(a.SHA256, leaf), "soft, responder down") + } + if n := f.logs.count("OCSP responder unreachable"); n != 1 { + t.Fatalf("outage logged %d times within the hour, want 1", n) + } + if n := f.audit.kinds(KindOCSPUnavailable); n != 1 { + t.Fatalf("%s audited %d times in one outage, want 1", KindOCSPUnavailable, n) + } + f.clock.Add(time.Hour) + allowed(t, f.rev.CheckWebCert(a.SHA256, leaf), "soft, responder down") + if n := f.logs.count("OCSP responder unreachable"); n != 2 { + t.Fatalf("outage logged %d times over an hour, want 2", n) + } + + r.set(func(r *testResponder) { r.down = false }) + f.clock.Add(time.Minute) + allowed(t, f.rev.CheckWebCert(a.SHA256, leaf), "responder back") + if st := f.rev.Status(a.SHA256); st.OCSPBanner != "" { + t.Fatalf("banner %q after the responder came back", st.OCSPBanner) + } + r.set(func(r *testResponder) { r.down = true }) + f.clock.Add(2 * time.Hour) + allowed(t, f.rev.CheckWebCert(a.SHA256, leaf), "second outage") + if n := f.audit.kinds(KindOCSPUnavailable); n != 2 { + t.Fatalf("%s audited %d times over two outages, want 2", KindOCSPUnavailable, n) + } + }) + t.Run("hard", func(t *testing.T) { + r := newTestResponder(t, ca, func() time.Time { return testNow }) + r.set(func(r *testResponder) { r.down = true }) + a := ocspAnchor(ca, store.CRLSourceNone, store.OCSPModeURL, r.URL()) + f := newOCSPFixture(t, PolicyHard, nil, a) + wantReason(t, f.rev.CheckWebCert(a.SHA256, leaf), apperr.CodeNoRevocationSource, fleetv1.ErrorReason_ERROR_REASON_STALE_OCSP) + }) +} + +// Hard with a stale CRL: a fresh OCSP good is current revocation data. +func TestCheckWebCert_HardWithAStaleCRL(t *testing.T) { + ca := newCA(t, caOpts{}) + f := newOCSPFixture(t, PolicyHard, nil) + r := newTestResponder(t, ca, f.clock.Now) + a := ocspAnchor(ca, store.CRLSourceURL, store.OCSPModeAIA, "") + f.rev.SetAnchors([]Anchor{a}) + mustStoreCRL(t, f.rev, a, ca.crl(t, crlOpts{})) + f.clock.Add(8 * 24 * time.Hour) + + allowed(t, f.rev.CheckWebCert(a.SHA256, ca.leaf(t, leafOpts{ocspServer: []string{r.URL()}})), "fresh OCSP good") + + down := newTestResponder(t, ca, f.clock.Now) + down.set(func(r *testResponder) { r.down = true }) + wantReason(t, f.rev.CheckWebCert(a.SHA256, ca.leaf(t, leafOpts{ocspServer: []string{down.URL()}})), + apperr.CodeNoRevocationSource, fleetv1.ErrorReason_ERROR_REASON_STALE_OCSP) + wantReason(t, f.rev.CheckWebCert(a.SHA256, ca.leaf(t, leafOpts{})), + apperr.CodeNoRevocationSource, fleetv1.ErrorReason_ERROR_REASON_STALE_CRL) +} + +// A denylist write wins over a cached good. +func TestCheckWebCert_DenylistBeatsACachedGood(t *testing.T) { + ca := newCA(t, caOpts{}) + r := newTestResponder(t, ca, func() time.Time { return testNow }) + a := ocspAnchor(ca, store.CRLSourceNone, store.OCSPModeURL, r.URL()) + f := newOCSPFixture(t, PolicySoft, nil, a) + leaf := ca.leaf(t, leafOpts{}) + allowed(t, f.rev.CheckWebCert(a.SHA256, leaf), "good") + if err := f.rev.Deny(context.Background(), store.DenylistEntry{IssuerSHA256: a.SHA256, SerialHex: SerialKey(leaf.SerialNumber)}); err != nil { + t.Fatal(err) + } + wantReason(t, f.rev.CheckWebCert(a.SHA256, leaf), apperr.CodeCertRejected, fleetv1.ErrorReason_ERROR_REASON_REVOKED) + if r.count() != 1 { + t.Fatalf("responder got %d requests, want 1 (cached)", r.count()) + } +} + +// MCP needs a fresh CRL; OCSP can only add a refusal there. +func TestCheckMCPCert_OCSP(t *testing.T) { + ca := newCA(t, caOpts{}) + other := newCA(t, caOpts{cn: "Example Other CA"}) + setup := func(t *testing.T, crl bool, mutate func(r *testResponder)) (ocspFixture, Anchor) { + t.Helper() + r := newTestResponder(t, ca, func() time.Time { return testNow }) + if mutate != nil { + r.set(mutate) + } + src := store.CRLSourceNone + if crl { + src = store.CRLSourceURL + } + a := ocspAnchor(ca, src, store.OCSPModeURL, r.URL()) + f := newOCSPFixture(t, PolicySoft, nil, a) + if crl { + mustStoreCRL(t, f.rev, a, ca.crl(t, crlOpts{})) + } + return f, a + } + leaf := ca.leaf(t, leafOpts{serial: big.NewInt(0x400)}) + + for name, mutate := range map[string]func(r *testResponder){ + "responder down": func(r *testResponder) { r.down = true }, + "invalid response": func(r *testResponder) { r.wrongKey = &other }, + "good": nil, + } { + f, a := setup(t, true, mutate) + allowed(t, f.rev.CheckMCPCert(a.SHA256, leaf), "fresh CRL, "+name) + } + f, a := setup(t, true, func(r *testResponder) { r.status["400"] = ocsp.Revoked }) + wantReason(t, f.rev.CheckMCPCert(a.SHA256, leaf), apperr.CodeCertRejected, fleetv1.ErrorReason_ERROR_REASON_REVOKED_OCSP) + f, a = setup(t, true, func(r *testResponder) { r.status["400"] = ocsp.Unknown }) + wantReason(t, f.rev.CheckMCPCert(a.SHA256, leaf), apperr.CodeCertRejected, fleetv1.ErrorReason_ERROR_REASON_OCSP_UNKNOWN) + + f, a = setup(t, false, nil) + wantReason(t, f.rev.CheckMCPCert(a.SHA256, leaf), apperr.CodeNoRevocationSource, fleetv1.ErrorReason_ERROR_REASON_NO_CRL) + + f, a = setup(t, true, nil) + f.clock.Add(8 * 24 * time.Hour) + _ = f.rev.Reload(t.Context()) + wantReason(t, f.rev.CheckMCPCert(a.SHA256, leaf), apperr.CodeNoRevocationSource, fleetv1.ErrorReason_ERROR_REASON_STALE_CRL) +} + +// The cache lives min(nextUpdate, 1 h), 5 minutes without nextUpdate, and +// 30 s for a failure; an entry in use is refreshed at half its life. +func TestOCSPCache_TTL(t *testing.T) { + ca := newCA(t, caOpts{}) + a := anchorFor(ca, store.CRLSourceNone, store.OCSPModeURL) + clock := &fakeClock{t: testNow} + r := newTestResponder(t, ca, clock.Now) + bg := &bgRunner{} + c := NewOCSPClient(OCSPOptions{Fetch: NewOCSPFetcher(), Now: clock.Now, Background: bg.Go}) + + for name, tc := range map[string]struct { + set func(r *testResponder) + want time.Duration + }{ + "nextUpdate in 30 min": {func(r *testResponder) { r.next, r.noNext = 30*time.Minute, false }, 30 * time.Minute}, + "nextUpdate in 3 h": {func(r *testResponder) { r.next, r.noNext = 3*time.Hour, false }, time.Hour}, + "no nextUpdate": {func(r *testResponder) { r.noNext = true }, 5 * time.Minute}, + } { + r.set(tc.set) + serial := nextSerial() + if _, err := c.Check(a, serial, r.URL(), notRevoked); err != nil { + t.Fatalf("%s: Check = %v", name, err) + } + e, ok := c.cached(a.SHA256, serial) + if !ok { + t.Fatalf("%s: nothing cached", name) + } + if got := e.expires.Sub(clock.Now()); got < tc.want-time.Second || got > tc.want+time.Second { + t.Fatalf("%s: cached for %s, want %s", name, got, tc.want) + } + } + + r.set(func(r *testResponder) { r.next, r.noNext = 30*time.Minute+time.Minute, false }) + serial := nextSerial() + if _, err := c.Check(a, serial, r.URL(), notRevoked); err != nil { + t.Fatal(err) + } + base := r.count() + clock.Add(14 * time.Minute) + if _, err := c.Check(a, serial, r.URL(), notRevoked); err != nil { + t.Fatal(err) + } + bg.wait() + if r.count() != base { + t.Fatalf("refreshed before half the TTL") + } + clock.Add(2 * time.Minute) + if _, err := c.Check(a, serial, r.URL(), notRevoked); err != nil { + t.Fatal(err) + } + bg.wait() + if r.count() != base+1 { + t.Fatalf("an entry in use past half its TTL: %d requests, want %d", r.count(), base+1) + } + clock.Add(2 * time.Hour) + if r.count() != base+1 { + t.Fatal("an idle entry was fetched") + } + if _, ok := c.cached(a.SHA256, serial); ok { + t.Fatal("an idle entry outlived its TTL") + } + + r.set(func(r *testResponder) { r.down = true }) + serial = nextSerial() + _, err := c.Check(a, serial, r.URL(), notRevoked) + wantReason(t, err, apperr.CodeOperatorCARejected, fleetv1.ErrorReason_ERROR_REASON_OCSP_UNREACHABLE) + base = r.count() + clock.Add(29 * time.Second) + _, err = c.Check(a, serial, r.URL(), notRevoked) + wantReason(t, err, apperr.CodeOperatorCARejected, fleetv1.ErrorReason_ERROR_REASON_OCSP_UNREACHABLE) + if r.count() != base { + t.Fatal("a failed fetch was retried within 30 s") + } + clock.Add(2 * time.Second) + _, _ = c.Check(a, serial, r.URL(), notRevoked) + if r.count() != base+1 { + t.Fatal("a failed fetch wasn't retried after 30 s") + } +} + +// Concurrent requests for one uncached certificate share one fetch. +func TestOCSPCache_SingleFlight(t *testing.T) { + ca := newCA(t, caOpts{}) + a := anchorFor(ca, store.CRLSourceNone, store.OCSPModeURL) + r := newTestResponder(t, ca, func() time.Time { return testNow }) + gate := make(chan struct{}) + r.set(func(r *testResponder) { r.gate = gate }) + c := NewOCSPClient(OCSPOptions{Fetch: NewOCSPFetcher(), Now: func() time.Time { return testNow }}) + + run := func(serials ...*big.Int) { + var wg sync.WaitGroup + started := make(chan struct{}, 50) + for i := range 50 { + wg.Add(1) + go func() { + defer wg.Done() + started <- struct{}{} + if _, err := c.Check(a, serials[i%len(serials)], r.URL(), notRevoked); err != nil { + t.Errorf("Check = %v", err) + } + }() + } + for range 50 { + <-started + } + time.Sleep(100 * time.Millisecond) + close(gate) + wg.Wait() + } + run(big.NewInt(0x500)) + if r.count() != 1 { + t.Fatalf("50 concurrent checks of one cert made %d requests, want 1", r.count()) + } + gate = make(chan struct{}) + r.set(func(r *testResponder) { r.gate = gate }) + run(big.NewInt(0x501), big.NewInt(0x502)) + if r.count() != 3 { + t.Fatalf("two serials made %d more requests, want 2", r.count()-1) + } +} diff --git a/internal/operatorca/ocsp_test.go b/internal/operatorca/ocsp_test.go new file mode 100644 index 0000000..6703683 --- /dev/null +++ b/internal/operatorca/ocsp_test.go @@ -0,0 +1,316 @@ +package operatorca + +/* +Copyright The CryptOS Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "bytes" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + fleetv1 "github.com/CryptOS-PKI/api/go/cryptos/fleet/v1" + "github.com/CryptOS-PKI/manager/internal/apperr" + "golang.org/x/crypto/ocsp" +) + +func mustQuery(t *testing.T, ca testCA, serial *big.Int) *ocspQuery { + t.Helper() + q, err := newOCSPQuery(ca.cert, serial) + if err != nil { + t.Fatalf("newOCSPQuery: %v", err) + } + return q +} + +func notRevoked(string) bool { return false } + +func wantInvalid(t *testing.T, err error) { + t.Helper() + wantReason(t, err, apperr.CodeOperatorCARejected, fleetv1.ErrorReason_ERROR_REASON_OCSP_INVALID) +} + +func TestOCSPValidate_Accepted(t *testing.T) { + ca := newCA(t, caOpts{}) + serial := big.NewInt(0x77) + signer := ca.delegated(t, delegatedOpts{noCheck: true}) + for name, o := range map[string]respOpts{ + "signed by the anchor": {}, + "signed by a delegated cert": {signer: &signer}, + } { + t.Run(name, func(t *testing.T) { + q := mustQuery(t, ca, serial) + o.serial = serial + o.nonce = q.nonce + res, err := q.validate(ca.ocspResponse(t, o), testNow, notRevoked) + if err != nil { + t.Fatalf("validate = %v", err) + } + if res.Status != OCSPGood || !res.NextUpdate.Equal(testNow.Add(29*time.Minute).Truncate(time.Second)) { + t.Fatalf("result = %+v", res) + } + }) + } +} + +func TestOCSPValidate_Refused(t *testing.T) { + ca := newCA(t, caOpts{}) + other := newCA(t, caOpts{cn: "Example Other CA"}) + serial := big.NewInt(0x77) + delegated := ca.delegated(t, delegatedOpts{noCheck: true}) + noEKU := ca.delegated(t, delegatedOpts{noEKU: true, noCheck: true}) + foreign := other.delegated(t, delegatedOpts{noCheck: true}) + expired := ca.delegated(t, delegatedOpts{noCheck: true, notAfter: testNow.Add(-time.Minute)}) + sameKeyOtherName := newCA(t, caOpts{cn: "Example Renamed CA", key: ca.key}) + sameNameOtherKey := newCA(t, caOpts{}) + + for name, o := range map[string]respOpts{ + "wrong signer": {signer: &other, responderID: ca.cert, embed: []*x509.Certificate{}}, + "delegated without OCSPSigning": {signer: &noEKU}, + "delegated issued by another CA": {signer: &foreign}, + "expired delegated cert": {signer: &expired}, + "ResponderID not the signer": {signer: &delegated, responderID: ca.cert}, + "CertID serial": {serial: big.NewInt(0x78)}, + "CertID issuer name hash": {certIDIssuer: sameKeyOtherName.cert}, + "CertID issuer key hash": {certIDIssuer: sameNameOtherKey.cert}, + "thisUpdate more than 5 min ahead": {thisUpdate: testNow.Add(6 * time.Minute)}, + "stale (nextUpdate passed)": {thisUpdate: testNow.Add(-2 * time.Hour), nextUpdate: testNow.Add(-time.Minute)}, + "unknown critical single ext": {singleExt: []pkix.Extension{{Id: asn1.ObjectIdentifier{1, 2, 3, 4}, Critical: true, Value: asn1.NullBytes}}}, + "unknown critical response ext": {responseExt: []pkix.Extension{{Id: asn1.ObjectIdentifier{1, 2, 3, 4}, Critical: true, Value: asn1.NullBytes}}}, + } { + t.Run(name, func(t *testing.T) { + q := mustQuery(t, ca, serial) + if o.serial == nil { + o.serial = serial + } + _, err := q.validate(ca.ocspResponse(t, o), testNow, notRevoked) + wantInvalid(t, err) + }) + } + + t.Run("a non-successful response status", func(t *testing.T) { + q := mustQuery(t, ca, serial) + for _, body := range [][]byte{ocsp.TryLaterErrorResponse, ocsp.UnauthorizedErrorResponse, ocsp.MalformedRequestErrorResponse} { + _, err := q.validate(body, testNow, notRevoked) + wantInvalid(t, err) + } + }) + t.Run("garbage", func(t *testing.T) { + q := mustQuery(t, ca, serial) + _, err := q.validate([]byte("SECRET-BODY-DO-NOT-ECHO"), testNow, notRevoked) + wantInvalid(t, err) + if strings.Contains(err.Error(), "SECRET") { + t.Fatalf("error echoes the body: %v", err) + } + }) +} + +func TestOCSPValidate_NoCheck(t *testing.T) { + ca := newCA(t, caOpts{}) + serial := big.NewInt(0x77) + withNoCheck := ca.delegated(t, delegatedOpts{noCheck: true}) + without := ca.delegated(t, delegatedOpts{}) + + q := mustQuery(t, ca, serial) + called := false + spy := func(string) bool { called = true; return true } + if _, err := q.validate(ca.ocspResponse(t, respOpts{serial: serial, signer: &withNoCheck}), testNow, spy); err != nil { + t.Fatalf("noCheck signer: %v", err) + } + if called { + t.Fatal("a delegated signer with noCheck was revocation-checked") + } + + q = mustQuery(t, ca, serial) + if _, err := q.validate(ca.ocspResponse(t, respOpts{serial: serial, signer: &without}), testNow, notRevoked); err != nil { + t.Fatalf("delegated signer without noCheck, not revoked: %v", err) + } + revokedSigner := func(s string) bool { return s == SerialKey(without.cert.SerialNumber) } + _, err := q.validate(ca.ocspResponse(t, respOpts{serial: serial, signer: &without}), testNow, revokedSigner) + wantInvalid(t, err) +} + +func TestOCSPRequest_Nonce(t *testing.T) { + ca := newCA(t, caOpts{}) + serial := big.NewInt(0x77) + q := mustQuery(t, ca, serial) + p := parseTestRequest(t, q.der) + var nonce []byte + if rest, err := asn1.Unmarshal(p.nonce, &nonce); err != nil || len(rest) > 0 { + t.Fatalf("the nonce extension isn't an OCTET STRING: %v", err) + } + if len(nonce) != 32 { + t.Fatalf("nonce is %d bytes, want 32", len(nonce)) + } + if p.serial.Cmp(serial) != 0 { + t.Fatalf("request serial = %v", p.serial) + } + if q2 := mustQuery(t, ca, serial); bytes.Equal(q2.nonce, q.nonce) { + t.Fatal("two requests carry the same nonce") + } + + if _, err := q.validate(ca.ocspResponse(t, respOpts{serial: serial, nonce: q.nonce}), testNow, notRevoked); err != nil { + t.Fatalf("matching nonce: %v", err) + } + if _, err := q.validate(ca.ocspResponse(t, respOpts{serial: serial}), testNow, notRevoked); err != nil { + t.Fatalf("no nonce in the response: %v", err) + } + other := mustQuery(t, ca, serial) + _, err := q.validate(ca.ocspResponse(t, respOpts{serial: serial, nonce: other.nonce}), testNow, notRevoked) + wantInvalid(t, err) +} + +func TestOCSPValidate_Statuses(t *testing.T) { + ca := newCA(t, caOpts{}) + serial := big.NewInt(0x77) + for status, want := range map[int]OCSPStatus{ocsp.Good: OCSPGood, ocsp.Revoked: OCSPRevoked, ocsp.Unknown: OCSPUnknown} { + q := mustQuery(t, ca, serial) + res, err := q.validate(ca.ocspResponse(t, respOpts{serial: serial, status: status}), testNow, notRevoked) + if err != nil || res.Status != want { + t.Fatalf("status %d: %+v, %v", status, res, err) + } + } +} + +func TestOCSPTransport(t *testing.T) { + ca := newCA(t, caOpts{}) + a := anchorFor(ca, "none", "url") + clock := &fakeClock{t: testNow} + newClient := func(f *Fetcher) *OCSPClient { + return NewOCSPClient(OCSPOptions{Fetch: f, Now: clock.Now}) + } + + t.Run("POST by default", func(t *testing.T) { + r := newTestResponder(t, ca, clock.Now) + res, err := newClient(NewOCSPFetcher()).Check(a, big.NewInt(0x10), r.URL(), notRevoked) + if err != nil || res.Status != OCSPGood { + t.Fatalf("Check = %+v, %v", res, err) + } + if got := r.last().method; got != http.MethodPost { + t.Fatalf("method = %s, want POST", got) + } + }) + t.Run("GET after a 405 for a small request", func(t *testing.T) { + r := newTestResponder(t, ca, clock.Now) + r.set(func(r *testResponder) { r.noPOST = true }) + res, err := newClient(NewOCSPFetcher()).Check(a, big.NewInt(0x11), r.URL(), notRevoked) + if err != nil || res.Status != OCSPGood { + t.Fatalf("Check = %+v, %v", res, err) + } + if got := r.last().method; got != http.MethodGet { + t.Fatalf("method = %s, want GET", got) + } + }) + t.Run("no GET for a large request", func(t *testing.T) { + r := newTestResponder(t, ca, clock.Now) + r.set(func(r *testResponder) { r.noPOST = true }) + huge := new(big.Int).Lsh(big.NewInt(1), 8*200) + _, err := newClient(NewOCSPFetcher()).Check(a, huge, r.URL(), notRevoked) + wantReason(t, err, apperr.CodeOperatorCARejected, fleetv1.ErrorReason_ERROR_REASON_OCSP_UNREACHABLE) + if r.count() != 0 { + t.Fatalf("the responder answered %d requests, want none (no GET fallback)", r.count()) + } + }) + t.Run("no GET after other errors", func(t *testing.T) { + r := newTestResponder(t, ca, clock.Now) + r.set(func(r *testResponder) { r.down = true }) + _, err := newClient(NewOCSPFetcher()).Check(a, big.NewInt(0x12), r.URL(), notRevoked) + wantReason(t, err, apperr.CodeOperatorCARejected, fleetv1.ErrorReason_ERROR_REASON_OCSP_UNREACHABLE) + if r.last().method != http.MethodPost || r.count() != 1 { + t.Fatalf("requests = %d, last %s; want one POST", r.count(), r.last().method) + } + }) + + mux := http.NewServeMux() + mux.HandleFunc("/big", func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(bytes.Repeat([]byte("x"), MaxOCSPSize+1)) + }) + mux.HandleFunc("/slow", func(w http.ResponseWriter, r *http.Request) { + select { + case <-r.Context().Done(): + case <-time.After(2 * time.Second): + } + }) + mux.HandleFunc("/to-file", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "file:///etc/passwd", http.StatusFound) + }) + mux.HandleFunc("/hop/", func(w http.ResponseWriter, r *http.Request) { + n := len(strings.TrimPrefix(r.URL.Path, "/hop/")) + http.Redirect(w, r, "/hop/"+strings.Repeat("x", n+1), http.StatusTemporaryRedirect) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + short := NewFetcher(FetchLimits{Timeout: 200 * time.Millisecond, MaxBytes: MaxOCSPSize}) + for name, c := range map[string]struct { + url string + reason fleetv1.ErrorReason + }{ + "non-http URL": {"ldap://ocsp.example.org/", fleetv1.ErrorReason_ERROR_REASON_OCSP_UNREACHABLE}, + "redirect to file://": {srv.URL + "/to-file", fleetv1.ErrorReason_ERROR_REASON_OCSP_UNREACHABLE}, + "more than 3 redirects": {srv.URL + "/hop/", fleetv1.ErrorReason_ERROR_REASON_OCSP_UNREACHABLE}, + "timeout": {srv.URL + "/slow", fleetv1.ErrorReason_ERROR_REASON_OCSP_UNREACHABLE}, + "body over 64 KiB": {srv.URL + "/big", fleetv1.ErrorReason_ERROR_REASON_OCSP_INVALID}, + } { + t.Run(name, func(t *testing.T) { + _, err := newClient(short).Check(a, big.NewInt(0x13), c.url, notRevoked) + wantReason(t, err, apperr.CodeOperatorCARejected, c.reason) + if strings.Contains(err.Error(), "xxxx") { + t.Fatalf("error echoes the body: %v", err) + } + }) + } +} + +func TestOCSPProbe(t *testing.T) { + ca := newCA(t, caOpts{}) + other := newCA(t, caOpts{cn: "Example Other CA"}) + clock := &fakeClock{t: testNow} + c := NewOCSPClient(OCSPOptions{Fetch: NewOCSPFetcher(), Now: clock.Now}) + + r := newTestResponder(t, ca, clock.Now) + r.set(func(r *testResponder) { r.defaultStatus = ocsp.Unknown }) + if err := c.Probe(t.Context(), ca.cert, r.URL()); err != nil { + t.Fatalf("Probe with a signed unknown = %v", err) + } + first := r.last().serial + if err := c.Probe(t.Context(), ca.cert, r.URL()); err != nil { + t.Fatalf("second Probe = %v", err) + } + if first == nil || first.Cmp(r.last().serial) == 0 || first.Sign() <= 0 { + t.Fatalf("probe serials %v and %v: want two different random positive serials", first, r.last().serial) + } + + r.set(func(r *testResponder) { r.wrongKey = &other }) + err := c.Probe(t.Context(), ca.cert, r.URL()) + wantReason(t, err, apperr.CodeOperatorCARejected, fleetv1.ErrorReason_ERROR_REASON_OCSP_INVALID) + + unsigned := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(ocsp.UnauthorizedErrorResponse) + })) + defer unsigned.Close() + err = c.Probe(t.Context(), ca.cert, unsigned.URL) + wantReason(t, err, apperr.CodeOperatorCARejected, fleetv1.ErrorReason_ERROR_REASON_OCSP_INVALID) + + r.set(func(r *testResponder) { r.down = true }) + err = c.Probe(t.Context(), ca.cert, r.URL()) + wantReason(t, err, apperr.CodeOperatorCARejected, fleetv1.ErrorReason_ERROR_REASON_OCSP_UNREACHABLE) +} diff --git a/internal/operatorca/ocspclient.go b/internal/operatorca/ocspclient.go new file mode 100644 index 0000000..87ba40b --- /dev/null +++ b/internal/operatorca/ocspclient.go @@ -0,0 +1,308 @@ +package operatorca + +/* +Copyright The CryptOS Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "errors" + "fmt" + "math/big" + "net/http" + "net/url" + "strings" + "time" + + fleetv1 "github.com/CryptOS-PKI/api/go/cryptos/fleet/v1" + "github.com/CryptOS-PKI/manager/internal/apperr" + "github.com/CryptOS-PKI/manager/internal/store" + "golang.org/x/sync/singleflight" +) + +// OCSP cache lifetimes (RFC 6960; the manager's own bounds). +const ( + ocspMaxTTL = time.Hour + ocspNoNextTTL = 5 * time.Minute + ocspFailureTTL = 30 * time.Second + ocspCacheSize = 4096 + maxOCSPGETBytes = 255 +) + +// Audit kinds written for OCSP. +const ( + KindOCSPUnknown = "operator-ocsp-unknown" + KindOCSPUnavailable = "operator-ocsp-unavailable" +) + +// OCSPOptions configures an OCSPClient. +type OCSPOptions struct { + // Fetch carries the requests; NewOCSPFetcher gives the standard limits. + Fetch *Fetcher + Now func() time.Time + Logf func(string, ...any) + // Audit records an audit event on behalf of the manager itself. + Audit func(store.AuditEvent) + // Background runs a half-life refresh; it defaults to a goroutine. + Background func(func()) +} + +type ocspKey struct { + anchor string + serial string +} + +// ocspEntry is one cached outcome: a validated response, or a failure +// ("no fresh response") that is kept for 30 seconds. +type ocspEntry struct { + result OCSPResult + err error + fetchedAt time.Time + expires time.Time + nextRefresh time.Time + refreshing bool +} + +// OCSPClient asks operator CAs' OCSP responders about operator +// certificates. It keeps a per-replica LRU of validated responses for +// min(nextUpdate, 1 h), or 5 minutes without a nextUpdate, refreshes an +// entry in the background once it is half way through its life and still +// in use, single-flights a miss per certificate, and remembers a failure +// for 30 seconds so a dead responder doesn't slow every request. +type OCSPClient struct { + fetch *Fetcher + now func() time.Time + logf func(string, ...any) + audit func(store.AuditEvent) + bg func(func()) + cache *lru[ocspKey, *ocspEntry] + group singleflight.Group +} + +// NewOCSPFetcher is a Fetcher with the OCSP limits: the CRL fetch limits +// with a 64 KiB body cap. +func NewOCSPFetcher() *Fetcher { + return NewFetcher(FetchLimits{Timeout: DefaultFetchTimeout, MaxBytes: MaxOCSPSize}) +} + +// NewOCSPClient builds an OCSPClient. +func NewOCSPClient(o OCSPOptions) *OCSPClient { + c := &OCSPClient{fetch: o.Fetch, now: o.Now, logf: o.Logf, audit: o.Audit, bg: o.Background, + cache: newLRU[ocspKey, *ocspEntry](ocspCacheSize)} + if c.fetch == nil { + c.fetch = NewOCSPFetcher() + } + if c.now == nil { + c.now = time.Now + } + if c.logf == nil { + c.logf = func(string, ...any) {} + } + if c.audit == nil { + c.audit = func(store.AuditEvent) {} + } + if c.bg == nil { + c.bg = func(f func()) { go f() } + } + return c +} + +// ResponderURL is where to ask about leaf under a: the anchor's URL in url +// mode, the leaf's authorityInfoAccess OCSP URI in aia mode, nothing when +// OCSP is off or an aia leaf carries no URI. The leaf must already have +// been verified to chain to a, so only the CA can choose the URI. +func ResponderURL(a Anchor, leaf *x509.Certificate) string { + switch a.OCSPMode { + case store.OCSPModeURL: + return a.OCSPURL + case store.OCSPModeAIA: + if leaf != nil && len(leaf.OCSPServer) > 0 { + return leaf.OCSPServer[0] + } + } + return "" +} + +// cached returns the live cache entry for a certificate. +func (c *OCSPClient) cached(anchor string, serial *big.Int) (ocspEntry, bool) { + e, ok := c.cache.get(ocspKey{anchor, SerialKey(serial)}) + if !ok { + return ocspEntry{}, false + } + c.cache.mu.Lock() + defer c.cache.mu.Unlock() + if !c.now().Before(e.expires) { + return ocspEntry{}, false + } + return *e, true +} + +// ClearAnchor drops every cached response for an operator CA, for when its +// OCSP settings change. +func (c *OCSPClient) ClearAnchor(anchorSHA256 string) { + c.cache.remove(func(k ocspKey) bool { return k.anchor == anchorSHA256 }) +} + +// Check returns the status of serial under a from the cache, or from one +// synchronous, single-flighted fetch from responderURL. An error means no +// fresh response; its sub-reason is OCSP_UNREACHABLE or OCSP_INVALID. +// isRevoked is the anchor's denylist and CRL, used for a delegated +// responder certificate without noCheck. +func (c *OCSPClient) Check(a Anchor, serial *big.Int, responderURL string, isRevoked func(serial string) bool) (OCSPResult, error) { + key := ocspKey{a.SHA256, SerialKey(serial)} + now := c.now() + if e, ok := c.cache.get(key); ok { + c.cache.mu.Lock() + live := now.Before(e.expires) + refresh := live && e.err == nil && !e.refreshing && !now.Before(e.nextRefresh) + if refresh { + e.refreshing = true + } + res, err := e.result, e.err + c.cache.mu.Unlock() + if refresh { + c.bg(func() { c.refresh(key, a, serial, responderURL, isRevoked) }) + } + if live { + return res, err + } + } + v, _, _ := c.group.Do(key.anchor+"/"+key.serial, func() (any, error) { + return c.fill(key, a, serial, responderURL, isRevoked, nil), nil + }) + e := v.(*ocspEntry) + return e.result, e.err +} + +// refresh fetches again in the background. A failed refresh keeps the +// response still in its lifetime and allows another try after 30 seconds. +func (c *OCSPClient) refresh(key ocspKey, a Anchor, serial *big.Int, responderURL string, isRevoked func(string) bool) { + old, _ := c.cache.get(key) + _, _, _ = c.group.Do(key.anchor+"/"+key.serial, func() (any, error) { + return c.fill(key, a, serial, responderURL, isRevoked, old), nil + }) +} + +func (c *OCSPClient) fill(key ocspKey, a Anchor, serial *big.Int, responderURL string, isRevoked func(string) bool, old *ocspEntry) *ocspEntry { + res, err := c.lookup(context.Background(), a.Cert, serial, responderURL, isRevoked) + now := c.now() + if err != nil { + c.logf("operatorca: OCSP check of serial %s under operator CA %s at %s failed (%s): %v", + key.serial, a.name(), redact(responderURL), ocspClass(err), err) + if old != nil { + c.cache.mu.Lock() + live := now.Before(old.expires) + if live { + old.refreshing = false + old.nextRefresh = now.Add(ocspFailureTTL) + } + c.cache.mu.Unlock() + if live { + return old + } + } + e := &ocspEntry{err: err, fetchedAt: now, expires: now.Add(ocspFailureTTL)} + c.cache.put(key, e) + return e + } + expires := now.Add(ocspMaxTTL) + switch { + case res.NextUpdate.IsZero(): + expires = now.Add(ocspNoNextTTL) + case res.NextUpdate.Before(expires): + expires = res.NextUpdate + } + e := &ocspEntry{result: res, fetchedAt: now, expires: expires, nextRefresh: now.Add(expires.Sub(now) / 2)} + c.cache.put(key, e) + if res.Status == OCSPUnknown { + c.audit(store.AuditEvent{ + Kind: KindOCSPUnknown, + Summary: fmt.Sprintf("The OCSP responder for operator CA %s answered unknown for serial %s; the certificate is refused as revoked", + a.name(), key.serial), + TargetKind: "operator-ca", + TargetPath: "/operator-cas/" + a.SHA256, + }) + } + return e +} + +// Probe checks at registration that responderURL answers for anchor: it +// asks about a random serial and needs a validly signed response, whatever +// the status. A failure is 1605 OCSP_UNREACHABLE or OCSP_INVALID. +func (c *OCSPClient) Probe(ctx context.Context, anchor *x509.Certificate, responderURL string) error { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return fmt.Errorf("operatorca: OCSP probe serial: %w", err) + } + b[0] &= 0x7f + b[0] |= 0x40 + serial := new(big.Int).SetBytes(b) + res, err := c.lookup(ctx, anchor, serial, responderURL, nil) + if err != nil { + c.logf("operatorca: OCSP probe of %s for operator CA %s failed (%s): %v", redact(responderURL), anchor.Subject.CommonName, ocspClass(err), err) + return err + } + c.logf("operatorca: OCSP probe of %s for operator CA %s answered %s", redact(responderURL), anchor.Subject.CommonName, res.Status) + return nil +} + +func ocspClass(err error) string { + if r, ok := apperr.ReasonOf(err); ok && r == fleetv1.ErrorReason_ERROR_REASON_OCSP_INVALID { + return "OCSP_INVALID" + } + return "OCSP_UNREACHABLE" +} + +func (c *OCSPClient) lookup(ctx context.Context, anchor *x509.Certificate, serial *big.Int, responderURL string, isRevoked func(string) bool) (OCSPResult, error) { + q, err := newOCSPQuery(anchor, serial) + if err != nil { + return OCSPResult{}, ocspInvalid("build the request: %v", err) + } + start := c.now() + body, err := c.post(ctx, responderURL, q.der) + if err != nil { + return OCSPResult{}, err + } + res, err := q.validate(body, c.now(), isRevoked) + if err == nil { + c.logf("operatorca: OCSP %s for serial %s from %s in %s", res.Status, SerialKey(serial), redact(responderURL), c.now().Sub(start)) + } + return res, err +} + +// post sends the request by POST. Only when the responder refuses POST +// with 405 and the base64 request is under 255 bytes does it retry with +// GET (RFC 6960 appendix A.1). +func (c *OCSPClient) post(ctx context.Context, responderURL string, der []byte) ([]byte, error) { + body, err := c.fetch.request(ctx, http.MethodPost, responderURL, "application/ocsp-request", bytes.NewReader(der)) + var se *httpStatusError + if errors.As(err, &se) && se.code == http.StatusMethodNotAllowed { + enc := url.PathEscape(base64.StdEncoding.EncodeToString(der)) + if len(enc) < maxOCSPGETBytes { + body, err = c.fetch.request(ctx, http.MethodGet, strings.TrimSuffix(responderURL, "/")+"/"+enc, "", nil) + } + } + switch { + case errors.Is(err, errBodyTooLarge): + return nil, ocspInvalid("the response is more than %d bytes", MaxOCSPSize) + case err != nil: + return nil, ocspUnreachable("%s: %v", redact(responderURL), err) + } + return body, nil +} diff --git a/internal/operatorca/ocsppki_test.go b/internal/operatorca/ocsppki_test.go new file mode 100644 index 0000000..9dd63f7 --- /dev/null +++ b/internal/operatorca/ocsppki_test.go @@ -0,0 +1,390 @@ +package operatorca + +/* +Copyright The CryptOS Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "crypto" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/base64" + "io" + "math/big" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "golang.org/x/crypto/ocsp" +) + +// The in-test OCSP responder. Responses are signed with +// golang.org/x/crypto/ocsp; a response that needs responseExtensions (the +// nonce echo) or several embedded certificates, which that package can't +// produce, is re-encoded and re-signed by resign. + +type delegatedOpts struct { + noEKU bool + noCheck bool + notBefore time.Time + notAfter time.Time +} + +// delegated issues an OCSP signing certificate from ca. +func (ca testCA) delegated(t *testing.T, o delegatedOpts) testCA { + t.Helper() + if o.notBefore.IsZero() { + o.notBefore = testNow.Add(-24 * time.Hour) + } + if o.notAfter.IsZero() { + o.notAfter = testNow.Add(30 * 24 * time.Hour) + } + key := newKey(t, keyP384) + eku := []x509.ExtKeyUsage{x509.ExtKeyUsageOCSPSigning} + if o.noEKU { + eku = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth} + } + tmpl := &x509.Certificate{ + SerialNumber: nextSerial(), + Subject: pkix.Name{CommonName: "Example Operator OCSP"}, + NotBefore: o.notBefore, + NotAfter: o.notAfter, + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: eku, + BasicConstraintsValid: true, + } + if o.noCheck { + tmpl.ExtraExtensions = []pkix.Extension{{Id: oidOCSPNoCheck, Value: asn1.NullBytes}} + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.cert, key.Public(), ca.key) + if err != nil { + t.Fatalf("create delegated OCSP signer: %v", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("parse delegated OCSP signer: %v", err) + } + return testCA{cert: cert, key: key} +} + +type respOpts struct { + status int + serial *big.Int + thisUpdate time.Time + nextUpdate time.Time + noNext bool + // signer signs the response; the anchor when nil. + signer *testCA + // responderID is the certificate the ResponderID names; the signer's + // when nil. + responderID *x509.Certificate + // embed is the certificates carried in the response; the signer's + // certificate when the signer isn't the anchor and embed is nil. + embed []*x509.Certificate + // certIDIssuer is the issuer the CertID hashes; the anchor when nil. + certIDIssuer *x509.Certificate + nonce []byte + singleExt []pkix.Extension + responseExt []pkix.Extension + hash crypto.Hash +} + +// ocspResponse builds a response for ca, the anchor. +func (ca testCA) ocspResponse(t *testing.T, o respOpts) []byte { + t.Helper() + signer := ca + if o.signer != nil { + signer = *o.signer + } + if o.responderID == nil { + o.responderID = signer.cert + } + if o.embed == nil && o.signer != nil && o.signer.cert != ca.cert { + o.embed = []*x509.Certificate{signer.cert} + } + if o.certIDIssuer == nil { + o.certIDIssuer = ca.cert + } + if o.thisUpdate.IsZero() { + o.thisUpdate = testNow.Add(-time.Minute) + } + if o.nextUpdate.IsZero() && !o.noNext { + o.nextUpdate = o.thisUpdate.Add(30 * time.Minute) + } + if o.noNext { + o.nextUpdate = time.Time{} + } + if o.hash == 0 { + o.hash = crypto.SHA1 + } + tmpl := ocsp.Response{ + Status: o.status, SerialNumber: o.serial, ThisUpdate: o.thisUpdate, NextUpdate: o.nextUpdate, + IssuerHash: o.hash, ExtraExtensions: o.singleExt, + } + if o.status == ocsp.Revoked { + tmpl.RevokedAt = o.thisUpdate.Add(-time.Hour) + } + if len(o.embed) > 0 { + tmpl.Certificate = o.embed[0] + } + der, err := ocsp.CreateResponse(o.certIDIssuer, o.responderID, tmpl, signer.key) + if err != nil { + t.Fatalf("create OCSP response: %v", err) + } + if o.nonce != nil || o.responseExt != nil || len(o.embed) > 1 { + exts := append([]pkix.Extension(nil), o.responseExt...) + if o.nonce != nil { + exts = append(exts, pkix.Extension{Id: oidOCSPNonce, Value: o.nonce}) + } + der = resign(t, der, signer.key, exts, o.embed) + } + return der +} + +// resign re-encodes a response with responseExtensions and the embedded +// certificates, and signs it again with key (a P-384 key, so SHA-384). +func resign(t *testing.T, der []byte, key crypto.Signer, exts []pkix.Extension, embed []*x509.Certificate) []byte { + t.Helper() + var outer ocspResponseASN1 + if _, err := asn1.Unmarshal(der, &outer); err != nil { + t.Fatalf("resign: outer: %v", err) + } + var basic ocspBasicResponse + if _, err := asn1.Unmarshal(outer.Response.Response, &basic); err != nil { + t.Fatalf("resign: basic: %v", err) + } + basic.TBSResponseData.Raw = nil + basic.TBSResponseData.ResponseExtensions = exts + tbs, err := asn1.Marshal(basic.TBSResponseData) + if err != nil { + t.Fatalf("resign: tbs: %v", err) + } + h := crypto.SHA384.New() + h.Write(tbs) + sig, err := key.Sign(rand.Reader, h.Sum(nil), crypto.SHA384) + if err != nil { + t.Fatalf("resign: sign: %v", err) + } + basic.TBSResponseData.Raw = tbs + basic.SignatureAlgorithm = pkix.AlgorithmIdentifier{Algorithm: asn1.ObjectIdentifier{1, 2, 840, 10045, 4, 3, 3}} + basic.Signature = asn1.BitString{Bytes: sig, BitLength: 8 * len(sig)} + basic.Certificates = nil + for _, c := range embed { + basic.Certificates = append(basic.Certificates, asn1.RawValue{FullBytes: c.Raw}) + } + basicDER, err := asn1.Marshal(basic) + if err != nil { + t.Fatalf("resign: basic marshal: %v", err) + } + outer.Response.Response = basicDER + out, err := asn1.Marshal(outer) + if err != nil { + t.Fatalf("resign: outer marshal: %v", err) + } + return out +} + +// parsedRequest is what the test responder read from a request. +type parsedRequest struct { + method string + serial *big.Int + hash asn1.ObjectIdentifier + nonce []byte // the nonce extension's extnValue + size int +} + +func parseTestRequest(t *testing.T, der []byte) parsedRequest { + t.Helper() + var req ocspRequestASN1 + if rest, err := asn1.Unmarshal(der, &req); err != nil || len(rest) > 0 { + t.Errorf("test responder: bad request: %v", err) + return parsedRequest{} + } + if len(req.TBSRequest.RequestList) != 1 { + t.Errorf("test responder: %d requests, want 1", len(req.TBSRequest.RequestList)) + return parsedRequest{} + } + p := parsedRequest{ + serial: req.TBSRequest.RequestList[0].Cert.SerialNumber, + hash: req.TBSRequest.RequestList[0].Cert.HashAlgorithm.Algorithm, + size: len(der), + } + for _, e := range req.TBSRequest.Extensions { + if e.Id.Equal(oidOCSPNonce) { + p.nonce = e.Value + } + } + return p +} + +// testResponder is an httptest OCSP responder for one anchor. By default it +// answers good for every serial, echoes the nonce and signs with the +// anchor. +type testResponder struct { + t *testing.T + ca testCA + now func() time.Time + srv *httptest.Server + mu sync.Mutex + status map[string]int + // defaultStatus answers serials missing from status. + defaultStatus int + // down makes the responder answer HTTP 500. + down bool + // wrongKey signs with a key that isn't the anchor's. + wrongKey *testCA + noNonce bool + noPOST bool + next time.Duration + noNext bool + gate chan struct{} + requests atomic.Int64 + seen []parsedRequest +} + +func newTestResponder(t *testing.T, ca testCA, now func() time.Time) *testResponder { + t.Helper() + r := &testResponder{t: t, ca: ca, now: now, status: map[string]int{}, next: 30 * time.Minute} + r.srv = httptest.NewServer(http.HandlerFunc(r.serve)) + t.Cleanup(r.srv.Close) + return r +} + +func (r *testResponder) URL() string { return r.srv.URL + "/ocsp" } + +func (r *testResponder) set(f func(r *testResponder)) { + r.mu.Lock() + defer r.mu.Unlock() + f(r) +} + +func (r *testResponder) count() int { return int(r.requests.Load()) } + +func (r *testResponder) last() parsedRequest { + r.mu.Lock() + defer r.mu.Unlock() + if len(r.seen) == 0 { + return parsedRequest{} + } + return r.seen[len(r.seen)-1] +} + +func (r *testResponder) serve(w http.ResponseWriter, req *http.Request) { + r.mu.Lock() + gate, noPOST := r.gate, r.noPOST + r.mu.Unlock() + var der []byte + switch req.Method { + case http.MethodPost: + if noPOST { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + if req.Header.Get("Content-Type") != "application/ocsp-request" { + r.t.Errorf("test responder: Content-Type %q", req.Header.Get("Content-Type")) + } + der, _ = io.ReadAll(req.Body) + case http.MethodGet: + enc := strings.TrimPrefix(req.URL.EscapedPath(), "/ocsp/") + s, err := url.PathUnescape(enc) + if err == nil { + der, err = base64.StdEncoding.DecodeString(s) + } + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + } + r.requests.Add(1) + if gate != nil { + <-gate + } + p := parseTestRequest(r.t, der) + p.method = req.Method + r.mu.Lock() + r.seen = append(r.seen, p) + down, wrong, noNonce, next, noNext := r.down, r.wrongKey, r.noNonce, r.next, r.noNext + status, ok := r.status[SerialKey(p.serial)] + if !ok { + status = r.defaultStatus + } + r.mu.Unlock() + if down || p.serial == nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + now := r.now() + o := respOpts{status: status, serial: p.serial, thisUpdate: now.Add(-time.Minute), nextUpdate: now.Add(next), noNext: noNext} + if !noNonce { + o.nonce = p.nonce + } + if wrong != nil { + o.signer = wrong + o.responderID = r.ca.cert + o.embed = []*x509.Certificate{} + } + w.Header().Set("Content-Type", "application/ocsp-response") + _, _ = w.Write(r.ca.ocspResponse(r.t, o)) +} + +// bgRunner runs background refreshes on goroutines the test can wait for. +type bgRunner struct{ wg sync.WaitGroup } + +func (b *bgRunner) Go(f func()) { + b.wg.Add(1) + go func() { + defer b.wg.Done() + f() + }() +} + +func (b *bgRunner) wait() { b.wg.Wait() } + +// ocspFixture is a Revocations with OCSP on, sharing a fake clock with its +// responder. +type ocspFixture struct { + revFixture + bg *bgRunner +} + +func newOCSPFixture(t *testing.T, policy string, fetch *Fetcher, anchors ...Anchor) ocspFixture { + t.Helper() + f := ocspFixture{revFixture: revFixture{st: newFakeTrust(), clock: &fakeClock{t: testNow}, logs: &logSink{}, audit: &auditSink{}}, bg: &bgRunner{}} + if fetch == nil { + fetch = NewOCSPFetcher() + } + f.rev = NewRevocations(RevocationOptions{ + Store: f.st, Policy: policy, Now: f.clock.Now, Logf: f.logs.Logf, Audit: f.audit.Record, + OCSPFetcher: fetch, OCSPBackground: f.bg.Go, + }) + f.rev.SetAnchors(anchors) + if err := f.rev.Reload(t.Context()); err != nil { + t.Fatalf("Reload: %v", err) + } + return f +} + +func ocspAnchor(ca testCA, crlSource, mode, responderURL string) Anchor { + a := anchorFor(ca, crlSource, mode) + a.OCSPURL = responderURL + return a +} diff --git a/internal/operatorca/revocation.go b/internal/operatorca/revocation.go index f3185cc..878e416 100644 --- a/internal/operatorca/revocation.go +++ b/internal/operatorca/revocation.go @@ -21,6 +21,7 @@ import ( "crypto/x509" "errors" "fmt" + "math/big" "sync" "time" @@ -94,6 +95,7 @@ type AnchorStatus struct { LastError string Banner string Badge string + OCSPBanner string } type anchorState struct { @@ -103,6 +105,10 @@ type anchorState struct { lastError string lastStaleLog time.Time expiredAudited bool + // ocspOutage is set while the web path is allowing certificates under + // soft policy with no fresh OCSP response and no fresh CRL. + ocspOutage bool + lastOCSPLog time.Time } // RevocationOptions configures a Revocations. @@ -113,6 +119,9 @@ type RevocationOptions struct { Logf func(format string, args ...any) // Audit records an audit event on behalf of the manager itself. Audit func(store.AuditEvent) + // OCSPFetcher turns OCSP on; nil leaves it off. + OCSPFetcher *Fetcher + OCSPBackground func(func()) } // Revocations holds, per anchor, the manager's denylist and the anchor's @@ -130,6 +139,8 @@ type Revocations struct { anchors map[string]*anchorState denylist map[string]map[string]struct{} polledAt time.Time + + ocsp *OCSPClient } // NewRevocations builds an empty engine; SetAnchors and Reload fill it. @@ -148,9 +159,15 @@ func NewRevocations(o RevocationOptions) *Revocations { if r.audit == nil { r.audit = func(store.AuditEvent) {} } + if o.OCSPFetcher != nil { + r.ocsp = NewOCSPClient(OCSPOptions{Fetch: o.OCSPFetcher, Now: r.now, Logf: r.logf, Audit: r.audit, Background: o.OCSPBackground}) + } return r } +// OCSP returns the OCSP client, nil when OCSP is off. +func (r *Revocations) OCSP() *OCSPClient { return r.ocsp } + // Policy returns the operatorRevocationPolicy in force. func (r *Revocations) Policy() string { return r.policy } @@ -361,35 +378,134 @@ func noRevocationSource(reason fleetv1.ErrorReason, format string, args ...any) return apperr.Reasoned(apperr.CodeNoRevocationSource, reason, fmt.Errorf("operatorca: "+format, args...)) } -// CheckWeb is the web-path revocation decision for a certificate that -// already verified against the anchor: refuse a revoked serial; allow when -// the anchor's CRL is within nextUpdate or the anchor has no CRL source; -// otherwise follow operatorRevocationPolicy. Soft keeps enforcing the last -// good CRL and the denylist, with a banner, an hourly log line and one -// audit row per expiry; hard refuses with 1608 STALE_CRL. +// CheckWeb is the web-path revocation decision for a serial under an +// anchor, when only the serial is known. It is CheckWebCert without the +// leaf, so an aia-mode anchor has no responder URI and no OCSP check. func (r *Revocations) CheckWeb(anchorSHA256, serial string) error { - serial = NormalizeSerial(serial) - r.mu.Lock() - defer r.mu.Unlock() + n, ok := new(big.Int).SetString(NormalizeSerial(serial), 16) + if !ok { + return rejectCert(fleetv1.ErrorReason_ERROR_REASON_REVOKED, "serial %q isn't hex", serial) + } + return r.checkWeb(anchorSHA256, n, nil) +} + +// CheckWebCert is the web-path revocation decision for a certificate that +// already verified against the anchor, in order: +// 1. the denylist or the last good CRL, fresh or not, lists the serial: +// refuse (1610 REVOKED); +// 2. OCSP is configured for the certificate: revoked or unknown refuse +// (1610 REVOKED_OCSP, OCSP_UNKNOWN); a fresh good allows; +// 3. otherwise a CRL within nextUpdate, or no CRL source and no OCSP for +// the certificate at all, allows; +// 4. otherwise operatorRevocationPolicy: soft allows, with a banner, an +// hourly log line and one audit row per CRL expiry or OCSP outage; hard +// refuses with 1608 STALE_OCSP when OCSP was configured for the +// certificate, else 1608 STALE_CRL. +func (r *Revocations) CheckWebCert(anchorSHA256 string, leaf *x509.Certificate) error { + return r.checkWeb(anchorSHA256, leaf.SerialNumber, leaf) +} + +func (r *Revocations) checkWeb(anchorSHA256 string, serial *big.Int, leaf *x509.Certificate) error { + key := SerialKey(serial) + r.mu.RLock() st, ok := r.anchors[anchorSHA256] if !ok { + r.mu.RUnlock() return unknownAnchor(anchorSHA256) } - if r.revokedLocked(anchorSHA256, serial) { - return revoked(serial, st.anchor) + a := st.anchor + isRevoked := r.revokedLocked(anchorSHA256, key) + r.mu.RUnlock() + if isRevoked { + return revoked(key, a) + } + + responder := r.responderFor(a, leaf) + if responder != "" { + res, err := r.ocsp.Check(a, serial, responder, func(s string) bool { return r.IsRevoked(anchorSHA256, s) }) + if err == nil { + r.endOCSPOutage(anchorSHA256) + if refusal := ocspRefusal(res, key, a); refusal != nil { + return refusal + } + return nil + } + } + + r.mu.Lock() + defer r.mu.Unlock() + st, ok = r.anchors[anchorSHA256] + if !ok { + return unknownAnchor(anchorSHA256) } now := r.now() - if !st.anchor.HasCRL() || (st.crl != nil && st.crl.Fresh(now)) { + crlFresh := st.anchor.HasCRL() && st.crl != nil && st.crl.Fresh(now) + if crlFresh || (!st.anchor.HasCRL() && responder == "") { return nil } if r.policy == PolicyHard { + if responder != "" { + return noRevocationSource(fleetv1.ErrorReason_ERROR_REASON_STALE_OCSP, + "no fresh OCSP response and no fresh CRL for serial %s under operator CA %s, and operatorRevocationPolicy is hard", key, st.anchor.name()) + } return noRevocationSource(fleetv1.ErrorReason_ERROR_REASON_STALE_CRL, "no fresh CRL for operator CA %s and operatorRevocationPolicy is hard", st.anchor.name()) } - r.noteStaleLocked(st, now) + if st.anchor.HasCRL() { + r.noteStaleLocked(st, now) + } + if responder != "" { + r.noteOCSPOutageLocked(st, responder, now) + } return nil } +func (r *Revocations) responderFor(a Anchor, leaf *x509.Certificate) string { + if r.ocsp == nil { + return "" + } + return ResponderURL(a, leaf) +} + +func ocspRefusal(res OCSPResult, serial string, a Anchor) error { + switch res.Status { + case OCSPRevoked: + return rejectCert(fleetv1.ErrorReason_ERROR_REASON_REVOKED_OCSP, + "the OCSP responder for operator CA %s says serial %s is revoked", a.name(), serial) + case OCSPUnknown: + return rejectCert(fleetv1.ErrorReason_ERROR_REASON_OCSP_UNKNOWN, + "the OCSP responder for operator CA %s says serial %s is unknown, which counts as revoked", a.name(), serial) + } + return nil +} + +func (r *Revocations) endOCSPOutage(anchorSHA256 string) { + r.mu.Lock() + defer r.mu.Unlock() + if st, ok := r.anchors[anchorSHA256]; ok && st.ocspOutage { + st.ocspOutage = false + r.logf("operatorca: the OCSP responder for operator CA %s answers again", st.anchor.name()) + } +} + +func (r *Revocations) noteOCSPOutageLocked(st *anchorState, responder string, now time.Time) { + if st.lastOCSPLog.IsZero() || now.Sub(st.lastOCSPLog) >= staleLogEvery { + st.lastOCSPLog = now + r.logf("operatorca: WARNING OCSP responder unreachable for operator CA %s (%s) and no fresh CRL; allowing under operatorRevocationPolicy soft", + st.anchor.name(), redact(responder)) + } + if !st.ocspOutage { + st.ocspOutage = true + r.audit(store.AuditEvent{ + Kind: KindOCSPUnavailable, + Summary: fmt.Sprintf("No fresh OCSP response and no fresh CRL for operator CA %s; certificates are allowed under operatorRevocationPolicy soft until the responder answers", + st.anchor.name()), + TargetKind: "operator-ca", + TargetPath: "/operator-cas/" + st.anchor.SHA256, + }) + } +} + func (r *Revocations) noteStaleLocked(st *anchorState, now time.Time) { if st.lastStaleLog.IsZero() || now.Sub(st.lastStaleLog) >= staleLogEvery { st.lastStaleLog = now @@ -412,36 +528,69 @@ func (r *Revocations) noteStaleLocked(st *anchorState, now time.Time) { } } -// CheckMCP is the stricter decision for MCP keys, which are long-lived +// CheckMCP is CheckMCPCert when only the serial is known, so an aia-mode +// anchor has no responder URI and no OCSP check. +func (r *Revocations) CheckMCP(anchorSHA256, serial string) error { + n, ok := new(big.Int).SetString(NormalizeSerial(serial), 16) + if !ok { + return rejectCert(fleetv1.ErrorReason_ERROR_REASON_REVOKED, "serial %q isn't hex", serial) + } + return r.checkMCP(anchorSHA256, n, nil) +} + +// CheckMCPCert is the stricter decision for MCP keys, which are long-lived // bearer credentials: the serial must not be revoked, this replica's last // successful revocation poll must be under 5 minutes old, and the anchor // must have a CRL source whose CRL is within nextUpdate. Both policies fail -// closed here. -func (r *Revocations) CheckMCP(anchorSHA256, serial string) error { - serial = NormalizeSerial(serial) +// closed here. When OCSP is configured for the certificate and a fresh +// response is available, it must be good; an OCSP failure is allowed, +// because the fresh CRL covers it. +func (r *Revocations) CheckMCPCert(anchorSHA256 string, cert *x509.Certificate) error { + return r.checkMCP(anchorSHA256, cert.SerialNumber, cert) +} + +func (r *Revocations) checkMCP(anchorSHA256 string, serial *big.Int, cert *x509.Certificate) error { + key := SerialKey(serial) + a, err := r.checkMCPSets(anchorSHA256, key) + if err != nil { + return err + } + responder := r.responderFor(a, cert) + if responder == "" { + return nil + } + res, err := r.ocsp.Check(a, serial, responder, func(s string) bool { return r.IsRevoked(anchorSHA256, s) }) + if err != nil { + return nil + } + r.endOCSPOutage(anchorSHA256) + return ocspRefusal(res, key, a) +} + +func (r *Revocations) checkMCPSets(anchorSHA256, serial string) (Anchor, error) { r.mu.RLock() defer r.mu.RUnlock() st, ok := r.anchors[anchorSHA256] if !ok { - return unknownAnchor(anchorSHA256) + return Anchor{}, unknownAnchor(anchorSHA256) } if r.revokedLocked(anchorSHA256, serial) { - return revoked(serial, st.anchor) + return Anchor{}, revoked(serial, st.anchor) } now := r.now() if now.Sub(r.polledAt) > maxDenylistAge { - return noRevocationSource(fleetv1.ErrorReason_ERROR_REASON_STALE_DENYLIST, + return Anchor{}, noRevocationSource(fleetv1.ErrorReason_ERROR_REASON_STALE_DENYLIST, "the last successful revocation poll was at %s, more than 5 minutes ago", r.polledAt.UTC().Format(time.RFC3339)) } if !st.anchor.HasCRL() { - return noRevocationSource(fleetv1.ErrorReason_ERROR_REASON_NO_CRL, + return Anchor{}, noRevocationSource(fleetv1.ErrorReason_ERROR_REASON_NO_CRL, "operator CA %s has no CRL source, and MCP needs one", st.anchor.name()) } if st.crl == nil || !st.crl.Fresh(now) { - return noRevocationSource(fleetv1.ErrorReason_ERROR_REASON_STALE_CRL, + return Anchor{}, noRevocationSource(fleetv1.ErrorReason_ERROR_REASON_STALE_CRL, "no CRL within nextUpdate for operator CA %s", st.anchor.name()) } - return nil + return st.anchor, nil } // Status reports the anchor's revocation state on this replica. @@ -454,6 +603,9 @@ func (r *Revocations) Status(anchorSHA256 string) AnchorStatus { } now := r.now() out := AnchorStatus{CRLConfigured: st.anchor.HasCRL(), LastError: st.lastError} + if st.ocspOutage { + out.OCSPBanner = fmt.Sprintf("OCSP responder unreachable for %s.", st.anchor.name()) + } if !st.anchor.HasCRL() { out.Badge = BadgeNotObserved if st.anchor.OCSPMode != "" && st.anchor.OCSPMode != store.OCSPModeOff { diff --git a/internal/operatorca/truststore.go b/internal/operatorca/truststore.go index d10a37b..7f61edf 100644 --- a/internal/operatorca/truststore.go +++ b/internal/operatorca/truststore.go @@ -260,12 +260,14 @@ type PeerAuthorizer struct { } // AuthorizePeer re-verifies a peer certificate and checks its revocation. +// The chain check comes first, so an OCSP URI is only ever read from a +// certificate the operator CA issued. func (p PeerAuthorizer) AuthorizePeer(leaf *x509.Certificate, intermediates []*x509.Certificate) (string, error) { a, err := p.Trust.VerifyPeer(leaf, intermediates) if err != nil { return "", err } - if err := p.Rev.CheckWeb(a.SHA256, SerialKey(leaf.SerialNumber)); err != nil { + if err := p.Rev.CheckWebCert(a.SHA256, leaf); err != nil { return "", err } return a.SHA256, nil @@ -282,5 +284,5 @@ func (p PeerAuthorizer) AdmitMCP(certDER []byte) error { if err != nil { return err } - return p.Rev.CheckMCP(a.SHA256, SerialKey(cert.SerialNumber)) + return p.Rev.CheckMCPCert(a.SHA256, cert) }