diff --git a/README.md b/README.md index 5d2a7c8..6e5e93a 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ The manager ships as a **single self-contained image**: the Go binary with the ` Bring your own trust material: a **server TLS cert** (`tlsCert`/`tlsKey`, any public or CryptOS-issued cert) and the **operator CA** (`operatorCAPath`, the client-auth trust anchor). No usernames or passwords are stored. +Or start from nothing: with Postgres and no `operatorCAPath`, the manager serves a self-signed certificate and prints a single-use bootstrap token in its log. The token holder registers the operator CA's certificate and checks the first admin certificate it signed; first run then closes for good. See [`docs/first-run.md`](docs/first-run.md). + **Docker:** ```sh diff --git a/cmd/manager/bootstrap.go b/cmd/manager/bootstrap.go new file mode 100644 index 0000000..8c725e0 --- /dev/null +++ b/cmd/manager/bootstrap.go @@ -0,0 +1,91 @@ +package main + +/* +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" + "crypto/tls" + "crypto/x509" + "net/http" + "os" + + fleetv1 "github.com/CryptOS-PKI/api/go/cryptos/fleet/v1" + "github.com/CryptOS-PKI/manager/internal/bootstrap" + "github.com/CryptOS-PKI/manager/internal/config" + "github.com/CryptOS-PKI/manager/internal/operatorca" + "github.com/CryptOS-PKI/manager/internal/store" +) + +// rootMounts is the optional route sets for the root handler: base, plus +// BootstrapService only when the manager serves TLS. Under authBypass the +// listener is plaintext, so the bootstrap mount is never added there. +func rootMounts(cfg config.Config, base []func(*http.ServeMux), boot func(*http.ServeMux)) []func(*http.ServeMux) { + if cfg.AuthBypass || boot == nil { + return base + } + return append(base, boot) +} + +// ocspProbe is the registration probe for a url-mode OCSP responder, run by +// the revocation engine's OCSP client. It is nil when there is no client. +// The client reports only whether the responder gave a validly signed +// answer, so the probe returns no details for the preview. +func ocspProbe(rev *operatorca.Revocations) bootstrap.OCSPProbe { + client := rev.OCSP() + if client == nil { + return nil + } + return func(ctx context.Context, anchor *x509.Certificate, url string) (*fleetv1.OcspProbeResult, error) { + return nil, client.Probe(ctx, anchor, url) + } +} + +// setupBootstrap builds the first-run service over the running operator +// trust, prints the first token when first run is open, and returns the +// route that mounts it. main mounts it on the TLS server only, never on the +// plaintext bypass listener, and outside the certificate middleware. bs is +// nil without Postgres, which leaves first run unavailable. +func setupBootstrap(ctx context.Context, cfg config.Config, st store.Store, bs bootstrap.Store, trust *operatorTrust, base *tls.Config, logf func(string, ...any)) (*bootstrap.Service, func(*http.ServeMux), error) { + var server bootstrap.ServerInfo + if len(base.Certificates) > 0 && base.Certificates[0].Leaf != nil { + leaf := base.Certificates[0].Leaf + server = bootstrap.ServerInfo{SHA256: operatorca.ColonFingerprint(leaf.Raw), NotAfter: leaf.NotAfter} + } + svc, err := bootstrap.New(ctx, bootstrap.Options{ + Store: bs, + Audit: st, + Trust: trust.trust, + Rev: trust.rev, + FirstRunDisabled: cfg.FirstRun == config.FirstRunDisabled, + NodeCAs: func() []*x509.Certificate { + return operatorca.NodeCAs(st.Nodes(), os.ReadFile, logf) + }, + OCSPProbe: ocspProbe(trust.rev), + FetchCRL: operatorca.NewFetcher(operatorca.FetchLimits{Timeout: operatorca.DefaultFetchTimeout, MaxBytes: operatorca.MaxCRLSize}).Fetch, + TrustedOrigins: cfg.CORSOrigins, + Server: server, + Logf: logf, + }) + if err != nil { + return nil, nil, err + } + if err := svc.Start(ctx); err != nil { + return nil, nil, err + } + path, handler := svc.Handler() + return svc, func(mux *http.ServeMux) { mux.Handle(path, handler) }, nil +} diff --git a/cmd/manager/bootstrap_test.go b/cmd/manager/bootstrap_test.go new file mode 100644 index 0000000..5b847b7 --- /dev/null +++ b/cmd/manager/bootstrap_test.go @@ -0,0 +1,167 @@ +package main + +/* +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" + "crypto/tls" + "net/http" + "net/http/httptest" + "strings" + "testing" + + fleetv1 "github.com/CryptOS-PKI/api/go/cryptos/fleet/v1" + "github.com/CryptOS-PKI/api/go/cryptos/fleet/v1/fleetv1connect" + "github.com/CryptOS-PKI/manager/internal/apperr" + "github.com/CryptOS-PKI/manager/internal/authz" + "github.com/CryptOS-PKI/manager/internal/bootstrap" + "github.com/CryptOS-PKI/manager/internal/config" + "github.com/CryptOS-PKI/manager/internal/operatorca" + "github.com/CryptOS-PKI/manager/internal/store/memory" +) + +func TestWithCORS_AllowsTheBootstrapSessionHeader(t *testing.T) { + h := withCORS([]string{"https://ui.example.org"}, stubHandler(http.StatusOK, "x")) + req := httptest.NewRequest(http.MethodOptions, "/", nil) + req.Header.Set("Origin", "https://ui.example.org") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if !strings.Contains(rec.Header().Get("Access-Control-Allow-Headers"), bootstrap.SessionHeader) { + t.Fatalf("Access-Control-Allow-Headers = %q, want %s", rec.Header().Get("Access-Control-Allow-Headers"), bootstrap.SessionHeader) + } +} + +// testBootstrap builds the first-run service the way main does, over the +// in-memory store (no Postgres, so first run is unavailable). +func testBootstrap(t *testing.T) (*bootstrap.Service, func(*http.ServeMux)) { + t.Helper() + st := memory.New(nil) + base, err := buildTLSConfig(context.Background(), config.Config{}, nil, t.Logf) + if err != nil { + t.Fatalf("buildTLSConfig: %v", err) + } + trust, err := setupOperatorTrust(context.Background(), config.Config{}, st, st, base, t.Logf) + if err != nil { + t.Fatalf("setupOperatorTrust: %v", err) + } + svc, mount, err := setupBootstrap(context.Background(), config.Config{}, st, nil, trust, base, t.Logf) + if err != nil { + t.Fatalf("setupBootstrap: %v", err) + } + return svc, mount +} + +// BootstrapService is mounted outside the certificate middleware, answers +// over TLS without a client certificate, and refuses plaintext. +func TestBootstrapMount_TLSOnlyAndOutsideTheCertificateMiddleware(t *testing.T) { + _, mount := testBootstrap(t) + h := newRootHandler("/cryptos.fleet.v1.FleetService/", stubHandler(http.StatusOK, "api"), stubHandler(http.StatusOK, "spa"), + authz.ClientCertMiddleware, nil, mount) + + body := `{}` + req := httptest.NewRequest(http.MethodPost, fleetv1connect.BootstrapServiceGetBootstrapStateProcedure, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.TLS = &tls.ConnectionState{HandshakeComplete: true} + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "BOOTSTRAP_STATE_UNAVAILABLE") { + t.Fatalf("GetBootstrapState over TLS with no client certificate = %d %s", rec.Code, rec.Body) + } + + req = httptest.NewRequest(http.MethodPost, fleetv1connect.BootstrapServiceGetBootstrapStateProcedure, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden || rec.Header().Get(apperr.MetadataKey) != "1603" { + t.Fatalf("GetBootstrapState over plaintext = %d (code %q), want 403 with 1603", rec.Code, rec.Header().Get(apperr.MetadataKey)) + } +} + +// A bootstrap session secret is not a credential anywhere else: the API, the +// MCP endpoint and the OAuth consent all still want a certificate or a key. +func TestSessionSecretOpensNothingElse(t *testing.T) { + _, boot := testBootstrap(t) + mcp, err := testMCPMount(t, livePool, noRevocations{}) + if err != nil { + t.Fatalf("mcpMount: %v", err) + } + h := newRootHandler("/cryptos.fleet.v1.FleetService/", stubHandler(http.StatusOK, "api"), stubHandler(http.StatusOK, "spa"), + authz.ClientCertMiddleware, nil, mcp, boot) + for _, target := range []string{"/cryptos.fleet.v1.FleetService/CreateMcpKey", "/mcp", "/oauth2/consent/some-id"} { + req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(`{}`)) + req.Header.Set(bootstrap.SessionHeader, "fos_bsess_ABCDEFGHJKMNPQRSTVWXYZ0123456789ABCDEFGHJKMNPQRSTV") + req.TLS = &tls.ConnectionState{HandshakeComplete: true} + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Errorf("%s with only a session secret = %d, want 401", target, rec.Code) + } + } +} + +// Registration probes a url-mode OCSP responder through the running +// revocation engine's OCSP client; a responder that doesn't answer is 1605 +// OCSP_UNREACHABLE. +func TestOCSPProbe_UsesTheRevocationEnginesClient(t *testing.T) { + if ocspProbe(operatorca.NewRevocations(operatorca.RevocationOptions{Store: memory.New(nil)})) != nil { + t.Fatal("a probe was built without an OCSP client") + } + probe := ocspProbe(operatorca.NewRevocations(operatorca.RevocationOptions{Store: memory.New(nil), OCSPFetcher: operatorca.NewOCSPFetcher()})) + if probe == nil { + t.Fatal("no probe with an OCSP client") + } + dead := httptest.NewServer(http.NotFoundHandler()) + dead.Close() + _, ca, _ := writeOperatorCA(t, t.TempDir()) + _, err := probe(context.Background(), ca, dead.URL) + if r, ok := apperr.ReasonOf(err); !ok || r != fleetv1.ErrorReason_ERROR_REASON_OCSP_UNREACHABLE { + t.Fatalf("probe of a dead responder = %v, want 1605 OCSP_UNREACHABLE", err) + } +} + +// BootstrapService never joins the plaintext listener authBypass serves, +// even if a mount for it were built; on the TLS server it is there. +func TestRootMounts_BootstrapOnlyOnTheTLSServer(t *testing.T) { + _, boot := testBootstrap(t) + serve := func(bypass bool) string { + mounts := rootMounts(config.Config{AuthBypass: bypass}, nil, boot) + h := newRootHandler("/cryptos.fleet.v1.FleetService/", stubHandler(http.StatusOK, "api"), stubHandler(http.StatusOK, "spa"), + authz.BypassMiddleware, nil, mounts...) + req := httptest.NewRequest(http.MethodPost, fleetv1connect.BootstrapServiceGetBootstrapStateProcedure, strings.NewReader(`{}`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec.Body.String() + } + if got := serve(true); got != "spa" { + t.Fatalf("under authBypass the bootstrap path reached %q, want the SPA", got) + } + if got := serve(false); got == "spa" { + t.Fatal("on the TLS server the bootstrap path didn't reach BootstrapService") + } +} + +// The HTTP redirect listener, the only other plaintext server, never +// reaches BootstrapService: a POST gets 405 and nothing else is served. +func TestRedirectListener_NeverServesBootstrap(t *testing.T) { + h := httpsRedirectHandler("443") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "http://fleet.example.org"+fleetv1connect.BootstrapServiceStartBootstrapSessionProcedure, strings.NewReader(`{"token":"x"}`))) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("POST to the redirect listener = %d, want 405", rec.Code) + } +} diff --git a/cmd/manager/main.go b/cmd/manager/main.go index 190d7f3..35a6596 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -39,6 +39,7 @@ import ( "github.com/CryptOS-PKI/manager/internal/apperr" "github.com/CryptOS-PKI/manager/internal/approval" "github.com/CryptOS-PKI/manager/internal/authz" + "github.com/CryptOS-PKI/manager/internal/bootstrap" "github.com/CryptOS-PKI/manager/internal/config" "github.com/CryptOS-PKI/manager/internal/fleet" "github.com/CryptOS-PKI/manager/internal/mcpauth" @@ -89,6 +90,7 @@ func main() { var ( st store.Store trustStore store.OperatorTrust + bootStore bootstrap.Store certStore serverCertStore storeCheck func(context.Context) error ) @@ -122,7 +124,7 @@ func main() { if err := pg.SeedIfEmpty(ctx, nodes, nil, nil, nil, nil); err != nil { log.Fatalf("manager: seed postgres: %v", err) } - st, trustStore, certStore = pg, pg, pg + st, trustStore, bootStore, certStore = pg, pg, pg, pg storeCheck = pg.Ping log.Printf("manager: using postgres store") } @@ -206,8 +208,9 @@ func main() { b := currentBuild() var ( - tlsCfg *tls.Config - trust *operatorTrust + tlsCfg *tls.Config + trust *operatorTrust + bootMount func(*http.ServeMux) ) if cfg.AuthBypass { log.Printf("manager: authBypass is set, so first run is disabled and no operator CA is used") @@ -223,13 +226,21 @@ func main() { } trust.refreshCRLs(ctx, log.Printf) trust.run(ctx, log.Printf) - svc = svc.WithOperatorTrust(trust.trust, trust.rev) + svc = svc.WithOperatorTrust(trust.trust, trust.rev). + WithNodeCAWatch(operatorca.NewNodeCAWatch(trust.trust, log.Printf)) tlsCfg = serverTLSConfig(base, trust.trust) - authMW = authz.ClientCertMiddlewareWith(trust.web) + boot, mount, err := setupBootstrap(ctx, cfg, st, bootStore, trust, base, log.Printf) + if err != nil { + log.Fatalf("manager: first run: %v", err) + } + bootMount = mount + // The latch sees every request that passed the certificate and + // revocation checks, from either operator CA source. + authMW = authz.ClientCertMiddlewareWith(trust.web, boot.Latch().Observe) mcpKeys.Admit = trust.auth.AdmitMCP } - mounts := []func(*http.ServeMux){healthMount(storeCheck)} + mounts := rootMounts(cfg, []func(*http.ServeMux){healthMount(storeCheck)}, bootMount) if cfg.MCP.Enabled { mount, err := mcpMount(cfg.MCP.PublicURL, svc, st, mcpKeys, approvals, trust.trust.Roots, trust.rev, authMW, b.Version) if err != nil { @@ -457,7 +468,7 @@ func withCORS(origins []string, next http.Handler) http.Handler { if r.Method == http.MethodOptions { w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", - "Content-Type, Connect-Protocol-Version, Connect-Timeout-Ms, Grpc-Timeout, X-Grpc-Web, X-User-Agent") + "Content-Type, Connect-Protocol-Version, Connect-Timeout-Ms, Grpc-Timeout, X-Grpc-Web, X-User-Agent, "+bootstrap.SessionHeader) w.WriteHeader(http.StatusNoContent) return diff --git a/docs/first-run.md b/docs/first-run.md new file mode 100644 index 0000000..6f605d1 --- /dev/null +++ b/docs/first-run.md @@ -0,0 +1,170 @@ +# ๐Ÿš€ First run + +A Fleet Manager started with no operator CA can be stood up from nothing. It +prints a single-use **bootstrap token** in its log. Whoever holds the token +registers the external operator CA's certificate and checks the first admin +certificate that CA signed. The manager signs nothing: the session only teaches +it which CA to trust. First run closes for good the first time an admin +certificate signs in. + +The user guide, with every step, is on the docs site under **Fleet Manager > +First run**. This page is the reference for how the manager behaves. + +## โœ… When first run is open + +First run is open only when all of these hold: + +- `operatorCAPath` is unset (with it set, first run reports `NOT_APPLICABLE`); +- `authBypass` is false; +- `firstRun` is `auto` (the default), not `disabled`; +- `database_url` is set (without Postgres, first run reports `UNAVAILABLE` with + `DATABASE_REQUIRED`); +- first run hasn't closed yet. + +## ๐ŸŽŸ๏ธ The token + +At start, and whenever the live token expires, the replica that holds the +`fleetos.bootstrap_token` advisory lock prints a banner: + +``` +manager: ================= FLEETOS FIRST RUN ================= +manager: bootstrap token: fos_boot_7K3Q-M2XD-...-9FJA (single use, expires 2026-09-30T15:04:05Z) +manager: server certificate SHA-256: AB:CD:...:EF (check this in the browser first) +manager: open https:/// and enter this token to register your operator CA certificate +manager: ====================================================== +``` + +Find it with `grep 'bootstrap token'` on the log (PowerShell: +`Select-String 'bootstrap token'`). That also returns the self-signed +certificate warning, with the fingerprint to check first. + +- The token is 256 random bits in Crockford base32, grouped in fours. Case, + dashes and the letters O, I and L (read as 0, 1 and 1) don't matter when it is + typed. +- It is printed once and never stored: Postgres holds only its SHA-256. +- It is single use. Starting a session consumes it and prints a new one at + once, on the replica that served the call. It expires after 60 minutes. +- Only one token is live at a time; issuing a new one retires the old. + +> [!CAUTION] +> Anyone who can read the manager's log can start first run. Keep log access +> as tight as access to the manager itself until first run is closed. + +## ๐Ÿ” The session + +`StartBootstrapSession{token}` returns a `fos_bsess_` secret. Send it in the +`Fleetos-Bootstrap-Session` request header, never in a cookie or a URL. + +- Postgres holds only its SHA-256. +- It ends after 15 minutes without a call, or 60 minutes after it started. +- There is one live session. Starting a new one ends the old one, whose secret + then gets 1604. +- It is good only for `RegisterOperatorCA` and `SubmitFirstAdminCertificate`. + It is not a credential anywhere else in the API, on `/mcp` or for OAuth. + +## ๐Ÿชช Registering the operator CA + +`RegisterOperatorCA` takes the CA certificate (DER or PEM, at most 8 KiB, +exactly one), a CRL source and an OCSP mode: + +- **CRL source:** `url` (fetched and checked now), `crl_der` (an initial CRL, + checked now; later ones by upload) or `none`, which needs the `NO_CRL` + acknowledgement. A CA with no CRL gets no MCP keys. +- **OCSP mode:** `aia` (default), `url` or `off`. In `url` mode the manager + probes the responder now, asking about a random serial; no answer is + `OCSP_UNREACHABLE`, a badly signed one `OCSP_INVALID`. The preview doesn't + carry `ocsp_probe` details yet: a confirmed registration means the probe + passed. + +The CA must be a CA with `keyCertSign`, have 30 days left, hold a P-384, P-256 +or RSA 3072+ key, and not be a CryptOS node's CA (by certificate or public +key). With a CRL source it also needs `cRLSign`. A CA issued by a node's CA is +allowed with a warning. + +The first call returns a preview with the fingerprint and `admin_extfile`, the +OpenSSL section for an admin certificate. Call again with `confirm_sha256` set +to that fingerprint (the output of `openssl x509 -fingerprint -sha256` can be +pasted as is) to store and trust it, with no restart. Registering another CA +during first run retires the earlier one at once. + +A new session keeps the registration but must confirm it again before it can +submit a certificate: call with `ca_cert_der` empty for the preview, then with +its `confirm_sha256`. With nothing registered, that preview is empty (no +`operator_ca`) and doesn't count as a failure. + +## ๐Ÿ‘ค The first admin certificate + +`SubmitFirstAdminCertificate{cert_der, csr_der?, full_name}` checks a +certificate the operator CA signed out of band against the confirmed CA: + +- it verifies against the CA alone for client authentication; +- the level extension `1.3.6.1.4.1.59999.1.1` is present, **non-critical** and + `admin`; +- EKU is exactly clientAuth, key usage has digitalSignature and no + keyCertSign or cRLSign, and basicConstraints is present with CA:FALSE; +- the subject is exactly `CN=`; +- the key is P-384 or RSA 3072+ (P-256 is refused for operator certificates); +- at least a day is left (over 400 days is a warning); +- it isn't on the denylist or in the CA's CRL, and where OCSP is configured + for it, the CA's responder doesn't say `revoked` (`REVOKED_OCSP`) or + `unknown` (`OCSP_UNKNOWN`). With no fresh revocation data, + `operatorRevocationPolicy: hard` refuses with 1608 (`STALE_OCSP` or + `STALE_CRL`); `soft` accepts. + +With `csr_der`, the CSR must verify, carry the same key and the same email. +Without it, the call is a pre-flight for a certificate made entirely at the CA. +`full_name` is 1 to 128 characters with no control characters. + +A later submission with a different certificate puts the earlier first-admin +certificate on the denylist (reason superseded). + +## ๐Ÿ”’ Closing + +The first API request from an admin certificate that chains to a trusted +operator CA and isn't revoked closes first run, from either operator CA +source. The manager ends every session, deletes every token, records the +certificate as the first admin if no pre-flight did, logs +`manager: first run CLOSED by ()` and writes `bootstrap-closed` to +the audit log. Viewer and operator certificates don't close it. + +After that `GetBootstrapState` reports `CLOSED`, every session procedure +returns 1601 before it reads a token or session, and no banner is printed +again. Denying or retiring every admin doesn't reopen it. + +If a manager whose first run is closed ends up trusting no operator CA (for +example `operatorCAPath` was removed), it refuses every caller and logs why. + +## ๐Ÿšฆ Limits + +- **Per client address:** 5 failures, one back each minute. Then 1602. +- **Everyone:** 50 failures within an hour rotate the token and end the live + session, with a new banner saying why. +- A failure is a bad token or session, or a refused CA, CRL, CSR or + certificate. Behind a proxy every client shares the proxy's address; + `X-Forwarded-For` isn't trusted. + +## ๐Ÿ›ก๏ธ Transport + +`BootstrapService` is mounted only on the HTTPS server, outside the +client-certificate middleware, and every procedure refuses a request that +didn't arrive over TLS. It is never on the `authBypass` plaintext listener or +the HTTP redirect listener. Cross-origin writes are refused unless the origin +is in `corsOrigins`. Request bodies are capped at 16 KiB, or 4 MiB plus a +little for `RegisterOperatorCA`, which can carry a CRL. + +## ๐Ÿงพ Audit + +Rows written with actor kind `bootstrap_session`: `bootstrap-session-started`, +`operator-ca-registered`, `operator-first-admin-recorded`, +`operator-first-admin-superseded`, `bootstrap-token-rotated`. The latch closing +is written as `bootstrap-closed`, with the admin certificate as the actor. +Neither the token nor the session secret appears in any audit row, error or log +line other than the token's own banner. + +## โš ๏ธ A node's CA as the operator CA, found at runtime + +The manager refuses a config-file operator CA that is a CryptOS node's CA at +start. If a node reached later reports a CA chain that holds a trusted +operator CA (the same certificate or key), the manager logs an ERROR and raises +an admin banner flag for that CA. It keeps trusting it until the next start, so +no admin is locked out mid-run. Replace it with an external operator CA. diff --git a/docs/operator-ca.md b/docs/operator-ca.md index ba09523..6d2f80b 100644 --- a/docs/operator-ca.md +++ b/docs/operator-ca.md @@ -21,6 +21,9 @@ The source is chosen only by what is configured: | 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 | +Registered CAs come from first run ([first-run.md](first-run.md)): the holder +of the bootstrap token registers the CA's certificate. + **The config file wins.** While `operatorCAPath` is set, any operator CA stored in the database is ignored, and the manager logs so at start. Use the file for GitOps-pinned deployments. diff --git a/docs/operator-credentials.md b/docs/operator-credentials.md index 9809f56..b37f2e9 100644 --- a/docs/operator-credentials.md +++ b/docs/operator-credentials.md @@ -126,6 +126,8 @@ validity over 400 days. | 1610 `WRONG_LEVEL`, `SUBJECT_MISMATCH`, `KEY_MISMATCH` | It doesn't match the request | | 1610 `DUPLICATE` | It is already recorded | | 1610 `REVOKED` | It is on the denylist or in the CA's CRL | +| 1610 `REVOKED_OCSP`, `OCSP_UNKNOWN` | The CA's OCSP responder says it is revoked, or doesn't know it | +| 1608 `STALE_OCSP`, `STALE_CRL` | No fresh revocation data, and `operatorRevocationPolicy` is `hard` | | 1610 `EKU`, `KEY_USAGE`, `BASIC_CONSTRAINTS`, `KEY_TYPE`, `EXPIRING` | It breaks the profile | | 1611 `NOT_FOUND`, `EXPIRED`, `NOT_PENDING` | The request can't be used | diff --git a/internal/authz/authorizer_test.go b/internal/authz/authorizer_test.go index c7885d6..4d4f794 100644 --- a/internal/authz/authorizer_test.go +++ b/internal/authz/authorizer_test.go @@ -17,6 +17,7 @@ limitations under the License. */ import ( + "context" "crypto/tls" "crypto/x509" "errors" @@ -89,3 +90,34 @@ func TestClientCertMiddlewareWith_RefusalCarriesTheCode(t *testing.T) { t.Fatalf("headers = %v, want 1610/REVOKED", rec.Header()) } } + +// Hooks see only requests that passed: the identity (with its operator CA) +// and the certificate, on a context that carries the identity. A refused +// request never reaches them. +func TestClientCertMiddlewareWith_HooksRunOnlyAfterAPass(t *testing.T) { + leaf := leafCert(t, LevelAdmin) + var calls []Identity + hook := func(ctx context.Context, id Identity, cert *x509.Certificate) { + if ctxID, ok := FromContext(ctx); !ok || ctxID.Serial != id.Serial { + t.Error("the hook's context doesn't carry the identity") + } + if cert != leaf { + t.Error("the hook didn't get the peer certificate") + } + calls = append(calls, id) + } + serve := func(auth PeerAuthorizer) { + req := httptest.NewRequest(http.MethodPost, "/rpc", nil) + req.TLS = &tls.ConnectionState{PeerCertificates: []*x509.Certificate{leaf}} + ClientCertMiddlewareWith(auth, hook)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})).ServeHTTP(httptest.NewRecorder(), req) + } + + serve(&fakeAuthorizer{err: apperr.Reasoned(apperr.CodeCertRejected, fleetv1.ErrorReason_ERROR_REASON_REVOKED, errors.New("denylisted"))}) + if len(calls) != 0 { + t.Fatal("a hook ran for a refused certificate") + } + serve(&fakeAuthorizer{anchor: "ab12"}) + if len(calls) != 1 || calls[0].IssuerSHA256 != "ab12" || calls[0].Level != LevelAdmin { + t.Fatalf("hook calls = %+v, want one admin call under ab12", calls) + } +} diff --git a/internal/authz/middleware.go b/internal/authz/middleware.go index 1df66a9..4a81586 100644 --- a/internal/authz/middleware.go +++ b/internal/authz/middleware.go @@ -47,14 +47,19 @@ type PeerAuthorizer interface { AuthorizePeer(leaf *x509.Certificate, intermediates []*x509.Certificate) (issuerSHA256 string, err error) } +// AuthHook is told about every request that passed ClientCertMiddlewareWith: +// the certificate chained to a trusted operator CA and isn't revoked. ctx +// carries the identity. The first-run latch is one. +type AuthHook func(ctx context.Context, id Identity, cert *x509.Certificate) + // ClientCertMiddlewareWith is ClientCertMiddleware plus a per-request // PeerAuthorizer check. A refused certificate gets 403 with the refusal's -// code and reason headers. -func ClientCertMiddlewareWith(auth PeerAuthorizer) func(http.Handler) http.Handler { - return func(next http.Handler) http.Handler { return clientCert(auth, next) } +// code and reason headers. Each hook runs, in order, after a request passes. +func ClientCertMiddlewareWith(auth PeerAuthorizer, hooks ...AuthHook) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { return clientCert(auth, next, hooks...) } } -func clientCert(auth PeerAuthorizer, next http.Handler) http.Handler { +func clientCert(auth PeerAuthorizer, next http.Handler, hooks ...AuthHook) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.TLS == nil || len(r.TLS.PeerCertificates) == 0 { // A connection whose handshake carried no certificate can never @@ -86,6 +91,11 @@ func clientCert(auth PeerAuthorizer, next http.Handler) http.Handler { } id.Via = ViaWeb ctx := context.WithValue(NewContext(r.Context(), id), peerCertCtxKey{}, cert) + if auth != nil { + for _, hook := range hooks { + hook(ctx, id, cert) + } + } next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/internal/bootstrap/fake_test.go b/internal/bootstrap/fake_test.go new file mode 100644 index 0000000..76e9baf --- /dev/null +++ b/internal/bootstrap/fake_test.go @@ -0,0 +1,455 @@ +package bootstrap + +/* +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" + "fmt" + "strings" + "sync" + "time" + + "github.com/CryptOS-PKI/manager/internal/store" +) + +// fakeStore is an in-memory store.OperatorTrust and store.Bootstrap that +// behaves like the Postgres store, and counts token and session lookups so a +// test can prove a closed first run never reads them. +type fakeStore struct { + mu sync.Mutex + + cas []store.OperatorCA + confirmed map[string]string + crls map[string]store.OperatorCRL + denylist map[string]store.DenylistEntry + epoch int64 + caChange int + locks map[string]bool + + latch store.BootstrapState + tokens []fakeToken + sessions map[string]store.BootstrapSession + creds map[string]store.OperatorCredential + + tokenLookups int + sessionLookups int +} + +type fakeToken struct { + store.BootstrapToken + used bool +} + +func newFakeStore() *fakeStore { + return &fakeStore{ + confirmed: map[string]string{}, crls: map[string]store.OperatorCRL{}, denylist: map[string]store.DenylistEntry{}, + locks: map[string]bool{}, sessions: map[string]store.BootstrapSession{}, creds: map[string]store.OperatorCredential{}, + } +} + +func (f *fakeStore) lookups() (tokens, sessions int) { + f.mu.Lock() + defer f.mu.Unlock() + return f.tokenLookups, f.sessionLookups +} + +func (f *fakeStore) tokenHashes() []string { + f.mu.Lock() + defer f.mu.Unlock() + var out []string + for _, t := range f.tokens { + out = append(out, t.Hash) + } + return out +} + +func (f *fakeStore) sessionHashes() []string { + f.mu.Lock() + defer f.mu.Unlock() + var out []string + for h := range f.sessions { + out = append(out, h) + } + return out +} + +// OperatorTrust + +func (f *fakeStore) OperatorCAs(context.Context) ([]store.OperatorCA, error) { + f.mu.Lock() + defer f.mu.Unlock() + return append([]store.OperatorCA(nil), f.cas...), nil +} + +func (f *fakeStore) AddOperatorCA(_ context.Context, ca store.OperatorCA) error { + f.mu.Lock() + defer f.mu.Unlock() + f.cas = append(f.cas, ca) + f.caChange++ + return nil +} + +func (f *fakeStore) SetOperatorCAState(_ context.Context, sha, state, reason string, at time.Time) error { + f.mu.Lock() + defer f.mu.Unlock() + for i := range f.cas { + if f.cas[i].SHA256 == sha { + f.cas[i].State, f.cas[i].RetiredReason, f.cas[i].RetiredAt = state, reason, at + f.caChange++ + return nil + } + } + return store.ErrOperatorCANotFound +} + +func (f *fakeStore) OperatorCRLs(context.Context) ([]store.OperatorCRL, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []store.OperatorCRL + for _, c := range f.crls { + out = append(out, c) + } + return out, nil +} + +func (f *fakeStore) PutOperatorCRL(_ context.Context, c store.OperatorCRL, decide store.DecideCRL) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.putCRLLocked(c, decide) +} + +func (f *fakeStore) putCRLLocked(c store.OperatorCRL, decide store.DecideCRL) (bool, error) { + cur, ok := f.crls[c.IssuerSHA256] + accept, err := decide(cur, ok && cur.DER != nil) + if err != nil || !accept { + return false, err + } + f.crls[c.IssuerSHA256] = c + f.epoch++ + return true, nil +} + +func (f *fakeStore) RecordOperatorCRLAttempt(_ context.Context, issuer, lastError string, at time.Time) error { + return nil +} + +func (f *fakeStore) OperatorDenylist(context.Context) ([]store.DenylistEntry, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []store.DenylistEntry + for _, e := range f.denylist { + out = append(out, e) + } + return out, nil +} + +func (f *fakeStore) AddOperatorDenylistEntry(_ context.Context, e store.DenylistEntry) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + k := e.IssuerSHA256 + "/" + e.SerialHex + if _, ok := f.denylist[k]; ok { + return false, nil + } + f.denylist[k] = e + f.epoch++ + return true, nil +} + +func (f *fakeStore) OperatorTrustVersion(context.Context) (store.TrustVersion, error) { + f.mu.Lock() + defer f.mu.Unlock() + return store.TrustVersion{CAs: fmt.Sprintf("%d:%d", len(f.cas), f.caChange), Epoch: f.epoch}, nil +} + +func (f *fakeStore) TryAdvisoryLock(_ context.Context, name string) (func(), bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.locks[name] { + return nil, false, nil + } + f.locks[name] = true + return func() { + f.mu.Lock() + defer f.mu.Unlock() + delete(f.locks, name) + }, true, nil +} + +// Bootstrap + +func (f *fakeStore) BootstrapState(context.Context) (store.BootstrapState, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.latch, nil +} + +func (f *fakeStore) CloseBootstrap(_ context.Context, by store.BootstrapState) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.latch.Closed() { + return false, nil + } + f.latch = by + for h, s := range f.sessions { + if s.EndedAt.IsZero() { + s.EndedAt, s.EndedReason = by.ClosedAt, store.SessionEndedClosed + f.sessions[h] = s + } + } + f.tokens = nil + return true, nil +} + +func (f *fakeStore) IssueBootstrapToken(_ context.Context, t store.BootstrapToken) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.latch.Closed() { + return store.ErrBootstrapClosed + } + for i := range f.tokens { + f.tokens[i].used = true + } + f.tokens = append(f.tokens, fakeToken{BootstrapToken: t}) + return nil +} + +func (f *fakeStore) LiveBootstrapToken(_ context.Context, now time.Time) (store.BootstrapToken, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.tokenLookups++ + for i := len(f.tokens) - 1; i >= 0; i-- { + if t := f.tokens[i]; !t.used && t.ExpiresAt.After(now) { + return t.BootstrapToken, true, nil + } + } + return store.BootstrapToken{}, false, nil +} + +func (f *fakeStore) StartBootstrapSession(_ context.Context, tokenHash string, s store.BootstrapSession, now time.Time) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.tokenLookups++ + if f.latch.Closed() { + return false, nil + } + for i := range f.tokens { + t := &f.tokens[i] + if t.Hash == tokenHash && !t.used && t.ExpiresAt.After(now) { + t.used = true + for h, old := range f.sessions { + if old.EndedAt.IsZero() { + old.EndedAt, old.EndedReason = now, store.SessionEndedSuperseded + f.sessions[h] = old + } + } + f.sessions[s.Hash] = s + return true, nil + } + } + return false, nil +} + +func (f *fakeStore) BootstrapSession(_ context.Context, hash string) (store.BootstrapSession, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.sessionLookups++ + s, ok := f.sessions[hash] + return s, ok, nil +} + +func (f *fakeStore) TouchBootstrapSession(_ context.Context, hash string, at time.Time) error { + f.mu.Lock() + defer f.mu.Unlock() + if s, ok := f.sessions[hash]; ok && s.EndedAt.IsZero() { + s.LastUsedAt = at + f.sessions[hash] = s + } + return nil +} + +func (f *fakeStore) EndBootstrapSession(_ context.Context, hash, reason string, at time.Time) error { + f.mu.Lock() + defer f.mu.Unlock() + if s, ok := f.sessions[hash]; ok && s.EndedAt.IsZero() { + s.EndedAt, s.EndedReason = at, reason + f.sessions[hash] = s + } + return nil +} + +func (f *fakeStore) EndBootstrapSessions(_ context.Context, reason string, at time.Time) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + n := 0 + for h, s := range f.sessions { + if s.EndedAt.IsZero() { + s.EndedAt, s.EndedReason = at, reason + f.sessions[h] = s + n++ + } + } + return n, nil +} + +func (f *fakeStore) HasLiveBootstrapSession(_ context.Context, now time.Time, idle time.Duration) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + for _, s := range f.sessions { + if s.EndedAt.IsZero() && s.ExpiresAt.After(now) && s.LastUsedAt.After(now.Add(-idle)) { + return true, nil + } + } + return false, nil +} + +func (f *fakeStore) RegisterFirstRunOperatorCA(_ context.Context, ca store.OperatorCA, sessionHash string, crl *store.OperatorCRL, decide store.DecideCRL) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.latch.Closed() { + return store.ErrBootstrapClosed + } + if crl != nil { + cur, ok := f.crls[crl.IssuerSHA256] + accept, err := decide(cur, ok && cur.DER != nil) + if err != nil { + return err + } + if accept { + f.crls[crl.IssuerSHA256] = *crl + } + } + if ca.OCSPMode == "" { + ca.OCSPMode = store.OCSPModeAIA + } + found := false + for i := range f.cas { + switch { + case f.cas[i].SHA256 == ca.SHA256: + ca.State = store.OperatorCAActive + f.cas[i] = ca + found = true + case f.cas[i].State == store.OperatorCAActive || f.cas[i].State == store.OperatorCARetiring: + f.cas[i].State, f.cas[i].RetiredReason, f.cas[i].RetiredAt = store.OperatorCARetired, store.RetiredSuperseded, ca.RegisteredAt + } + } + if !found { + ca.State = store.OperatorCAActive + f.cas = append(f.cas, ca) + } + f.confirmed[ca.SHA256] = sessionHash + f.caChange++ + f.epoch++ + return nil +} + +func (f *fakeStore) ConfirmOperatorCA(_ context.Context, sha, sessionHash string) error { + f.mu.Lock() + defer f.mu.Unlock() + for _, c := range f.cas { + if c.SHA256 == sha { + f.confirmed[sha] = sessionHash + return nil + } + } + return store.ErrOperatorCANotFound +} + +func (f *fakeStore) OperatorCAConfirmedBy(_ context.Context, sha string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.confirmed[sha], nil +} + +func (f *fakeStore) RecordFirstAdmin(_ context.Context, c store.OperatorCredential, at time.Time) error { + f.mu.Lock() + defer f.mu.Unlock() + c.Kind = store.OperatorCredentialFirstAdmin + f.creds[c.IssuerSHA256+"/"+c.SerialHex] = c + return nil +} + +func (f *fakeStore) RecordFirstUse(_ context.Context, c store.OperatorCredential, at time.Time) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + k := c.IssuerSHA256 + "/" + c.SerialHex + if _, ok := f.creds[k]; ok { + return false, nil + } + c.Kind = store.OperatorCredentialFirstAdmin + f.creds[k] = c + return true, nil +} + +func (f *fakeStore) FirstAdminCredentials(context.Context) ([]store.OperatorCredential, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []store.OperatorCredential + for _, c := range f.creds { + if c.Kind == store.OperatorCredentialFirstAdmin { + out = append(out, c) + } + } + return out, nil +} + +// fakeClock is a settable clock. +type fakeClock struct { + mu sync.Mutex + t time.Time +} + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.t +} + +func (c *fakeClock) Add(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.t = c.t.Add(d) +} + +// logSink captures log lines. +type logSink struct { + mu sync.Mutex + lines []string +} + +func (l *logSink) Logf(format string, args ...any) { + l.mu.Lock() + defer l.mu.Unlock() + l.lines = append(l.lines, fmt.Sprintf(format, args...)) +} + +func (l *logSink) all() string { + l.mu.Lock() + defer l.mu.Unlock() + return strings.Join(l.lines, "\n") +} + +func (l *logSink) count(sub string) int { + l.mu.Lock() + defer l.mu.Unlock() + n := 0 + for _, line := range l.lines { + if strings.Contains(line, sub) { + n++ + } + } + return n +} diff --git a/internal/bootstrap/firstadmin.go b/internal/bootstrap/firstadmin.go new file mode 100644 index 0000000..ed04be6 --- /dev/null +++ b/internal/bootstrap/firstadmin.go @@ -0,0 +1,200 @@ +package bootstrap + +/* +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" + "crypto/x509" + "errors" + "fmt" + "time" + + connect "connectrpc.com/connect" + fleetv1 "github.com/CryptOS-PKI/api/go/cryptos/fleet/v1" + "github.com/CryptOS-PKI/manager/internal/apperr" + "github.com/CryptOS-PKI/manager/internal/operatorca" + "github.com/CryptOS-PKI/manager/internal/store" +) + +// reasonSuperseded is the RFC 5280 CRLReason superseded(4), recorded on the +// denylist entry of a first-admin certificate a later one replaced. +const reasonSuperseded = 4 + +func certRejected(reason fleetv1.ErrorReason, format string, args ...any) error { + cause := fmt.Errorf("bootstrap: "+format, args...) + if reason == fleetv1.ErrorReason_ERROR_REASON_UNSPECIFIED { + return connect.NewError(connect.CodeInvalidArgument, apperr.Coded(apperr.CodeCertRejected, cause)) + } + return connect.NewError(connect.CodeInvalidArgument, apperr.Reasoned(apperr.CodeCertRejected, reason, cause)) +} + +// parseLeaf reads exactly one DER certificate of at most MaxCertSize bytes. +func parseLeaf(der []byte) (*x509.Certificate, error) { + if len(der) == 0 || len(der) > operatorca.MaxCertSize { + return nil, certRejected(0, "the certificate is %d bytes; send one DER certificate of at most %d bytes", len(der), operatorca.MaxCertSize) + } + certs, err := x509.ParseCertificates(der) + if err != nil { + return nil, certRejected(0, "the certificate doesn't parse: %v", err) + } + if len(certs) != 1 { + return nil, certRejected(0, "%d certificates; send exactly one", len(certs)) + } + return certs[0], nil +} + +// SubmitFirstAdminCertificate checks and records the first admin's +// certificate, which the external operator CA signed out of band. The +// manager signs nothing and dials no node. +func (s *Service) SubmitFirstAdminCertificate(ctx context.Context, req *connect.Request[fleetv1.SubmitFirstAdminCertificateRequest]) (*connect.Response[fleetv1.SubmitFirstAdminCertificateResponse], error) { + if err := s.admit(ctx); err != nil { + return nil, err + } + sessionHash, err := s.session(ctx, req.Header()) + if err != nil { + return nil, err + } + // Another replica may have registered the CA a moment ago. + if err := s.trust.Rebuild(ctx); err != nil { + return nil, fmt.Errorf("bootstrap: refresh the trusted operator CAs: %w", err) + } + row := s.activeCA(ctx) + if row == nil { + return nil, s.fail(ctx, caRejected(connect.CodeFailedPrecondition, fleetv1.ErrorReason_ERROR_REASON_NOT_CONFIRMED, + "no operator CA is registered yet")) + } + by, err := s.st.OperatorCAConfirmedBy(ctx, row.SHA256) + if err != nil { + return nil, fmt.Errorf("bootstrap: read the operator CA confirmation: %w", err) + } + if by != sessionHash { + return nil, s.fail(ctx, caRejected(connect.CodeFailedPrecondition, fleetv1.ErrorReason_ERROR_REASON_NOT_CONFIRMED, + "this session hasn't confirmed the registered operator CA; confirm its fingerprint first")) + } + anchor, err := x509.ParseCertificate(row.CertDER) + if err != nil { + return nil, fmt.Errorf("bootstrap: the registered operator CA %s doesn't parse: %w", row.SHA256, err) + } + + m := req.Msg + res, cert, err := s.checkFirstAdmin(m, anchor) + if err != nil { + // No fresh revocation data under a hard policy is the deployment's + // state, not the caller's mistake, so it isn't counted as a failure. + if code, _ := apperr.Code(err); code == apperr.CodeNoRevocationSource { + return nil, connect.NewError(connect.CodeFailedPrecondition, err) + } + return nil, s.fail(ctx, rejected(err)) + } + + now := s.now().UTC() + if err := s.supersede(ctx, row.SHA256, res.Serial, res.Email); err != nil { + return nil, err + } + cred := store.OperatorCredential{ + IssuerSHA256: row.SHA256, SerialHex: res.Serial, CommonName: res.Email, Email: res.Email, FullName: m.GetFullName(), + Level: res.Level.Token(), NotAfter: cert.NotAfter.UTC().Format(time.RFC3339), LeafSHA256: operatorca.Fingerprint(cert), + } + if err := s.st.RecordFirstAdmin(ctx, cred, now); err != nil { + return nil, fmt.Errorf("bootstrap: record the first admin: %w", err) + } + ip := clientIP(ctx) + s.logf("manager: first admin certificate recorded for %s (serial %s, expires %s) from %s", res.Email, res.Serial, cred.NotAfter, ip) + s.record(ctx, store.AuditEvent{ + Kind: KindFirstAdminRecorded, + Summary: fmt.Sprintf("Recorded the first admin certificate CN=%s, serial %s, expires %s, issued by operator CA %s, from %s", + res.Email, res.Serial, cred.NotAfter, row.SHA256, ip), + TargetKind: "operator-credential", + TargetPath: "/operators/" + res.Serial, + }) + return connect.NewResponse(&fleetv1.SubmitFirstAdminCertificateResponse{ + SerialHex: res.Serial, + NotAfter: cred.NotAfter, + Email: res.Email, + IssuerSha256: row.SHA256, + Warnings: res.Warnings, + }), nil +} + +// checkFirstAdmin checks the inputs and the certificate profile: the name, +// the upload, the CSR when there is one, and the admin profile against the +// active anchor. +func (s *Service) checkFirstAdmin(m *fleetv1.SubmitFirstAdminCertificateRequest, anchor *x509.Certificate) (operatorca.CertResult, *x509.Certificate, error) { + if err := operatorca.ValidateFullName(m.GetFullName()); err != nil { + return operatorca.CertResult{}, nil, certRejected(0, "full_name: %v", err) + } + cert, err := parseLeaf(m.GetCertDer()) + if err != nil { + return operatorca.CertResult{}, nil, err + } + var csr *operatorca.CSRResult + if len(m.GetCsrDer()) > 0 { + c, err := operatorca.CheckCSR(m.GetCsrDer()) + if err != nil { + return operatorca.CertResult{}, nil, err + } + csr = &c + } + check := operatorca.CertCheck{Now: s.now(), WantLevel: "admin", Revoked: s.rev.IsRevoked, CheckRevocation: s.rev.CheckWebCert} + if csr != nil { + check.CSR = csr.CSR + } + res, err := operatorca.CheckOperatorCert(cert, anchor, check) + if err != nil { + return operatorca.CertResult{}, nil, err + } + if csr != nil && csr.Email != res.Email { + return operatorca.CertResult{}, nil, certRejected(fleetv1.ErrorReason_ERROR_REASON_SUBJECT_MISMATCH, + "the certificate is for %s but the CSR is for %s", res.Email, csr.Email) + } + return res, cert, nil +} + +// supersede denylists every earlier first-admin certificate with another +// issuer or serial, so only the newest one submitted in first run can sign +// in. The denylist write reloads this replica at once and moves the +// revocation epoch for the others. +func (s *Service) supersede(ctx context.Context, issuer, serial, email string) error { + earlier, err := s.st.FirstAdminCredentials(ctx) + if err != nil { + return fmt.Errorf("bootstrap: read earlier first-admin records: %w", err) + } + for _, c := range earlier { + if c.IssuerSHA256 == issuer && operatorca.NormalizeSerial(c.SerialHex) == operatorca.NormalizeSerial(serial) { + continue + } + if s.rev.Denylisted(c.IssuerSHA256, c.SerialHex) { + continue + } + err := s.rev.Deny(ctx, store.DenylistEntry{ + IssuerSHA256: c.IssuerSHA256, SerialHex: c.SerialHex, Reason: reasonSuperseded, + RevokedByCN: registeredBy, Note: fmt.Sprintf("superseded by first-admin certificate %s for %s", serial, email), + }) + if err != nil && !errors.Is(err, operatorca.ErrUnknownAnchor) { + return fmt.Errorf("bootstrap: denylist the superseded first admin %s: %w", c.SerialHex, err) + } + s.logf("manager: first admin certificate %s (%s) superseded by %s and put on the denylist", c.SerialHex, c.Email, serial) + s.record(ctx, store.AuditEvent{ + Kind: KindFirstAdminSuperseded, + Summary: fmt.Sprintf("Put the earlier first admin certificate CN=%s, serial %s (operator CA %s) on the denylist: superseded by serial %s", + c.Email, c.SerialHex, c.IssuerSHA256, serial), + TargetKind: "operator-credential", + TargetPath: "/operators/" + c.SerialHex, + }) + } + return nil +} diff --git a/internal/bootstrap/firstadmin_test.go b/internal/bootstrap/firstadmin_test.go new file mode 100644 index 0000000..27b49f9 --- /dev/null +++ b/internal/bootstrap/firstadmin_test.go @@ -0,0 +1,249 @@ +package bootstrap + +/* +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 ( + "go/parser" + "go/token" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + + fleetv1 "github.com/CryptOS-PKI/api/go/cryptos/fleet/v1" + "github.com/CryptOS-PKI/manager/internal/apperr" + "github.com/CryptOS-PKI/manager/internal/operatorca" + "github.com/CryptOS-PKI/manager/internal/store" +) + +// registered is a harness with a session that registered and confirmed ca. +func registered(t *testing.T) (*harness, string, testCA) { + t.Helper() + h := newHarness(t) + secret := h.startSession() + ca := newCA(t, "Example Operator CA") + h.registerCA(secret, ca) + return h, secret, ca +} + +func TestSubmit_WithACSR(t *testing.T) { + h, secret, ca := registered(t) + key := p384(t) + cert := ca.leaf(t, leafOpts{key: key}) + + t.Run("key mismatch", func(t *testing.T) { + _, err := h.clientFrom("192.0.2.21:1", true).SubmitFirstAdminCertificate(h.ctx, withSession(secret, + &fleetv1.SubmitFirstAdminCertificateRequest{CertDer: cert.Raw, CsrDer: csrDER(t, p384(t), "admin@example.org"), FullName: "Ada Example"})) + wantCode(t, err, apperr.CodeCertRejected, "KEY_MISMATCH") + }) + t.Run("CSR subject", func(t *testing.T) { + _, err := h.clientFrom("192.0.2.22:1", true).SubmitFirstAdminCertificate(h.ctx, withSession(secret, + &fleetv1.SubmitFirstAdminCertificateRequest{CertDer: cert.Raw, CsrDer: csrDER(t, key, "Ada Example"), FullName: "Ada Example"})) + wantCode(t, err, apperr.CodeCSRRejected, "SUBJECT_MISMATCH") + }) + t.Run("P-256 CSR", func(t *testing.T) { + _, err := h.clientFrom("192.0.2.23:1", true).SubmitFirstAdminCertificate(h.ctx, withSession(secret, + &fleetv1.SubmitFirstAdminCertificateRequest{CertDer: cert.Raw, CsrDer: csrDER(t, p256(t), "admin@example.org"), FullName: "Ada Example"})) + wantCode(t, err, apperr.CodeCSRRejected, "KEY_TYPE") + }) + t.Run("CSR for another email", func(t *testing.T) { + _, err := h.clientFrom("192.0.2.24:1", true).SubmitFirstAdminCertificate(h.ctx, withSession(secret, + &fleetv1.SubmitFirstAdminCertificateRequest{CertDer: cert.Raw, CsrDer: csrDER(t, key, "other@example.org"), FullName: "Ada Example"})) + wantCode(t, err, apperr.CodeCertRejected, "SUBJECT_MISMATCH") + }) + t.Run("matching", func(t *testing.T) { + resp, err := h.clientFrom("192.0.2.25:1", true).SubmitFirstAdminCertificate(h.ctx, withSession(secret, + &fleetv1.SubmitFirstAdminCertificateRequest{CertDer: cert.Raw, CsrDer: csrDER(t, key, "admin@example.org"), FullName: "Ada Example"})) + if err != nil { + t.Fatalf("a matching certificate and CSR were refused: %v", err) + } + if resp.Msg.GetEmail() != "admin@example.org" || resp.Msg.GetIssuerSha256() != operatorca.Fingerprint(ca.cert) || + resp.Msg.GetSerialHex() != operatorca.SerialKey(cert.SerialNumber) || resp.Msg.GetNotAfter() == "" { + t.Fatalf("response = %+v", resp.Msg) + } + }) +} + +func TestSubmit_PathBPreflightRunsTheSameChecks(t *testing.T) { + h, secret, ca := registered(t) + for i, tc := range []struct { + name string + opts leafOpts + reason string + }{ + {"operator level", leafOpts{level: "operator"}, "WRONG_LEVEL"}, + {"critical level", leafOpts{critical: true}, "LEVEL_EXT_CRITICAL"}, + {"P-256 key", leafOpts{key: p256(t)}, "KEY_TYPE"}, + {"not an email", leafOpts{cn: "Ada Example"}, "SUBJECT_MISMATCH"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := h.clientFrom("192.0.2.3"+strconv.Itoa(i)+":1", true).SubmitFirstAdminCertificate(h.ctx, withSession(secret, + &fleetv1.SubmitFirstAdminCertificateRequest{CertDer: ca.leaf(t, tc.opts).Raw, FullName: "Ada Example"})) + wantCode(t, err, apperr.CodeCertRejected, tc.reason) + }) + } + other := newCA(t, "Example Other CA") + _, err := h.clientFrom("192.0.2.39:1", true).SubmitFirstAdminCertificate(h.ctx, withSession(secret, + &fleetv1.SubmitFirstAdminCertificateRequest{CertDer: other.leaf(t, leafOpts{}).Raw, FullName: "Ada Example"})) + wantCode(t, err, apperr.CodeCertRejected, "NOT_CHAINED") + + if _, err := h.submit(secret, &fleetv1.SubmitFirstAdminCertificateRequest{CertDer: ca.leaf(t, leafOpts{}).Raw, FullName: "Ada Example"}); err != nil { + t.Fatalf("a good certificate with no CSR was refused: %v", err) + } +} + +func TestSubmit_RecordsTheFirstAdminAndAudits(t *testing.T) { + h, secret, ca := registered(t) + cert := ca.leaf(t, leafOpts{cn: "admin@example.org"}) + if _, err := h.submit(secret, &fleetv1.SubmitFirstAdminCertificateRequest{CertDer: cert.Raw, FullName: "Ada Example"}); err != nil { + t.Fatalf("submit: %v", err) + } + creds, _ := h.st.FirstAdminCredentials(h.ctx) + if len(creds) != 1 { + t.Fatalf("first admin records = %+v", creds) + } + c := creds[0] + if c.Kind != store.OperatorCredentialFirstAdmin || c.IssuerSHA256 != operatorca.Fingerprint(ca.cert) || c.Email != "admin@example.org" || + c.FullName != "Ada Example" || c.CommonName != "admin@example.org" || c.Level != "admin" || c.LeafSHA256 != operatorca.Fingerprint(cert) || + c.SerialHex != operatorca.SerialKey(cert.SerialNumber) || c.NotAfter == "" { + t.Fatalf("record = %+v", c) + } + rows := h.auditKinds(KindFirstAdminRecorded) + if len(rows) != 1 || rows[0].ActorKind != ActorBootstrapSession || !strings.Contains(rows[0].Summary, "admin@example.org") || + !strings.Contains(rows[0].Summary, c.SerialHex) || !strings.Contains(rows[0].Summary, "192.0.2.10") { + t.Fatalf("audit = %+v", rows) + } +} + +// The first-run code has no way to reach a node: it never imports the node +// client or the fleet service that dials nodes. +func TestPackage_NeverDialsANode(t *testing.T) { + files, err := filepath.Glob("*.go") + if err != nil { + t.Fatal(err) + } + for _, f := range files { + if strings.HasSuffix(f, "_test.go") { + continue + } + src, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + parsed, err := parser.ParseFile(token.NewFileSet(), f, src, parser.ImportsOnly) + if err != nil { + t.Fatal(err) + } + for _, imp := range parsed.Imports { + p, _ := strconv.Unquote(imp.Path.Value) + if strings.HasSuffix(p, "/internal/nodeclient") || strings.HasSuffix(p, "/internal/fleet") { + t.Errorf("%s imports %s", f, p) + } + } + } +} + +func TestSubmit_SupersedeDenylistsTheEarlierFirstAdmin(t *testing.T) { + h, secret, ca := registered(t) + first := ca.leaf(t, leafOpts{}) + second := ca.leaf(t, leafOpts{}) + if _, err := h.submit(secret, &fleetv1.SubmitFirstAdminCertificateRequest{CertDer: first.Raw, FullName: "Ada Example"}); err != nil { + t.Fatalf("first submit: %v", err) + } + // The same certificate again is not a supersede. + if _, err := h.submit(secret, &fleetv1.SubmitFirstAdminCertificateRequest{CertDer: first.Raw, FullName: "Ada Example"}); err != nil { + t.Fatalf("repeat submit: %v", err) + } + if h.rev.Denylisted(operatorca.Fingerprint(ca.cert), operatorca.SerialKey(first.SerialNumber)) { + t.Fatal("submitting the same certificate twice denylisted it") + } + if _, err := h.submit(secret, &fleetv1.SubmitFirstAdminCertificateRequest{CertDer: second.Raw, FullName: "Ada Example"}); err != nil { + t.Fatalf("second submit: %v", err) + } + if !h.rev.Denylisted(operatorca.Fingerprint(ca.cert), operatorca.SerialKey(first.SerialNumber)) { + t.Fatal("the superseded first-admin certificate isn't on the denylist") + } + entries, _ := h.st.OperatorDenylist(h.ctx) + if len(entries) != 1 || entries[0].Reason != 4 { + t.Fatalf("denylist = %+v; want one entry with reason 4 (superseded)", entries) + } + auth := operatorca.PeerAuthorizer{Trust: h.trust, Rev: h.rev} + if _, err := auth.AuthorizePeer(first, nil); err == nil { + t.Fatal("the superseded certificate still authenticates on its next request") + } else if apperr.ReasonName(mustReason(err)) != "REVOKED" { + t.Fatalf("refusal = %v; want REVOKED", err) + } + if _, err := auth.AuthorizePeer(second, nil); err != nil { + t.Fatalf("the newer first-admin certificate was refused: %v", err) + } + if len(h.auditKinds(KindFirstAdminSuperseded)) != 1 { + t.Fatal("the supersede wasn't audited") + } +} + +func mustReason(err error) fleetv1.ErrorReason { + r, _ := apperr.ReasonOf(err) + return r +} + +func TestSubmit_NameAndEmailValidation(t *testing.T) { + h, secret, ca := registered(t) + cert := ca.leaf(t, leafOpts{cn: "Admin@Example.org"}) + for i, tc := range []struct { + name string + full string + ok bool + }{ + {"empty", "", false}, + {"too long", strings.Repeat("a", 129), false}, + {"control character", "Ada\u0007Example", false}, + {"newline", "Ada\nExample", false}, + {"128 characters", strings.Repeat("รฉ", 128), true}, + {"plain", "Ada Example", true}, + } { + t.Run(tc.name, func(t *testing.T) { + resp, err := h.clientFrom("192.0.2.4"+strconv.Itoa(i)+":1", true).SubmitFirstAdminCertificate(h.ctx, withSession(secret, + &fleetv1.SubmitFirstAdminCertificateRequest{CertDer: cert.Raw, FullName: tc.full})) + if tc.ok { + if err != nil { + t.Fatalf("refused: %v", err) + } + if resp.Msg.GetEmail() != "admin@example.org" { + t.Fatalf("email = %q, want the CN lower-cased", resp.Msg.GetEmail()) + } + return + } + wantCode(t, err, apperr.CodeCertRejected, "") + }) + } + for i, cn := range []string{"Ada ", "not-an-email", strings.Repeat("a", 250) + "@example.org"} { + _, err := h.clientFrom("192.0.2.5"+strconv.Itoa(i)+":1", true).SubmitFirstAdminCertificate(h.ctx, withSession(secret, + &fleetv1.SubmitFirstAdminCertificateRequest{CertDer: ca.leaf(t, leafOpts{cn: cn}).Raw, FullName: "Ada Example"})) + wantCode(t, err, apperr.CodeCertRejected, "SUBJECT_MISMATCH") + } +} + +func TestSubmit_RefusesAMalformedUpload(t *testing.T) { + h, secret, ca := registered(t) + two := append(append([]byte{}, ca.leaf(t, leafOpts{}).Raw...), ca.leaf(t, leafOpts{}).Raw...) + for i, der := range [][]byte{nil, []byte("junk"), two, make([]byte, 8<<10+1)} { + _, err := h.clientFrom("192.0.2.6"+strconv.Itoa(i)+":1", true).SubmitFirstAdminCertificate(h.ctx, withSession(secret, + &fleetv1.SubmitFirstAdminCertificateRequest{CertDer: der, FullName: "Ada Example"})) + wantCode(t, err, apperr.CodeCertRejected, "") + } +} diff --git a/internal/bootstrap/harness_test.go b/internal/bootstrap/harness_test.go new file mode 100644 index 0000000..c4b601b --- /dev/null +++ b/internal/bootstrap/harness_test.go @@ -0,0 +1,265 @@ +package bootstrap + +/* +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" + "crypto/tls" + "crypto/x509" + "errors" + "net/http" + "net/http/httptest" + "regexp" + "strconv" + "strings" + "sync" + "testing" + "time" + + connect "connectrpc.com/connect" + fleetv1 "github.com/CryptOS-PKI/api/go/cryptos/fleet/v1" + "github.com/CryptOS-PKI/api/go/cryptos/fleet/v1/fleetv1connect" + "github.com/CryptOS-PKI/manager/internal/apperr" + "github.com/CryptOS-PKI/manager/internal/operatorca" + "github.com/CryptOS-PKI/manager/internal/store" + "github.com/CryptOS-PKI/manager/internal/store/memory" +) + +var tokenPattern = regexp.MustCompile(`fos_boot_[0-9A-Z-]+`) + +// harness is a BootstrapService wired to fakes and served in process: a +// client's requests go straight to the handler with the remote address and +// TLS state the test picks. +type harness struct { + t *testing.T + ctx context.Context + clock *fakeClock + st *fakeStore + audit *memory.Store + trust *operatorca.TrustStore + rev *operatorca.Revocations + svc *Service + h http.Handler + logs *logSink + nodeCA []testCA + + mu sync.Mutex + banners [][]string +} + +type harnessOpt func(*Options) + +func newHarness(t *testing.T, opts ...harnessOpt) *harness { + t.Helper() + ctx := context.Background() + h := &harness{t: t, ctx: ctx, clock: &fakeClock{t: testNow}, st: newFakeStore(), audit: memory.New(nil), logs: &logSink{}} + h.rev = operatorca.NewRevocations(operatorca.RevocationOptions{Store: h.st, Now: h.clock.Now, Logf: h.logs.Logf, + OCSPFetcher: operatorca.NewOCSPFetcher()}) + var err error + h.trust, err = operatorca.NewTrustStore(ctx, operatorca.Source{Kind: operatorca.KindRegistered}, h.st, h.rev, &tls.Config{}, h.logs.Logf) + if err != nil { + t.Fatalf("NewTrustStore: %v", err) + } + o := Options{ + Store: h.st, Audit: h.audit, Trust: h.trust, Rev: h.rev, + FetchCRL: operatorca.NewFetcher(operatorca.FetchLimits{Timeout: operatorca.DefaultFetchTimeout, MaxBytes: operatorca.MaxCRLSize}).Fetch, + Server: ServerInfo{SHA256: "AB:CD", NotAfter: testNow.Add(90 * 24 * time.Hour)}, + Now: h.clock.Now, + Logf: h.logs.Logf, + PrintBanner: func(lines []string) { + h.mu.Lock() + defer h.mu.Unlock() + h.banners = append(h.banners, lines) + }, + NodeCAs: func() []*x509.Certificate { + var out []*x509.Certificate + for _, n := range h.nodeCA { + out = append(out, n.cert) + } + return out + }, + } + for _, fn := range opts { + fn(&o) + } + h.svc, err = New(ctx, o) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := h.svc.Start(ctx); err != nil { + t.Fatalf("Start: %v", err) + } + _, h.h = h.svc.Handler() + return h +} + +// token is the newest token printed in a banner. +func (h *harness) token() string { + h.t.Helper() + h.mu.Lock() + defer h.mu.Unlock() + for i := len(h.banners) - 1; i >= 0; i-- { + for _, line := range h.banners[i] { + if m := tokenPattern.FindString(line); m != "" { + return m + } + } + } + h.t.Fatal("no token was printed") + return "" +} + +func (h *harness) bannerCount() int { + h.mu.Lock() + defer h.mu.Unlock() + return len(h.banners) +} + +func (h *harness) bannerText() string { + h.mu.Lock() + defer h.mu.Unlock() + var b strings.Builder + for _, lines := range h.banners { + b.WriteString(strings.Join(lines, "\n")) + b.WriteString("\n") + } + return b.String() +} + +type inProcess struct { + h http.Handler + remote string + tls bool +} + +func (p inProcess) RoundTrip(r *http.Request) (*http.Response, error) { + r = r.Clone(r.Context()) + r.RemoteAddr = p.remote + if p.tls { + r.TLS = &tls.ConnectionState{HandshakeComplete: true} + } + if r.Body == nil { + r.Body = http.NoBody + } + rec := httptest.NewRecorder() + p.h.ServeHTTP(rec, r) + return rec.Result(), nil +} + +func (h *harness) clientFrom(remote string, withTLS bool) fleetv1connect.BootstrapServiceClient { + return fleetv1connect.NewBootstrapServiceClient(&http.Client{Transport: inProcess{h: h.h, remote: remote, tls: withTLS}}, "https://fm.example.org") +} + +func (h *harness) client() fleetv1connect.BootstrapServiceClient { + return h.clientFrom("192.0.2.10:40000", true) +} + +func (h *harness) state() *fleetv1.GetBootstrapStateResponse { + h.t.Helper() + resp, err := h.client().GetBootstrapState(h.ctx, connect.NewRequest(&fleetv1.GetBootstrapStateRequest{})) + if err != nil { + h.t.Fatalf("GetBootstrapState: %v", err) + } + return resp.Msg +} + +// startSession starts a session with the newest token and returns its +// secret. +func (h *harness) startSession() string { + h.t.Helper() + resp, err := h.client().StartBootstrapSession(h.ctx, connect.NewRequest(&fleetv1.StartBootstrapSessionRequest{Token: h.token()})) + if err != nil { + h.t.Fatalf("StartBootstrapSession: %v", err) + } + return resp.Msg.GetSessionSecret() +} + +func withSession[T any](secret string, msg *T) *connect.Request[T] { + req := connect.NewRequest(msg) + req.Header().Set(SessionHeader, secret) + return req +} + +func (h *harness) register(secret string, msg *fleetv1.BootstrapServiceRegisterOperatorCARequest) (*fleetv1.BootstrapServiceRegisterOperatorCAResponse, error) { + resp, err := h.client().RegisterOperatorCA(h.ctx, withSession(secret, msg)) + if err != nil { + return nil, err + } + return resp.Msg, nil +} + +// registerCA previews and confirms ca with no CRL and OCSP off. +func (h *harness) registerCA(secret string, ca testCA) { + h.t.Helper() + msg := &fleetv1.BootstrapServiceRegisterOperatorCARequest{ + CaCertDer: ca.cert.Raw, + CrlSource: &fleetv1.BootstrapServiceRegisterOperatorCARequest_None{None: true}, + OcspMode: fleetv1.OcspMode_OCSP_MODE_OFF, + Acknowledgements: []fleetv1.OperatorCAAcknowledgement{fleetv1.OperatorCAAcknowledgement_OPERATOR_CA_ACKNOWLEDGEMENT_NO_CRL}, + } + preview, err := h.register(secret, msg) + if err != nil { + h.t.Fatalf("RegisterOperatorCA preview: %v", err) + } + msg.ConfirmSha256 = preview.GetOperatorCa().GetSha256() + if _, err := h.register(secret, msg); err != nil { + h.t.Fatalf("RegisterOperatorCA confirm: %v", err) + } +} + +func (h *harness) submit(secret string, msg *fleetv1.SubmitFirstAdminCertificateRequest) (*fleetv1.SubmitFirstAdminCertificateResponse, error) { + resp, err := h.client().SubmitFirstAdminCertificate(h.ctx, withSession(secret, msg)) + if err != nil { + return nil, err + } + return resp.Msg, nil +} + +func (h *harness) auditKinds(kind string) []store.AuditEvent { + var out []store.AuditEvent + for _, e := range h.audit.Audit() { + if e.Kind == kind { + out = append(out, e) + } + } + return out +} + +// codeOf returns the numeric code and sub-reason a refusal carries. +func codeOf(t *testing.T, err error) (int, string) { + t.Helper() + var ce *connect.Error + if !errors.As(err, &ce) { + t.Fatalf("error %v is not a Connect error", err) + } + n, convErr := strconv.Atoi(ce.Meta().Get(apperr.MetadataKey)) + if convErr != nil { + t.Fatalf("error %v has no numeric code (%q)", err, ce.Meta().Get(apperr.MetadataKey)) + } + return n, ce.Meta().Get(apperr.ReasonKey) +} + +func wantCode(t *testing.T, err error, code int, reason string) { + t.Helper() + if err == nil { + t.Fatalf("got no error, want %d %s", code, reason) + } + gotCode, gotReason := codeOf(t, err) + if gotCode != code || gotReason != reason { + t.Fatalf("got %d %q (%v), want %d %q", gotCode, gotReason, err, code, reason) + } +} diff --git a/internal/bootstrap/latch.go b/internal/bootstrap/latch.go new file mode 100644 index 0000000..f24cd08 --- /dev/null +++ b/internal/bootstrap/latch.go @@ -0,0 +1,140 @@ +package bootstrap + +/* +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" + "crypto/x509" + "fmt" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/CryptOS-PKI/manager/internal/auditlog" + "github.com/CryptOS-PKI/manager/internal/authz" + "github.com/CryptOS-PKI/manager/internal/operatorca" + "github.com/CryptOS-PKI/manager/internal/store" +) + +// LatchOptions configures a Latch. +type LatchOptions struct { + // Store is nil without Postgres; the latch then never closes, because + // there is no first run to close. + Store store.Bootstrap + Audit store.Store + Now func() time.Time + Logf func(string, ...any) + // OnClose runs once when this process learns first run is closed. + OnClose func() +} + +// Latch closes first run the first time an admin certificate that chains to +// a trusted operator CA, and isn't revoked, makes an API request, from +// either operator CA source. It is one-way: nothing here reopens it. +// +// Each process keeps a flag, so a closed latch costs one atomic load per +// request. +type Latch struct { + st store.Bootstrap + audit store.Store + now func() time.Time + logf func(string, ...any) + onClose func() + + closed atomic.Bool + mu sync.Mutex + closeOnce sync.Once +} + +// NewLatch builds a latch and reads whether first run is already closed. +func NewLatch(ctx context.Context, o LatchOptions) (*Latch, error) { + l := &Latch{st: o.Store, audit: o.Audit, now: o.Now, logf: o.Logf, onClose: o.OnClose} + if l.now == nil { + l.now = time.Now + } + if l.logf == nil { + l.logf = func(string, ...any) {} + } + if l.onClose == nil { + l.onClose = func() {} + } + if l.st != nil { + st, err := l.st.BootstrapState(ctx) + if err != nil { + return nil, fmt.Errorf("bootstrap: read the first-run latch: %w", err) + } + if st.Closed() { + l.markClosed() + } + } + return l, nil +} + +// Closed reports whether this process knows first run is closed. +func (l *Latch) Closed() bool { return l != nil && l.closed.Load() } + +func (l *Latch) markClosed() { + l.closed.Store(true) + l.closeOnce.Do(l.onClose) +} + +// Observe is the authz.AuthHook: it runs after a request's certificate +// passed the chain and revocation checks. An admin certificate closes first +// run, ends every session, deletes every token, and is recorded as the first +// admin unless a pre-flight already recorded it. A failure to close is +// logged and retried on the next admin request. +func (l *Latch) Observe(ctx context.Context, id authz.Identity, cert *x509.Certificate) { + if l == nil || l.st == nil || id.Level != authz.LevelAdmin || l.closed.Load() { + return + } + l.mu.Lock() + defer l.mu.Unlock() + if l.closed.Load() { + return + } + now := l.now().UTC() + serial := operatorca.SerialKey(cert.SerialNumber) + closedNow, err := l.st.CloseBootstrap(ctx, store.BootstrapState{ + ClosedAt: now, ClosedBySerial: serial, ClosedByCN: id.CN, ClosedByIssuerSHA256: id.IssuerSHA256, + }) + if err != nil { + l.logf("bootstrap: ERROR can't close first run for %s (%s), will retry on the next admin request: %v", id.CN, serial, err) + return + } + l.markClosed() + if !closedNow { + return + } + + email := strings.ToLower(cert.Subject.CommonName) + if _, err := l.st.RecordFirstUse(ctx, store.OperatorCredential{ + IssuerSHA256: id.IssuerSHA256, SerialHex: serial, CommonName: email, Email: email, Level: id.Level.Token(), + NotAfter: cert.NotAfter.UTC().Format(time.RFC3339), LeafSHA256: operatorca.Fingerprint(cert), + }, now); err != nil { + l.logf("bootstrap: can't record %s (%s) as the first admin: %v", id.CN, serial, err) + } + l.logf("manager: first run CLOSED by %s (%s)", id.CN, serial) + if l.audit != nil { + auditlog.Record(ctx, l.audit, store.AuditEvent{ + Kind: KindBootstrapClosed, + Summary: fmt.Sprintf("First run closed by %s (serial %s, operator CA %s); bootstrap sessions ended and tokens deleted", id.CN, serial, id.IssuerSHA256), + TargetKind: "operator-credential", + TargetPath: "/operators/" + serial, + }) + } +} diff --git a/internal/bootstrap/latch_test.go b/internal/bootstrap/latch_test.go new file mode 100644 index 0000000..872de7e --- /dev/null +++ b/internal/bootstrap/latch_test.go @@ -0,0 +1,170 @@ +package bootstrap + +/* +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/tls" + "crypto/x509" + "net/http" + "net/http/httptest" + "testing" + + fleetv1 "github.com/CryptOS-PKI/api/go/cryptos/fleet/v1" + "github.com/CryptOS-PKI/manager/internal/authz" + "github.com/CryptOS-PKI/manager/internal/operatorca" + "github.com/CryptOS-PKI/manager/internal/store" +) + +func adminIdentity(cert *x509.Certificate, ca testCA) authz.Identity { + id, err := authz.IdentityFromCertificate(cert) + if err != nil { + panic(err) + } + id.IssuerSHA256 = operatorca.Fingerprint(ca.cert) + id.Via = authz.ViaWeb + return id +} + +// serveAs sends one request through the real client-certificate middleware +// with the latch hook, as the certificate's holder. +func serveAs(h *harness, cert *x509.Certificate) int { + mw := authz.ClientCertMiddlewareWith(operatorca.PeerAuthorizer{Trust: h.trust, Rev: h.rev}, h.svc.Latch().Observe) + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + req := httptest.NewRequest(http.MethodPost, "/cryptos.fleet.v1.FleetService/WhoAmI", nil) + req.TLS = &tls.ConnectionState{PeerCertificates: []*x509.Certificate{cert}} + rec := httptest.NewRecorder() + mw(next).ServeHTTP(rec, req) + return rec.Code +} + +func TestLatch_FirstAdminRequestClosesFirstRun(t *testing.T) { + h, secret, ca := registered(t) + _ = secret + admin := ca.leaf(t, leafOpts{cn: "Admin@Example.org"}) + if code := serveAs(h, admin); code != http.StatusOK { + t.Fatalf("the admin request got %d", code) + } + st, _ := h.st.BootstrapState(h.ctx) + if !st.Closed() || st.ClosedBySerial != operatorca.SerialKey(admin.SerialNumber) || st.ClosedByCN != "Admin@Example.org" || + st.ClosedByIssuerSHA256 != operatorca.Fingerprint(ca.cert) { + t.Fatalf("latch = %+v", st) + } + if len(h.st.tokenHashes()) != 0 { + t.Fatal("tokens survived") + } + creds, _ := h.st.FirstAdminCredentials(h.ctx) + if len(creds) != 1 || creds[0].SerialHex != operatorca.SerialKey(admin.SerialNumber) || creds[0].Email != "admin@example.org" { + t.Fatalf("path B without a pre-flight wasn't recorded as first_admin: %+v", creds) + } + if h.logs.count("first run CLOSED by Admin@Example.org") != 1 { + t.Error("the latch closing wasn't logged") + } + rows := h.auditKinds(KindBootstrapClosed) + if len(rows) != 1 || rows[0].ActorKind != authz.ActorCert || rows[0].ActorCN != "Admin@Example.org" { + t.Fatalf("latch audit = %+v", rows) + } + if h.state().GetState() != fleetv1.BootstrapState_BOOTSTRAP_STATE_CLOSED { + t.Fatal("GetBootstrapState isn't CLOSED") + } +} + +func TestLatch_PreflightRecordIsKept(t *testing.T) { + h, secret, ca := registered(t) + admin := ca.leaf(t, leafOpts{}) + if _, err := h.submit(secret, &fleetv1.SubmitFirstAdminCertificateRequest{CertDer: admin.Raw, FullName: "Ada Example"}); err != nil { + t.Fatalf("submit: %v", err) + } + serveAs(h, admin) + creds, _ := h.st.FirstAdminCredentials(h.ctx) + if len(creds) != 1 || creds[0].FullName != "Ada Example" { + t.Fatalf("the latch overwrote the pre-flight record: %+v", creds) + } +} + +func TestLatch_ViewerOperatorAndDenylistedAdminDontCloseIt(t *testing.T) { + h, _, ca := registered(t) + for _, level := range []string{"viewer", "operator"} { + if code := serveAs(h, ca.leaf(t, leafOpts{level: level})); code != http.StatusOK { + t.Fatalf("%s request got %d", level, code) + } + } + denied := ca.leaf(t, leafOpts{}) + if err := h.rev.Deny(h.ctx, store.DenylistEntry{IssuerSHA256: operatorca.Fingerprint(ca.cert), SerialHex: operatorca.SerialKey(denied.SerialNumber)}); err != nil { + t.Fatalf("Deny: %v", err) + } + if code := serveAs(h, denied); code != http.StatusForbidden { + t.Fatalf("a denylisted admin got %d, want 403", code) + } + if st, _ := h.st.BootstrapState(h.ctx); st.Closed() { + t.Fatalf("the latch closed: %+v", st) + } +} + +func TestLatch_StaysClosedWhenEveryAdminIsDeniedOrTheCARetired(t *testing.T) { + h, _, ca := registered(t) + admin := ca.leaf(t, leafOpts{}) + serveAs(h, admin) + if err := h.rev.Deny(h.ctx, store.DenylistEntry{IssuerSHA256: operatorca.Fingerprint(ca.cert), SerialHex: operatorca.SerialKey(admin.SerialNumber)}); err != nil { + t.Fatalf("Deny: %v", err) + } + if err := h.st.SetOperatorCAState(h.ctx, operatorca.Fingerprint(ca.cert), store.OperatorCARetired, "test", testNow); err != nil { + t.Fatalf("retire: %v", err) + } + if err := h.trust.Rebuild(h.ctx); err != nil { + t.Fatalf("Rebuild: %v", err) + } + restarted, err := New(h.ctx, Options{Store: h.st, Audit: h.audit, Trust: h.trust, Rev: h.rev, Now: h.clock.Now, Logf: h.logs.Logf, + PrintBanner: func([]string) { t.Error("a banner was printed after the latch closed") }}) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := restarted.Start(h.ctx); err != nil { + t.Fatalf("Start: %v", err) + } + if st, _ := h.st.BootstrapState(h.ctx); !st.Closed() { + t.Fatal("the latch reopened") + } + if h.logs.count("no operator CA is trusted and first run is closed") == 0 { + t.Error("a closed first run with nothing trusted wasn't logged") + } +} + +func TestLatch_FileSourceClosesItToo(t *testing.T) { + st := newFakeStore() + ca := newCA(t, "Example Operator CA") + rev := operatorca.NewRevocations(operatorca.RevocationOptions{Store: st}) + trust, err := operatorca.NewTrustStore(t.Context(), operatorca.Source{Kind: operatorca.KindFile, + File: []operatorca.Anchor{{Cert: ca.cert, SHA256: operatorca.Fingerprint(ca.cert), State: store.OperatorCAActive, FromConfig: true}}}, + st, rev, &tls.Config{}, nil) + if err != nil { + t.Fatal(err) + } + h := newHarness(t, func(o *Options) { o.Store, o.Trust, o.Rev = st, trust, rev }) + h.st, h.trust, h.rev = st, trust, rev + serveAs(h, ca.leaf(t, leafOpts{})) + if s, _ := st.BootstrapState(h.ctx); !s.Closed() { + t.Fatal("the first admin request in the file source didn't close the latch") + } + if h.bannerCount() != 0 { + t.Fatal("the file source printed a banner") + } +} + +func TestLatch_NilIsANoOp(t *testing.T) { + var l *Latch + l.Observe(t.Context(), authz.Identity{Level: authz.LevelAdmin}, nil) +} diff --git a/internal/bootstrap/ocsp_test.go b/internal/bootstrap/ocsp_test.go new file mode 100644 index 0000000..e045c06 --- /dev/null +++ b/internal/bootstrap/ocsp_test.go @@ -0,0 +1,118 @@ +package bootstrap + +/* +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 ( + "io" + "net/http" + "net/http/httptest" + "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/operatorca" + "golang.org/x/crypto/ocsp" +) + +// ocspResponder answers OCSP requests for ca, signed by ca, with the status +// set per serial (good by default). +type ocspResponder struct { + t *testing.T + ca testCA + srv *httptest.Server + mu sync.Mutex + status map[string]int +} + +func newOCSPResponder(t *testing.T, ca testCA) *ocspResponder { + t.Helper() + r := &ocspResponder{t: t, ca: ca, status: map[string]int{}} + r.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + body, _ := io.ReadAll(req.Body) + q, err := ocsp.ParseRequest(body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + r.mu.Lock() + st, ok := r.status[operatorca.SerialKey(q.SerialNumber)] + r.mu.Unlock() + if !ok { + st = ocsp.Good + } + tmpl := ocsp.Response{Status: st, SerialNumber: q.SerialNumber, IssuerHash: q.HashAlgorithm, + ThisUpdate: time.Now().Add(-time.Minute), NextUpdate: time.Now().Add(30 * time.Minute)} + if st == ocsp.Revoked { + tmpl.RevokedAt = time.Now().Add(-time.Hour) + } + der, err := ocsp.CreateResponse(r.ca.cert, r.ca.cert, tmpl, r.ca.key) + if err != nil { + r.t.Errorf("responder: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/ocsp-response") + _, _ = w.Write(der) + })) + t.Cleanup(r.srv.Close) + return r +} + +func (r *ocspResponder) set(serial string, status int) { + r.mu.Lock() + defer r.mu.Unlock() + r.status[serial] = status +} + +// A first-admin certificate the CA's OCSP responder says is revoked, or +// doesn't know, is refused, like one on the denylist. +func TestSubmit_RefusesWhatTheOCSPResponderRevokes(t *testing.T) { + ca := newCA(t, "Example Operator CA") + resp := newOCSPResponder(t, ca) + h := newHarness(t) + secret := h.startSession() + // aia: each certificate names its own responder, which only the + // certificate itself can tell the check. + msg := noCRL(ca) + msg.OcspMode = fleetv1.OcspMode_OCSP_MODE_AIA + preview, err := h.register(secret, msg) + if err != nil { + t.Fatalf("preview: %v", err) + } + msg.ConfirmSha256 = preview.GetOperatorCa().GetSha256() + if _, err := h.register(secret, msg); err != nil { + t.Fatalf("confirm: %v", err) + } + + aia := []string{resp.srv.URL} + revoked := ca.leaf(t, leafOpts{ocsp: aia}) + unknown := ca.leaf(t, leafOpts{ocsp: aia}) + resp.set(operatorca.SerialKey(revoked.SerialNumber), ocsp.Revoked) + resp.set(operatorca.SerialKey(unknown.SerialNumber), ocsp.Unknown) + + _, err = h.clientFrom("192.0.2.71:1", true).SubmitFirstAdminCertificate(h.ctx, withSession(secret, + &fleetv1.SubmitFirstAdminCertificateRequest{CertDer: revoked.Raw, FullName: "Ada Example"})) + wantCode(t, err, apperr.CodeCertRejected, "REVOKED_OCSP") + _, err = h.clientFrom("192.0.2.72:1", true).SubmitFirstAdminCertificate(h.ctx, withSession(secret, + &fleetv1.SubmitFirstAdminCertificateRequest{CertDer: unknown.Raw, FullName: "Ada Example"})) + wantCode(t, err, apperr.CodeCertRejected, "OCSP_UNKNOWN") + if _, err := h.submit(secret, &fleetv1.SubmitFirstAdminCertificateRequest{CertDer: ca.leaf(t, leafOpts{ocsp: aia}).Raw, FullName: "Ada Example"}); err != nil { + t.Fatalf("a certificate the responder says is good was refused: %v", err) + } +} diff --git a/internal/bootstrap/pki_test.go b/internal/bootstrap/pki_test.go new file mode 100644 index 0000000..c6ef176 --- /dev/null +++ b/internal/bootstrap/pki_test.go @@ -0,0 +1,189 @@ +package bootstrap + +/* +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/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "math/big" + "sync" + "testing" + "time" + + "github.com/CryptOS-PKI/manager/internal/authz" +) + +// The test PKI. Every name is a fake: example.org, "Example Operator CA". + +var ( + oidLevel = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 59999, 1, 1} + testNow = time.Now().UTC().Truncate(time.Second) + + serialMu sync.Mutex + serialCounter = big.NewInt(0x5000) +) + +func nextSerial() *big.Int { + serialMu.Lock() + defer serialMu.Unlock() + serialCounter = new(big.Int).Add(serialCounter, big.NewInt(1)) + return new(big.Int).Set(serialCounter) +} + +func p384(t *testing.T) crypto.Signer { + t.Helper() + k, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + return k +} + +func p256(t *testing.T) crypto.Signer { + t.Helper() + k, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + return k +} + +type testCA struct { + cert *x509.Certificate + key crypto.Signer +} + +func newCA(t *testing.T, cn string) testCA { + t.Helper() + return newCAWith(t, cn, x509.KeyUsageCertSign|x509.KeyUsageCRLSign, nil) +} + +func newCAWith(t *testing.T, cn string, ku x509.KeyUsage, parent *testCA) testCA { + t.Helper() + key := p384(t) + spki, err := x509.MarshalPKIXPublicKey(key.Public()) + if err != nil { + t.Fatalf("marshal spki: %v", err) + } + ski := sha256.Sum256(spki) + tmpl := &x509.Certificate{ + SerialNumber: nextSerial(), + Subject: pkix.Name{Organization: []string{"Example"}, CommonName: cn}, + NotBefore: testNow.Add(-24 * time.Hour), + NotAfter: testNow.Add(10 * 365 * 24 * time.Hour), + KeyUsage: ku, + BasicConstraintsValid: true, + IsCA: true, + MaxPathLenZero: true, + SubjectKeyId: ski[:20], + } + parentCert, parentKey := tmpl, key + if parent != nil { + parentCert, parentKey = parent.cert, parent.key + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, parentCert, key.Public(), parentKey) + if err != nil { + t.Fatalf("create CA: %v", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("parse CA: %v", err) + } + return testCA{cert: cert, key: key} +} + +type leafOpts struct { + cn string + level string + key crypto.Signer + notAfter time.Time + critical bool + ocsp []string +} + +func (ca testCA) leaf(t *testing.T, o leafOpts) *x509.Certificate { + t.Helper() + if o.cn == "" { + o.cn = "admin@example.org" + } + if o.level == "" { + o.level = "admin" + } + if o.key == nil { + o.key = p384(t) + } + if o.notAfter.IsZero() { + o.notAfter = testNow.Add(365 * 24 * time.Hour) + } + _, lvl, err := authz.MarshalLevelExtension(o.level) + if err != nil { + t.Fatalf("level extension: %v", err) + } + tmpl := &x509.Certificate{ + SerialNumber: nextSerial(), + Subject: pkix.Name{CommonName: o.cn}, + NotBefore: testNow.Add(-time.Hour), + NotAfter: o.notAfter, + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, + BasicConstraintsValid: true, + ExtraExtensions: []pkix.Extension{{Id: oidLevel, Critical: o.critical, Value: lvl}}, + OCSPServer: o.ocsp, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, ca.cert, o.key.Public(), ca.key) + if err != nil { + t.Fatalf("create leaf: %v", err) + } + cert, err := x509.ParseCertificate(der) + if err != nil { + t.Fatalf("parse leaf: %v", err) + } + return cert +} + +func csrDER(t *testing.T, key crypto.Signer, cn string) []byte { + t.Helper() + der, err := x509.CreateCertificateRequest(rand.Reader, &x509.CertificateRequest{Subject: pkix.Name{CommonName: cn}}, key) + if err != nil { + t.Fatalf("create CSR: %v", err) + } + return der +} + +func (ca testCA) crl(t *testing.T, number int64, revoked ...*big.Int) []byte { + t.Helper() + var entries []x509.RevocationListEntry + for _, s := range revoked { + entries = append(entries, x509.RevocationListEntry{SerialNumber: s, RevocationTime: testNow.Add(-time.Hour)}) + } + der, err := x509.CreateRevocationList(rand.Reader, &x509.RevocationList{ + Number: big.NewInt(number), + ThisUpdate: testNow.Add(-time.Hour), + NextUpdate: testNow.Add(7 * 24 * time.Hour), + RevokedCertificateEntries: entries, + }, ca.cert, ca.key) + if err != nil { + t.Fatalf("create CRL: %v", err) + } + return der +} diff --git a/internal/bootstrap/register.go b/internal/bootstrap/register.go new file mode 100644 index 0000000..f336f36 --- /dev/null +++ b/internal/bootstrap/register.go @@ -0,0 +1,370 @@ +package bootstrap + +/* +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" + "crypto/x509" + "errors" + "fmt" + "strings" + "time" + + connect "connectrpc.com/connect" + fleetv1 "github.com/CryptOS-PKI/api/go/cryptos/fleet/v1" + "github.com/CryptOS-PKI/manager/internal/apperr" + "github.com/CryptOS-PKI/manager/internal/operatorca" + "github.com/CryptOS-PKI/manager/internal/store" +) + +// registeredBy is what a first-run registration records as its registrar. +const registeredBy = "first-run session" + +const ackNoCRL = "NO_CRL" + +// rejected gives a refusal the InvalidArgument transport code unless it +// already carries one. +func rejected(err error) error { + var ce *connect.Error + if errors.As(err, &ce) { + return err + } + return connect.NewError(connect.CodeInvalidArgument, err) +} + +func caRejected(code connect.Code, reason fleetv1.ErrorReason, format string, args ...any) error { + return connect.NewError(code, apperr.Reasoned(apperr.CodeOperatorCARejected, reason, fmt.Errorf("bootstrap: "+format, args...))) +} + +// normalizeFingerprint accepts a SHA-256 in any case, with or without the +// colons openssl prints. +func normalizeFingerprint(s string) string { + return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(s), ":", "")) +} + +// registration is an operator CA upload that passed every check. +type registration struct { + cert *x509.Certificate + warnings []string + acks []string + crlSource string + crlURL string + crl *operatorca.VerifiedCRL + ocspMode string + ocspURL string + probe *fleetv1.OcspProbeResult +} + +// RegisterOperatorCA previews, confirms or re-confirms the external operator +// CA. Session-gated and audited. +func (s *Service) RegisterOperatorCA(ctx context.Context, req *connect.Request[fleetv1.BootstrapServiceRegisterOperatorCARequest]) (*connect.Response[fleetv1.BootstrapServiceRegisterOperatorCAResponse], error) { + if err := s.admit(ctx); err != nil { + return nil, err + } + sessionHash, err := s.session(ctx, req.Header()) + if err != nil { + return nil, err + } + m := req.Msg + if len(m.GetCaCertDer()) == 0 { + return s.reconfirm(ctx, sessionHash, m.GetConfirmSha256()) + } + + reg, err := s.check(ctx, m) + if err != nil { + return nil, s.fail(ctx, rejected(err)) + } + extfile, err := operatorca.ExtfileSection("admin") + if err != nil { + return nil, err + } + fp := operatorca.Fingerprint(reg.cert) + resp := &fleetv1.BootstrapServiceRegisterOperatorCAResponse{ + OperatorCa: s.previewOf(reg), + AdminExtfile: extfile, + OcspProbe: reg.probe, + } + if m.GetConfirmSha256() == "" { + return connect.NewResponse(resp), nil + } + if normalizeFingerprint(m.GetConfirmSha256()) != fp { + return nil, s.fail(ctx, caRejected(connect.CodeInvalidArgument, fleetv1.ErrorReason_ERROR_REASON_NOT_CONFIRMED, + "confirm_sha256 doesn't match the uploaded operator CA")) + } + + now := s.now().UTC() + row := store.OperatorCA{ + SHA256: fp, CertDER: reg.cert.Raw, State: store.OperatorCAActive, + CRLSource: reg.crlSource, CRLURL: reg.crlURL, OCSPMode: reg.ocspMode, OCSPURL: reg.ocspURL, + Acknowledgements: reg.acks, Warnings: reg.warnings, RegisteredAt: now, RegisteredBy: registeredBy, + } + var ( + crlRow *store.OperatorCRL + decide store.DecideCRL + ) + if v := reg.crl; v != nil { + crlRow = &store.OperatorCRL{ + IssuerSHA256: fp, DER: v.DER, Number: v.Number, ThisUpdate: v.List.ThisUpdate, NextUpdate: v.List.NextUpdate, + FetchedAt: now, Source: reg.crlSource, + } + decide = func(cur store.OperatorCRL, has bool) (bool, error) { return operatorca.AcceptNewer(cur, has, v) } + } + if err := s.st.RegisterFirstRunOperatorCA(ctx, row, sessionHash, crlRow, decide); err != nil { + switch { + case errors.Is(err, store.ErrBootstrapClosed): + return nil, connect.NewError(connect.CodeFailedPrecondition, apperr.Coded(apperr.CodeFirstRunClosed, err)) + case isReasoned(err): + return nil, s.fail(ctx, rejected(err)) + default: + return nil, fmt.Errorf("bootstrap: store the operator CA: %w", err) + } + } + if err := s.trust.Rebuild(ctx); err != nil { + return nil, fmt.Errorf("bootstrap: trust the new operator CA: %w", err) + } + if err := s.rev.Reload(ctx); err != nil { + s.logf("bootstrap: can't reload revocation data after registering %s: %v", fp, err) + } + + ip := clientIP(ctx) + s.logf("manager: operator CA registered %s, SHA-256 %s, from %s", reg.cert.Subject, operatorca.ColonFingerprint(reg.cert.Raw), ip) + for _, w := range reg.warnings { + s.logf("manager: WARNING operator CA %s: %s", fp, w) + } + s.record(ctx, store.AuditEvent{ + Kind: KindOperatorCARegistered, + Summary: fmt.Sprintf("Registered operator CA %s (SHA-256 %s) from %s: CRL source %s%s, OCSP mode %s%s, acknowledgements [%s], warnings [%s]", + reg.cert.Subject, fp, ip, reg.crlSource, suffix(reg.crlURL), reg.ocspMode, suffix(reg.ocspURL), + strings.Join(reg.acks, ", "), strings.Join(reg.warnings, "; ")), + TargetKind: "operator-ca", + TargetPath: "/operator-cas/" + fp, + }) + + resp.Confirmed = true + resp.OperatorCa.State = fleetv1.OperatorCAState_OPERATOR_CA_STATE_ACTIVE + resp.OperatorCa.RegisteredAt = now.Format(time.RFC3339) + return connect.NewResponse(resp), nil +} + +func suffix(s string) string { + if s == "" { + return "" + } + return " " + s +} + +func isReasoned(err error) bool { + _, ok := apperr.ReasonOf(err) + return ok +} + +// check runs every registration check, in order, fail-closed: the upload, +// the CRL source choice, the anchor rules, the CRL itself, and the OCSP +// settings. +func (s *Service) check(ctx context.Context, m *fleetv1.BootstrapServiceRegisterOperatorCARequest) (*registration, error) { + cert, err := operatorca.ParseAnchorUpload(m.GetCaCertDer()) + if err != nil { + return nil, err + } + reg := ®istration{cert: cert, crlSource: store.CRLSourceNone} + for _, a := range m.GetAcknowledgements() { + if a != fleetv1.OperatorCAAcknowledgement_OPERATOR_CA_ACKNOWLEDGEMENT_UNSPECIFIED { + reg.acks = append(reg.acks, strings.TrimPrefix(a.String(), "OPERATOR_CA_ACKNOWLEDGEMENT_")) + } + } + var crlDER []byte + switch src := m.GetCrlSource().(type) { + case *fleetv1.BootstrapServiceRegisterOperatorCARequest_Url: + reg.crlSource, reg.crlURL = store.CRLSourceURL, src.Url + case *fleetv1.BootstrapServiceRegisterOperatorCARequest_CrlDer: + reg.crlSource, crlDER = store.CRLSourceUpload, src.CrlDer + } + + now := s.now() + reg.warnings, err = operatorca.ValidateAnchor(cert, operatorca.AnchorOptions{ + Now: now, NodeCAs: s.nodeCAs(), CRLSource: reg.crlSource != store.CRLSourceNone, + }) + if err != nil { + return nil, err + } + if reg.crlSource == store.CRLSourceNone { + acknowledged := false + for _, a := range reg.acks { + acknowledged = acknowledged || a == ackNoCRL + } + if !acknowledged { + return nil, caRejected(connect.CodeInvalidArgument, fleetv1.ErrorReason_ERROR_REASON_NO_CRL_NOT_ACKNOWLEDGED, + "no CRL source was chosen and NO_CRL wasn't acknowledged") + } + } + + if reg.crlSource == store.CRLSourceURL { + if err := operatorca.ValidateFetchURL(reg.crlURL); err != nil { + return nil, caRejected(connect.CodeInvalidArgument, fleetv1.ErrorReason_ERROR_REASON_CRL_UNREACHABLE, "CRL URL refused: %v", err) + } + crlDER, err = s.fetchCRL(ctx, reg.crlURL) + if err != nil { + if !isReasoned(err) { + err = apperr.Reasoned(apperr.CodeOperatorCARejected, fleetv1.ErrorReason_ERROR_REASON_CRL_UNREACHABLE, err) + } + return nil, err + } + } + if crlDER != nil { + reg.crl, err = operatorca.VerifyCRL(crlDER, cert, now) + if err != nil { + return nil, err + } + } + + switch m.GetOcspMode() { + case fleetv1.OcspMode_OCSP_MODE_OFF: + reg.ocspMode = store.OCSPModeOff + case fleetv1.OcspMode_OCSP_MODE_URL: + reg.ocspMode, reg.ocspURL = store.OCSPModeURL, m.GetOcspUrl() + if err := operatorca.ValidateFetchURL(reg.ocspURL); err != nil { + return nil, caRejected(connect.CodeInvalidArgument, fleetv1.ErrorReason_ERROR_REASON_OCSP_UNREACHABLE, "OCSP URL refused: %v", err) + } + // The probe is nil only when the manager runs without an OCSP + // client. + if s.probe != nil { + reg.probe, err = s.probe(ctx, cert, reg.ocspURL) + if err != nil { + return nil, err + } + } + default: + reg.ocspMode = store.OCSPModeAIA + } + return reg, nil +} + +// previewOf is the operator CA a registration would store. +func (s *Service) previewOf(reg *registration) *fleetv1.OperatorCA { + oc := describeCA(reg.cert, reg.warnings, reg.acks, reg.crlSource, reg.crlURL, reg.ocspMode, reg.ocspURL) + if v := reg.crl; v != nil { + oc.Crl = crlStatus(v, s.now(), s.now(), "") + } + return oc +} + +// reconfirm previews the current registration for a new session, or records +// the new session's confirmation of it without changing anything. With +// nothing registered the preview is empty. +func (s *Service) reconfirm(ctx context.Context, sessionHash, confirm string) (*connect.Response[fleetv1.BootstrapServiceRegisterOperatorCAResponse], error) { + row := s.activeCA(ctx) + if row == nil { + // A new session asks for the current registration first. Nothing + // registered is an empty answer, not the caller's mistake. + if confirm == "" { + return connect.NewResponse(&fleetv1.BootstrapServiceRegisterOperatorCAResponse{}), nil + } + return nil, s.fail(ctx, caRejected(connect.CodeFailedPrecondition, fleetv1.ErrorReason_ERROR_REASON_NOT_CONFIRMED, + "no operator CA is registered yet; upload one")) + } + cert, err := x509.ParseCertificate(row.CertDER) + if err != nil { + return nil, fmt.Errorf("bootstrap: the registered operator CA %s doesn't parse: %w", row.SHA256, err) + } + extfile, err := operatorca.ExtfileSection("admin") + if err != nil { + return nil, err + } + oc := describeCA(cert, row.Warnings, row.Acknowledgements, row.CRLSource, row.CRLURL, row.OCSPMode, row.OCSPURL) + oc.State = fleetv1.OperatorCAState_OPERATOR_CA_STATE_ACTIVE + oc.RegisteredAt = row.RegisteredAt.UTC().Format(time.RFC3339) + if v, ok := s.rev.CRL(row.SHA256); ok { + oc.Crl = crlStatus(v, time.Time{}, s.now(), s.rev.Status(row.SHA256).LastError) + } + resp := &fleetv1.BootstrapServiceRegisterOperatorCAResponse{OperatorCa: oc, AdminExtfile: extfile} + if confirm == "" { + return connect.NewResponse(resp), nil + } + if normalizeFingerprint(confirm) != row.SHA256 { + return nil, s.fail(ctx, caRejected(connect.CodeInvalidArgument, fleetv1.ErrorReason_ERROR_REASON_NOT_CONFIRMED, + "confirm_sha256 doesn't match the registered operator CA")) + } + if err := s.st.ConfirmOperatorCA(ctx, row.SHA256, sessionHash); err != nil { + return nil, fmt.Errorf("bootstrap: record the confirmation: %w", err) + } + s.logf("manager: operator CA %s confirmed again by a new first-run session from %s", row.SHA256, clientIP(ctx)) + resp.Confirmed = true + return connect.NewResponse(resp), nil +} + +func describeCA(cert *x509.Certificate, warnings, acks []string, crlSource, crlURL, ocspMode, ocspURL string) *fleetv1.OperatorCA { + oc := &fleetv1.OperatorCA{ + Sha256: operatorca.Fingerprint(cert), + Subject: cert.Subject.String(), + Issuer: cert.Issuer.String(), + NotAfter: cert.NotAfter.UTC().Format(time.RFC3339), + CrlSource: crlSourceEnum(crlSource), + CrlLocation: crlURL, + OcspMode: ocspModeEnum(ocspMode), + OcspUrl: ocspURL, + Warnings: warnings, + } + for _, a := range acks { + if v, ok := fleetv1.OperatorCAAcknowledgement_value["OPERATOR_CA_ACKNOWLEDGEMENT_"+a]; ok { + oc.Acknowledgements = append(oc.Acknowledgements, fleetv1.OperatorCAAcknowledgement(v)) + } + } + return oc +} + +func crlStatus(v *operatorca.VerifiedCRL, fetched, now time.Time, lastError string) *fleetv1.CrlStatus { + st := &fleetv1.CrlStatus{ + ThisUpdate: v.List.ThisUpdate.UTC().Format(time.RFC3339), + NextUpdate: v.List.NextUpdate.UTC().Format(time.RFC3339), + RevokedCount: int64(len(v.Revoked)), + LastError: lastError, + Stale: !v.Fresh(now), + } + if v.Number != nil { + st.CrlNumber = v.Number.String() + } + if !fetched.IsZero() { + st.FetchedAt = fetched.UTC().Format(time.RFC3339) + } + return st +} + +func crlSourceEnum(s string) fleetv1.CrlSource { + switch s { + case store.CRLSourceNone: + return fleetv1.CrlSource_CRL_SOURCE_NONE + case store.CRLSourceURL: + return fleetv1.CrlSource_CRL_SOURCE_URL + case store.CRLSourceUpload: + return fleetv1.CrlSource_CRL_SOURCE_UPLOAD + case store.CRLSourcePath: + return fleetv1.CrlSource_CRL_SOURCE_PATH + } + return fleetv1.CrlSource_CRL_SOURCE_UNSPECIFIED +} + +func ocspModeEnum(s string) fleetv1.OcspMode { + switch s { + case store.OCSPModeOff: + return fleetv1.OcspMode_OCSP_MODE_OFF + case store.OCSPModeURL: + return fleetv1.OcspMode_OCSP_MODE_URL + case store.OCSPModeAIA, "": + return fleetv1.OcspMode_OCSP_MODE_AIA + } + return fleetv1.OcspMode_OCSP_MODE_UNSPECIFIED +} diff --git a/internal/bootstrap/register_test.go b/internal/bootstrap/register_test.go new file mode 100644 index 0000000..c92a167 --- /dev/null +++ b/internal/bootstrap/register_test.go @@ -0,0 +1,342 @@ +package bootstrap + +/* +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" + "crypto/rand" + "crypto/x509" + "net/http" + "net/http/httptest" + "strings" + "testing" + + connect "connectrpc.com/connect" + fleetv1 "github.com/CryptOS-PKI/api/go/cryptos/fleet/v1" + "github.com/CryptOS-PKI/manager/internal/apperr" + "github.com/CryptOS-PKI/manager/internal/authz" + "github.com/CryptOS-PKI/manager/internal/operatorca" + "github.com/CryptOS-PKI/manager/internal/store" +) + +func noCRL(ca testCA) *fleetv1.BootstrapServiceRegisterOperatorCARequest { + return &fleetv1.BootstrapServiceRegisterOperatorCARequest{ + CaCertDer: ca.cert.Raw, + CrlSource: &fleetv1.BootstrapServiceRegisterOperatorCARequest_None{None: true}, + Acknowledgements: []fleetv1.OperatorCAAcknowledgement{fleetv1.OperatorCAAcknowledgement_OPERATOR_CA_ACKNOWLEDGEMENT_NO_CRL}, + } +} + +func TestRegister_PreviewThenConfirmWithTheFingerprint(t *testing.T) { + h := newHarness(t) + secret := h.startSession() + ca := newCA(t, "Example Operator CA") + + preview, err := h.register(secret, noCRL(ca)) + if err != nil { + t.Fatalf("preview: %v", err) + } + oc := preview.GetOperatorCa() + if preview.GetConfirmed() || oc.GetSha256() != operatorca.Fingerprint(ca.cert) || + !strings.Contains(oc.GetSubject(), "Example Operator CA") || oc.GetNotAfter() == "" { + t.Fatalf("preview = %+v", preview) + } + if cas, _ := h.st.OperatorCAs(h.ctx); len(cas) != 0 { + t.Fatal("a preview stored the operator CA") + } + _, der, _ := authz.MarshalLevelExtension("admin") + if want := colonHex(der); !strings.Contains(preview.GetAdminExtfile(), "1.3.6.1.4.1.59999.1.1") || !strings.Contains(preview.GetAdminExtfile(), "DER:"+want) { + t.Fatalf("admin_extfile = %q; want the level extension DER:%s from MarshalLevelExtension", preview.GetAdminExtfile(), want) + } + + wrong := noCRL(ca) + wrong.ConfirmSha256 = strings.Repeat("ab", 32) + _, err = h.register(secret, wrong) + wantCode(t, err, apperr.CodeOperatorCARejected, "NOT_CONFIRMED") + + ok := noCRL(ca) + ok.ConfirmSha256 = operatorca.ColonFingerprint(ca.cert.Raw) // pasted from openssl, colons and upper case + done, err := h.register(secret, ok) + if err != nil { + t.Fatalf("confirm: %v", err) + } + if !done.GetConfirmed() || done.GetOperatorCa().GetState() != fleetv1.OperatorCAState_OPERATOR_CA_STATE_ACTIVE { + t.Fatalf("confirm response = %+v", done) + } + anchors := h.trust.Anchors() + if len(anchors) != 1 || anchors[0].SHA256 != operatorca.Fingerprint(ca.cert) { + t.Fatalf("trusted anchors after confirm = %+v; want the registered CA with no restart", anchors) + } + rows := h.auditKinds(KindOperatorCARegistered) + if len(rows) != 1 || rows[0].ActorKind != ActorBootstrapSession || !strings.Contains(rows[0].Summary, operatorca.Fingerprint(ca.cert)) || + !strings.Contains(rows[0].Summary, "192.0.2.10") || !strings.Contains(rows[0].Summary, "NO_CRL") { + t.Fatalf("registration audit = %+v", rows) + } + if h.state().GetState() != fleetv1.BootstrapState_BOOTSTRAP_STATE_OPEN_IN_PROGRESS { + t.Fatal("a registration didn't make first run in progress") + } +} + +func TestRegister_RefusesABadAnchor(t *testing.T) { + h := newHarness(t) + secret := h.startSession() + node := newCA(t, "Example Node CA") + h.nodeCA = []testCA{node} + + notCA := node.leaf(t, leafOpts{}) + _, err := h.register(secret, &fleetv1.BootstrapServiceRegisterOperatorCARequest{CaCertDer: notCA.Raw, + CrlSource: &fleetv1.BootstrapServiceRegisterOperatorCARequest_None{None: true}, + Acknowledgements: []fleetv1.OperatorCAAcknowledgement{fleetv1.OperatorCAAcknowledgement_OPERATOR_CA_ACKNOWLEDGEMENT_NO_CRL}}) + wantCode(t, err, apperr.CodeOperatorCARejected, "NOT_A_CA") + + _, err = h.clientFrom("192.0.2.11:1", true).RegisterOperatorCA(h.ctx, withSession(secret, noCRL(node))) + wantCode(t, err, apperr.CodeOperatorCARejected, "IS_NODE_CA") + + sub := newCAWith(t, "Example Sub CA", x509.KeyUsageCertSign|x509.KeyUsageCRLSign, &node) + resp, err := h.clientFrom("192.0.2.12:1", true).RegisterOperatorCA(h.ctx, withSession(secret, noCRL(sub))) + if err != nil { + t.Fatalf("a sub-CA of a node CA was refused: %v", err) + } + if len(resp.Msg.GetOperatorCa().GetWarnings()) == 0 { + t.Fatal("a sub-CA of a node CA carries no warning") + } +} + +func TestRegister_CRLSources(t *testing.T) { + ca := newCA(t, "Example Operator CA") + other := newCA(t, "Example Other CA") + good := ca.crl(t, 10) + foreign := other.crl(t, 10) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/good.crl": + _, _ = w.Write(good) + case "/foreign.crl": + _, _ = w.Write(foreign) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + withURL := func(u string) *fleetv1.BootstrapServiceRegisterOperatorCARequest { + return &fleetv1.BootstrapServiceRegisterOperatorCARequest{CaCertDer: ca.cert.Raw, CrlSource: &fleetv1.BootstrapServiceRegisterOperatorCARequest_Url{Url: u}} + } + withDER := func(der []byte) *fleetv1.BootstrapServiceRegisterOperatorCARequest { + return &fleetv1.BootstrapServiceRegisterOperatorCARequest{CaCertDer: ca.cert.Raw, CrlSource: &fleetv1.BootstrapServiceRegisterOperatorCARequest_CrlDer{CrlDer: der}} + } + + cases := []struct { + name string + msg *fleetv1.BootstrapServiceRegisterOperatorCARequest + reason string + }{ + {"url unreachable", withURL(srv.URL + "/missing.crl"), "CRL_UNREACHABLE"}, + {"url not http", withURL("file:///etc/passwd"), "CRL_UNREACHABLE"}, + {"url from another CA", withURL(srv.URL + "/foreign.crl"), "CRL_INVALID"}, + {"upload from another CA", withDER(foreign), "CRL_INVALID"}, + {"none without the acknowledgement", &fleetv1.BootstrapServiceRegisterOperatorCARequest{CaCertDer: ca.cert.Raw, + CrlSource: &fleetv1.BootstrapServiceRegisterOperatorCARequest_None{None: true}}, "NO_CRL_NOT_ACKNOWLEDGED"}, + {"no CRL source at all", &fleetv1.BootstrapServiceRegisterOperatorCARequest{CaCertDer: ca.cert.Raw}, "NO_CRL_NOT_ACKNOWLEDGED"}, + } + for i, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + h := newHarness(t) + secret := h.startSession() + _, err := h.clientFrom("192.0.2."+string(rune('a'+i))+":1", true).RegisterOperatorCA(h.ctx, withSession(secret, tc.msg)) + wantCode(t, err, apperr.CodeOperatorCARejected, tc.reason) + if strings.Contains(err.Error(), string(foreign)) { + t.Fatal("the refusal echoes the fetched body") + } + }) + } + + t.Run("CRL signing missing", func(t *testing.T) { + h := newHarness(t) + secret := h.startSession() + noSign := newCAWith(t, "Example No-CRL CA", x509.KeyUsageCertSign, nil) + _, err := h.register(secret, &fleetv1.BootstrapServiceRegisterOperatorCARequest{CaCertDer: noSign.cert.Raw, + CrlSource: &fleetv1.BootstrapServiceRegisterOperatorCARequest_Url{Url: srv.URL + "/good.crl"}}) + wantCode(t, err, apperr.CodeOperatorCARejected, "CRL_SIGN_MISSING") + }) + + t.Run("url stores the CRL and the OCSP settings", func(t *testing.T) { + h := newHarness(t) + secret := h.startSession() + msg := withURL(srv.URL + "/good.crl") + msg.OcspMode = fleetv1.OcspMode_OCSP_MODE_URL + msg.OcspUrl = "http://ocsp.example.org/" + preview, err := h.register(secret, msg) + if err != nil { + t.Fatalf("preview: %v", err) + } + if st := preview.GetOperatorCa().GetCrl(); st.GetNextUpdate() == "" || st.GetCrlNumber() != "10" { + t.Fatalf("preview CRL status = %+v", st) + } + msg.ConfirmSha256 = preview.GetOperatorCa().GetSha256() + if _, err := h.register(secret, msg); err != nil { + t.Fatalf("confirm: %v", err) + } + cas, _ := h.st.OperatorCAs(h.ctx) + if len(cas) != 1 || cas[0].CRLSource != store.CRLSourceURL || cas[0].CRLURL != srv.URL+"/good.crl" || + cas[0].OCSPMode != store.OCSPModeURL || cas[0].OCSPURL != "http://ocsp.example.org/" { + t.Fatalf("stored row = %+v", cas) + } + if crl, ok := h.rev.CRL(cas[0].SHA256); !ok || crl.Number.Int64() != 10 { + t.Fatal("the registered CRL isn't enforced at once") + } + }) + + t.Run("upload is verified and stored", func(t *testing.T) { + h := newHarness(t) + secret := h.startSession() + msg := withDER(good) + preview, err := h.register(secret, msg) + if err != nil { + t.Fatalf("preview: %v", err) + } + msg.ConfirmSha256 = preview.GetOperatorCa().GetSha256() + if _, err := h.register(secret, msg); err != nil { + t.Fatalf("confirm: %v", err) + } + cas, _ := h.st.OperatorCAs(h.ctx) + if len(cas) != 1 || cas[0].CRLSource != store.CRLSourceUpload || cas[0].OCSPMode != store.OCSPModeAIA { + t.Fatalf("stored row = %+v; want the upload source and the default OCSP mode aia", cas) + } + }) + + t.Run("OCSP url mode needs an http URL", func(t *testing.T) { + h := newHarness(t) + secret := h.startSession() + msg := noCRL(ca) + msg.OcspMode = fleetv1.OcspMode_OCSP_MODE_URL + msg.OcspUrl = "ldap://ocsp.example.org/" + _, err := h.register(secret, msg) + wantCode(t, err, apperr.CodeOperatorCARejected, "OCSP_UNREACHABLE") + }) + + t.Run("OCSP url mode runs the probe", func(t *testing.T) { + probed := "" + h := newHarness(t, func(o *Options) { + o.OCSPProbe = func(_ context.Context, anchor *x509.Certificate, url string) (*fleetv1.OcspProbeResult, error) { + probed = url + return &fleetv1.OcspProbeResult{Signer: fleetv1.OcspSigner_OCSP_SIGNER_ANCHOR, CertStatus: "unknown"}, nil + } + }) + secret := h.startSession() + msg := noCRL(ca) + msg.OcspMode = fleetv1.OcspMode_OCSP_MODE_URL + msg.OcspUrl = "http://ocsp.example.org/" + resp, err := h.register(secret, msg) + if err != nil { + t.Fatalf("preview: %v", err) + } + if probed != "http://ocsp.example.org/" || resp.GetOcspProbe().GetSigner() != fleetv1.OcspSigner_OCSP_SIGNER_ANCHOR { + t.Fatalf("probe url %q, result %+v", probed, resp.GetOcspProbe()) + } + + aia := noCRL(ca) + probed = "" + if _, err := h.register(secret, aia); err != nil || probed != "" { + t.Fatalf("aia mode: err %v, probed %q; want no probe", err, probed) + } + }) +} + +func TestRegister_LaterSessionRetiresTheEarlierAndMustReconfirm(t *testing.T) { + h := newHarness(t) + rogue := newCA(t, "Example Rogue CA") + genuine := newCA(t, "Example Operator CA") + + a := h.startSession() + h.registerCA(a, rogue) + rogueAdmin := rogue.leaf(t, leafOpts{}) + auth := operatorca.PeerAuthorizer{Trust: h.trust, Rev: h.rev} + if _, err := auth.AuthorizePeer(rogueAdmin, nil); err != nil { + t.Fatalf("the registered CA's admin was refused: %v", err) + } + + b := h.startSession() + // A new session sees the current registration and must confirm it again. + cur, err := h.register(b, &fleetv1.BootstrapServiceRegisterOperatorCARequest{}) + if err != nil { + t.Fatalf("preview of the current registration: %v", err) + } + if cur.GetConfirmed() || cur.GetOperatorCa().GetSha256() != operatorca.Fingerprint(rogue.cert) { + t.Fatalf("current registration preview = %+v", cur) + } + _, err = h.submit(b, &fleetv1.SubmitFirstAdminCertificateRequest{CertDer: rogueAdmin.Raw, FullName: "Ada Example"}) + wantCode(t, err, apperr.CodeOperatorCARejected, "NOT_CONFIRMED") + + h.registerCA(b, genuine) + cas, _ := h.st.OperatorCAs(h.ctx) + for _, c := range cas { + if c.SHA256 == operatorca.Fingerprint(rogue.cert) && (c.State != store.OperatorCARetired || c.RetiredReason != store.RetiredSuperseded) { + t.Fatalf("the earlier registration = %+v; want retired at once", c) + } + } + if _, err := auth.AuthorizePeer(rogueAdmin, nil); err == nil { + t.Fatal("a certificate from the replaced CA still authenticates") + } + + // Re-confirming the current registration without changing it. + c := h.startSession() + again := &fleetv1.BootstrapServiceRegisterOperatorCARequest{ConfirmSha256: operatorca.Fingerprint(genuine.cert)} + resp, err := h.register(c, again) + if err != nil || !resp.GetConfirmed() { + t.Fatalf("re-confirm = %+v, %v", resp, err) + } + if by, _ := h.st.OperatorCAConfirmedBy(h.ctx, operatorca.Fingerprint(genuine.cert)); by != HashSecret(c) { + t.Fatal("the re-confirmation wasn't recorded for the new session") + } +} + +// A new session probes for the current registration. With nothing +// registered that is an empty preview, not a refusal, and it spends no +// rate-limit budget, so restarting first run costs the token holder nothing. +// Confirming a CA that isn't registered is still refused. +func TestRegister_NothingRegisteredToPreview(t *testing.T) { + h := newHarness(t) + secret := h.startSession() + for i := 0; i < 10; i++ { + resp, err := h.register(secret, &fleetv1.BootstrapServiceRegisterOperatorCARequest{}) + if err != nil { + t.Fatalf("preview %d with nothing registered: %v", i+1, err) + } + if resp.GetOperatorCa() != nil || resp.GetConfirmed() { + t.Fatalf("preview with nothing registered = %+v, want empty", resp) + } + } + bad, _ := NewToken(rand.Reader) + _, err := h.client().StartBootstrapSession(h.ctx, connect.NewRequest(&fleetv1.StartBootstrapSessionRequest{Token: bad})) + wantCode(t, err, apperr.CodeTokenInvalid, "") // not 1602: the previews spent nothing + + _, err = h.register(secret, &fleetv1.BootstrapServiceRegisterOperatorCARequest{ConfirmSha256: strings.Repeat("ab", 32)}) + wantCode(t, err, apperr.CodeOperatorCARejected, "NOT_CONFIRMED") + if connect.CodeOf(err) != connect.CodeFailedPrecondition { + t.Errorf("Connect code %v, want FailedPrecondition", connect.CodeOf(err)) + } +} + +func colonHex(b []byte) string { + const digits = "0123456789ABCDEF" + parts := make([]string, len(b)) + for i, c := range b { + parts[i] = string([]byte{digits[c>>4], digits[c&0x0f]}) + } + return strings.Join(parts, ":") +} diff --git a/internal/bootstrap/service.go b/internal/bootstrap/service.go new file mode 100644 index 0000000..65ad981 --- /dev/null +++ b/internal/bootstrap/service.go @@ -0,0 +1,539 @@ +// Package bootstrap is the Fleet Manager's first run: before any operator CA +// is trusted, the holder of a one-time token from the manager's log registers +// the external operator CA's certificate and checks the first admin +// certificate that CA signed. The manager signs nothing here. First run +// closes for good the first time an admin certificate authenticates. +// +// BootstrapService is served over HTTPS only, outside the client-certificate +// middleware. Tokens and session secrets are kept only as SHA-256 hashes in +// Postgres; without Postgres first run is unavailable. +package bootstrap + +/* +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" + "crypto/rand" + "crypto/x509" + "errors" + "fmt" + "io" + "log" + "net/http" + "time" + + connect "connectrpc.com/connect" + fleetv1 "github.com/CryptOS-PKI/api/go/cryptos/fleet/v1" + "github.com/CryptOS-PKI/api/go/cryptos/fleet/v1/fleetv1connect" + "github.com/CryptOS-PKI/manager/internal/apperr" + "github.com/CryptOS-PKI/manager/internal/auditlog" + "github.com/CryptOS-PKI/manager/internal/authz" + "github.com/CryptOS-PKI/manager/internal/operatorca" + "github.com/CryptOS-PKI/manager/internal/ratelimit" + "github.com/CryptOS-PKI/manager/internal/store" +) + +// ActorBootstrapSession is the audit actor kind of whatever a bootstrap +// session did. +const ActorBootstrapSession = "bootstrap_session" + +// Audit kinds written by first run. +const ( + KindSessionStarted = "bootstrap-session-started" + KindOperatorCARegistered = "operator-ca-registered" + KindFirstAdminRecorded = "operator-first-admin-recorded" + KindFirstAdminSuperseded = "operator-first-admin-superseded" + KindBootstrapClosed = "bootstrap-closed" + KindTokenRotated = "bootstrap-token-rotated" +) + +// Failure limits: per client, a burst of 5 that refills one a minute; +// across every client, 50 in an hour rotate the token and end the session. +const ( + perClientBurst = 5 + perClientRefill = time.Minute + globalFailures = 50 + globalWindow = time.Hour + tokenTick = 30 * time.Second +) + +// Request body limits: the session procedures carry at most a certificate, +// a CSR and a name; RegisterOperatorCA can carry a CRL. +const ( + maxSessionBody = 16 << 10 + maxRegisterBody = operatorca.MaxCRLSize + 64<<10 +) + +// Store is what first run needs from the database. +type Store interface { + store.OperatorTrust + store.Bootstrap +} + +// OCSPProbe checks, at registration, that an OCSP responder set in url mode +// answers for the anchor with a validly signed response. A failure carries +// 1605 OCSP_UNREACHABLE or OCSP_INVALID. +type OCSPProbe func(ctx context.Context, anchor *x509.Certificate, url string) (*fleetv1.OcspProbeResult, error) + +// ServerInfo is the server certificate the banner tells the operator to +// check in the browser. +type ServerInfo struct { + SHA256 string + NotAfter time.Time +} + +// Options configures a Service. +type Options struct { + // Store is nil without Postgres, which makes first run unavailable. + Store Store + // Audit is the audit chain. + Audit store.Store + Trust *operatorca.TrustStore + Rev *operatorca.Revocations + // FirstRunDisabled is firstRun: disabled. + FirstRunDisabled bool + // NodeCAs returns the CryptOS node CA certificates the manager knows, so + // a node's CA is refused as the operator CA. + NodeCAs func() []*x509.Certificate + // FetchCRL fetches a CRL URL with the manager's fetch limits. + FetchCRL func(ctx context.Context, url string) ([]byte, error) + // OCSPProbe checks a url-mode OCSP responder at registration. It is nil + // only without an OCSP client; the mode and URL are then stored + // unprobed. + OCSPProbe OCSPProbe + // TrustedOrigins are extra origins allowed to POST cross-origin (the + // configured CORS origins). + TrustedOrigins []string + Server ServerInfo + Now func() time.Time + Rand io.Reader + Logf func(string, ...any) + // PrintBanner prints a token banner. It defaults to the process log. + PrintBanner func(lines []string) +} + +// Service is BootstrapService. +type Service struct { + fleetv1connect.UnimplementedBootstrapServiceHandler + + st Store + audit store.Store + trust *operatorca.TrustStore + rev *operatorca.Revocations + disabled bool + nodeCAs func() []*x509.Certificate + fetchCRL func(ctx context.Context, url string) ([]byte, error) + probe OCSPProbe + origins []string + now func() time.Time + rand io.Reader + logf func(string, ...any) + + tokens *tokens + latch *Latch + perClient *ratelimit.Failures + global *ratelimit.Window +} + +// New builds the service and reads the latch. +func New(ctx context.Context, o Options) (*Service, error) { + s := &Service{ + st: o.Store, audit: o.Audit, trust: o.Trust, rev: o.Rev, disabled: o.FirstRunDisabled, + nodeCAs: o.NodeCAs, fetchCRL: o.FetchCRL, probe: o.OCSPProbe, origins: o.TrustedOrigins, + now: o.Now, rand: o.Rand, logf: o.Logf, + perClient: ratelimit.NewFailures(perClientBurst, perClientRefill), + global: ratelimit.NewWindow(globalFailures, globalWindow), + } + if s.now == nil { + s.now = time.Now + } + if s.rand == nil { + s.rand = rand.Reader + } + if s.logf == nil { + s.logf = log.Printf + } + if s.nodeCAs == nil { + s.nodeCAs = func() []*x509.Certificate { return nil } + } + printBanner := o.PrintBanner + if printBanner == nil { + printBanner = func(lines []string) { + for _, l := range lines { + log.Print(l) + } + } + } + var bs store.Bootstrap + if s.st != nil { + bs = s.st + s.tokens = &tokens{st: s.st, lock: s.st.TryAdvisoryLock, now: s.now, rand: s.rand, print: printBanner, server: o.Server, logf: s.logf} + } + latch, err := NewLatch(ctx, LatchOptions{Store: bs, Audit: s.audit, Now: s.now, Logf: s.logf, OnClose: s.onClose}) + if err != nil { + return nil, err + } + s.latch = latch + return s, nil +} + +// Latch is the first-run latch, to hook into the client-certificate +// middleware. +func (s *Service) Latch() *Latch { return s.latch } + +func (s *Service) onClose() { + if s.tokens != nil { + s.tokens.stop() + } +} + +// firstRunApplies reports whether this deployment has a first run at all: +// the registered source with Postgres and firstRun not disabled. +func (s *Service) firstRunApplies() bool { + return s.st != nil && !s.disabled && s.trust != nil && s.trust.Source().Kind == operatorca.KindRegistered +} + +// Start prints the first token when first run is open and this replica +// holds the token lock, then keeps a token live until first run closes. A +// closed first run that trusts nothing is logged, so the operator knows why +// every caller is refused. +func (s *Service) Start(ctx context.Context) error { + if !s.firstRunApplies() { + return nil + } + closed, err := s.closed(ctx) + if err != nil { + return err + } + if closed { + if len(s.trust.Anchors()) == 0 { + s.logf("manager: WARNING no operator CA is trusted and first run is closed, so every API caller is refused; " + + "restore operatorCAPath, or reopen first run with the break-glass reset") + } + return nil + } + s.tokens.ensure(ctx, true) + go func() { + t := time.NewTicker(tokenTick) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if s.latch.Closed() { + return + } + s.TickTokens(ctx) + } + } + }() + return nil +} + +// TickTokens issues a new token when the live one expired and this replica +// holds the token lock. It does nothing once first run is closed. +func (s *Service) TickTokens(ctx context.Context) { + if !s.firstRunApplies() || s.latch.Closed() { + return + } + if closed, err := s.closed(ctx); err != nil || closed { + return + } + s.tokens.ensure(ctx, false) +} + +func (s *Service) closed(ctx context.Context) (bool, error) { + if s.latch.Closed() { + return true, nil + } + st, err := s.st.BootstrapState(ctx) + if err != nil { + return false, fmt.Errorf("bootstrap: read the first-run latch: %w", err) + } + if st.Closed() { + s.latch.markClosed() + } + return st.Closed(), nil +} + +// state computes the first-run state. +func (s *Service) state(ctx context.Context) (fleetv1.BootstrapState, fleetv1.ErrorReason, error) { + switch { + case s.trust != nil && s.trust.Source().Kind == operatorca.KindFile: + return fleetv1.BootstrapState_BOOTSTRAP_STATE_NOT_APPLICABLE, 0, nil + case s.st == nil: + return fleetv1.BootstrapState_BOOTSTRAP_STATE_UNAVAILABLE, fleetv1.ErrorReason_ERROR_REASON_DATABASE_REQUIRED, nil + case s.disabled: + return fleetv1.BootstrapState_BOOTSTRAP_STATE_UNAVAILABLE, fleetv1.ErrorReason_ERROR_REASON_FIRST_RUN_DISABLED, nil + } + closed, err := s.closed(ctx) + if err != nil { + return 0, 0, err + } + if closed { + return fleetv1.BootstrapState_BOOTSTRAP_STATE_CLOSED, 0, nil + } + live, err := s.st.HasLiveBootstrapSession(ctx, s.now(), SessionIdle) + if err != nil { + return 0, 0, fmt.Errorf("bootstrap: look for a live session: %w", err) + } + if live || s.activeCA(ctx) != nil { + return fleetv1.BootstrapState_BOOTSTRAP_STATE_OPEN_IN_PROGRESS, 0, nil + } + return fleetv1.BootstrapState_BOOTSTRAP_STATE_OPEN, 0, nil +} + +// activeCA returns the active registered operator CA row, if any. +func (s *Service) activeCA(ctx context.Context) *store.OperatorCA { + rows, err := s.st.OperatorCAs(ctx) + if err != nil { + s.logf("bootstrap: can't read operator CAs: %v", err) + return nil + } + for _, r := range rows { + if r.State == store.OperatorCAActive { + return &r + } + } + return nil +} + +// gate refuses a session procedure unless first run is open. It reads +// neither the token nor the session, so a closed deployment is no oracle. +func (s *Service) gate(ctx context.Context) error { + st, reason, err := s.state(ctx) + if err != nil { + return err + } + switch st { + case fleetv1.BootstrapState_BOOTSTRAP_STATE_OPEN, fleetv1.BootstrapState_BOOTSTRAP_STATE_OPEN_IN_PROGRESS: + return nil + case fleetv1.BootstrapState_BOOTSTRAP_STATE_UNAVAILABLE: + return connect.NewError(connect.CodeFailedPrecondition, apperr.Reasoned(apperr.CodeUnavailable, reason, + errors.New("bootstrap: first run is unavailable"))) + default: + return connect.NewError(connect.CodeFailedPrecondition, apperr.Coded(apperr.CodeFirstRunClosed, + errors.New("bootstrap: first run is closed"))) + } +} + +// admit is the start of every session procedure: first run open, then the +// client's failure budget. +func (s *Service) admit(ctx context.Context) error { + if err := s.gate(ctx); err != nil { + return err + } + if s.perClient.Blocked(clientIP(ctx)) { + s.logf("bootstrap: refused %s from %s: rate limited", procedure(ctx), clientIP(ctx)) + return connect.NewError(connect.CodeResourceExhausted, apperr.Coded(apperr.CodeRateLimited, + errors.New("bootstrap: too many failures from this client"))) + } + return nil +} + +// fail counts a refusal against the client and the global cap, logs it by +// class, and returns it. Tripping the global cap rotates the token and ends +// the live session. +func (s *Service) fail(ctx context.Context, err error) error { + ip := clientIP(ctx) + s.logf("bootstrap: refused %s from %s: %s", procedure(ctx), ip, errorClass(err)) + s.perClient.Fail(ip) + if s.global.Fail() { + s.rotateAfterFailures(ctx) + } + return err +} + +func (s *Service) rotateAfterFailures(ctx context.Context) { + now := s.now().UTC() + s.logf("manager: WARNING %d failed first-run requests within an hour; rotating the bootstrap token and ending the live session", globalFailures) + if _, err := s.st.EndBootstrapSessions(ctx, store.SessionEndedRateLimited, now); err != nil { + s.logf("bootstrap: can't end the live session: %v", err) + } + if err := s.tokens.issue(ctx, fmt.Sprintf("%d failed first-run requests within an hour", globalFailures)); err != nil { + s.logf("bootstrap: can't rotate the bootstrap token: %v", err) + } + s.record(ctx, store.AuditEvent{ + Kind: KindTokenRotated, + Summary: fmt.Sprintf("Rotated the bootstrap token and ended the live session after %d failed first-run requests within an hour", globalFailures), + }) +} + +// errorClass names a refusal for the log by its code and sub-reason, never +// by its message. +func errorClass(err error) string { + code, _ := apperr.Code(err) + class := fmt.Sprintf("%d", code) + if r, ok := apperr.ReasonOf(err); ok { + class += " " + apperr.ReasonName(r) + } + return class +} + +func invalidToken() error { + return connect.NewError(connect.CodeUnauthenticated, apperr.Coded(apperr.CodeTokenInvalid, + errors.New("bootstrap: the bootstrap token is wrong, expired or used"))) +} + +func invalidSession() error { + return connect.NewError(connect.CodeUnauthenticated, apperr.Coded(apperr.CodeSessionInvalid, + errors.New("bootstrap: the bootstrap session is unknown, expired or ended"))) +} + +// session checks the session header and records the use. It returns the +// session's hash. +func (s *Service) session(ctx context.Context, h http.Header) (string, error) { + secret := h.Get(SessionHeader) + if len(secret) <= len(SessionPrefix) || secret[:len(SessionPrefix)] != SessionPrefix { + return "", s.fail(ctx, invalidSession()) + } + hash := HashSecret(secret) + sess, ok, err := s.st.BootstrapSession(ctx, hash) + if err != nil { + return "", fmt.Errorf("bootstrap: read the session: %w", err) + } + now := s.now().UTC() + if !ok || !sess.EndedAt.IsZero() { + return "", s.fail(ctx, invalidSession()) + } + if !now.Before(sess.ExpiresAt) || now.Sub(sess.LastUsedAt) >= SessionIdle { + if err := s.st.EndBootstrapSession(ctx, hash, store.SessionEndedExpired, now); err != nil { + s.logf("bootstrap: can't end an expired session: %v", err) + } + return "", s.fail(ctx, invalidSession()) + } + if err := s.st.TouchBootstrapSession(ctx, hash, now); err != nil { + return "", fmt.Errorf("bootstrap: touch the session: %w", err) + } + return hash, nil +} + +// record writes an audit row for something a bootstrap session did. +func (s *Service) record(ctx context.Context, e store.AuditEvent) { + if s.audit == nil { + return + } + if _, ok := authz.FromContext(ctx); !ok { + e.ActorKind = ActorBootstrapSession + e.Via = authz.ViaWeb + } + auditlog.Record(ctx, s.audit, e) +} + +// GetBootstrapState reports whether first run is open. Anonymous. +func (s *Service) GetBootstrapState(ctx context.Context, _ *connect.Request[fleetv1.GetBootstrapStateRequest]) (*connect.Response[fleetv1.GetBootstrapStateResponse], error) { + st, reason, err := s.state(ctx) + if err != nil { + return nil, err + } + resp := &fleetv1.GetBootstrapStateResponse{State: st, ReasonCode: reason} + if st == fleetv1.BootstrapState_BOOTSTRAP_STATE_OPEN || st == fleetv1.BootstrapState_BOOTSTRAP_STATE_OPEN_IN_PROGRESS { + if tok, ok, err := s.st.LiveBootstrapToken(ctx, s.now()); err == nil && ok { + resp.TokenExpiresAt = tok.ExpiresAt.UTC().Format(time.RFC3339) + } + } + return connect.NewResponse(resp), nil +} + +// StartBootstrapSession consumes the token and starts the one live session. +func (s *Service) StartBootstrapSession(ctx context.Context, req *connect.Request[fleetv1.StartBootstrapSessionRequest]) (*connect.Response[fleetv1.StartBootstrapSessionResponse], error) { + if err := s.admit(ctx); err != nil { + return nil, err + } + canon, ok := CanonicalToken(req.Msg.GetToken()) + if !ok { + return nil, s.fail(ctx, invalidToken()) + } + secret, err := newSessionSecret(s.rand) + if err != nil { + return nil, err + } + now := s.now().UTC() + sess := store.BootstrapSession{Hash: HashSecret(secret), CreatedAt: now, LastUsedAt: now, ExpiresAt: now.Add(SessionMax)} + started, err := s.st.StartBootstrapSession(ctx, HashSecret(canon), sess, now) + if err != nil { + return nil, fmt.Errorf("bootstrap: start the session: %w", err) + } + if !started { + return nil, s.fail(ctx, invalidToken()) + } + if err := s.tokens.issue(ctx, "the previous token started a session"); err != nil { + s.logf("bootstrap: can't issue the next bootstrap token: %v", err) + } + s.logf("manager: bootstrap session started from %s", clientIP(ctx)) + s.record(ctx, store.AuditEvent{ + Kind: KindSessionStarted, + Summary: fmt.Sprintf("Started a first-run session from %s; any earlier session was ended", clientIP(ctx)), + }) + return connect.NewResponse(&fleetv1.StartBootstrapSessionResponse{ + SessionSecret: secret, + ExpiresAt: sess.ExpiresAt.Format(time.RFC3339), + }), nil +} + +type requestInfoKey struct{} + +type requestInfo struct { + ip string + procedure string +} + +func clientIP(ctx context.Context) string { + if ri, ok := ctx.Value(requestInfoKey{}).(requestInfo); ok { + return ri.ip + } + return "" +} + +func procedure(ctx context.Context) string { + if ri, ok := ctx.Value(requestInfoKey{}).(requestInfo); ok { + return ri.procedure + } + return "" +} + +// Handler returns BootstrapService's path and handler. The handler refuses +// any request that didn't arrive over TLS, refuses cross-origin writes, +// bounds request bodies, and gives every refusal a numeric code. +func (s *Service) Handler() (string, http.Handler) { + path, h := fleetv1connect.NewBootstrapServiceHandler(s, + connect.WithInterceptors(apperr.Interceptor()), + connect.WithReadMaxBytes(maxRegisterBody), + ) + cop := http.NewCrossOriginProtection() + for _, o := range s.origins { + if err := cop.AddTrustedOrigin(o); err != nil { + s.logf("bootstrap: WARNING ignoring CORS origin %q for first run: %v", o, err) + } + } + return path, cop.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.TLS == nil { + s.logf("bootstrap: refused %s from %s: not over TLS", r.URL.Path, ratelimit.ClientIP(r)) + apperr.WriteHTTP(w, http.StatusForbidden, apperr.Coded(apperr.CodeUnavailable, + errors.New("bootstrap: first run is served over HTTPS only"))) + return + } + limit := int64(maxSessionBody) + if r.URL.Path == fleetv1connect.BootstrapServiceRegisterOperatorCAProcedure { + limit = maxRegisterBody + } + r.Body = http.MaxBytesReader(w, r.Body, limit) + ctx := context.WithValue(r.Context(), requestInfoKey{}, requestInfo{ip: ratelimit.ClientIP(r), procedure: r.URL.Path}) + h.ServeHTTP(w, r.WithContext(ctx)) + })) +} diff --git a/internal/bootstrap/service_test.go b/internal/bootstrap/service_test.go new file mode 100644 index 0000000..a8bec14 --- /dev/null +++ b/internal/bootstrap/service_test.go @@ -0,0 +1,241 @@ +package bootstrap + +/* +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/tls" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + connect "connectrpc.com/connect" + fleetv1 "github.com/CryptOS-PKI/api/go/cryptos/fleet/v1" + "github.com/CryptOS-PKI/api/go/cryptos/fleet/v1/fleetv1connect" + "github.com/CryptOS-PKI/manager/internal/apperr" + "github.com/CryptOS-PKI/manager/internal/operatorca" + "github.com/CryptOS-PKI/manager/internal/store" +) + +func TestGetBootstrapState_PerSourceAndState(t *testing.T) { + t.Run("open, then in progress", func(t *testing.T) { + h := newHarness(t) + st := h.state() + if st.GetState() != fleetv1.BootstrapState_BOOTSTRAP_STATE_OPEN || st.GetTokenExpiresAt() == "" { + t.Fatalf("fresh state = %+v; want OPEN with the token expiry", st) + } + h.startSession() + if got := h.state().GetState(); got != fleetv1.BootstrapState_BOOTSTRAP_STATE_OPEN_IN_PROGRESS { + t.Fatalf("state with a live session = %v, want OPEN_IN_PROGRESS", got) + } + h.clock.Add(16 * time.Minute) + if got := h.state().GetState(); got != fleetv1.BootstrapState_BOOTSTRAP_STATE_OPEN { + t.Fatalf("state after the session idled out = %v, want OPEN", got) + } + }) + t.Run("closed", func(t *testing.T) { + h := newHarness(t) + ca := newCA(t, "Example Operator CA") + admin := ca.leaf(t, leafOpts{}) + h.svc.Latch().Observe(h.ctx, adminIdentity(admin, ca), admin) + st := h.state() + if st.GetState() != fleetv1.BootstrapState_BOOTSTRAP_STATE_CLOSED || st.GetTokenExpiresAt() != "" { + t.Fatalf("state = %+v; want CLOSED and no token expiry", st) + } + }) + t.Run("file source", func(t *testing.T) { + h := newHarness(t, func(o *Options) { + rev := operatorca.NewRevocations(operatorca.RevocationOptions{Store: newFakeStore()}) + ts, err := operatorca.NewTrustStore(t.Context(), operatorca.Source{Kind: operatorca.KindFile}, newFakeStore(), rev, &tls.Config{}, nil) + if err != nil { + t.Fatal(err) + } + o.Trust, o.Rev = ts, rev + }) + if got := h.state().GetState(); got != fleetv1.BootstrapState_BOOTSTRAP_STATE_NOT_APPLICABLE { + t.Fatalf("file source state = %v, want NOT_APPLICABLE", got) + } + if h.bannerCount() != 0 { + t.Fatal("the file source printed a banner") + } + if len(h.st.tokenHashes()) != 0 { + t.Fatal("the file source wrote a token") + } + _, err := h.client().StartBootstrapSession(h.ctx, connect.NewRequest(&fleetv1.StartBootstrapSessionRequest{Token: "fos_boot_x"})) + wantCode(t, err, apperr.CodeFirstRunClosed, "") + }) + t.Run("no database", func(t *testing.T) { + h := newHarness(t, func(o *Options) { o.Store = nil }) + st := h.state() + if st.GetState() != fleetv1.BootstrapState_BOOTSTRAP_STATE_UNAVAILABLE || st.GetReasonCode() != fleetv1.ErrorReason_ERROR_REASON_DATABASE_REQUIRED { + t.Fatalf("state without Postgres = %+v", st) + } + _, err := h.client().StartBootstrapSession(h.ctx, connect.NewRequest(&fleetv1.StartBootstrapSessionRequest{Token: "fos_boot_x"})) + wantCode(t, err, apperr.CodeUnavailable, "DATABASE_REQUIRED") + }) + t.Run("first run disabled", func(t *testing.T) { + h := newHarness(t, func(o *Options) { o.FirstRunDisabled = true }) + st := h.state() + if st.GetState() != fleetv1.BootstrapState_BOOTSTRAP_STATE_UNAVAILABLE || st.GetReasonCode() != fleetv1.ErrorReason_ERROR_REASON_FIRST_RUN_DISABLED { + t.Fatalf("state with firstRun disabled = %+v", st) + } + if h.bannerCount() != 0 { + t.Fatal("a banner was printed with firstRun disabled") + } + }) +} + +func TestClosed_SessionRPCsReturn1601WithoutLookingAnythingUp(t *testing.T) { + h := newHarness(t) + secret := h.startSession() + tok := h.token() + ca := newCA(t, "Example Operator CA") + admin := ca.leaf(t, leafOpts{}) + h.svc.Latch().Observe(h.ctx, adminIdentity(admin, ca), admin) + + tokens, sessions := h.st.lookups() + _, err := h.client().StartBootstrapSession(h.ctx, connect.NewRequest(&fleetv1.StartBootstrapSessionRequest{Token: tok})) + wantCode(t, err, apperr.CodeFirstRunClosed, "") + _, err = h.register(secret, noCRL(ca)) + wantCode(t, err, apperr.CodeFirstRunClosed, "") + _, err = h.submit(secret, &fleetv1.SubmitFirstAdminCertificateRequest{CertDer: admin.Raw, FullName: "Ada Example"}) + wantCode(t, err, apperr.CodeFirstRunClosed, "") + if connect.CodeOf(err) != connect.CodeFailedPrecondition { + t.Errorf("Connect code %v, want FailedPrecondition", connect.CodeOf(err)) + } + if t2, s2 := h.st.lookups(); t2 != tokens || s2 != sessions { + t.Fatalf("a closed first run looked up tokens (%d -> %d) or sessions (%d -> %d)", tokens, t2, sessions, s2) + } +} + +func TestRateLimit_PerClient(t *testing.T) { + h := newHarness(t) + c := h.clientFrom("203.0.113.7:5000", true) + bad, _ := NewToken(rand.Reader) + for i := 0; i < 5; i++ { + _, err := c.StartBootstrapSession(h.ctx, connect.NewRequest(&fleetv1.StartBootstrapSessionRequest{Token: bad})) + wantCode(t, err, apperr.CodeTokenInvalid, "") + } + _, err := c.StartBootstrapSession(h.ctx, connect.NewRequest(&fleetv1.StartBootstrapSessionRequest{Token: h.token()})) + wantCode(t, err, apperr.CodeRateLimited, "") + if connect.CodeOf(err) != connect.CodeResourceExhausted { + t.Errorf("Connect code %v, want ResourceExhausted", connect.CodeOf(err)) + } + // A bad session counts too, and another client is unaffected. + if _, err := h.clientFrom("203.0.113.8:5000", true).StartBootstrapSession(h.ctx, connect.NewRequest(&fleetv1.StartBootstrapSessionRequest{Token: h.token()})); err != nil { + t.Fatalf("another client was throttled: %v", err) + } + h.clock.Add(61 * time.Second) + if h.logs.count("203.0.113.7") == 0 { + t.Error("refusals weren't logged with the client address") + } +} + +func TestRateLimit_GlobalCapRotatesTheTokenAndEndsTheSession(t *testing.T) { + h := newHarness(t) + secret := h.startSession() + tok := h.token() + bad, _ := NewToken(rand.Reader) + for i := 0; i < 50; i++ { + c := h.clientFrom("198.51.100."+strconv.Itoa(i)+":1", true) + _, _ = c.StartBootstrapSession(h.ctx, connect.NewRequest(&fleetv1.StartBootstrapSessionRequest{Token: bad})) + } + if h.token() == tok { + t.Fatal("50 failures in an hour didn't rotate the token") + } + if !strings.Contains(h.bannerText(), "failed") { + t.Error("the new banner doesn't say why the token was rotated") + } + _, err := h.clientFrom("192.0.2.200:1", true).RegisterOperatorCA(h.ctx, withSession(secret, noCRL(newCA(t, "Example Operator CA")))) + wantCode(t, err, apperr.CodeSessionInvalid, "") + _, err = h.clientFrom("192.0.2.201:1", true).StartBootstrapSession(h.ctx, connect.NewRequest(&fleetv1.StartBootstrapSessionRequest{Token: tok})) + wantCode(t, err, apperr.CodeTokenInvalid, "") + if len(h.auditKinds(KindTokenRotated)) != 1 { + t.Error("the failure-driven rotation wasn't audited") + } +} + +func TestEveryProcedureRefusesPlaintext(t *testing.T) { + h := newHarness(t) + c := h.clientFrom("192.0.2.10:1", false) + checks := map[string]func() error{ + "GetBootstrapState": func() error { + _, err := c.GetBootstrapState(h.ctx, connect.NewRequest(&fleetv1.GetBootstrapStateRequest{})) + return err + }, + "StartBootstrapSession": func() error { + _, err := c.StartBootstrapSession(h.ctx, connect.NewRequest(&fleetv1.StartBootstrapSessionRequest{Token: h.token()})) + return err + }, + "RegisterOperatorCA": func() error { + _, err := c.RegisterOperatorCA(h.ctx, connect.NewRequest(&fleetv1.BootstrapServiceRegisterOperatorCARequest{})) + return err + }, + "SubmitFirstAdminCertificate": func() error { + _, err := c.SubmitFirstAdminCertificate(h.ctx, connect.NewRequest(&fleetv1.SubmitFirstAdminCertificateRequest{})) + return err + }, + } + for name, call := range checks { + if err := call(); err == nil { + t.Errorf("%s answered a request that didn't come over TLS", name) + } + } + if len(h.st.sessionHashes()) != 0 { + t.Fatal("a plaintext request started a session") + } +} + +func TestHandler_BodyLimits(t *testing.T) { + h := newHarness(t) + path, _ := h.svc.Handler() + if path != "/"+fleetv1connect.BootstrapServiceName+"/" { + t.Fatalf("path = %q", path) + } + big := bytes.Repeat([]byte("a"), 17<<10) + req := httptest.NewRequest(http.MethodPost, fleetv1connect.BootstrapServiceStartBootstrapSessionProcedure, bytes.NewReader(big)) + req.Header.Set("Content-Type", "application/json") + req.TLS = &tls.ConnectionState{} + rec := httptest.NewRecorder() + h.h.ServeHTTP(rec, req) + if rec.Code == http.StatusOK { + t.Fatal("a 17 KiB StartBootstrapSession body was accepted") + } + if len(h.st.sessionHashes()) != 0 { + t.Fatal("an oversized request started a session") + } +} + +func TestHandler_CrossOriginWritesRefused(t *testing.T) { + h := newHarness(t) + req := httptest.NewRequest(http.MethodPost, fleetv1connect.BootstrapServiceStartBootstrapSessionProcedure, strings.NewReader(`{"token":"x"}`)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Sec-Fetch-Site", "cross-site") + req.Header.Set("Origin", "https://evil.example.net") + req.TLS = &tls.ConnectionState{} + rec := httptest.NewRecorder() + h.h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("a cross-site POST got %d, want 403", rec.Code) + } +} + +var _ = store.SessionEndedClosed diff --git a/internal/bootstrap/token.go b/internal/bootstrap/token.go new file mode 100644 index 0000000..1066e5b --- /dev/null +++ b/internal/bootstrap/token.go @@ -0,0 +1,242 @@ +package bootstrap + +/* +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" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "strings" + "sync" + "time" + + "github.com/CryptOS-PKI/manager/internal/store" +) + +// Bootstrap token and session formats and lifetimes. +const ( + // TokenPrefix starts every bootstrap token, so secret scanners can spot + // a pasted one. + TokenPrefix = "fos_boot_" + // SessionPrefix starts every bootstrap session secret. + SessionPrefix = "fos_bsess_" + // SessionHeader carries the session secret. It is never a cookie and + // never in a URL. + SessionHeader = "Fleetos-Bootstrap-Session" + + // TokenTTL is how long a token can start a session. + TokenTTL = time.Hour + // SessionIdle ends a session that goes this long without a call. + SessionIdle = 15 * time.Minute + // SessionMax ends a session this long after it started. + SessionMax = time.Hour + + secretBytes = 32 + tokenBodyLength = 52 // 256 bits in base32 + tokenGroup = 4 + tokenLockName = "fleetos.bootstrap_token" +) + +const crockfordAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + +// crockford encodes b in Crockford base32, most significant bit first, with +// no padding. +func crockford(b []byte) string { + var ( + out strings.Builder + acc uint + bits uint + ) + for _, c := range b { + acc = acc<<8 | uint(c) + bits += 8 + for bits >= 5 { + bits -= 5 + out.WriteByte(crockfordAlphabet[(acc>>bits)&0x1f]) + } + } + if bits > 0 { + out.WriteByte(crockfordAlphabet[(acc<<(5-bits))&0x1f]) + } + return out.String() +} + +// NewToken returns a new bootstrap token: the prefix and 256 random bits in +// Crockford base32, grouped in fours for reading aloud. +func NewToken(r io.Reader) (string, error) { + b := make([]byte, secretBytes) + if _, err := io.ReadFull(r, b); err != nil { + return "", fmt.Errorf("bootstrap: token randomness: %w", err) + } + body := crockford(b) + groups := make([]string, 0, len(body)/tokenGroup) + for i := 0; i < len(body); i += tokenGroup { + groups = append(groups, body[i:i+tokenGroup]) + } + return TokenPrefix + strings.Join(groups, "-"), nil +} + +// CanonicalToken brings a token as an operator typed or pasted it to the one +// form that is hashed: surrounding space, case and the group dashes don't +// matter, and the letters Crockford base32 reads as digits (O, I, L) are +// taken as those digits. It reports false for anything that can't be a +// token. +func CanonicalToken(s string) (string, bool) { + s = strings.TrimSpace(s) + if len(s) < len(TokenPrefix) || !strings.EqualFold(s[:len(TokenPrefix)], TokenPrefix) { + return "", false + } + var body strings.Builder + for _, c := range strings.ToUpper(s[len(TokenPrefix):]) { + switch { + case c == '-': + continue + case c == 'O': + c = '0' + case c == 'I' || c == 'L': + c = '1' + case !strings.ContainsRune(crockfordAlphabet, c): + return "", false + } + body.WriteRune(c) + } + if body.Len() != tokenBodyLength { + return "", false + } + return TokenPrefix + body.String(), true +} + +// HashSecret is the lowercase hex SHA-256 of a token or session secret: the +// only form either is stored in. +func HashSecret(s string) string { + sum := sha256.Sum256([]byte(s)) + return hex.EncodeToString(sum[:]) +} + +func newSessionSecret(r io.Reader) (string, error) { + b := make([]byte, secretBytes) + if _, err := io.ReadFull(r, b); err != nil { + return "", fmt.Errorf("bootstrap: session randomness: %w", err) + } + return SessionPrefix + crockford(b), nil +} + +// tokens issues bootstrap tokens and prints each once, in a banner. The +// replica that holds the token lock issues one at start and whenever the +// live one expires; any replica issues one when a session starts or the +// failure cap trips, so a used token is replaced at once. +type tokens struct { + st store.Bootstrap + lock func(ctx context.Context, name string) (func(), bool, error) + now func() time.Time + rand io.Reader + print func([]string) + server ServerInfo + logf func(string, ...any) + + mu sync.Mutex + release func() +} + +// issue stores a new token, retiring the earlier one, and prints it. why, +// when set, says in the banner why a new token was needed. +func (t *tokens) issue(ctx context.Context, why string) error { + tok, err := NewToken(t.rand) + if err != nil { + return err + } + canon, _ := CanonicalToken(tok) + now := t.now().UTC() + expires := now.Add(TokenTTL) + if err := t.st.IssueBootstrapToken(ctx, store.BootstrapToken{Hash: HashSecret(canon), CreatedAt: now, ExpiresAt: expires}); err != nil { + return err + } + lines := []string{"manager: ================= FLEETOS FIRST RUN ================="} + if why != "" { + lines = append(lines, "manager: new bootstrap token: "+why) + } + lines = append(lines, + fmt.Sprintf("manager: bootstrap token: %s (single use, expires %s)", tok, expires.Format(time.RFC3339)), + fmt.Sprintf("manager: server certificate SHA-256: %s (check this in the browser first)", t.server.SHA256), + "manager: open https:/// and enter this token to register your operator CA certificate", + "manager: ======================================================", + ) + t.print(lines) + return nil +} + +// holdLock takes the token lock if this replica doesn't hold it yet, and +// reports whether it holds it. +func (t *tokens) holdLock(ctx context.Context) bool { + t.mu.Lock() + defer t.mu.Unlock() + if t.release != nil { + return true + } + release, ok, err := t.lock(ctx, tokenLockName) + if err != nil { + t.logf("bootstrap: can't take the token lock: %v", err) + return false + } + if ok { + t.release = release + } + return ok +} + +// stop gives up the token lock. +func (t *tokens) stop() { + t.mu.Lock() + defer t.mu.Unlock() + if t.release != nil { + t.release() + t.release = nil + } +} + +// ensure issues a token when this replica holds the lock and no token is +// live. force issues one even when a token is live (a fresh start, whose +// earlier token was printed by a process that is gone). +func (t *tokens) ensure(ctx context.Context, force bool) { + if !t.holdLock(ctx) { + return + } + if !force { + _, live, err := t.st.LiveBootstrapToken(ctx, t.now()) + if err != nil { + t.logf("bootstrap: can't read the bootstrap token: %v", err) + return + } + if live { + return + } + } + why := "" + if !force { + why = "the previous token expired" + } + if err := t.issue(ctx, why); err != nil { + if errors.Is(err, store.ErrBootstrapClosed) { + t.stop() + return + } + t.logf("bootstrap: can't issue a bootstrap token: %v", err) + } +} diff --git a/internal/bootstrap/token_test.go b/internal/bootstrap/token_test.go new file mode 100644 index 0000000..ca7104a --- /dev/null +++ b/internal/bootstrap/token_test.go @@ -0,0 +1,344 @@ +package bootstrap + +/* +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/rand" + "strings" + "testing" + "time" + + connect "connectrpc.com/connect" + fleetv1 "github.com/CryptOS-PKI/api/go/cryptos/fleet/v1" + "github.com/CryptOS-PKI/manager/internal/apperr" + "github.com/CryptOS-PKI/manager/internal/store" +) + +const wantAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + +func TestNewToken_FormatAndEntropy(t *testing.T) { + seen := map[string]bool{} + for i := 0; i < 50; i++ { + tok, err := NewToken(rand.Reader) + if err != nil { + t.Fatalf("NewToken: %v", err) + } + if !strings.HasPrefix(tok, TokenPrefix) { + t.Fatalf("token %q lacks the %s prefix", tok, TokenPrefix) + } + groups := strings.Split(strings.TrimPrefix(tok, TokenPrefix), "-") + body := strings.Join(groups, "") + // 256 bits in base32 is 52 characters (5 bits each), grouped in fours. + if len(body) != 52 { + t.Fatalf("token body has %d characters, want 52 for 256 bits", len(body)) + } + for i, g := range groups { + if len(g) != 4 { + t.Fatalf("group %d of %q has %d characters, want 4", i, tok, len(g)) + } + } + for _, c := range body { + if !strings.ContainsRune(wantAlphabet, c) { + t.Fatalf("token %q has %q, outside the Crockford base32 alphabet", tok, c) + } + } + if seen[tok] { + t.Fatalf("token %q repeated", tok) + } + seen[tok] = true + } +} + +func TestCanonicalToken_ForgivesReadingMistakes(t *testing.T) { + tok, err := NewToken(rand.Reader) + if err != nil { + t.Fatalf("NewToken: %v", err) + } + want, ok := CanonicalToken(tok) + if !ok { + t.Fatalf("CanonicalToken(%q) refused a fresh token", tok) + } + for _, typed := range []string{ + " " + tok + "\n", + strings.ToLower(tok), + strings.ReplaceAll(tok, "-", ""), + } { + got, ok := CanonicalToken(typed) + if !ok || got != want { + t.Errorf("CanonicalToken(%q) = %q, %v; want %q", typed, got, ok, want) + } + } + for _, bad := range []string{"", "fos_boot_", "fos_bsess_" + strings.TrimPrefix(tok, TokenPrefix), tok + "U", tok[:len(tok)-1]} { + if _, ok := CanonicalToken(bad); ok { + t.Errorf("CanonicalToken(%q) accepted a malformed token", bad) + } + } +} + +func TestStartSession_OnlyHashesAreStored(t *testing.T) { + h := newHarness(t) + tok := h.token() + secret := h.startSession() + if !strings.HasPrefix(secret, SessionPrefix) { + t.Fatalf("session secret %q lacks the %s prefix", secret, SessionPrefix) + } + canon, _ := CanonicalToken(tok) + for _, hash := range h.st.tokenHashes() { + if strings.Contains(hash, strings.TrimPrefix(canon, TokenPrefix)) || hash == tok { + t.Fatalf("the store holds the token itself: %q", hash) + } + if len(hash) != 64 { + t.Fatalf("stored token hash %q is not a hex SHA-256", hash) + } + } + found := false + for _, hash := range h.st.sessionHashes() { + if strings.Contains(hash, strings.TrimPrefix(secret, SessionPrefix)) { + t.Fatalf("the store holds the session secret itself: %q", hash) + } + if hash == HashSecret(secret) { + found = true + } + } + if !found { + t.Fatal("the session's SHA-256 wasn't stored") + } +} + +func TestStartSession_WrongExpiredOrUsedTokenGiveTheSame1600(t *testing.T) { + h := newHarness(t) + first := h.token() + if _, err := h.client().StartBootstrapSession(h.ctx, connect.NewRequest(&fleetv1.StartBootstrapSessionRequest{Token: first})); err != nil { + t.Fatalf("a good token: %v", err) + } + + wrong, _ := NewToken(rand.Reader) + cases := map[string]string{"wrong": wrong, "used": first, "malformed": "fos_boot_nope"} + var messages []string + for name, tok := range cases { + _, err := h.clientFrom("198.51.100."+name[:1]+":1", true).StartBootstrapSession(h.ctx, connect.NewRequest(&fleetv1.StartBootstrapSessionRequest{Token: tok})) + wantCode(t, err, apperr.CodeTokenInvalid, "") + if connect.CodeOf(err) != connect.CodeUnauthenticated { + t.Errorf("%s token: Connect code %v, want Unauthenticated", name, connect.CodeOf(err)) + } + messages = append(messages, err.Error()) + } + + // Expired: the token printed after the session start, an hour later. + next := h.token() + h.clock.Add(61 * time.Minute) + _, err := h.clientFrom("198.51.100.99:1", true).StartBootstrapSession(h.ctx, connect.NewRequest(&fleetv1.StartBootstrapSessionRequest{Token: next})) + wantCode(t, err, apperr.CodeTokenInvalid, "") + messages = append(messages, err.Error()) + for _, m := range messages[1:] { + if m != messages[0] { + t.Fatalf("refusals differ, which tells a caller why: %q vs %q", m, messages[0]) + } + } +} + +func TestStartSession_PrintsANewTokenAtOnce(t *testing.T) { + h := newHarness(t) + if n := h.bannerCount(); n != 1 { + t.Fatalf("%d banners at start, want 1", n) + } + first := h.token() + h.startSession() + if n := h.bannerCount(); n != 2 { + t.Fatalf("%d banners after the session started, want 2", n) + } + if next := h.token(); next == first { + t.Fatal("the session start printed the same token again") + } +} + +func TestTokenExpiry_PrintsANewToken(t *testing.T) { + h := newHarness(t) + first := h.token() + h.clock.Add(30 * time.Minute) + h.svc.TickTokens(h.ctx) + if h.bannerCount() != 1 { + t.Fatal("a live token was replaced before it expired") + } + h.clock.Add(31 * time.Minute) + h.svc.TickTokens(h.ctx) + if h.bannerCount() != 2 || h.token() == first { + t.Fatal("no new token was printed after the first expired") + } + if got := h.state().GetTokenExpiresAt(); got != h.clock.Now().Add(TokenTTL).Format(time.RFC3339) { + t.Errorf("token_expires_at = %q, want the new token's expiry", got) + } +} + +func TestTokens_OnlyTheLockHolderPrintsOnStartAndExpiry(t *testing.T) { + h := newHarness(t) + second, err := New(h.ctx, Options{Store: h.st, Audit: h.audit, Trust: h.trust, Rev: h.rev, Now: h.clock.Now, Logf: h.logs.Logf, + PrintBanner: func([]string) { t.Error("a second replica printed a banner while the first holds the token lock") }}) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := second.Start(h.ctx); err != nil { + t.Fatalf("Start: %v", err) + } + h.clock.Add(61 * time.Minute) + second.TickTokens(h.ctx) +} + +func TestNewSessionEndsThePrevious(t *testing.T) { + h := newHarness(t) + ca := newCA(t, "Example Operator CA") + first := h.startSession() + second := h.startSession() + _, err := h.register(first, &fleetv1.BootstrapServiceRegisterOperatorCARequest{CaCertDer: ca.cert.Raw, + CrlSource: &fleetv1.BootstrapServiceRegisterOperatorCARequest_None{None: true}, + Acknowledgements: []fleetv1.OperatorCAAcknowledgement{fleetv1.OperatorCAAcknowledgement_OPERATOR_CA_ACKNOWLEDGEMENT_NO_CRL}}) + wantCode(t, err, apperr.CodeSessionInvalid, "") + if _, err := h.register(second, &fleetv1.BootstrapServiceRegisterOperatorCARequest{CaCertDer: ca.cert.Raw, + CrlSource: &fleetv1.BootstrapServiceRegisterOperatorCARequest_None{None: true}, + Acknowledgements: []fleetv1.OperatorCAAcknowledgement{fleetv1.OperatorCAAcknowledgement_OPERATOR_CA_ACKNOWLEDGEMENT_NO_CRL}}); err != nil { + t.Fatalf("the newest session was refused: %v", err) + } +} + +func TestSession_IdleAndAbsoluteExpiry(t *testing.T) { + h := newHarness(t) + ca := newCA(t, "Example Operator CA") + preview := func(secret string) error { + _, err := h.register(secret, &fleetv1.BootstrapServiceRegisterOperatorCARequest{CaCertDer: ca.cert.Raw, + CrlSource: &fleetv1.BootstrapServiceRegisterOperatorCARequest_None{None: true}, + Acknowledgements: []fleetv1.OperatorCAAcknowledgement{fleetv1.OperatorCAAcknowledgement_OPERATOR_CA_ACKNOWLEDGEMENT_NO_CRL}}) + return err + } + + idle := h.startSession() + h.clock.Add(16 * time.Minute) + wantCode(t, preview(idle), apperr.CodeSessionInvalid, "") + + busy := h.startSession() + for i := 0; i < 5; i++ { + h.clock.Add(10 * time.Minute) + if err := preview(busy); err != nil { + t.Fatalf("a session in use was refused after %d minutes: %v", (i+1)*10, err) + } + } + h.clock.Add(10 * time.Minute) + wantCode(t, preview(busy), apperr.CodeSessionInvalid, "") + + if err := preview("fos_bsess_unknown"); err == nil { + t.Fatal("an unknown session was accepted") + } else { + wantCode(t, err, apperr.CodeSessionInvalid, "") + } + if err := preview(""); err == nil { + t.Fatal("a call with no session header was accepted") + } else { + wantCode(t, err, apperr.CodeSessionInvalid, "") + } +} + +func TestAfterTheLatch_NoTokensSessionsOrBanner(t *testing.T) { + h := newHarness(t) + h.startSession() + ca := newCA(t, "Example Operator CA") + admin := ca.leaf(t, leafOpts{}) + h.svc.Latch().Observe(h.ctx, adminIdentity(admin, ca), admin) + + if len(h.st.tokenHashes()) != 0 { + t.Fatal("tokens survived the latch closing") + } + for _, hash := range h.st.sessionHashes() { + s, _, _ := h.st.BootstrapSession(h.ctx, hash) + if s.EndedAt.IsZero() { + t.Fatal("a session survived the latch closing") + } + } + before := h.bannerCount() + h.clock.Add(2 * time.Hour) + h.svc.TickTokens(h.ctx) + if h.bannerCount() != before { + t.Fatal("a banner was printed after the latch closed") + } + + restarted, err := New(h.ctx, Options{Store: h.st, Audit: h.audit, Trust: h.trust, Rev: h.rev, Now: h.clock.Now, Logf: h.logs.Logf, + PrintBanner: func([]string) { t.Error("a restart printed a banner after the latch closed") }}) + if err != nil { + t.Fatalf("New: %v", err) + } + if err := restarted.Start(h.ctx); err != nil { + t.Fatalf("Start: %v", err) + } +} + +func TestSecretsNeverInErrorsAuditOrLogs(t *testing.T) { + h := newHarness(t) + tok := h.token() + secret := h.startSession() + usedTok := tok + nextTok := h.token() + + // Drive refusals that could echo what was sent. + _, err1 := h.client().StartBootstrapSession(h.ctx, connect.NewRequest(&fleetv1.StartBootstrapSessionRequest{Token: usedTok})) + _, err2 := h.register(secret+"x", &fleetv1.BootstrapServiceRegisterOperatorCARequest{}) + ca := newCA(t, "Example Operator CA") + h.registerCA(secret, ca) + _, err3 := h.submit(secret, &fleetv1.SubmitFirstAdminCertificateRequest{CertDer: []byte("junk"), FullName: "Ada Example"}) + + secrets := []string{usedTok, nextTok, secret, strings.TrimPrefix(secret, SessionPrefix)} + for _, tk := range []string{usedTok, nextTok} { + c, _ := CanonicalToken(tk) + secrets = append(secrets, c, strings.TrimPrefix(c, TokenPrefix)) + } + var haystack strings.Builder + for _, err := range []error{err1, err2, err3} { + if err != nil { + haystack.WriteString(err.Error()) + } + } + for _, e := range h.audit.Audit() { + haystack.WriteString(strings.Join([]string{e.Summary, e.ActorCN, e.ActorSerial, e.KeyID, e.TargetPath, e.RequestDigest}, "|")) + } + haystack.WriteString(h.logs.all()) + for _, s := range secrets { + if strings.Contains(haystack.String(), s) { + t.Fatalf("a token or session secret leaked into an error, audit row or log line: %q", s) + } + } + // The one place a token may appear is its own banner. + if !strings.Contains(h.bannerText(), nextTok) { + t.Fatal("the banner doesn't carry the token") + } + if strings.Contains(h.bannerText(), secret) { + t.Fatal("a banner carries the session secret") + } +} + +func TestStartSession_AuditedWithTheClientAddress(t *testing.T) { + h := newHarness(t) + h.startSession() + rows := h.auditKinds(KindSessionStarted) + if len(rows) != 1 { + t.Fatalf("%d %s rows, want 1", len(rows), KindSessionStarted) + } + if rows[0].ActorKind != ActorBootstrapSession || !strings.Contains(rows[0].Summary, "192.0.2.10") { + t.Fatalf("audit row = %+v; want actor %s and the client address", rows[0], ActorBootstrapSession) + } + if h.logs.count("bootstrap session started from 192.0.2.10") != 1 { + t.Error("the session start wasn't logged with the client address") + } +} + +var _ = store.SessionEndedClosed diff --git a/internal/fleet/credreq_ocsp_test.go b/internal/fleet/credreq_ocsp_test.go new file mode 100644 index 0000000..562b7d5 --- /dev/null +++ b/internal/fleet/credreq_ocsp_test.go @@ -0,0 +1,87 @@ +package fleet + +/* +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" + "crypto/tls" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + connect "connectrpc.com/connect" + fleetv1 "github.com/CryptOS-PKI/api/go/cryptos/fleet/v1" + "github.com/CryptOS-PKI/manager/internal/apperr" + "github.com/CryptOS-PKI/manager/internal/operatorca" + "github.com/CryptOS-PKI/manager/internal/store" + "golang.org/x/crypto/ocsp" +) + +// Recording asks the CA's OCSP responder too: a certificate it says is +// revoked, or doesn't know, isn't recorded. +func TestRecordOperatorCredential_RefusesWhatTheOCSPResponderRevokes(t *testing.T) { + ca := newTestCA(t, "Example Operator CA") + statuses := map[string]int{"a1": ocsp.Revoked, "a2": ocsp.Unknown} + responder := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + q, err := ocsp.ParseRequest(body) + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + st, ok := statuses[operatorca.SerialKey(q.SerialNumber)] + if !ok { + st = ocsp.Good + } + tmpl := ocsp.Response{Status: st, SerialNumber: q.SerialNumber, IssuerHash: q.HashAlgorithm, + ThisUpdate: time.Now().Add(-time.Minute), NextUpdate: time.Now().Add(30 * time.Minute), RevokedAt: time.Now().Add(-time.Hour)} + der, err := ocsp.CreateResponse(ca.cert, ca.cert, tmpl, ca.key) + if err != nil { + t.Errorf("responder: %v", err) + return + } + _, _ = w.Write(der) + })) + defer responder.Close() + + st := newCredStore() + anchor := ca.anchor(store.OperatorCAActive) + anchor.OCSPMode, anchor.OCSPURL = store.OCSPModeURL, responder.URL + rev := operatorca.NewRevocations(operatorca.RevocationOptions{Store: st, OCSPFetcher: operatorca.NewOCSPFetcher()}) + trust, err := operatorca.NewTrustStore(context.Background(), operatorca.Source{Kind: operatorca.KindFile, File: []operatorca.Anchor{anchor}}, st, rev, &tls.Config{}, nil) + if err != nil { + t.Fatal(err) + } + if err := rev.Reload(context.Background()); err != nil { + t.Fatal(err) + } + svc := New(st, noDial(t)).WithOperatorTrust(trust, rev) + + _, key := newCSR(t, "erin@example.org", false) + record := func(serial int64) error { + cert := ca.leaf(t, &key.PublicKey, "erin@example.org", "operator", serial) + _, err := svc.RecordOperatorCredential(adminCtx(), connect.NewRequest(&fleetv1.RecordOperatorCredentialRequest{CertDer: cert.Raw, FullName: "Erin Example"})) + return err + } + requireReason(t, record(0xa1), apperr.CodeCertRejected, fleetv1.ErrorReason_ERROR_REASON_REVOKED_OCSP) + requireReason(t, record(0xa2), apperr.CodeCertRejected, fleetv1.ErrorReason_ERROR_REASON_OCSP_UNKNOWN) + if err := record(0xa3); err != nil { + t.Fatalf("a certificate the responder says is good was refused: %v", err) + } +} diff --git a/internal/fleet/credreqs.go b/internal/fleet/credreqs.go index 0cfba90..be9699b 100644 --- a/internal/fleet/credreqs.go +++ b/internal/fleet/credreqs.go @@ -263,7 +263,7 @@ func (s *Service) RecordOperatorCredential(ctx context.Context, req *connect.Req } now := time.Now().UTC() - check := operatorca.CertCheck{Now: now, Revoked: s.revocations.IsRevoked} + check := operatorca.CertCheck{Now: now, Revoked: s.revocations.IsRevoked, CheckRevocation: s.revocations.CheckWebCert} var request store.OperatorCredentialRequest if rid := req.Msg.GetRequestId(); rid != "" { request, err = cs.OperatorCredentialRequest(ctx, rid, now) @@ -294,6 +294,11 @@ func (s *Service) RecordOperatorCredential(ctx context.Context, req *connect.Req } res, err := operatorca.CheckOperatorCert(cert, anchor.Cert, check) if err != nil { + // No fresh revocation data (1608) is the deployment's state, not a + // problem with the certificate. + if code, _ := apperr.Code(err); code == apperr.CodeNoRevocationSource { + return nil, connect.NewError(connect.CodeFailedPrecondition, err) + } return nil, connect.NewError(connect.CodeInvalidArgument, err) } diff --git a/internal/fleet/nodecawatch_test.go b/internal/fleet/nodecawatch_test.go new file mode 100644 index 0000000..cab7bd4 --- /dev/null +++ b/internal/fleet/nodecawatch_test.go @@ -0,0 +1,70 @@ +package fleet + +/* +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" + "crypto/tls" + "testing" + + connect "connectrpc.com/connect" + fleetv1 "github.com/CryptOS-PKI/api/go/cryptos/fleet/v1" + cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1" + "github.com/CryptOS-PKI/manager/internal/operatorca" + "github.com/CryptOS-PKI/manager/internal/store" + "github.com/CryptOS-PKI/manager/internal/store/memory" +) + +// A node whose reported CA chain holds a trusted operator CA raises the +// runtime node-CA flag, through both the node list and the node detail. +func TestNodeCAWatch_SeesTheChainsNodesReport(t *testing.T) { + caDER, caCert, _ := signCert(t, "Example Operator CA", nil, nil) + st := memory.New(nil) + rev := operatorca.NewRevocations(operatorca.RevocationOptions{Store: st}) + trust, err := operatorca.NewTrustStore(context.Background(), operatorca.Source{Kind: operatorca.KindFile, + File: []operatorca.Anchor{{Cert: caCert, SHA256: operatorca.Fingerprint(caCert), State: store.OperatorCAActive, FromConfig: true}}}, + st, rev, &tls.Config{}, nil) + if err != nil { + t.Fatal(err) + } + + for name, call := range map[string]func(*Service) error{ + "ListNodes": func(s *Service) error { + _, err := s.ListNodes(context.Background(), connect.NewRequest(&fleetv1.ListNodesRequest{})) + return err + }, + "GetNode": func(s *Service) error { + _, err := s.GetNode(context.Background(), connect.NewRequest(&fleetv1.GetNodeRequest{Name: "A"})) + return err + }, + } { + t.Run(name, func(t *testing.T) { + conn := &fakeConn{ + status: &cryptosv1.GetStatusResponse{Status: &cryptosv1.NodeStatus{}}, + identity: &cryptosv1.GetIdentityResponse{Identity: &cryptosv1.Identity{ChainDer: [][]byte{caDER}}}, + } + watch := operatorca.NewNodeCAWatch(trust, nil) + svc := New(testStore(), dialFor(map[string]*fakeConn{"A": conn})).WithNodeCAWatch(watch) + if err := call(svc); err != nil { + t.Fatalf("%s: %v", name, err) + } + if _, ok := watch.Flags()[operatorca.Fingerprint(caCert)]; !ok { + t.Fatalf("%s didn't pass the node's chain to the watch", name) + } + }) + } +} diff --git a/internal/fleet/nodes.go b/internal/fleet/nodes.go index d5084fa..2869c01 100644 --- a/internal/fleet/nodes.go +++ b/internal/fleet/nodes.go @@ -82,6 +82,7 @@ func (s *Service) GetNode(ctx context.Context, req *connect.Request[fleetv1.GetN status := statusResp.GetStatus() cn, issuer := leafCNs(identityResp.GetIdentity()) + s.nodeCAWatch.Observe(n.Name, identityResp.GetIdentity().GetChainDer()) return connect.NewResponse(&fleetv1.GetNodeResponse{ Node: &fleetv1.NodeDetail{ @@ -112,6 +113,7 @@ func (s *Service) summarize(ctx context.Context, n store.Node) *fleetv1.NodeSumm cn, issuer := "", "" if identityResp, ierr := conn.GetIdentity(ctx); ierr == nil { cn, issuer = leafCNs(identityResp.GetIdentity()) + s.nodeCAWatch.Observe(n.Name, identityResp.GetIdentity().GetChainDer()) } return s.withProtocolState(ctx, s.withTrustState(n, upSummary(n, statusResp.GetStatus(), cn, issuer)), statusResp.GetStatus()) diff --git a/internal/fleet/service.go b/internal/fleet/service.go index aaff066..e65b624 100644 --- a/internal/fleet/service.go +++ b/internal/fleet/service.go @@ -105,6 +105,9 @@ type Service struct { // installed node's fingerprint. adoptions adoptionWaits + // nodeCAWatch sees every CA chain a node reports. + nodeCAWatch *operatorca.NodeCAWatch + log log.Logger } @@ -131,6 +134,14 @@ func (s *Service) WithOperatorTrust(trust *operatorca.TrustStore, rev *operatorc return s } +// WithNodeCAWatch supplies the watch that flags a trusted operator CA a +// node's reported chain shows to be that node's CA. Returns s for chaining. +func (s *Service) WithNodeCAWatch(w *operatorca.NodeCAWatch) *Service { + s.nodeCAWatch = w + + return s +} + // WithAdoption supplies the S10 adoption seams: previewCert fetches a // maintenance node's TOFU fingerprint, and dialMaintenance opens a pinned // maintenance connection. Returns s for chaining. diff --git a/internal/mcpauth/middleware.go b/internal/mcpauth/middleware.go index 171279f..bfdecd0 100644 --- a/internal/mcpauth/middleware.go +++ b/internal/mcpauth/middleware.go @@ -19,14 +19,12 @@ limitations under the License. import ( "context" "log" - "net" "net/http" - "sync" "time" "github.com/CryptOS-PKI/manager/internal/authz" + "github.com/CryptOS-PKI/manager/internal/ratelimit" "github.com/modelcontextprotocol/go-sdk/auth" - "golang.org/x/time/rate" ) const identityExtra = "fleetos.identity" @@ -38,11 +36,11 @@ const identityExtra = "fleetos.identity" // the reason goes only to the log. A client that keeps presenting bad keys is // throttled with 429. func Middleware(r *Resolver, resourceMetadataURL string) func(http.Handler) http.Handler { - limiter := newFailureLimiter() + limiter := ratelimit.NewFailures(failureBurst, failureRefill) verify := func(ctx context.Context, token string, req *http.Request) (*auth.TokenInfo, error) { id, err := r.Resolve(ctx, token) if err != nil { - limiter.fail(clientIP(req)) + limiter.Fail(ratelimit.ClientIP(req)) log.Printf("mcpauth: refused %s %q from %s: %v", req.Method, req.URL.Path, req.RemoteAddr, err) return nil, auth.ErrInvalidToken } @@ -63,7 +61,7 @@ func Middleware(r *Resolver, resourceMetadataURL string) func(http.Handler) http guarded := bearer(withIdentity) return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - if limiter.blocked(clientIP(req)) { + if limiter.Blocked(ratelimit.ClientIP(req)) { http.Error(w, "too many failed attempts", http.StatusTooManyRequests) return } @@ -72,59 +70,13 @@ func Middleware(r *Resolver, resourceMetadataURL string) func(http.Handler) http } } -func clientIP(r *http.Request) string { - host, _, err := net.SplitHostPort(r.RemoteAddr) - if err != nil { - return r.RemoteAddr - } - return host -} - -// failureLimiter allows each client a burst of failed keys that refills -// slowly. Only failures spend tokens, so a client with a good key is never -// slowed down. -type failureLimiter struct { - mu sync.Mutex - clients map[string]*rate.Limiter -} - +// MCP keys are long-lived, so a client may retry a few bad ones; each +// failure comes back after failureRefill. const ( - failureBurst = 10 - failureRefill = 6 * time.Second - maxTrackedClient = 10000 + failureBurst = 10 + failureRefill = 6 * time.Second ) -func newFailureLimiter() *failureLimiter { - return &failureLimiter{clients: map[string]*rate.Limiter{}} -} - -func (f *failureLimiter) limiter(ip string) *rate.Limiter { - l, ok := f.clients[ip] - if !ok { - // A bounded map is enough: forgetting every client at once only - // restores their burst. - if len(f.clients) >= maxTrackedClient { - f.clients = map[string]*rate.Limiter{} - } - l = rate.NewLimiter(rate.Every(failureRefill), failureBurst) - f.clients[ip] = l - } - return l -} - -func (f *failureLimiter) fail(ip string) { - f.mu.Lock() - defer f.mu.Unlock() - f.limiter(ip).Allow() -} - -func (f *failureLimiter) blocked(ip string) bool { - f.mu.Lock() - defer f.mu.Unlock() - l, ok := f.clients[ip] - return ok && l.Tokens() < 1 -} - // IdentityFromTokenInfo returns the identity Middleware resolved for the // request that carried ti. MCP tool handlers read it from the request's // TokenInfo. diff --git a/internal/operatorca/certcheck.go b/internal/operatorca/certcheck.go index e4206b8..d906768 100644 --- a/internal/operatorca/certcheck.go +++ b/internal/operatorca/certcheck.go @@ -59,6 +59,11 @@ type CertCheck struct { CSR *x509.CertificateRequest // Revoked reports whether the anchor's denylist or CRL lists the serial. Revoked func(anchorSHA256, serial string) bool + // CheckRevocation, when set, is the live revocation decision for the + // certificate (Revocations.CheckWebCert): the denylist and CRL, the CA's + // OCSP responder where one is configured, and operatorRevocationPolicy. + // Its refusal is returned as is. + CheckRevocation func(anchorSHA256 string, leaf *x509.Certificate) error } // CertResult is what a certificate that passed the check carries. @@ -80,7 +85,9 @@ func rejectCert(reason fleetv1.ErrorReason, format string, args ...any) error { // non-critical, have EKU exactly clientAuth, key usage digitalSignature // without certificate or CRL signing, basicConstraints CA:FALSE, subject // exactly CN=, a P-384 or RSA 3072+ key, at least a day left, and not -// be revoked. More than 400 days of validity is a warning. +// be revoked: by the denylist or CRL through Revoked, and by the live +// decision, OCSP included, through CheckRevocation. More than 400 days of +// validity is a warning. func CheckOperatorCert(cert, anchor *x509.Certificate, c CertCheck) (CertResult, error) { levelExt, hasLevel := findExtension(cert, oidAccessLevel) if hasLevel && levelExt.Critical { @@ -148,6 +155,11 @@ func CheckOperatorCert(cert, anchor *x509.Certificate, c CertCheck) (CertResult, return CertResult{}, rejectCert(fleetv1.ErrorReason_ERROR_REASON_REVOKED, "serial %s under %s is on the denylist or in the CRL", serial, anchor.Subject) } + if c.CheckRevocation != nil { + if err := c.CheckRevocation(Fingerprint(anchor), cert); err != nil { + return CertResult{}, err + } + } res := CertResult{Level: level, Email: email, Serial: serial} if cert.NotAfter.Sub(cert.NotBefore) > warnLeafValidity { diff --git a/internal/operatorca/certcheck_revocation_test.go b/internal/operatorca/certcheck_revocation_test.go new file mode 100644 index 0000000..cf7fdcd --- /dev/null +++ b/internal/operatorca/certcheck_revocation_test.go @@ -0,0 +1,80 @@ +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/x509" + "errors" + "testing" + "time" + + "github.com/CryptOS-PKI/manager/internal/apperr" + "github.com/CryptOS-PKI/manager/internal/store" + "golang.org/x/crypto/ocsp" +) + +// The shared acceptance check asks the live revocation decision, OCSP +// included, about a certificate it is shown, so first-admin submission and +// credential recording both refuse what the CA's responder says is revoked +// or unknown. +func TestCheckOperatorCert_AsksTheLiveRevocationDecision(t *testing.T) { + ca := newCA(t, caOpts{}) + resp := newTestResponder(t, ca, func() time.Time { return testNow }) + f := newOCSPFixture(t, PolicySoft, nil, ocspAnchor(ca, store.CRLSourceNone, store.OCSPModeURL, resp.URL())) + + good := ca.leaf(t, leafOpts{}) + revoked := ca.leaf(t, leafOpts{}) + unknown := ca.leaf(t, leafOpts{}) + resp.set(func(r *testResponder) { + r.status[SerialKey(revoked.SerialNumber)] = ocsp.Revoked + r.status[SerialKey(unknown.SerialNumber)] = ocsp.Unknown + }) + check := CertCheck{Now: testNow, WantLevel: "admin", Revoked: f.rev.IsRevoked, CheckRevocation: f.rev.CheckWebCert} + + if _, err := CheckOperatorCert(good, ca.cert, check); err != nil { + t.Fatalf("a certificate the responder says is good was refused: %v", err) + } + for name, tc := range map[string]struct { + cert *x509.Certificate + reason string + }{"revoked": {revoked, "REVOKED_OCSP"}, "unknown": {unknown, "OCSP_UNKNOWN"}} { + _, err := CheckOperatorCert(tc.cert, ca.cert, check) + code, _ := apperr.Code(err) + r, _ := apperr.ReasonOf(err) + if code != apperr.CodeCertRejected || apperr.ReasonName(r) != tc.reason { + t.Errorf("%s: got %v, want 1610 %s", name, err, tc.reason) + } + } +} + +func TestCheckOperatorCert_CheckRevocationGetsTheLeafAndAnchor(t *testing.T) { + ca := newCA(t, caOpts{}) + leaf := ca.leaf(t, leafOpts{}) + refusal := errors.New("refused") + var gotAnchor string + var gotLeaf *x509.Certificate + _, err := CheckOperatorCert(leaf, ca.cert, CertCheck{Now: testNow, CheckRevocation: func(anchor string, l *x509.Certificate) error { + gotAnchor, gotLeaf = anchor, l + return refusal + }}) + if !errors.Is(err, refusal) { + t.Fatalf("err = %v, want the revocation decision's error as is", err) + } + if gotAnchor != Fingerprint(ca.cert) || gotLeaf != leaf { + t.Fatalf("CheckRevocation got anchor %q and leaf %v", gotAnchor, gotLeaf) + } +} diff --git a/internal/operatorca/nodecawatch.go b/internal/operatorca/nodecawatch.go new file mode 100644 index 0000000..2070768 --- /dev/null +++ b/internal/operatorca/nodecawatch.go @@ -0,0 +1,98 @@ +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/x509" + "fmt" + "sync" +) + +// NodeCAWatch catches, at runtime, a trusted operator CA that turns out to be +// a CryptOS node's CA: a node reached through GetIdentity reports a chain +// holding the anchor, or a certificate with the anchor's public key. The +// start-up check only sees the CA chains the inventory had then. +// +// A match logs one ERROR per anchor and raises an admin banner flag. The +// anchor stays trusted: dropping it mid-run could lock every admin out. +// For the config-file source the next start refuses it. +type NodeCAWatch struct { + trust *TrustStore + logf func(string, ...any) + + mu sync.Mutex + flags map[string]string +} + +// NewNodeCAWatch builds a watch over trust's current anchors. +func NewNodeCAWatch(trust *TrustStore, logf func(string, ...any)) *NodeCAWatch { + if logf == nil { + logf = func(string, ...any) {} + } + return &NodeCAWatch{trust: trust, logf: logf, flags: map[string]string{}} +} + +// Observe checks the chain a node reported against the trusted anchors. +// Certificates that don't parse are skipped. +func (w *NodeCAWatch) Observe(node string, chainDER [][]byte) { + if w == nil || w.trust == nil { + return + } + var chain []*x509.Certificate + for _, der := range chainDER { + if c, err := x509.ParseCertificate(der); err == nil { + chain = append(chain, c) + } + } + if len(chain) == 0 { + return + } + for _, a := range w.trust.Anchors() { + nodeCA := matchingNodeCA(a.Cert, chain) + if nodeCA == nil { + continue + } + w.mu.Lock() + _, seen := w.flags[a.SHA256] + if !seen { + w.flags[a.SHA256] = fmt.Sprintf( + "Operator CA %s is the CA of CryptOS node %s. A CryptOS node can't be the operator CA: replace it with an external operator CA.", + a.Cert.Subject.CommonName, node) + } + w.mu.Unlock() + if !seen { + w.logf("operatorca: ERROR operator CA %s (SHA-256 %s, source %s) is the CA of CryptOS node %s (%s, same certificate or key); "+ + "it stays trusted until the next start so no admin is locked out, but a CryptOS node can't be the operator CA: replace it with an external operator CA", + a.Cert.Subject, ColonFingerprint(a.Cert.Raw), w.trust.Source().Kind, node, nodeCA.Subject) + } + } +} + +// Flags returns the banner text for each flagged anchor, keyed by its +// fingerprint. +func (w *NodeCAWatch) Flags() map[string]string { + if w == nil { + return nil + } + w.mu.Lock() + defer w.mu.Unlock() + out := make(map[string]string, len(w.flags)) + for k, v := range w.flags { + out[k] = v + } + return out +} diff --git a/internal/operatorca/nodecawatch_test.go b/internal/operatorca/nodecawatch_test.go new file mode 100644 index 0000000..fe5f57c --- /dev/null +++ b/internal/operatorca/nodecawatch_test.go @@ -0,0 +1,89 @@ +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 ( + "strings" + "testing" + + "github.com/CryptOS-PKI/manager/internal/store" +) + +func fileSource(cas ...testCA) Source { + src := Source{Kind: KindFile} + for _, ca := range cas { + src.File = append(src.File, Anchor{Cert: ca.cert, SHA256: Fingerprint(ca.cert), State: store.OperatorCAActive, FromConfig: true}) + } + return src +} + +// A trusted operator CA that a node's reported chain shows to be that node's +// CA is flagged with an ERROR and a banner, and stays trusted until the next +// start: dropping it mid-run could lock every admin out. +func TestNodeCAWatch_FlagsAnAnchorThatIsANodeCA(t *testing.T) { + op := newCA(t, caOpts{cn: "Example Operator CA"}) + f := newTrustFixture(t, newFakeTrust(), fileSource(op)) + w := NewNodeCAWatch(f.trust, f.logs.Logf) + + other := newCA(t, caOpts{cn: "Example Workload CA"}) + w.Observe("ca-1", [][]byte{other.leaf(t, leafOpts{}).Raw, other.cert.Raw}) + if len(w.Flags()) != 0 { + t.Fatalf("an unrelated chain raised a flag: %v", w.Flags()) + } + + leaf := op.leaf(t, leafOpts{}) + w.Observe("ca-1", [][]byte{leaf.Raw, op.cert.Raw}) + w.Observe("ca-1", [][]byte{leaf.Raw, op.cert.Raw}) + flags := w.Flags() + banner, ok := flags[Fingerprint(op.cert)] + if !ok || !strings.Contains(banner, "ca-1") { + t.Fatalf("flags = %v; want a banner for the operator CA naming node ca-1", flags) + } + if n := f.logs.count("ERROR"); n != 1 { + t.Fatalf("%d ERROR lines, want exactly one per anchor", n) + } + if got := f.trust.Anchors(); len(got) != 1 { + t.Fatal("the anchor was dropped mid-run") + } +} + +// A node CA re-issued with the operator CA's key is the same CA. +func TestNodeCAWatch_MatchesBySubjectPublicKey(t *testing.T) { + op := newCA(t, caOpts{cn: "Example Operator CA"}) + reissued := newCA(t, caOpts{cn: "Example Node CA", key: op.key}) + f := newTrustFixture(t, newFakeTrust(), fileSource(op)) + w := NewNodeCAWatch(f.trust, f.logs.Logf) + w.Observe("ca-2", [][]byte{reissued.cert.Raw}) + if _, ok := w.Flags()[Fingerprint(op.cert)]; !ok { + t.Fatal("a node CA with the operator CA's key wasn't flagged") + } +} + +func TestNodeCAWatch_IgnoresJunkAndNil(t *testing.T) { + var nilWatch *NodeCAWatch + nilWatch.Observe("ca-1", [][]byte{{1, 2, 3}}) + if nilWatch.Flags() != nil { + t.Fatal("a nil watch has flags") + } + op := newCA(t, caOpts{cn: "Example Operator CA"}) + f := newTrustFixture(t, newFakeTrust(), fileSource(op)) + w := NewNodeCAWatch(f.trust, f.logs.Logf) + w.Observe("ca-1", [][]byte{{1, 2, 3}, nil}) + if len(w.Flags()) != 0 { + t.Fatal("an unparseable chain raised a flag") + } +} diff --git a/internal/ratelimit/ratelimit.go b/internal/ratelimit/ratelimit.go new file mode 100644 index 0000000..cd24b99 --- /dev/null +++ b/internal/ratelimit/ratelimit.go @@ -0,0 +1,122 @@ +// Package ratelimit holds the manager's failure limiters: per client, where +// only failures spend tokens, and a global rolling window. +package ratelimit + +/* +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 ( + "net" + "net/http" + "sync" + "time" + + "golang.org/x/time/rate" +) + +// maxTrackedClients bounds the per-client map. Forgetting every client at +// once only restores their bursts. +const maxTrackedClients = 10000 + +// Failures allows each client a burst of failures that refills slowly. Only +// failures spend tokens, so a client that keeps succeeding is never slowed +// down. +type Failures struct { + mu sync.Mutex + burst int + refill time.Duration + clients map[string]*rate.Limiter + now func() time.Time +} + +// NewFailures builds a limiter that allows burst failures per client and +// gives one back every refill. +func NewFailures(burst int, refill time.Duration) *Failures { + return &Failures{burst: burst, refill: refill, clients: map[string]*rate.Limiter{}, now: time.Now} +} + +func (f *Failures) limiter(client string) *rate.Limiter { + l, ok := f.clients[client] + if !ok { + if len(f.clients) >= maxTrackedClients { + f.clients = map[string]*rate.Limiter{} + } + l = rate.NewLimiter(rate.Every(f.refill), f.burst) + f.clients[client] = l + } + return l +} + +// Fail spends one of the client's tokens. +func (f *Failures) Fail(client string) { + f.mu.Lock() + defer f.mu.Unlock() + f.limiter(client).AllowN(f.now(), 1) +} + +// Blocked reports whether the client has no failures left. +func (f *Failures) Blocked(client string) bool { + f.mu.Lock() + defer f.mu.Unlock() + l, ok := f.clients[client] + return ok && l.TokensAt(f.now()) < 1 +} + +// Window counts failures from every client in a rolling window and trips +// when limit of them fall inside it. A trip starts the count over. +type Window struct { + mu sync.Mutex + limit int + window time.Duration + times []time.Time + now func() time.Time +} + +// NewWindow builds a window that trips at limit failures within window. +func NewWindow(limit int, window time.Duration) *Window { + return &Window{limit: limit, window: window, now: time.Now} +} + +// Fail records a failure and reports whether it tripped the limit. +func (w *Window) Fail() bool { + w.mu.Lock() + defer w.mu.Unlock() + now := w.now() + cutoff := now.Add(-w.window) + kept := w.times[:0] + for _, t := range w.times { + if t.After(cutoff) { + kept = append(kept, t) + } + } + w.times = append(kept, now) + if len(w.times) >= w.limit { + w.times = nil + return true + } + return false +} + +// ClientIP is the peer address of the request, without the port. +// X-Forwarded-For is never trusted: behind a proxy every client shares the +// proxy's address, and a global limit is the real control. +func ClientIP(r *http.Request) string { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return host +} diff --git a/internal/ratelimit/ratelimit_test.go b/internal/ratelimit/ratelimit_test.go new file mode 100644 index 0000000..5a0e5c9 --- /dev/null +++ b/internal/ratelimit/ratelimit_test.go @@ -0,0 +1,105 @@ +package ratelimit + +/* +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 ( + "net/http/httptest" + "testing" + "time" +) + +func TestFailures_BlocksAfterTheBurstPerClient(t *testing.T) { + f := NewFailures(5, time.Minute) + for i := 0; i < 5; i++ { + if f.Blocked("192.0.2.1") { + t.Fatalf("blocked after %d failures, want a burst of 5", i) + } + f.Fail("192.0.2.1") + } + if !f.Blocked("192.0.2.1") { + t.Fatal("not blocked after 5 failures") + } + if f.Blocked("192.0.2.2") { + t.Fatal("another client is blocked by the first client's failures") + } +} + +func TestFailures_SuccessesSpendNothing(t *testing.T) { + f := NewFailures(1, time.Hour) + for i := 0; i < 100; i++ { + if f.Blocked("192.0.2.1") { + t.Fatal("a client that never failed is blocked") + } + } +} + +func TestFailures_RefillsOverTime(t *testing.T) { + now := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) + f := NewFailures(2, time.Minute) + f.now = func() time.Time { return now } + f.Fail("192.0.2.1") + f.Fail("192.0.2.1") + if !f.Blocked("192.0.2.1") { + t.Fatal("not blocked after the burst") + } + now = now.Add(61 * time.Second) + if f.Blocked("192.0.2.1") { + t.Fatal("still blocked after one refill interval") + } +} + +func TestWindow_TripsAtTheLimitWithinTheWindowAndResets(t *testing.T) { + now := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) + w := NewWindow(3, time.Hour) + w.now = func() time.Time { return now } + for i := 0; i < 2; i++ { + if w.Fail() { + t.Fatalf("tripped at failure %d, before the limit of 3", i+1) + } + } + if !w.Fail() { + t.Fatal("the third failure within the hour didn't trip") + } + if w.Fail() { + t.Fatal("tripped again straight after a trip; the count should start over") + } +} + +func TestWindow_ForgetsFailuresOlderThanTheWindow(t *testing.T) { + now := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) + w := NewWindow(3, time.Hour) + w.now = func() time.Time { return now } + w.Fail() + w.Fail() + now = now.Add(61 * time.Minute) + if w.Fail() { + t.Fatal("failures older than the window still counted") + } +} + +func TestClientIP(t *testing.T) { + r := httptest.NewRequest("GET", "/", nil) + r.RemoteAddr = "192.0.2.7:4431" + r.Header.Set("X-Forwarded-For", "198.51.100.9") + if got := ClientIP(r); got != "192.0.2.7" { + t.Fatalf("ClientIP = %q, want the peer address, never X-Forwarded-For", got) + } + r.RemoteAddr = "not-an-addr" + if got := ClientIP(r); got != "not-an-addr" { + t.Fatalf("ClientIP = %q, want the raw RemoteAddr when it has no port", got) + } +} diff --git a/internal/store/bootstrap.go b/internal/store/bootstrap.go new file mode 100644 index 0000000..a5cb3be --- /dev/null +++ b/internal/store/bootstrap.go @@ -0,0 +1,134 @@ +package store + +/* +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" + "errors" + "time" +) + +// Why a bootstrap session ended. +const ( + SessionEndedSuperseded = "superseded" + SessionEndedExpired = "expired" + SessionEndedClosed = "closed" + SessionEndedRateLimited = "rate_limited" +) + +// RetiredSuperseded is the retired_reason of a first-run operator CA that a +// later first-run registration replaced. +const RetiredSuperseded = "superseded" + +// ErrBootstrapClosed is returned by first-run writes once first run is +// closed. +var ErrBootstrapClosed = errors.New("store: first run is closed") + +// BootstrapState is the one-way first-run latch. A zero ClosedAt means first +// run is open. +type BootstrapState struct { + ClosedAt time.Time + ClosedBySerial string + ClosedByCN string + ClosedByIssuerSHA256 string +} + +// Closed reports whether first run is closed. +func (s BootstrapState) Closed() bool { return !s.ClosedAt.IsZero() } + +// BootstrapToken is a stored bootstrap token. Only the SHA-256 of the token +// is kept; the token itself is printed once and never stored. +type BootstrapToken struct { + Hash string + CreatedAt time.Time + ExpiresAt time.Time +} + +// BootstrapSession is a stored bootstrap session. Only the SHA-256 of the +// session secret is kept. A zero EndedAt means the session wasn't ended, +// though it may have expired. +type BootstrapSession struct { + Hash string + CreatedAt time.Time + LastUsedAt time.Time + ExpiresAt time.Time + EndedAt time.Time + EndedReason string +} + +// Bootstrap is the storage behind first run: the latch, the token and +// session hashes, first-run operator CA registration and the first admin's +// credential record. Only the Postgres store implements it; without +// Postgres first run is unavailable. +type Bootstrap interface { + // BootstrapState returns the latch. + BootstrapState(ctx context.Context) (BootstrapState, error) + // CloseBootstrap closes first run if it is open, recording who closed + // it, and ends every session and deletes every token in the same step. + // It reports whether this call closed it; closing an already closed + // latch changes nothing. + CloseBootstrap(ctx context.Context, by BootstrapState) (bool, error) + + // IssueBootstrapToken stores a new token and marks every other unused + // token used, so exactly one token is live. It returns + // ErrBootstrapClosed once first run is closed. + IssueBootstrapToken(ctx context.Context, t BootstrapToken) error + // LiveBootstrapToken returns the unused token that hasn't expired at now. + LiveBootstrapToken(ctx context.Context, now time.Time) (BootstrapToken, bool, error) + + // StartBootstrapSession consumes the token with tokenHash, if it is + // unused and unexpired at now and first run is open, ends every live + // session as superseded and stores s, all in one step. It reports false, + // changing nothing, when the token can't be used. + StartBootstrapSession(ctx context.Context, tokenHash string, s BootstrapSession, now time.Time) (bool, error) + // BootstrapSession returns the session with the given hash, ended or not. + BootstrapSession(ctx context.Context, hash string) (BootstrapSession, bool, error) + // TouchBootstrapSession records a use of the session at at. + TouchBootstrapSession(ctx context.Context, hash string, at time.Time) error + // EndBootstrapSession ends one session. + EndBootstrapSession(ctx context.Context, hash, reason string, at time.Time) error + // EndBootstrapSessions ends every session not yet ended and reports how + // many it ended. + EndBootstrapSessions(ctx context.Context, reason string, at time.Time) (int, error) + // HasLiveBootstrapSession reports whether a session is neither ended nor + // past its absolute expiry or idle limit at now. + HasLiveBootstrapSession(ctx context.Context, now time.Time, idle time.Duration) (bool, error) + + // RegisterFirstRunOperatorCA makes ca the active operator CA in one + // step: every other active or retiring row is retired as superseded, + // ca is stored as active and confirmed by sessionHash (a row with the + // same fingerprint is brought back), crl, when set, is stored if decide + // accepts it, and the revocation epoch moves. It returns + // ErrBootstrapClosed once first run is closed. + RegisterFirstRunOperatorCA(ctx context.Context, ca OperatorCA, sessionHash string, crl *OperatorCRL, decide DecideCRL) error + // ConfirmOperatorCA records that sessionHash confirmed the operator CA + // with the given fingerprint. + ConfirmOperatorCA(ctx context.Context, sha256, sessionHash string) error + // OperatorCAConfirmedBy returns the hash of the session that last + // confirmed the operator CA, empty if none. + OperatorCAConfirmedBy(ctx context.Context, sha256 string) (string, error) + + // RecordFirstAdmin stores c as the first admin's credential, replacing + // the name and kind of a row with the same issuer and serial. + RecordFirstAdmin(ctx context.Context, c OperatorCredential, at time.Time) error + // RecordFirstUse stores c, kind first_admin, unless a credential with + // the same issuer and serial is already recorded. It reports whether it + // stored it. + RecordFirstUse(ctx context.Context, c OperatorCredential, at time.Time) (bool, error) + // FirstAdminCredentials returns every first_admin credential. + FirstAdminCredentials(ctx context.Context) ([]OperatorCredential, error) +} diff --git a/internal/store/postgres/bootstrap.go b/internal/store/postgres/bootstrap.go new file mode 100644 index 0000000..005b586 --- /dev/null +++ b/internal/store/postgres/bootstrap.go @@ -0,0 +1,357 @@ +package postgres + +/* +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" + "errors" + "fmt" + "time" + + "github.com/CryptOS-PKI/manager/internal/store" + "github.com/jackc/pgx/v5" +) + +var _ store.Bootstrap = (*Store)(nil) + +// BootstrapState returns the first-run latch. +func (s *Store) BootstrapState(ctx context.Context) (store.BootstrapState, error) { + var ( + st store.BootstrapState + closed *time.Time + ) + err := s.pool.QueryRow(ctx, `SELECT closed_at, closed_by_serial, closed_by_cn, closed_by_issuer_sha256 FROM bootstrap_state WHERE id`). + Scan(&closed, &st.ClosedBySerial, &st.ClosedByCN, &st.ClosedByIssuerSHA256) + if errors.Is(err, pgx.ErrNoRows) { + return store.BootstrapState{}, nil + } + if err != nil { + return store.BootstrapState{}, fmt.Errorf("postgres: read bootstrap state: %w", err) + } + st.ClosedAt = timeOrZero(closed) + return st, nil +} + +// CloseBootstrap closes the latch if it is open, and in the same +// transaction ends every session and deletes every token. +func (s *Store) CloseBootstrap(ctx context.Context, by store.BootstrapState) (bool, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return false, fmt.Errorf("postgres: begin closing first run: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + tag, err := tx.Exec(ctx, `INSERT INTO bootstrap_state (id, closed_at, closed_by_serial, closed_by_cn, closed_by_issuer_sha256) + VALUES (true, $1, $2, $3, $4) + ON CONFLICT (id) DO UPDATE SET closed_at = excluded.closed_at, closed_by_serial = excluded.closed_by_serial, + closed_by_cn = excluded.closed_by_cn, closed_by_issuer_sha256 = excluded.closed_by_issuer_sha256 + WHERE bootstrap_state.closed_at IS NULL`, + by.ClosedAt, by.ClosedBySerial, by.ClosedByCN, by.ClosedByIssuerSHA256) + if err != nil { + return false, fmt.Errorf("postgres: close first run: %w", err) + } + if tag.RowsAffected() == 0 { + return false, nil + } + if _, err := tx.Exec(ctx, `UPDATE bootstrap_sessions SET ended_at = $1, ended_reason = $2 WHERE ended_at IS NULL`, + by.ClosedAt, store.SessionEndedClosed); err != nil { + return false, fmt.Errorf("postgres: end bootstrap sessions: %w", err) + } + if _, err := tx.Exec(ctx, `DELETE FROM bootstrap_tokens`); err != nil { + return false, fmt.Errorf("postgres: delete bootstrap tokens: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return false, fmt.Errorf("postgres: commit closing first run: %w", err) + } + return true, nil +} + +// lockOpen locks the latch row for the transaction and returns +// ErrBootstrapClosed when first run is closed, so a first-run write and the +// latch closing can't interleave. +func lockOpen(ctx context.Context, tx pgx.Tx) error { + var closed *time.Time + err := tx.QueryRow(ctx, `SELECT closed_at FROM bootstrap_state WHERE id FOR UPDATE`).Scan(&closed) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return fmt.Errorf("postgres: read bootstrap state: %w", err) + } + if closed != nil { + return store.ErrBootstrapClosed + } + return nil +} + +// IssueBootstrapToken stores t and marks every other unused token used. +func (s *Store) IssueBootstrapToken(ctx context.Context, t store.BootstrapToken) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("postgres: begin token issue: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if err := lockOpen(ctx, tx); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE bootstrap_tokens SET used_at = $1 WHERE used_at IS NULL`, t.CreatedAt); err != nil { + return fmt.Errorf("postgres: retire earlier bootstrap tokens: %w", err) + } + if _, err := tx.Exec(ctx, `INSERT INTO bootstrap_tokens (token_hash, created_at, expires_at) VALUES ($1, $2, $3)`, + t.Hash, t.CreatedAt, t.ExpiresAt); err != nil { + return fmt.Errorf("postgres: store bootstrap token: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("postgres: commit bootstrap token: %w", err) + } + return nil +} + +// LiveBootstrapToken returns the newest unused, unexpired token. +func (s *Store) LiveBootstrapToken(ctx context.Context, now time.Time) (store.BootstrapToken, bool, error) { + var t store.BootstrapToken + err := s.pool.QueryRow(ctx, `SELECT token_hash, created_at, expires_at FROM bootstrap_tokens + WHERE used_at IS NULL AND expires_at > $1 ORDER BY id DESC LIMIT 1`, now).Scan(&t.Hash, &t.CreatedAt, &t.ExpiresAt) + if errors.Is(err, pgx.ErrNoRows) { + return store.BootstrapToken{}, false, nil + } + if err != nil { + return store.BootstrapToken{}, false, fmt.Errorf("postgres: read bootstrap token: %w", err) + } + t.CreatedAt, t.ExpiresAt = t.CreatedAt.UTC(), t.ExpiresAt.UTC() + return t, true, nil +} + +// StartBootstrapSession consumes the token and starts the session in one +// transaction. The conditional UPDATE takes the token row's lock, so of two +// concurrent starts exactly one sees the token unused. +func (s *Store) StartBootstrapSession(ctx context.Context, tokenHash string, bs store.BootstrapSession, now time.Time) (bool, error) { + tx, err := s.pool.Begin(ctx) + if err != nil { + return false, fmt.Errorf("postgres: begin session start: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if err := lockOpen(ctx, tx); err != nil { + if errors.Is(err, store.ErrBootstrapClosed) { + return false, nil + } + return false, err + } + tag, err := tx.Exec(ctx, `UPDATE bootstrap_tokens SET used_at = $2 + WHERE token_hash = $1 AND used_at IS NULL AND expires_at > $2`, tokenHash, now) + if err != nil { + return false, fmt.Errorf("postgres: consume bootstrap token: %w", err) + } + if tag.RowsAffected() == 0 { + return false, nil + } + if _, err := tx.Exec(ctx, `UPDATE bootstrap_sessions SET ended_at = $1, ended_reason = $2 WHERE ended_at IS NULL`, + now, store.SessionEndedSuperseded); err != nil { + return false, fmt.Errorf("postgres: end earlier bootstrap sessions: %w", err) + } + if _, err := tx.Exec(ctx, `INSERT INTO bootstrap_sessions (session_hash, created_at, last_used_at, expires_at) VALUES ($1, $2, $3, $4)`, + bs.Hash, bs.CreatedAt, bs.LastUsedAt, bs.ExpiresAt); err != nil { + return false, fmt.Errorf("postgres: store bootstrap session: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return false, fmt.Errorf("postgres: commit bootstrap session: %w", err) + } + return true, nil +} + +// BootstrapSession returns the session with the given hash. +func (s *Store) BootstrapSession(ctx context.Context, hash string) (store.BootstrapSession, bool, error) { + var ( + bs store.BootstrapSession + ended *time.Time + ) + err := s.pool.QueryRow(ctx, `SELECT session_hash, created_at, last_used_at, expires_at, ended_at, ended_reason + FROM bootstrap_sessions WHERE session_hash = $1`, hash). + Scan(&bs.Hash, &bs.CreatedAt, &bs.LastUsedAt, &bs.ExpiresAt, &ended, &bs.EndedReason) + if errors.Is(err, pgx.ErrNoRows) { + return store.BootstrapSession{}, false, nil + } + if err != nil { + return store.BootstrapSession{}, false, fmt.Errorf("postgres: read bootstrap session: %w", err) + } + bs.CreatedAt, bs.LastUsedAt, bs.ExpiresAt = bs.CreatedAt.UTC(), bs.LastUsedAt.UTC(), bs.ExpiresAt.UTC() + bs.EndedAt = timeOrZero(ended) + return bs, true, nil +} + +// TouchBootstrapSession records a use of a live session. +func (s *Store) TouchBootstrapSession(ctx context.Context, hash string, at time.Time) error { + if _, err := s.pool.Exec(ctx, `UPDATE bootstrap_sessions SET last_used_at = $2 WHERE session_hash = $1 AND ended_at IS NULL`, hash, at); err != nil { + return fmt.Errorf("postgres: touch bootstrap session: %w", err) + } + return nil +} + +// EndBootstrapSession ends one session, keeping the first end. +func (s *Store) EndBootstrapSession(ctx context.Context, hash, reason string, at time.Time) error { + if _, err := s.pool.Exec(ctx, `UPDATE bootstrap_sessions SET ended_at = $2, ended_reason = $3 + WHERE session_hash = $1 AND ended_at IS NULL`, hash, at, reason); err != nil { + return fmt.Errorf("postgres: end bootstrap session: %w", err) + } + return nil +} + +// EndBootstrapSessions ends every session not yet ended. +func (s *Store) EndBootstrapSessions(ctx context.Context, reason string, at time.Time) (int, error) { + tag, err := s.pool.Exec(ctx, `UPDATE bootstrap_sessions SET ended_at = $1, ended_reason = $2 WHERE ended_at IS NULL`, at, reason) + if err != nil { + return 0, fmt.Errorf("postgres: end bootstrap sessions: %w", err) + } + return int(tag.RowsAffected()), nil +} + +// HasLiveBootstrapSession reports whether a session is live at now. +func (s *Store) HasLiveBootstrapSession(ctx context.Context, now time.Time, idle time.Duration) (bool, error) { + var live bool + if err := s.pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM bootstrap_sessions + WHERE ended_at IS NULL AND expires_at > $1 AND last_used_at > $2)`, now, now.Add(-idle)).Scan(&live); err != nil { + return false, fmt.Errorf("postgres: look for a live bootstrap session: %w", err) + } + return live, nil +} + +// RegisterFirstRunOperatorCA retires the earlier registration, stores ca as +// active, stores its CRL and moves the trust version, in one transaction. +func (s *Store) RegisterFirstRunOperatorCA(ctx context.Context, ca store.OperatorCA, sessionHash string, crl *store.OperatorCRL, decide store.DecideCRL) error { + mode := ca.OCSPMode + if mode == "" { + mode = store.OCSPModeAIA + } + registered := ca.RegisteredAt + if registered.IsZero() { + registered = time.Now().UTC() + } + + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("postgres: begin operator CA registration: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + if err := lockOpen(ctx, tx); err != nil { + return err + } + if _, err := tx.Exec(ctx, `UPDATE operator_cas SET state = 'retired', retired_at = $2, retired_reason = $3, updated_at = clock_timestamp() + WHERE state IN ('active', 'retiring') AND sha256 <> $1`, ca.SHA256, registered, store.RetiredSuperseded); err != nil { + return fmt.Errorf("postgres: retire the earlier operator CA: %w", err) + } + if _, err := tx.Exec(ctx, `INSERT INTO operator_cas + (cert_der, sha256, state, crl_source, crl_url, ocsp_mode, ocsp_url, acknowledgements, warnings, + registered_at, registered_by, confirmed_session_hash) + VALUES ($1, $2, 'active', $3, $4, $5, nullif($6, ''), $7, $8, $9, $10, $11) + ON CONFLICT (sha256) DO UPDATE SET cert_der = excluded.cert_der, state = 'active', + crl_source = excluded.crl_source, crl_url = excluded.crl_url, ocsp_mode = excluded.ocsp_mode, ocsp_url = excluded.ocsp_url, + acknowledgements = excluded.acknowledgements, warnings = excluded.warnings, registered_at = excluded.registered_at, + registered_by = excluded.registered_by, confirmed_session_hash = excluded.confirmed_session_hash, + retired_at = NULL, retired_reason = '', updated_at = clock_timestamp()`, + ca.CertDER, ca.SHA256, ca.CRLSource, ca.CRLURL, mode, ca.OCSPURL, + nonNil(ca.Acknowledgements), nonNil(ca.Warnings), registered, ca.RegisteredBy, sessionHash); err != nil { + return fmt.Errorf("postgres: store operator CA %s: %w", ca.SHA256, err) + } + if crl != nil { + if _, err := putCRLTx(ctx, tx, *crl, decide); err != nil { + return err + } + } + if err := bumpEpoch(ctx, tx); err != nil { + return err + } + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("postgres: commit operator CA registration: %w", err) + } + return nil +} + +// ConfirmOperatorCA records the session that confirmed the operator CA. +func (s *Store) ConfirmOperatorCA(ctx context.Context, sha256, sessionHash string) error { + tag, err := s.pool.Exec(ctx, `UPDATE operator_cas SET confirmed_session_hash = $2 WHERE sha256 = $1`, sha256, sessionHash) + if err != nil { + return fmt.Errorf("postgres: confirm operator CA %s: %w", sha256, err) + } + if tag.RowsAffected() == 0 { + return store.ErrOperatorCANotFound + } + return nil +} + +// OperatorCAConfirmedBy returns the session hash that last confirmed the +// operator CA. +func (s *Store) OperatorCAConfirmedBy(ctx context.Context, sha256 string) (string, error) { + var by *string + err := s.pool.QueryRow(ctx, `SELECT confirmed_session_hash FROM operator_cas WHERE sha256 = $1`, sha256).Scan(&by) + if errors.Is(err, pgx.ErrNoRows) { + return "", store.ErrOperatorCANotFound + } + if err != nil { + return "", fmt.Errorf("postgres: read operator CA confirmation: %w", err) + } + if by == nil { + return "", nil + } + return *by, nil +} + +// RecordFirstAdmin upserts the first admin's credential. +func (s *Store) RecordFirstAdmin(ctx context.Context, c store.OperatorCredential, at time.Time) error { + if _, err := s.pool.Exec(ctx, `INSERT INTO operator_credentials (serial_hex, common_name, level, not_after, revoked, + issuer_sha256, kind, email, full_name, leaf_sha256, first_seen_at) + VALUES ($1, $2, $3, $4, false, $5, $6, $7, $8, $9, $10) + ON CONFLICT (issuer_sha256, serial_hex) DO UPDATE SET kind = excluded.kind, full_name = excluded.full_name, + email = excluded.email, common_name = excluded.common_name, level = excluded.level, + not_after = excluded.not_after, leaf_sha256 = excluded.leaf_sha256`, + c.SerialHex, c.CommonName, c.Level, c.NotAfter, c.IssuerSHA256, store.OperatorCredentialFirstAdmin, + c.Email, c.FullName, c.LeafSHA256, nullTime(at)); err != nil { + return fmt.Errorf("postgres: record first admin %s/%s: %w", c.IssuerSHA256, c.SerialHex, err) + } + return nil +} + +// RecordFirstUse stores c as first_admin unless it is already recorded. +func (s *Store) RecordFirstUse(ctx context.Context, c store.OperatorCredential, at time.Time) (bool, error) { + tag, err := s.pool.Exec(ctx, `INSERT INTO operator_credentials (serial_hex, common_name, level, not_after, revoked, + issuer_sha256, kind, email, full_name, leaf_sha256, first_seen_at, last_seen_at) + VALUES ($1, $2, $3, $4, false, $5, $6, $7, $8, $9, $10, $10) + ON CONFLICT (issuer_sha256, serial_hex) DO NOTHING`, + c.SerialHex, c.CommonName, c.Level, c.NotAfter, c.IssuerSHA256, store.OperatorCredentialFirstAdmin, + c.Email, c.FullName, c.LeafSHA256, nullTime(at)) + if err != nil { + return false, fmt.Errorf("postgres: record first use of %s/%s: %w", c.IssuerSHA256, c.SerialHex, err) + } + return tag.RowsAffected() == 1, nil +} + +// FirstAdminCredentials returns every first_admin credential, oldest first. +func (s *Store) FirstAdminCredentials(ctx context.Context) ([]store.OperatorCredential, error) { + rows, err := s.pool.Query(ctx, `SELECT common_name, serial_hex, level, not_after, revoked, issuer_sha256, kind, email, full_name, leaf_sha256 + FROM operator_credentials WHERE kind = $1 ORDER BY issued_at, issuer_sha256, serial_hex`, store.OperatorCredentialFirstAdmin) + if err != nil { + return nil, fmt.Errorf("postgres: query first admin credentials: %w", err) + } + out, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (store.OperatorCredential, error) { + var c store.OperatorCredential + err := row.Scan(&c.CommonName, &c.SerialHex, &c.Level, &c.NotAfter, &c.Revoked, &c.IssuerSHA256, &c.Kind, &c.Email, &c.FullName, &c.LeafSHA256) + return c, err + }) + if err != nil { + return nil, fmt.Errorf("postgres: read first admin credentials: %w", err) + } + return out, nil +} diff --git a/internal/store/postgres/bootstrap_test.go b/internal/store/postgres/bootstrap_test.go new file mode 100644 index 0000000..4350821 --- /dev/null +++ b/internal/store/postgres/bootstrap_test.go @@ -0,0 +1,27 @@ +package postgres + +/* +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 ( + "testing" + + "github.com/CryptOS-PKI/manager/internal/store/storetest" +) + +func TestBootstrap(t *testing.T) { + storetest.Bootstrap(t, func(t *testing.T) storetest.BootstrapStore { return testStore(t) }) +} diff --git a/internal/store/postgres/operatortrust.go b/internal/store/postgres/operatortrust.go index 2faa081..81f9b37 100644 --- a/internal/store/postgres/operatortrust.go +++ b/internal/store/postgres/operatortrust.go @@ -143,6 +143,19 @@ func (s *Store) PutOperatorCRL(ctx context.Context, c store.OperatorCRL, decide } defer func() { _ = tx.Rollback(ctx) }() + ok, err := putCRLTx(ctx, tx, c, decide) + if err != nil || !ok { + return false, err + } + if err := tx.Commit(ctx); err != nil { + return false, fmt.Errorf("postgres: commit CRL for %s: %w", c.IssuerSHA256, err) + } + return true, nil +} + +// putCRLTx is PutOperatorCRL inside tx: it locks the stored row, asks +// decide, and on acceptance writes c and bumps the revocation epoch. +func putCRLTx(ctx context.Context, tx pgx.Tx, c store.OperatorCRL, decide store.DecideCRL) (bool, error) { rows, err := tx.Query(ctx, `SELECT issuer_sha256, crl_der, crl_number::text, this_update, next_update, fetched_at, source, last_error, last_attempt_at FROM operator_crls WHERE issuer_sha256 = $1 FOR UPDATE`, c.IssuerSHA256) if err != nil { @@ -178,9 +191,6 @@ func (s *Store) PutOperatorCRL(ctx context.Context, c store.OperatorCRL, decide if err := bumpEpoch(ctx, tx); err != nil { return false, err } - if err := tx.Commit(ctx); err != nil { - return false, fmt.Errorf("postgres: commit CRL for %s: %w", c.IssuerSHA256, err) - } return true, nil } diff --git a/internal/store/postgres/testhelp_test.go b/internal/store/postgres/testhelp_test.go index cd68b27..af13b55 100644 --- a/internal/store/postgres/testhelp_test.go +++ b/internal/store/postgres/testhelp_test.go @@ -68,7 +68,8 @@ func truncateAll(t *testing.T, pool *pgxpool.Pool) { t.Helper() if _, err := pool.Exec(context.Background(), `TRUNCATE nodes, node_names, profiles, adapters, audit_events, enrollments, operator_credentials, mcp_keys, oauth_requests, oauth_codes, approvals, - operator_cas, operator_crls, operator_denylist, operator_credential_requests, bootstrap_tokens, bootstrap_sessions, bootstrap_server_cert RESTART IDENTITY`); err != nil { + operator_cas, operator_crls, operator_denylist, operator_credential_requests, bootstrap_tokens, bootstrap_sessions, bootstrap_server_cert RESTART IDENTITY; + UPDATE bootstrap_state SET closed_at = NULL, closed_by_serial = '', closed_by_cn = '', closed_by_issuer_sha256 = ''`); err != nil { t.Fatalf("truncate: %v", err) } } diff --git a/internal/store/storetest/bootstrap.go b/internal/store/storetest/bootstrap.go new file mode 100644 index 0000000..2fa5359 --- /dev/null +++ b/internal/store/storetest/bootstrap.go @@ -0,0 +1,371 @@ +package storetest + +/* +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" + "errors" + "math/big" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/CryptOS-PKI/manager/internal/store" +) + +// BootstrapStore is what first run needs from a store. +type BootstrapStore interface { + store.OperatorTrust + store.Bootstrap +} + +// Bootstrap runs the conformance suite for store.Bootstrap. newStore must +// return an empty store with first run open. +func Bootstrap(t *testing.T, newStore func(t *testing.T) BootstrapStore) { + ctx := context.Background() + at := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC) + session := func(hash string) store.BootstrapSession { + return store.BootstrapSession{Hash: hash, CreatedAt: at, LastUsedAt: at, ExpiresAt: at.Add(time.Hour)} + } + issue := func(t *testing.T, st BootstrapStore, hash string, expires time.Time) { + t.Helper() + if err := st.IssueBootstrapToken(ctx, store.BootstrapToken{Hash: hash, CreatedAt: at, ExpiresAt: expires}); err != nil { + t.Fatalf("IssueBootstrapToken(%s): %v", hash, err) + } + } + start := func(t *testing.T, st BootstrapStore, token, sess string) bool { + t.Helper() + ok, err := st.StartBootstrapSession(ctx, token, session(sess), at) + if err != nil { + t.Fatalf("StartBootstrapSession: %v", err) + } + return ok + } + + t.Run("LatchStartsOpenAndClosesOnce", func(t *testing.T) { + st := newStore(t) + s, err := st.BootstrapState(ctx) + if err != nil || s.Closed() { + t.Fatalf("BootstrapState() = %+v, %v; want open", s, err) + } + issue(t, st, "t1", at.Add(time.Hour)) + if !start(t, st, "t1", "s1") { + t.Fatal("the session didn't start") + } + issue(t, st, "t2", at.Add(time.Hour)) + + by := store.BootstrapState{ClosedAt: at, ClosedBySerial: "1f", ClosedByCN: "admin@example.org", ClosedByIssuerSHA256: "aa"} + closed, err := st.CloseBootstrap(ctx, by) + if err != nil || !closed { + t.Fatalf("CloseBootstrap() = %v, %v; want true", closed, err) + } + again, err := st.CloseBootstrap(ctx, store.BootstrapState{ClosedAt: at.Add(time.Hour), ClosedByCN: "other@example.org"}) + if err != nil || again { + t.Fatalf("second CloseBootstrap() = %v, %v; want false", again, err) + } + s, err = st.BootstrapState(ctx) + if err != nil || !s.ClosedAt.Equal(at) || s.ClosedBySerial != "1f" || s.ClosedByCN != "admin@example.org" || s.ClosedByIssuerSHA256 != "aa" { + t.Fatalf("BootstrapState() = %+v, %v; want the first closer", s, err) + } + if _, ok, _ := st.LiveBootstrapToken(ctx, at); ok { + t.Error("a token is still live after the latch closed") + } + sess, ok, err := st.BootstrapSession(ctx, "s1") + if err != nil { + t.Fatalf("BootstrapSession: %v", err) + } + if ok && sess.EndedAt.IsZero() { + t.Error("the session wasn't ended when the latch closed") + } + if err := st.IssueBootstrapToken(ctx, store.BootstrapToken{Hash: "t3", CreatedAt: at, ExpiresAt: at.Add(time.Hour)}); !errors.Is(err, store.ErrBootstrapClosed) { + t.Errorf("IssueBootstrapToken after close = %v, want ErrBootstrapClosed", err) + } + }) + + t.Run("OneLiveToken", func(t *testing.T) { + st := newStore(t) + if _, ok, err := st.LiveBootstrapToken(ctx, at); err != nil || ok { + t.Fatalf("LiveBootstrapToken() on an empty store = %v, %v", ok, err) + } + issue(t, st, "t1", at.Add(time.Hour)) + issue(t, st, "t2", at.Add(time.Hour)) + tok, ok, err := st.LiveBootstrapToken(ctx, at) + if err != nil || !ok || tok.Hash != "t2" || !tok.ExpiresAt.Equal(at.Add(time.Hour)) { + t.Fatalf("LiveBootstrapToken() = %+v, %v, %v; want t2", tok, ok, err) + } + if start(t, st, "t1", "s1") { + t.Fatal("an older token still started a session after a new one was issued") + } + if _, ok, _ := st.LiveBootstrapToken(ctx, at.Add(2*time.Hour)); ok { + t.Error("an expired token is reported live") + } + }) + + t.Run("TokenIsSingleUseAndExpires", func(t *testing.T) { + st := newStore(t) + issue(t, st, "t1", at.Add(time.Hour)) + if start(t, st, "wrong", "s0") { + t.Fatal("an unknown token started a session") + } + if !start(t, st, "t1", "s1") { + t.Fatal("a good token didn't start a session") + } + if start(t, st, "t1", "s2") { + t.Fatal("a used token started a second session") + } + issue(t, st, "t2", at.Add(-time.Minute)) + if start(t, st, "t2", "s3") { + t.Fatal("an expired token started a session") + } + if _, ok, _ := st.BootstrapSession(ctx, "s2"); ok { + t.Error("a refused start stored a session") + } + }) + + t.Run("ConcurrentDoubleConsumeStartsOneSession", func(t *testing.T) { + st := newStore(t) + issue(t, st, "t1", at.Add(time.Hour)) + var ( + wg sync.WaitGroup + started atomic.Int32 + ) + for i := 0; i < 20; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + ok, err := st.StartBootstrapSession(ctx, "t1", session(string(rune('a'+i))), at) + if err != nil { + t.Errorf("StartBootstrapSession: %v", err) + } + if ok { + started.Add(1) + } + }(i) + } + wg.Wait() + if n := started.Load(); n != 1 { + t.Fatalf("%d sessions started from one token, want exactly 1", n) + } + }) + + t.Run("NewSessionSupersedesTheOld", func(t *testing.T) { + st := newStore(t) + issue(t, st, "t1", at.Add(time.Hour)) + start(t, st, "t1", "s1") + issue(t, st, "t2", at.Add(time.Hour)) + start(t, st, "t2", "s2") + old, ok, err := st.BootstrapSession(ctx, "s1") + if err != nil || !ok || old.EndedAt.IsZero() || old.EndedReason != store.SessionEndedSuperseded { + t.Fatalf("the first session = %+v, %v, %v; want ended as superseded", old, ok, err) + } + cur, ok, err := st.BootstrapSession(ctx, "s2") + if err != nil || !ok || !cur.EndedAt.IsZero() || !cur.ExpiresAt.Equal(at.Add(time.Hour)) { + t.Fatalf("the second session = %+v, %v, %v; want live", cur, ok, err) + } + }) + + t.Run("SessionTouchEndAndLiveness", func(t *testing.T) { + st := newStore(t) + idle := 15 * time.Minute + if live, err := st.HasLiveBootstrapSession(ctx, at, idle); err != nil || live { + t.Fatalf("HasLiveBootstrapSession() on an empty store = %v, %v", live, err) + } + issue(t, st, "t1", at.Add(time.Hour)) + start(t, st, "t1", "s1") + if live, _ := st.HasLiveBootstrapSession(ctx, at.Add(time.Minute), idle); !live { + t.Fatal("a fresh session isn't live") + } + if live, _ := st.HasLiveBootstrapSession(ctx, at.Add(16*time.Minute), idle); live { + t.Fatal("an idle session is live") + } + if err := st.TouchBootstrapSession(ctx, "s1", at.Add(10*time.Minute)); err != nil { + t.Fatalf("TouchBootstrapSession: %v", err) + } + s, _, _ := st.BootstrapSession(ctx, "s1") + if !s.LastUsedAt.Equal(at.Add(10 * time.Minute)) { + t.Fatalf("LastUsedAt = %v after a touch", s.LastUsedAt) + } + if live, _ := st.HasLiveBootstrapSession(ctx, at.Add(16*time.Minute), idle); !live { + t.Fatal("a touched session isn't live") + } + if live, _ := st.HasLiveBootstrapSession(ctx, at.Add(61*time.Minute), time.Hour); live { + t.Fatal("a session past its absolute expiry is live") + } + if err := st.EndBootstrapSession(ctx, "s1", store.SessionEndedExpired, at.Add(11*time.Minute)); err != nil { + t.Fatalf("EndBootstrapSession: %v", err) + } + s, _, _ = st.BootstrapSession(ctx, "s1") + if s.EndedReason != store.SessionEndedExpired || !s.EndedAt.Equal(at.Add(11*time.Minute)) { + t.Fatalf("ended session = %+v", s) + } + if live, _ := st.HasLiveBootstrapSession(ctx, at.Add(12*time.Minute), idle); live { + t.Fatal("an ended session is live") + } + + issue(t, st, "t2", at.Add(time.Hour)) + start(t, st, "t2", "s2") + n, err := st.EndBootstrapSessions(ctx, store.SessionEndedRateLimited, at.Add(13*time.Minute)) + if err != nil || n != 1 { + t.Fatalf("EndBootstrapSessions() = %d, %v; want 1", n, err) + } + }) + + t.Run("StartRefusedOnceClosed", func(t *testing.T) { + st := newStore(t) + issue(t, st, "t1", at.Add(time.Hour)) + if _, err := st.CloseBootstrap(ctx, store.BootstrapState{ClosedAt: at, ClosedByCN: "admin@example.org"}); err != nil { + t.Fatalf("CloseBootstrap: %v", err) + } + if start(t, st, "t1", "s1") { + t.Fatal("a session started after the latch closed") + } + }) + + t.Run("RegisterFirstRunOperatorCA", func(t *testing.T) { + st := newStore(t) + before := trustVersion(t, st) + first := store.OperatorCA{SHA256: "aa", CertDER: []byte{1}, State: store.OperatorCAActive, CRLSource: store.CRLSourceNone, + OCSPMode: store.OCSPModeOff, Acknowledgements: []string{"NO_CRL"}, RegisteredAt: at, RegisteredBy: "bootstrap"} + if err := st.RegisterFirstRunOperatorCA(ctx, first, "s1", nil, nil); err != nil { + t.Fatalf("RegisterFirstRunOperatorCA: %v", err) + } + if by, err := st.OperatorCAConfirmedBy(ctx, "aa"); err != nil || by != "s1" { + t.Fatalf("OperatorCAConfirmedBy() = %q, %v; want s1", by, err) + } + if after := trustVersion(t, st); after.CAs == before.CAs || after.Epoch == before.Epoch { + t.Fatalf("trust version %+v -> %+v; want both the CAs and the epoch to move", before, after) + } + + second := store.OperatorCA{SHA256: "bb", CertDER: []byte{2}, State: store.OperatorCAActive, CRLSource: store.CRLSourceURL, + CRLURL: "http://pki.example.org/op.crl", OCSPMode: store.OCSPModeURL, OCSPURL: "http://ocsp.example.org/", RegisteredAt: at} + crl := &store.OperatorCRL{IssuerSHA256: "bb", DER: []byte{9}, Number: big.NewInt(7), ThisUpdate: at, NextUpdate: at.Add(time.Hour), FetchedAt: at, Source: store.CRLSourceURL} + if err := st.RegisterFirstRunOperatorCA(ctx, second, "s2", crl, acceptAll); err != nil { + t.Fatalf("RegisterFirstRunOperatorCA(second): %v", err) + } + cas, err := st.OperatorCAs(ctx) + if err != nil { + t.Fatalf("OperatorCAs: %v", err) + } + states := map[string]store.OperatorCA{} + for _, c := range cas { + states[c.SHA256] = c + } + if a := states["aa"]; a.State != store.OperatorCARetired || a.RetiredReason != store.RetiredSuperseded { + t.Fatalf("the earlier registration = %+v; want retired as superseded", a) + } + if b := states["bb"]; b.State != store.OperatorCAActive || b.OCSPMode != store.OCSPModeURL || b.OCSPURL != "http://ocsp.example.org/" || b.CRLURL != second.CRLURL { + t.Fatalf("the new registration = %+v", b) + } + crls, err := st.OperatorCRLs(ctx) + if err != nil || len(crls) != 1 || crls[0].IssuerSHA256 != "bb" || crls[0].Number.Int64() != 7 { + t.Fatalf("OperatorCRLs() = %+v, %v; want the registered CRL", crls, err) + } + + // Registering the first CA again brings its row back. + if err := st.RegisterFirstRunOperatorCA(ctx, first, "s3", nil, nil); err != nil { + t.Fatalf("RegisterFirstRunOperatorCA(again): %v", err) + } + cas, _ = st.OperatorCAs(ctx) + for _, c := range cas { + want := store.OperatorCARetired + if c.SHA256 == "aa" { + want = store.OperatorCAActive + } + if c.State != want { + t.Errorf("%s is %s, want %s", c.SHA256, c.State, want) + } + } + if by, _ := st.OperatorCAConfirmedBy(ctx, "aa"); by != "s3" { + t.Errorf("OperatorCAConfirmedBy(aa) = %q, want s3", by) + } + }) + + t.Run("RegisterRefusesARolledBackCRL", func(t *testing.T) { + st := newStore(t) + ca := store.OperatorCA{SHA256: "aa", CertDER: []byte{1}, State: store.OperatorCAActive, CRLSource: store.CRLSourceUpload, RegisteredAt: at} + refuse := errors.New("older CRL") + err := st.RegisterFirstRunOperatorCA(ctx, ca, "s1", &store.OperatorCRL{IssuerSHA256: "aa", DER: []byte{1}, ThisUpdate: at, NextUpdate: at.Add(time.Hour)}, + func(store.OperatorCRL, bool) (bool, error) { return false, refuse }) + if !errors.Is(err, refuse) { + t.Fatalf("RegisterFirstRunOperatorCA with a refused CRL = %v, want the decision's error", err) + } + if cas, _ := st.OperatorCAs(ctx); len(cas) != 0 { + t.Fatalf("a registration whose CRL was refused stored %+v", cas) + } + }) + + t.Run("RegisterRefusedOnceClosed", func(t *testing.T) { + st := newStore(t) + if _, err := st.CloseBootstrap(ctx, store.BootstrapState{ClosedAt: at}); err != nil { + t.Fatalf("CloseBootstrap: %v", err) + } + err := st.RegisterFirstRunOperatorCA(ctx, store.OperatorCA{SHA256: "aa", CertDER: []byte{1}, State: store.OperatorCAActive, CRLSource: store.CRLSourceNone}, "s1", nil, nil) + if !errors.Is(err, store.ErrBootstrapClosed) { + t.Fatalf("RegisterFirstRunOperatorCA after close = %v, want ErrBootstrapClosed", err) + } + }) + + t.Run("ConfirmOperatorCA", func(t *testing.T) { + st := newStore(t) + if err := st.RegisterFirstRunOperatorCA(ctx, store.OperatorCA{SHA256: "aa", CertDER: []byte{1}, State: store.OperatorCAActive, CRLSource: store.CRLSourceNone}, "s1", nil, nil); err != nil { + t.Fatalf("RegisterFirstRunOperatorCA: %v", err) + } + if err := st.ConfirmOperatorCA(ctx, "aa", "s2"); err != nil { + t.Fatalf("ConfirmOperatorCA: %v", err) + } + if by, _ := st.OperatorCAConfirmedBy(ctx, "aa"); by != "s2" { + t.Fatalf("OperatorCAConfirmedBy() = %q, want s2", by) + } + if err := st.ConfirmOperatorCA(ctx, "zz", "s2"); !errors.Is(err, store.ErrOperatorCANotFound) { + t.Fatalf("ConfirmOperatorCA(unknown) = %v, want ErrOperatorCANotFound", err) + } + }) + + t.Run("FirstAdminRecords", func(t *testing.T) { + st := newStore(t) + c := store.OperatorCredential{IssuerSHA256: "aa", SerialHex: "1f", CommonName: "admin@example.org", Level: "admin", + NotAfter: "2027-09-30T12:00:00Z", Email: "admin@example.org", FullName: "Ada Example", LeafSHA256: "cc"} + if err := st.RecordFirstAdmin(ctx, c, at); err != nil { + t.Fatalf("RecordFirstAdmin: %v", err) + } + c.FullName = "Ada B. Example" + if err := st.RecordFirstAdmin(ctx, c, at); err != nil { + t.Fatalf("RecordFirstAdmin again: %v", err) + } + got, err := st.FirstAdminCredentials(ctx) + if err != nil || len(got) != 1 || got[0].FullName != "Ada B. Example" || got[0].Kind != store.OperatorCredentialFirstAdmin || + got[0].IssuerSHA256 != "aa" || got[0].SerialHex != "1f" || got[0].Email != "admin@example.org" || got[0].LeafSHA256 != "cc" { + t.Fatalf("FirstAdminCredentials() = %+v, %v", got, err) + } + + stored, err := st.RecordFirstUse(ctx, c, at) + if err != nil || stored { + t.Fatalf("RecordFirstUse of a recorded credential = %v, %v; want false", stored, err) + } + other := c + other.SerialHex, other.FullName = "20", "" + stored, err = st.RecordFirstUse(ctx, other, at) + if err != nil || !stored { + t.Fatalf("RecordFirstUse of a new credential = %v, %v; want true", stored, err) + } + got, _ = st.FirstAdminCredentials(ctx) + if len(got) != 2 { + t.Fatalf("FirstAdminCredentials() = %+v, want both", got) + } + }) +}