Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
91 changes: 91 additions & 0 deletions cmd/manager/bootstrap.go
Original file line number Diff line number Diff line change
@@ -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
}
167 changes: 167 additions & 0 deletions cmd/manager/bootstrap_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
25 changes: 18 additions & 7 deletions cmd/manager/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -89,6 +90,7 @@ func main() {
var (
st store.Store
trustStore store.OperatorTrust
bootStore bootstrap.Store
certStore serverCertStore
storeCheck func(context.Context) error
)
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -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")
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading