From 4030b0a75839784fb30b01dc1322e6d63e727150 Mon Sep 17 00:00:00 2001 From: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Date: Thu, 1 Oct 2026 09:47:02 -0400 Subject: [PATCH 1/4] feat(operatorca): store rotation, CRL source and OCSP changes; report the OCSP probe signer and last error Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> --- internal/operatorca/faketrust_test.go | 12 + internal/operatorca/ocsp.go | 5 +- internal/operatorca/ocspclient.go | 41 ++- internal/operatorca/ocsppki_test.go | 7 +- internal/operatorca/revocation.go | 7 +- internal/operatorca/rotation_test.go | 143 +++++++++ internal/store/memory/operatortrust.go | 15 + internal/store/memory/operatortrust_test.go | 9 + internal/store/operatortrust.go | 27 ++ internal/store/postgres/operatortrust.go | 116 +++++++ internal/store/storetest/memorytrust.go | 315 +++++++++++++++++++ internal/store/storetest/memorytrust_test.go | 30 ++ internal/store/storetest/operatortrust.go | 184 +++++++++++ 13 files changed, 903 insertions(+), 8 deletions(-) create mode 100644 internal/operatorca/rotation_test.go create mode 100644 internal/store/storetest/memorytrust.go create mode 100644 internal/store/storetest/memorytrust_test.go diff --git a/internal/operatorca/faketrust_test.go b/internal/operatorca/faketrust_test.go index 0e39abc..af5b46a 100644 --- a/internal/operatorca/faketrust_test.go +++ b/internal/operatorca/faketrust_test.go @@ -243,3 +243,15 @@ func (a *auditSink) kinds(kind string) int { } return n } + +func (f *fakeTrust) RotateOperatorCA(context.Context, store.OperatorCA, *store.OperatorCRL, store.DecideCRL) error { + return errors.New("fake: rotation isn't used by these tests") +} + +func (f *fakeTrust) SetOperatorCACRLSource(context.Context, string, string, string, []string, *store.OperatorCRL, store.DecideCRL) error { + return errors.New("fake: CRL source changes aren't used by these tests") +} + +func (f *fakeTrust) SetOperatorCAOCSP(context.Context, string, string, string) error { + return errors.New("fake: OCSP changes aren't used by these tests") +} diff --git a/internal/operatorca/ocsp.go b/internal/operatorca/ocsp.go index 607db18..258bc94 100644 --- a/internal/operatorca/ocsp.go +++ b/internal/operatorca/ocsp.go @@ -132,6 +132,9 @@ func (s OCSPStatus) String() string { type OCSPResult struct { Status OCSPStatus ThisUpdate, NextUpdate time.Time + // Signer is the certificate that signed the response: the anchor, or a + // delegated responder certificate it issued. + Signer *x509.Certificate } func ocspInvalid(format string, args ...any) error { @@ -275,7 +278,7 @@ func (q *ocspQuery) validate(der []byte, now time.Time, isRevoked func(serial st if !single.NextUpdate.IsZero() && !now.Before(single.NextUpdate) { return OCSPResult{}, ocspInvalid("stale: nextUpdate %s has passed", single.NextUpdate.UTC().Format(time.RFC3339)) } - res := OCSPResult{ThisUpdate: single.ThisUpdate, NextUpdate: single.NextUpdate} + res := OCSPResult{ThisUpdate: single.ThisUpdate, NextUpdate: single.NextUpdate, Signer: signer} switch resp.Status { case ocsp.Good: res.Status = OCSPGood diff --git a/internal/operatorca/ocspclient.go b/internal/operatorca/ocspclient.go index f9f5f22..501439e 100644 --- a/internal/operatorca/ocspclient.go +++ b/internal/operatorca/ocspclient.go @@ -28,6 +28,7 @@ import ( "net/http" "net/url" "strings" + "sync" "time" fleetv1 "github.com/CryptOS-PKI/cryptos-manager/gen/go/cryptos/fleet/v1" @@ -93,6 +94,9 @@ type OCSPClient struct { bg func(func()) cache *lru[ocspKey, *ocspEntry] group singleflight.Group + + errMu sync.Mutex + lastErr map[string]string } // NewOCSPFetcher is a Fetcher with the OCSP limits: the CRL fetch limits @@ -104,7 +108,7 @@ func NewOCSPFetcher() *Fetcher { // NewOCSPClient builds an OCSPClient. func NewOCSPClient(o OCSPOptions) *OCSPClient { c := &OCSPClient{fetch: o.Fetch, now: o.Now, logf: o.Logf, audit: o.Audit, bg: o.Background, - cache: newLRU[ocspKey, *ocspEntry](ocspCacheSize)} + cache: newLRU[ocspKey, *ocspEntry](ocspCacheSize), lastErr: map[string]string{}} if c.fetch == nil { c.fetch = NewOCSPFetcher() } @@ -157,6 +161,7 @@ func (c *OCSPClient) cached(anchor string, serial *big.Int) (ocspEntry, bool) { // OCSP settings change. func (c *OCSPClient) ClearAnchor(anchorSHA256 string) { c.cache.remove(func(k ocspKey) bool { return k.anchor == anchorSHA256 }) + c.noteResult(anchorSHA256, "", nil) } // Check returns the status of serial under a from the cache, or from one @@ -201,6 +206,7 @@ func (c *OCSPClient) refresh(key ocspKey, a Anchor, serial *big.Int, responderUR func (c *OCSPClient) fill(key ocspKey, a Anchor, serial *big.Int, responderURL string, isRevoked func(string) bool, old *ocspEntry) *ocspEntry { res, err := c.lookup(context.Background(), a.Cert, serial, responderURL, isRevoked) + c.noteResult(a.SHA256, responderURL, err) now := c.now() if err != nil { c.logf("operatorca: OCSP check of serial %s under operator CA %s at %s failed (%s): %v", @@ -246,9 +252,16 @@ func (c *OCSPClient) fill(key ocspKey, a Anchor, serial *big.Int, responderURL s // asks about a random serial and needs a validly signed response, whatever // the status. A failure is 1605 OCSP_UNREACHABLE or OCSP_INVALID. func (c *OCSPClient) Probe(ctx context.Context, anchor *x509.Certificate, responderURL string) error { + _, err := c.ProbeResult(ctx, anchor, responderURL) + return err +} + +// ProbeResult is Probe returning the validated answer, including the +// certificate that signed it. +func (c *OCSPClient) ProbeResult(ctx context.Context, anchor *x509.Certificate, responderURL string) (OCSPResult, error) { b := make([]byte, 16) if _, err := rand.Read(b); err != nil { - return fmt.Errorf("operatorca: OCSP probe serial: %w", err) + return OCSPResult{}, fmt.Errorf("operatorca: OCSP probe serial: %w", err) } b[0] &= 0x7f b[0] |= 0x40 @@ -256,10 +269,28 @@ func (c *OCSPClient) Probe(ctx context.Context, anchor *x509.Certificate, respon res, err := c.lookup(ctx, anchor, serial, responderURL, nil) if err != nil { c.logf("operatorca: OCSP probe of %s for operator CA %s failed (%s): %v", redact(responderURL), anchor.Subject.CommonName, ocspClass(err), err) - return err + return OCSPResult{}, err + } + c.logf("operatorca: OCSP probe of %s for operator CA %s answered %s, signed by %s", redact(responderURL), anchor.Subject.CommonName, res.Status, res.Signer.Subject) + return res, nil +} + +// LastError is this replica's last OCSP failure for a certificate under the +// operator CA, empty when the responder last answered or nothing failed. +func (c *OCSPClient) LastError(anchorSHA256 string) string { + c.errMu.Lock() + defer c.errMu.Unlock() + return c.lastErr[anchorSHA256] +} + +func (c *OCSPClient) noteResult(anchorSHA256, responderURL string, err error) { + c.errMu.Lock() + defer c.errMu.Unlock() + if err == nil { + delete(c.lastErr, anchorSHA256) + return } - c.logf("operatorca: OCSP probe of %s for operator CA %s answered %s", redact(responderURL), anchor.Subject.CommonName, res.Status) - return nil + c.lastErr[anchorSHA256] = fmt.Sprintf("%s from %s at %s", ocspClass(err), redact(responderURL), c.now().UTC().Format(time.RFC3339)) } func ocspClass(err error) string { diff --git a/internal/operatorca/ocsppki_test.go b/internal/operatorca/ocsppki_test.go index 9dd63f7..78a0b7f 100644 --- a/internal/operatorca/ocsppki_test.go +++ b/internal/operatorca/ocsppki_test.go @@ -252,6 +252,8 @@ type testResponder struct { down bool // wrongKey signs with a key that isn't the anchor's. wrongKey *testCA + // delegate signs as a delegated responder the anchor issued. + delegate *testCA noNonce bool noPOST bool next time.Duration @@ -322,7 +324,7 @@ func (r *testResponder) serve(w http.ResponseWriter, req *http.Request) { p.method = req.Method r.mu.Lock() r.seen = append(r.seen, p) - down, wrong, noNonce, next, noNext := r.down, r.wrongKey, r.noNonce, r.next, r.noNext + down, wrong, delegate, noNonce, next, noNext := r.down, r.wrongKey, r.delegate, r.noNonce, r.next, r.noNext status, ok := r.status[SerialKey(p.serial)] if !ok { status = r.defaultStatus @@ -337,6 +339,9 @@ func (r *testResponder) serve(w http.ResponseWriter, req *http.Request) { if !noNonce { o.nonce = p.nonce } + if delegate != nil { + o.signer = delegate + } if wrong != nil { o.signer = wrong o.responderID = r.ca.cert diff --git a/internal/operatorca/revocation.go b/internal/operatorca/revocation.go index 24b0414..a28ebf8 100644 --- a/internal/operatorca/revocation.go +++ b/internal/operatorca/revocation.go @@ -172,7 +172,8 @@ func (r *Revocations) OCSP() *OCSPClient { return r.ocsp } func (r *Revocations) Policy() string { return r.policy } // SetAnchors replaces the set of trusted anchors, keeping what is already -// loaded for anchors that stay. +// loaded for anchors that stay. An anchor whose OCSP mode or URL changed has +// its cached OCSP answers dropped, so the old responder's answers aren't used. func (r *Revocations) SetAnchors(anchors []Anchor) { r.mu.Lock() defer r.mu.Unlock() @@ -182,6 +183,10 @@ func (r *Revocations) SetAnchors(anchors []Anchor) { if !ok { st = &anchorState{} } + if ok && r.ocsp != nil && (st.anchor.OCSPMode != a.OCSPMode || st.anchor.OCSPURL != a.OCSPURL) { + r.ocsp.ClearAnchor(a.SHA256) + st.ocspOutage = false + } st.anchor = a st.denied = r.denylist[a.SHA256] next[a.SHA256] = st diff --git a/internal/operatorca/rotation_test.go b/internal/operatorca/rotation_test.go new file mode 100644 index 0000000..27e2631 --- /dev/null +++ b/internal/operatorca/rotation_test.go @@ -0,0 +1,143 @@ +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 ( + "math/big" + "strings" + "testing" + "time" + + "github.com/CryptOS-PKI/manager/internal/store" + "golang.org/x/crypto/ocsp" +) + +// The registration probe reports who signed the answer, so the Operator CAs +// page can show the responder certificate an admin is about to rely on. +func TestOCSPProbeResult_ReportsTheSigner(t *testing.T) { + ca := newCA(t, caOpts{}) + clock := &fakeClock{t: testNow} + c := NewOCSPClient(OCSPOptions{Fetch: NewOCSPFetcher(), Now: clock.Now}) + r := newTestResponder(t, ca, clock.Now) + r.set(func(r *testResponder) { r.defaultStatus = ocsp.Unknown }) + + res, err := c.ProbeResult(t.Context(), ca.cert, r.URL()) + if err != nil { + t.Fatalf("ProbeResult = %v", err) + } + if res.Status != OCSPUnknown || res.Signer == nil || !res.Signer.Equal(ca.cert) { + t.Fatalf("ProbeResult = %+v, want unknown signed by the CA", res) + } + + delegate := ca.delegated(t, delegatedOpts{noCheck: true}) + r.set(func(r *testResponder) { r.delegate = &delegate }) + res, err = c.ProbeResult(t.Context(), ca.cert, r.URL()) + if err != nil { + t.Fatalf("ProbeResult with a delegated signer = %v", err) + } + if res.Signer == nil || !res.Signer.Equal(delegate.cert) { + t.Fatalf("signer = %v, want the delegated responder", res.Signer) + } +} + +// Each replica remembers the last OCSP failure per operator CA for the +// Operator CAs page, and forgets it once the responder answers again or the +// CA's OCSP settings are cleared. +func TestOCSPClient_LastErrorPerAnchor(t *testing.T) { + ca := newCA(t, caOpts{}) + clock := &fakeClock{t: testNow} + c := NewOCSPClient(OCSPOptions{Fetch: NewOCSPFetcher(), Now: clock.Now}) + r := newTestResponder(t, ca, clock.Now) + a := ocspAnchor(ca, store.CRLSourceNone, store.OCSPModeURL, r.URL()) + + r.set(func(r *testResponder) { r.down = true }) + if _, err := c.Check(a, big.NewInt(7), r.URL(), notRevoked); err == nil { + t.Fatal("Check against a down responder succeeded") + } + if got := c.LastError(a.SHA256); !strings.Contains(got, "OCSP_UNREACHABLE") { + t.Fatalf("LastError = %q, want the OCSP_UNREACHABLE class", got) + } + if got := c.LastError("other"); got != "" { + t.Fatalf("LastError(other) = %q, want empty", got) + } + + r.set(func(r *testResponder) { r.down = false }) + if _, err := c.Check(a, big.NewInt(8), r.URL(), notRevoked); err != nil { + t.Fatalf("Check = %v", err) + } + if got := c.LastError(a.SHA256); got != "" { + t.Fatalf("LastError after a good answer = %q, want empty", got) + } + + r.set(func(r *testResponder) { r.down = true }) + _, _ = c.Check(a, big.NewInt(9), r.URL(), notRevoked) + c.ClearAnchor(a.SHA256) + if got := c.LastError(a.SHA256); got != "" { + t.Fatalf("LastError after ClearAnchor = %q, want empty", got) + } +} + +// When an operator CA's OCSP settings change, every replica drops the +// answers it cached for that CA as soon as it installs the new settings, so +// the old responder's answers aren't used for up to an hour. +func TestSetAnchors_ClearsCachedOCSPWhenTheSettingsChange(t *testing.T) { + ca := newCA(t, caOpts{}) + other := newCA(t, caOpts{cn: "Example Other Operator CA"}) + r := newTestResponder(t, ca, func() time.Time { return testNow }) + r2 := newTestResponder(t, other, func() time.Time { return testNow }) + a := ocspAnchor(ca, store.CRLSourceNone, store.OCSPModeURL, r.URL()) + b := ocspAnchor(other, store.CRLSourceNone, store.OCSPModeURL, r2.URL()) + f := newOCSPFixture(t, PolicySoft, nil, a, b) + leaf := ca.leaf(t, leafOpts{}) + otherLeaf := other.leaf(t, leafOpts{}) + + check := func() { + t.Helper() + if err := f.rev.CheckWebCert(a.SHA256, leaf); err != nil { + t.Fatalf("CheckWebCert = %v", err) + } + if err := f.rev.CheckWebCert(b.SHA256, otherLeaf); err != nil { + t.Fatalf("CheckWebCert(other) = %v", err) + } + } + check() + if r.count() != 1 || r2.count() != 1 { + t.Fatalf("requests = %d, %d; want one each", r.count(), r2.count()) + } + + f.rev.SetAnchors([]Anchor{a, b}) + check() + if r.count() != 1 || r2.count() != 1 { + t.Fatalf("requests after unchanged settings = %d, %d; want the cache used", r.count(), r2.count()) + } + + moved := a + moved.OCSPURL = r.URL() + "/" + f.rev.SetAnchors([]Anchor{moved, b}) + if err := f.rev.CheckWebCert(a.SHA256, leaf); err != nil { + t.Fatalf("CheckWebCert after the change = %v", err) + } + if err := f.rev.CheckWebCert(b.SHA256, otherLeaf); err != nil { + t.Fatalf("CheckWebCert(other) after the change = %v", err) + } + if r.count() != 2 { + t.Fatalf("requests to the changed CA's responder = %d, want a fresh fetch", r.count()) + } + if r2.count() != 1 { + t.Fatalf("requests to the unchanged CA's responder = %d, want its cache kept", r2.count()) + } +} diff --git a/internal/store/memory/operatortrust.go b/internal/store/memory/operatortrust.go index a51f5ad..32dee25 100644 --- a/internal/store/memory/operatortrust.go +++ b/internal/store/memory/operatortrust.go @@ -117,3 +117,18 @@ func (s *Store) OperatorTrustVersion(context.Context) (store.TrustVersion, error func (s *Store) TryAdvisoryLock(context.Context, string) (func(), bool, error) { return func() {}, true, nil } + +// RotateOperatorCA refuses: registered operator CAs need Postgres. +func (s *Store) RotateOperatorCA(context.Context, store.OperatorCA, *store.OperatorCRL, store.DecideCRL) error { + return store.ErrDatabaseRequired +} + +// SetOperatorCACRLSource refuses: registered operator CAs need Postgres. +func (s *Store) SetOperatorCACRLSource(context.Context, string, string, string, []string, *store.OperatorCRL, store.DecideCRL) error { + return store.ErrDatabaseRequired +} + +// SetOperatorCAOCSP refuses: registered operator CAs need Postgres. +func (s *Store) SetOperatorCAOCSP(context.Context, string, string, string) error { + return store.ErrDatabaseRequired +} diff --git a/internal/store/memory/operatortrust_test.go b/internal/store/memory/operatortrust_test.go index 2c57543..e9ea944 100644 --- a/internal/store/memory/operatortrust_test.go +++ b/internal/store/memory/operatortrust_test.go @@ -37,6 +37,15 @@ func TestOperatorTrust_MemoryNeedsPostgresForRowsAndDenylist(t *testing.T) { if cas, err := st.OperatorCAs(ctx); err != nil || len(cas) != 0 { t.Fatalf("OperatorCAs() = %+v, %v; want none", cas, err) } + if err := st.RotateOperatorCA(ctx, store.OperatorCA{SHA256: "aa", State: store.OperatorCAActive}, nil, nil); !errors.Is(err, store.ErrDatabaseRequired) { + t.Fatalf("RotateOperatorCA error = %v, want ErrDatabaseRequired", err) + } + if err := st.SetOperatorCACRLSource(ctx, "aa", store.CRLSourceNone, "", nil, nil, nil); !errors.Is(err, store.ErrDatabaseRequired) { + t.Fatalf("SetOperatorCACRLSource error = %v, want ErrDatabaseRequired", err) + } + if err := st.SetOperatorCAOCSP(ctx, "aa", store.OCSPModeOff, ""); !errors.Is(err, store.ErrDatabaseRequired) { + t.Fatalf("SetOperatorCAOCSP error = %v, want ErrDatabaseRequired", err) + } if _, err := st.AddOperatorDenylistEntry(ctx, store.DenylistEntry{IssuerSHA256: "aa", SerialHex: "1"}); !errors.Is(err, store.ErrDatabaseRequired) { t.Fatalf("AddOperatorDenylistEntry error = %v, want ErrDatabaseRequired", err) } diff --git a/internal/store/operatortrust.go b/internal/store/operatortrust.go index 65d7751..36da493 100644 --- a/internal/store/operatortrust.go +++ b/internal/store/operatortrust.go @@ -67,6 +67,14 @@ var ErrDatabaseRequired = errors.New("store: this needs the Postgres store") // fingerprint. var ErrOperatorCANotFound = errors.New("store: operator CA not found") +// ErrRotationInProgress is returned by RotateOperatorCA while an operator +// CA is retiring: at most one can be retiring at a time. +var ErrRotationInProgress = errors.New("store: an operator CA is already retiring") + +// ErrOperatorCATrusted is returned by RotateOperatorCA for an operator CA +// that is already active or retiring. +var ErrOperatorCATrusted = errors.New("store: the operator CA is already trusted") + // OperatorCA is one registered operator CA: the certificate of an external CA // that signs operator credentials. The manager never holds its key. SHA256 // is the lowercase hex SHA-256 of CertDER. A zero time means unset. @@ -143,6 +151,25 @@ type OperatorTrust interface { // recording reason and at when it is retired. SetOperatorCAState(ctx context.Context, sha256, state, reason string, at time.Time) error + // RotateOperatorCA makes ca the active operator CA after first run, in + // one step: the current active row becomes retiring, ca is stored as + // active (a retired row with the same fingerprint is brought back), + // crl, when set, is stored if decide accepts it, and the trust version + // and revocation epoch move. It returns ErrRotationInProgress while a row + // is retiring and ErrOperatorCATrusted when ca is already active or + // retiring; on any error nothing is written. + RotateOperatorCA(ctx context.Context, ca OperatorCA, crl *OperatorCRL, decide DecideCRL) error + // SetOperatorCACRLSource changes the CRL source, URL and + // acknowledgements of the active or retiring row with the fingerprint, + // stores crl when set if decide accepts it, and moves the trust version + // and revocation epoch, in one step. It returns ErrOperatorCANotFound + // when no active or retiring row has the fingerprint. + SetOperatorCACRLSource(ctx context.Context, sha256, source, url string, acks []string, crl *OperatorCRL, decide DecideCRL) error + // SetOperatorCAOCSP changes the OCSP mode and URL of the active or + // retiring row with the fingerprint and moves the trust version. It + // returns ErrOperatorCANotFound when no such row exists. + SetOperatorCAOCSP(ctx context.Context, sha256, mode, url string) error + // OperatorCRLs returns the stored CRL and last attempt of every operator // CA that has either. OperatorCRLs(ctx context.Context) ([]OperatorCRL, error) diff --git a/internal/store/postgres/operatortrust.go b/internal/store/postgres/operatortrust.go index 195cb25..3c9a2a1 100644 --- a/internal/store/postgres/operatortrust.go +++ b/internal/store/postgres/operatortrust.go @@ -357,3 +357,119 @@ func bumpEpoch(ctx context.Context, tx pgx.Tx) error { } return nil } + +// RotateOperatorCA makes ca the active operator CA, moves the current +// active row to retiring, stores crl, and moves the trust version and the +// revocation epoch, in one transaction. The trusted rows are locked first, +// so two rotations can't both pass the retiring check. +func (s *Store) RotateOperatorCA(ctx context.Context, ca store.OperatorCA, 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 rotation: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + rows, err := tx.Query(ctx, `SELECT sha256, state FROM operator_cas WHERE state IN ('active', 'retiring') FOR UPDATE`) + if err != nil { + return fmt.Errorf("postgres: lock the trusted operator CAs: %w", err) + } + type trusted struct{ sha, state string } + current, err := pgx.CollectRows(rows, func(row pgx.CollectableRow) (trusted, error) { + var t trusted + err := row.Scan(&t.sha, &t.state) + return t, err + }) + if err != nil { + return fmt.Errorf("postgres: read the trusted operator CAs: %w", err) + } + for _, t := range current { + if t.sha == ca.SHA256 { + return store.ErrOperatorCATrusted + } + if t.state == store.OperatorCARetiring { + return store.ErrRotationInProgress + } + } + + if _, err := tx.Exec(ctx, `UPDATE operator_cas SET state = 'retiring', updated_at = clock_timestamp() WHERE state = 'active'`); err != nil { + return fmt.Errorf("postgres: move the active operator CA to retiring: %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) + VALUES ($1, $2, 'active', $3, $4, $5, nullif($6, ''), $7, $8, $9, $10) + 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, 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); 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 rotation: %w", err) + } + return nil +} + +// SetOperatorCACRLSource changes a trusted row's CRL source and stores its +// new CRL, moving the trust version and the revocation epoch, in one +// transaction. +func (s *Store) SetOperatorCACRLSource(ctx context.Context, sha256, source, url string, acks []string, crl *store.OperatorCRL, decide store.DecideCRL) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return fmt.Errorf("postgres: begin CRL source change: %w", err) + } + defer func() { _ = tx.Rollback(ctx) }() + + tag, err := tx.Exec(ctx, `UPDATE operator_cas SET crl_source = $2, crl_url = $3, acknowledgements = $4, updated_at = clock_timestamp() + WHERE sha256 = $1 AND state IN ('active', 'retiring')`, sha256, source, url, nonNil(acks)) + if err != nil { + return fmt.Errorf("postgres: set the CRL source of operator CA %s: %w", sha256, err) + } + if tag.RowsAffected() == 0 { + return store.ErrOperatorCANotFound + } + 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 CRL source change: %w", err) + } + return nil +} + +// SetOperatorCAOCSP changes a trusted row's OCSP mode and URL. The table +// refuses url mode with no URL. +func (s *Store) SetOperatorCAOCSP(ctx context.Context, sha256, mode, url string) error { + tag, err := s.pool.Exec(ctx, `UPDATE operator_cas SET ocsp_mode = $2, ocsp_url = nullif($3, ''), updated_at = clock_timestamp() + WHERE sha256 = $1 AND state IN ('active', 'retiring')`, sha256, mode, url) + if err != nil { + return fmt.Errorf("postgres: set the OCSP mode of operator CA %s: %w", sha256, err) + } + if tag.RowsAffected() == 0 { + return store.ErrOperatorCANotFound + } + return nil +} diff --git a/internal/store/storetest/memorytrust.go b/internal/store/storetest/memorytrust.go new file mode 100644 index 0000000..b8658cb --- /dev/null +++ b/internal/store/storetest/memorytrust.go @@ -0,0 +1,315 @@ +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" + "fmt" + "slices" + "sync" + "time" + + "github.com/CryptOS-PKI/manager/internal/store" +) + +var _ store.OperatorTrust = (*MemoryTrust)(nil) + +// MemoryTrust is an in-memory store.OperatorTrust that behaves like the +// Postgres store, registered operator CAs and the denylist included, so tests +// in other packages can run the registered source without a database. Two +// trust stores sharing one MemoryTrust stand in for two replicas sharing one +// database. OperatorTrust checks it against the same contract as Postgres. +type MemoryTrust struct { + mu sync.Mutex + cas []store.OperatorCA + crls map[string]store.OperatorCRL + denylist []store.DenylistEntry + epoch int64 + changes int + locks map[string]bool +} + +// NewMemoryTrust returns an empty MemoryTrust. +func NewMemoryTrust() *MemoryTrust { + return &MemoryTrust{crls: map[string]store.OperatorCRL{}, locks: map[string]bool{}} +} + +// OperatorCAs returns every row, oldest first. +func (m *MemoryTrust) OperatorCAs(context.Context) ([]store.OperatorCA, error) { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]store.OperatorCA, len(m.cas)) + for i, c := range m.cas { + c.Acknowledgements = slices.Clone(c.Acknowledgements) + c.Warnings = slices.Clone(c.Warnings) + out[i] = c + } + return out, nil +} + +func (m *MemoryTrust) checkLocked(next []store.OperatorCA) error { + seen := map[string]bool{} + var active, retiring int + for _, c := range next { + if seen[c.SHA256] { + return fmt.Errorf("storetest: duplicate operator CA %s", c.SHA256) + } + seen[c.SHA256] = true + switch c.State { + case store.OperatorCAActive: + active++ + case store.OperatorCARetiring: + retiring++ + } + if c.OCSPMode == store.OCSPModeURL && c.OCSPURL == "" { + return fmt.Errorf("storetest: operator CA %s is in OCSP url mode with no URL", c.SHA256) + } + if c.CRLSource == store.CRLSourceURL && c.CRLURL == "" { + return fmt.Errorf("storetest: operator CA %s has a CRL url source with no URL", c.SHA256) + } + } + if active > 1 || retiring > 1 { + return errors.New("storetest: more than one active or retiring operator CA") + } + return nil +} + +func (m *MemoryTrust) commitLocked(next []store.OperatorCA) error { + if err := m.checkLocked(next); err != nil { + return err + } + m.cas = next + m.changes++ + return nil +} + +func normalized(ca store.OperatorCA) store.OperatorCA { + if ca.OCSPMode == "" { + ca.OCSPMode = store.OCSPModeAIA + } + if ca.RegisteredAt.IsZero() { + ca.RegisteredAt = time.Now().UTC() + } + ca.Acknowledgements = slices.Clone(ca.Acknowledgements) + ca.Warnings = slices.Clone(ca.Warnings) + ca.UpdatedAt = time.Now().UTC() + return ca +} + +// AddOperatorCA stores a new row. +func (m *MemoryTrust) AddOperatorCA(_ context.Context, ca store.OperatorCA) error { + m.mu.Lock() + defer m.mu.Unlock() + return m.commitLocked(append(slices.Clone(m.cas), normalized(ca))) +} + +// SetOperatorCAState moves a row to state. +func (m *MemoryTrust) SetOperatorCAState(_ context.Context, sha256, state, reason string, at time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + next := slices.Clone(m.cas) + i := slices.IndexFunc(next, func(c store.OperatorCA) bool { return c.SHA256 == sha256 }) + if i < 0 { + return store.ErrOperatorCANotFound + } + next[i].State, next[i].UpdatedAt = state, time.Now().UTC() + if state == store.OperatorCARetired { + next[i].RetiredAt, next[i].RetiredReason = at, reason + } + return m.commitLocked(next) +} + +// RotateOperatorCA is the rotation step, all or nothing. +func (m *MemoryTrust) RotateOperatorCA(_ context.Context, ca store.OperatorCA, crl *store.OperatorCRL, decide store.DecideCRL) error { + m.mu.Lock() + defer m.mu.Unlock() + next := slices.Clone(m.cas) + for _, c := range next { + if c.SHA256 == ca.SHA256 && c.State != store.OperatorCARetired { + return store.ErrOperatorCATrusted + } + if c.State == store.OperatorCARetiring { + return store.ErrRotationInProgress + } + } + now := time.Now().UTC() + for i := range next { + if next[i].State == store.OperatorCAActive { + next[i].State, next[i].UpdatedAt = store.OperatorCARetiring, now + } + } + ca = normalized(ca) + ca.State, ca.RetiredAt, ca.RetiredReason = store.OperatorCAActive, time.Time{}, "" + if i := slices.IndexFunc(next, func(c store.OperatorCA) bool { return c.SHA256 == ca.SHA256 }); i >= 0 { + next[i] = ca + } else { + next = append(next, ca) + } + if err := m.checkLocked(next); err != nil { + return err + } + if crl != nil { + if _, err := m.putCRLLocked(*crl, decide); err != nil { + return err + } + } + m.cas = next + m.changes++ + m.epoch++ + return nil +} + +func (m *MemoryTrust) trustedLocked(sha256 string) int { + return slices.IndexFunc(m.cas, func(c store.OperatorCA) bool { + return c.SHA256 == sha256 && c.State != store.OperatorCARetired + }) +} + +// SetOperatorCACRLSource changes a trusted row's CRL source. +func (m *MemoryTrust) SetOperatorCACRLSource(_ context.Context, sha256, source, url string, acks []string, crl *store.OperatorCRL, decide store.DecideCRL) error { + m.mu.Lock() + defer m.mu.Unlock() + i := m.trustedLocked(sha256) + if i < 0 { + return store.ErrOperatorCANotFound + } + next := slices.Clone(m.cas) + next[i].CRLSource, next[i].CRLURL, next[i].Acknowledgements = source, url, slices.Clone(acks) + next[i].UpdatedAt = time.Now().UTC() + if err := m.checkLocked(next); err != nil { + return err + } + if crl != nil { + if _, err := m.putCRLLocked(*crl, decide); err != nil { + return err + } + } + m.cas = next + m.changes++ + m.epoch++ + return nil +} + +// SetOperatorCAOCSP changes a trusted row's OCSP settings. +func (m *MemoryTrust) SetOperatorCAOCSP(_ context.Context, sha256, mode, url string) error { + m.mu.Lock() + defer m.mu.Unlock() + i := m.trustedLocked(sha256) + if i < 0 { + return store.ErrOperatorCANotFound + } + next := slices.Clone(m.cas) + next[i].OCSPMode, next[i].OCSPURL, next[i].UpdatedAt = mode, url, time.Now().UTC() + return m.commitLocked(next) +} + +// OperatorCRLs returns every stored CRL and last attempt. +func (m *MemoryTrust) OperatorCRLs(context.Context) ([]store.OperatorCRL, error) { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]store.OperatorCRL, 0, len(m.crls)) + for _, c := range m.crls { + out = append(out, c) + } + slices.SortFunc(out, func(a, b store.OperatorCRL) int { + switch { + case a.IssuerSHA256 < b.IssuerSHA256: + return -1 + case a.IssuerSHA256 > b.IssuerSHA256: + return 1 + } + return 0 + }) + return out, nil +} + +func (m *MemoryTrust) putCRLLocked(c store.OperatorCRL, decide store.DecideCRL) (bool, error) { + cur, ok := m.crls[c.IssuerSHA256] + accept, err := decide(cur, ok && cur.DER != nil) + if err != nil || !accept { + return false, err + } + c.LastError, c.LastAttemptAt = "", c.FetchedAt + m.crls[c.IssuerSHA256] = c + return true, nil +} + +// PutOperatorCRL stores c when decide accepts it and bumps the epoch. +func (m *MemoryTrust) PutOperatorCRL(_ context.Context, c store.OperatorCRL, decide store.DecideCRL) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + ok, err := m.putCRLLocked(c, decide) + if ok { + m.epoch++ + } + return ok, err +} + +// RecordOperatorCRLAttempt records a failed refresh. +func (m *MemoryTrust) RecordOperatorCRLAttempt(_ context.Context, issuerSHA256, lastError string, at time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + c := m.crls[issuerSHA256] + c.IssuerSHA256, c.LastError, c.LastAttemptAt = issuerSHA256, lastError, at + m.crls[issuerSHA256] = c + return nil +} + +// OperatorDenylist returns every entry. +func (m *MemoryTrust) OperatorDenylist(context.Context) ([]store.DenylistEntry, error) { + m.mu.Lock() + defer m.mu.Unlock() + return slices.Clone(m.denylist), nil +} + +// AddOperatorDenylistEntry adds e unless it exists, bumping the epoch. +func (m *MemoryTrust) AddOperatorDenylistEntry(_ context.Context, e store.DenylistEntry) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + for _, have := range m.denylist { + if have.IssuerSHA256 == e.IssuerSHA256 && have.SerialHex == e.SerialHex { + return false, nil + } + } + m.denylist = append(m.denylist, e) + m.epoch++ + return true, nil +} + +// OperatorTrustVersion is the row count with a change counter, and the +// epoch. +func (m *MemoryTrust) OperatorTrustVersion(context.Context) (store.TrustVersion, error) { + m.mu.Lock() + defer m.mu.Unlock() + return store.TrustVersion{CAs: fmt.Sprintf("%d:%d", len(m.cas), m.changes), Epoch: m.epoch}, nil +} + +// TryAdvisoryLock takes the named lock if it is free. +func (m *MemoryTrust) TryAdvisoryLock(_ context.Context, name string) (func(), bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.locks[name] { + return nil, false, nil + } + m.locks[name] = true + return func() { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.locks, name) + }, true, nil +} diff --git a/internal/store/storetest/memorytrust_test.go b/internal/store/storetest/memorytrust_test.go new file mode 100644 index 0000000..08582fb --- /dev/null +++ b/internal/store/storetest/memorytrust_test.go @@ -0,0 +1,30 @@ +package storetest_test + +/* +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" + "github.com/CryptOS-PKI/manager/internal/store/storetest" +) + +// The fake other packages' tests use must keep the Postgres store's +// contract. +func TestMemoryTrust(t *testing.T) { + storetest.OperatorTrust(t, func(*testing.T) store.OperatorTrust { return storetest.NewMemoryTrust() }) +} diff --git a/internal/store/storetest/operatortrust.go b/internal/store/storetest/operatortrust.go index e8f4c47..a3388f5 100644 --- a/internal/store/storetest/operatortrust.go +++ b/internal/store/storetest/operatortrust.go @@ -250,6 +250,162 @@ func OperatorTrust(t *testing.T, newStore func(t *testing.T) store.OperatorTrust } }) + t.Run("RotateMovesTheActiveCAToRetiring", func(t *testing.T) { + st := newStore(t) + mustAddCA(t, st, "a1", store.OperatorCAActive) + mustAddCA(t, st, "x1", store.OperatorCARetired) + before := trustVersion(t, st) + + next := ca("a2", store.OperatorCAActive) + next.CRLSource, next.CRLURL, next.Acknowledgements = store.CRLSourceURL, "http://pki.example.org/g2.crl", []string{} + next.OCSPMode, next.OCSPURL = store.OCSPModeURL, "http://ocsp.example.org/" + next.RegisteredBy = "admin@example.org" + crl := &store.OperatorCRL{IssuerSHA256: "a2", DER: []byte("crl-a2"), Number: big.NewInt(5), ThisUpdate: at, NextUpdate: at.Add(time.Hour), FetchedAt: at, Source: store.CRLSourceURL} + if err := st.RotateOperatorCA(ctx, next, crl, acceptAll); err != nil { + t.Fatalf("RotateOperatorCA: %v", err) + } + states := caStates(t, st) + if states["a1"] != store.OperatorCARetiring || states["a2"] != store.OperatorCAActive || states["x1"] != store.OperatorCARetired { + t.Fatalf("states after a rotation = %v", states) + } + got := caRow(t, st, "a2") + if got.CRLSource != store.CRLSourceURL || got.CRLURL != next.CRLURL || got.OCSPMode != store.OCSPModeURL || + got.OCSPURL != next.OCSPURL || got.RegisteredBy != "admin@example.org" { + t.Fatalf("the new row = %+v", got) + } + crls, _ := st.OperatorCRLs(ctx) + if len(crls) != 1 || string(crls[0].DER) != "crl-a2" { + t.Fatalf("OperatorCRLs() = %+v, want the new CA's CRL", crls) + } + after := trustVersion(t, st) + if after.CAs == before.CAs || after.Epoch <= before.Epoch { + t.Fatalf("trust version %+v -> %+v, want both parts moved", before, after) + } + }) + + t.Run("RotateRefusesWhileACAIsRetiring", func(t *testing.T) { + st := newStore(t) + mustAddCA(t, st, "a1", store.OperatorCAActive) + mustAddCA(t, st, "r1", store.OperatorCARetiring) + before := trustVersion(t, st) + err := st.RotateOperatorCA(ctx, ca("a2", store.OperatorCAActive), nil, nil) + if !errors.Is(err, store.ErrRotationInProgress) { + t.Fatalf("RotateOperatorCA error = %v, want ErrRotationInProgress", err) + } + if states := caStates(t, st); len(states) != 2 || states["a1"] != store.OperatorCAActive || states["r1"] != store.OperatorCARetiring { + t.Fatalf("states after a refused rotation = %v", states) + } + if after := trustVersion(t, st); after != before { + t.Fatalf("trust version moved on a refused rotation: %+v -> %+v", before, after) + } + }) + + t.Run("RotateRefusesACAThatIsAlreadyTrusted", func(t *testing.T) { + st := newStore(t) + mustAddCA(t, st, "a1", store.OperatorCAActive) + if err := st.RotateOperatorCA(ctx, ca("a1", store.OperatorCAActive), nil, nil); !errors.Is(err, store.ErrOperatorCATrusted) { + t.Fatalf("RotateOperatorCA(the active CA) error = %v, want ErrOperatorCATrusted", err) + } + if states := caStates(t, st); states["a1"] != store.OperatorCAActive { + t.Fatalf("states = %v", states) + } + }) + + t.Run("RotateBringsBackARetiredCA", func(t *testing.T) { + st := newStore(t) + mustAddCA(t, st, "x1", store.OperatorCARetired) + if err := st.SetOperatorCAState(ctx, "x1", store.OperatorCARetired, "retired", at); err != nil { + t.Fatal(err) + } + mustAddCA(t, st, "a1", store.OperatorCAActive) + if err := st.RotateOperatorCA(ctx, ca("x1", store.OperatorCAActive), nil, nil); err != nil { + t.Fatalf("RotateOperatorCA(a retired CA): %v", err) + } + got := caRow(t, st, "x1") + if got.State != store.OperatorCAActive || !got.RetiredAt.IsZero() || got.RetiredReason != "" { + t.Fatalf("the CA brought back = %+v", got) + } + if states := caStates(t, st); states["a1"] != store.OperatorCARetiring { + t.Fatalf("states = %v", states) + } + }) + + t.Run("RotateWithARefusedCRLWritesNothing", func(t *testing.T) { + st := newStore(t) + mustAddCA(t, st, "a1", store.OperatorCAActive) + refuse := errors.New("rollback") + crl := &store.OperatorCRL{IssuerSHA256: "a2", DER: []byte("crl"), ThisUpdate: at, NextUpdate: at.Add(time.Hour), FetchedAt: at} + err := st.RotateOperatorCA(ctx, ca("a2", store.OperatorCAActive), crl, func(store.OperatorCRL, bool) (bool, error) { return false, refuse }) + if !errors.Is(err, refuse) { + t.Fatalf("RotateOperatorCA error = %v, want the decide error", err) + } + if states := caStates(t, st); len(states) != 1 || states["a1"] != store.OperatorCAActive { + t.Fatalf("states after a refused CRL = %v", states) + } + }) + + t.Run("SetOperatorCACRLSourceChangesATrustedCA", func(t *testing.T) { + st := newStore(t) + mustAddCA(t, st, "a1", store.OperatorCAActive) + mustAddCA(t, st, "x1", store.OperatorCARetired) + before := trustVersion(t, st) + + crl := &store.OperatorCRL{IssuerSHA256: "a1", DER: []byte("crl-a1"), ThisUpdate: at, NextUpdate: at.Add(time.Hour), FetchedAt: at, Source: store.CRLSourceURL} + if err := st.SetOperatorCACRLSource(ctx, "a1", store.CRLSourceURL, "http://pki.example.org/a1.crl", []string{}, crl, acceptAll); err != nil { + t.Fatalf("SetOperatorCACRLSource: %v", err) + } + got := caRow(t, st, "a1") + if got.CRLSource != store.CRLSourceURL || got.CRLURL != "http://pki.example.org/a1.crl" || len(got.Acknowledgements) != 0 { + t.Fatalf("row after the change = %+v", got) + } + crls, _ := st.OperatorCRLs(ctx) + if len(crls) != 1 || string(crls[0].DER) != "crl-a1" { + t.Fatalf("OperatorCRLs() = %+v", crls) + } + after := trustVersion(t, st) + if after.CAs == before.CAs || after.Epoch <= before.Epoch { + t.Fatalf("trust version %+v -> %+v, want both parts moved", before, after) + } + + if err := st.SetOperatorCACRLSource(ctx, "a1", store.CRLSourceNone, "", []string{"NO_CRL"}, nil, nil); err != nil { + t.Fatalf("SetOperatorCACRLSource(none): %v", err) + } + if got := caRow(t, st, "a1"); got.CRLSource != store.CRLSourceNone || got.CRLURL != "" || len(got.Acknowledgements) != 1 { + t.Fatalf("row after switching to none = %+v", got) + } + if err := st.SetOperatorCACRLSource(ctx, "x1", store.CRLSourceNone, "", []string{"NO_CRL"}, nil, nil); !errors.Is(err, store.ErrOperatorCANotFound) { + t.Fatalf("SetOperatorCACRLSource(retired) error = %v, want ErrOperatorCANotFound", err) + } + }) + + t.Run("SetOperatorCAOCSPChangesATrustedCA", func(t *testing.T) { + st := newStore(t) + mustAddCA(t, st, "a1", store.OperatorCAActive) + mustAddCA(t, st, "x1", store.OperatorCARetired) + before := trustVersion(t, st) + if err := st.SetOperatorCAOCSP(ctx, "a1", store.OCSPModeURL, "http://ocsp.example.org/"); err != nil { + t.Fatalf("SetOperatorCAOCSP: %v", err) + } + if got := caRow(t, st, "a1"); got.OCSPMode != store.OCSPModeURL || got.OCSPURL != "http://ocsp.example.org/" { + t.Fatalf("row = %+v", got) + } + if after := trustVersion(t, st); after.CAs == before.CAs { + t.Fatal("trust version unchanged after an OCSP change") + } + if err := st.SetOperatorCAOCSP(ctx, "a1", store.OCSPModeOff, ""); err != nil { + t.Fatalf("SetOperatorCAOCSP(off): %v", err) + } + if got := caRow(t, st, "a1"); got.OCSPMode != store.OCSPModeOff || got.OCSPURL != "" { + t.Fatalf("row after off = %+v", got) + } + if err := st.SetOperatorCAOCSP(ctx, "a1", store.OCSPModeURL, ""); err == nil { + t.Fatal("url mode with no URL was stored") + } + if err := st.SetOperatorCAOCSP(ctx, "x1", store.OCSPModeOff, ""); !errors.Is(err, store.ErrOperatorCANotFound) { + t.Fatalf("SetOperatorCAOCSP(retired) error = %v, want ErrOperatorCANotFound", err) + } + }) + t.Run("TryAdvisoryLockIsExclusive", func(t *testing.T) { st := newStore(t) release, ok, err := st.TryAdvisoryLock(ctx, "fleetos.crl.test") @@ -287,6 +443,34 @@ func mustAddCA(t *testing.T, st store.OperatorTrust, sha, state string) { } } +func caStates(t *testing.T, st store.OperatorTrust) map[string]string { + t.Helper() + cas, err := st.OperatorCAs(context.Background()) + if err != nil { + t.Fatalf("OperatorCAs: %v", err) + } + out := map[string]string{} + for _, c := range cas { + out[c.SHA256] = c.State + } + return out +} + +func caRow(t *testing.T, st store.OperatorTrust, sha string) store.OperatorCA { + t.Helper() + cas, err := st.OperatorCAs(context.Background()) + if err != nil { + t.Fatalf("OperatorCAs: %v", err) + } + for _, c := range cas { + if c.SHA256 == sha { + return c + } + } + t.Fatalf("no operator CA %s", sha) + return store.OperatorCA{} +} + func trustVersion(t *testing.T, st store.OperatorTrust) store.TrustVersion { t.Helper() v, err := st.OperatorTrustVersion(context.Background()) From 73e464efd4144f14f3687282c27c8c35ef88fe89 Mon Sep 17 00:00:00 2001 From: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:34:41 -0400 Subject: [PATCH 2/4] feat(fleet): serve the operator CA admin RPCs: list, rotate, retire, CRL source, CRL upload and OCSP mode Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> --- cmd/manager/main.go | 3 +- internal/fleet/contract_test.go | 42 +- internal/fleet/operatorca_rpcs.go | 759 ++++++++++++++++++- internal/fleet/operatorca_rpcs_test.go | 757 ++++++++++++++++++ internal/fleet/service.go | 3 + internal/operatorca/registration.go | 121 +++ internal/operatorca/rotation_test.go | 2 +- internal/store/storetest/memorytrust.go | 2 +- internal/store/storetest/memorytrust_test.go | 4 +- 9 files changed, 1635 insertions(+), 58 deletions(-) create mode 100644 internal/fleet/operatorca_rpcs_test.go create mode 100644 internal/operatorca/registration.go diff --git a/cmd/manager/main.go b/cmd/manager/main.go index 732594c..948e3f7 100644 --- a/cmd/manager/main.go +++ b/cmd/manager/main.go @@ -263,7 +263,8 @@ func main() { trust.refreshCRLs(ctx, log.Printf) trust.run(ctx, log.Printf) svc = svc.WithOperatorTrust(trust.trust, trust.rev). - WithNodeCAWatch(operatorca.NewNodeCAWatch(trust.trust, log.Printf)) + WithNodeCAWatch(operatorca.NewNodeCAWatch(trust.trust, log.Printf)). + WithOperatorCAAdmin(fleet.OperatorCAAdmin{FetchCRL: trust.refresher.Fetch.Fetch}) tlsCfg = serverTLSConfig(base, trust.trust) boot, mount, err := setupBootstrap(ctx, cfg, st, bootStore, trust, base, log.Printf) if err != nil { diff --git a/internal/fleet/contract_test.go b/internal/fleet/contract_test.go index c207f8e..2dc6e8c 100644 --- a/internal/fleet/contract_test.go +++ b/internal/fleet/contract_test.go @@ -17,12 +17,15 @@ limitations under the License. */ import ( + "context" "reflect" + "strings" "testing" connect "connectrpc.com/connect" fleetv1 "github.com/CryptOS-PKI/cryptos-manager/gen/go/cryptos/fleet/v1" fleetv1connect "github.com/CryptOS-PKI/cryptos-manager/gen/go/cryptos/fleet/v1/fleetv1connect" + "github.com/CryptOS-PKI/cryptos-manager/internal/apperr" "github.com/CryptOS-PKI/cryptos-manager/internal/authz" ) @@ -38,40 +41,19 @@ func TestFleetService_HasNoIssueOperatorCredential(t *testing.T) { } } -// Methods the contract has but this build doesn't serve answer Unimplemented, -// never a success or a generic failure. -func TestFleetService_UnservedOperatorCAMethodsAreUnimplemented(t *testing.T) { +// The operator CA methods are served: with no operator CA configured they +// answer the operator-CA-unconfigured code, never Unimplemented. +func TestFleetService_OperatorCAMethodsNeedAnOperatorCA(t *testing.T) { svc := New(operatorsStore(), dialFor(nil)) ctx := operatorCtx("admin@example.org", authz.LevelAdmin) - calls := map[string]func() error{ - "ListOperatorCAs": func() error { - _, err := svc.ListOperatorCAs(ctx, connect.NewRequest(&fleetv1.ListOperatorCAsRequest{})) - return err - }, - "RegisterOperatorCA": func() error { - _, err := svc.RegisterOperatorCA(ctx, connect.NewRequest(&fleetv1.RegisterOperatorCARequest{})) - return err - }, - "RetireOperatorCA": func() error { - _, err := svc.RetireOperatorCA(ctx, connect.NewRequest(&fleetv1.RetireOperatorCARequest{})) - return err - }, - "SetOperatorCACRLSource": func() error { - _, err := svc.SetOperatorCACRLSource(ctx, connect.NewRequest(&fleetv1.SetOperatorCACRLSourceRequest{})) - return err - }, - "UploadOperatorCRL": func() error { - _, err := svc.UploadOperatorCRL(ctx, connect.NewRequest(&fleetv1.UploadOperatorCRLRequest{})) - return err - }, - "SetOperatorCAOCSP": func() error { - _, err := svc.SetOperatorCAOCSP(ctx, connect.NewRequest(&fleetv1.SetOperatorCAOCSPRequest{})) - return err - }, + calls := writeCalls(svc, strings.Repeat("ab", 32), nil) + calls["ListOperatorCAs"] = func(ctx context.Context) error { + _, err := svc.ListOperatorCAs(ctx, connect.NewRequest(&fleetv1.ListOperatorCAsRequest{})) + return err } for name, call := range calls { - if got := connect.CodeOf(call()); got != connect.CodeUnimplemented { - t.Errorf("%s: code = %v, want Unimplemented", name, got) + if code, _ := apperr.Code(call(ctx)); code != apperr.CodeOperatorCAUnconfigured { + t.Errorf("%s: code = %d, want %d", name, code, apperr.CodeOperatorCAUnconfigured) } } } diff --git a/internal/fleet/operatorca_rpcs.go b/internal/fleet/operatorca_rpcs.go index e31a0d3..42762df 100644 --- a/internal/fleet/operatorca_rpcs.go +++ b/internal/fleet/operatorca_rpcs.go @@ -18,46 +18,759 @@ limitations under the License. import ( "context" + "crypto/x509" + "errors" "fmt" + "log" + "os" + "slices" + "strings" + "time" connect "connectrpc.com/connect" fleetv1 "github.com/CryptOS-PKI/cryptos-manager/gen/go/cryptos/fleet/v1" + "github.com/CryptOS-PKI/cryptos-manager/internal/apperr" + "github.com/CryptOS-PKI/cryptos-manager/internal/auditlog" + "github.com/CryptOS-PKI/cryptos-manager/internal/authz" + "github.com/CryptOS-PKI/cryptos-manager/internal/operatorca" + "github.com/CryptOS-PKI/cryptos-manager/internal/store" ) -// The operator CA management RPCs are part of the contract but not served by -// this build yet. Each answers Unimplemented so a client can tell "not here -// yet" from a refusal. +// After first run, operator CA changes go through these admin RPCs: register +// a new CA (the old active one becomes retiring, both trusted), retire one, +// and change a CA's CRL source, CRL or OCSP mode. Every change is audited, +// rebuilds the trust store on this replica and moves the trust version, so +// the other replicas follow within one poll. A config-file operator CA can't +// be changed here (1607). -// ListOperatorCAs is not served yet. -func (s *Service) ListOperatorCAs(context.Context, *connect.Request[fleetv1.ListOperatorCAsRequest]) (*connect.Response[fleetv1.ListOperatorCAsResponse], error) { - return nil, notServed("ListOperatorCAs") +// Retired reasons. +const ( + retiredByAdmin = "retired" +) + +// OperatorCAAdmin is what the operator CA admin RPCs need besides the trust +// store: a CRL fetch for CRL URLs, and the OCSP responder probe for url +// mode. A nil Probe uses the revocation engine's OCSP client. +type OperatorCAAdmin struct { + FetchCRL func(ctx context.Context, url string) ([]byte, error) + Probe func(ctx context.Context, anchor *x509.Certificate, url string) (operatorca.OCSPResult, error) +} + +// WithOperatorCAAdmin supplies the operator CA admin seams. Returns s for +// chaining. +func (s *Service) WithOperatorCAAdmin(a OperatorCAAdmin) *Service { + s.caAdmin = a + + return s +} + +func (s *Service) probe() func(context.Context, *x509.Certificate, string) (operatorca.OCSPResult, error) { + if s.caAdmin.Probe != nil { + return s.caAdmin.Probe + } + if s.revocations != nil && s.revocations.OCSP() != nil { + return s.revocations.OCSP().ProbeResult + } + return nil +} + +func (s *Service) nodeCAs() []*x509.Certificate { + return operatorca.NodeCAs(s.store.Nodes(), os.ReadFile, log.Printf) +} + +// caErr gives an operator CA refusal its transport code: a 1605 rejection +// is InvalidArgument, any other coded refusal FailedPrecondition. +func caErr(err error) error { + var ce *connect.Error + if errors.As(err, &ce) { + return err + } + code, ok := apperr.Code(err) + switch { + case ok && code == apperr.CodeOperatorCARejected: + return connect.NewError(connect.CodeInvalidArgument, err) + case ok: + return connect.NewError(connect.CodeFailedPrecondition, err) + default: + return connect.NewError(connect.CodeInternal, err) + } +} + +func caReject(code connect.Code, reason fleetv1.ErrorReason, format string, args ...any) error { + return connect.NewError(code, apperr.Reasoned(apperr.CodeOperatorCARejected, reason, fmt.Errorf("fleet: "+format, args...))) +} + +func failedPrecondition(format string, args ...any) error { + return connect.NewError(connect.CodeFailedPrecondition, fmt.Errorf("fleet: "+format, args...)) +} + +// operatorCAWrite is the gate every operator CA write passes: an admin, an +// operator CA source, and not the config file. +func (s *Service) operatorCAWrite(ctx context.Context, op string) (authz.Identity, store.OperatorTrust, error) { + id, err := operatorLevel(ctx) + if err != nil { + return authz.Identity{}, nil, err + } + if err := requireAdmin(ctx); err != nil { + return authz.Identity{}, nil, err + } + if !s.hasOperatorCA() { + return authz.Identity{}, nil, errOperatorCAUnconfigured(op) + } + if s.trust.Source().Kind == operatorca.KindFile { + log.Printf("fleet: %s refused: the operator CA comes from operatorCAPath", op) + return authz.Identity{}, nil, connect.NewError(connect.CodeFailedPrecondition, apperr.Coded(apperr.CodeOperatorCAManagedByConfig, + fmt.Errorf("fleet: the operator CA comes from operatorCAPath in the config file (%s); change the config instead", s.trust.Source().Path))) + } + ot, ok := s.store.(store.OperatorTrust) + if !ok { + return authz.Identity{}, nil, errOperatorCAUnconfigured(op) + } + return id, ot, nil } -// RegisterOperatorCA is not served yet. -func (s *Service) RegisterOperatorCA(context.Context, *connect.Request[fleetv1.RegisterOperatorCARequest]) (*connect.Response[fleetv1.RegisterOperatorCAResponse], error) { - return nil, notServed("RegisterOperatorCA") +// trustedRow returns the active or retiring row with the fingerprint and +// its certificate. +func trustedRow(ctx context.Context, ot store.OperatorTrust, sha string) (store.OperatorCA, *x509.Certificate, error) { + row, err := findRow(ctx, ot, sha) + if err != nil { + return store.OperatorCA{}, nil, err + } + if row.State == store.OperatorCARetired { + return store.OperatorCA{}, nil, failedPrecondition("operator CA %s is retired; register it again to trust it", row.SHA256) + } + cert, err := x509.ParseCertificate(row.CertDER) + if err != nil { + return store.OperatorCA{}, nil, connect.NewError(connect.CodeInternal, fmt.Errorf("fleet: the stored operator CA %s doesn't parse: %w", row.SHA256, err)) + } + return row, cert, nil } -// RetireOperatorCA is not served yet. -func (s *Service) RetireOperatorCA(context.Context, *connect.Request[fleetv1.RetireOperatorCARequest]) (*connect.Response[fleetv1.RetireOperatorCAResponse], error) { - return nil, notServed("RetireOperatorCA") +func findRow(ctx context.Context, ot store.OperatorTrust, sha string) (store.OperatorCA, error) { + sha = normalizeFingerprint(sha) + rows, err := ot.OperatorCAs(ctx) + if err != nil { + return store.OperatorCA{}, connect.NewError(connect.CodeInternal, err) + } + for _, r := range rows { + if r.SHA256 == sha { + return r, nil + } + } + return store.OperatorCA{}, connect.NewError(connect.CodeNotFound, fmt.Errorf("fleet: no operator CA has the SHA-256 %s", sha)) } -// SetOperatorCACRLSource is not served yet. -func (s *Service) SetOperatorCACRLSource(context.Context, *connect.Request[fleetv1.SetOperatorCACRLSourceRequest]) (*connect.Response[fleetv1.SetOperatorCACRLSourceResponse], error) { - return nil, notServed("SetOperatorCACRLSource") +// applyTrustChange makes a stored change take effect on this replica at +// once; the others follow on their next poll. +func (s *Service) applyTrustChange(ctx context.Context) error { + if err := s.trust.Rebuild(ctx); err != nil { + return connect.NewError(connect.CodeInternal, fmt.Errorf("fleet: rebuild operator CA trust: %w", err)) + } + if err := s.revocations.Reload(ctx); err != nil { + log.Printf("fleet: can't reload operator revocation data after an operator CA change: %v", err) + } + return nil } -// UploadOperatorCRL is not served yet. -func (s *Service) UploadOperatorCRL(context.Context, *connect.Request[fleetv1.UploadOperatorCRLRequest]) (*connect.Response[fleetv1.UploadOperatorCRLResponse], error) { - return nil, notServed("UploadOperatorCRL") +func (s *Service) audit(ctx context.Context, kind, sha, summary string) { + auditlog.Record(ctx, s.store, store.AuditEvent{ + ID: newAuditID(), + At: time.Now().UTC().Format(time.RFC3339), + Kind: kind, + Summary: summary, + TargetKind: "operator-ca", + TargetPath: "/operator-cas/" + sha, + }) } -// SetOperatorCAOCSP is not served yet. -func (s *Service) SetOperatorCAOCSP(context.Context, *connect.Request[fleetv1.SetOperatorCAOCSPRequest]) (*connect.Response[fleetv1.SetOperatorCAOCSPResponse], error) { - return nil, notServed("SetOperatorCAOCSP") +func acksOf(in []fleetv1.OperatorCAAcknowledgement) []string { + out := []string{} + for _, a := range in { + if a != fleetv1.OperatorCAAcknowledgement_OPERATOR_CA_ACKNOWLEDGEMENT_UNSPECIFIED { + out = append(out, strings.TrimPrefix(a.String(), "OPERATOR_CA_ACKNOWLEDGEMENT_")) + } + } + return out } -func notServed(method string) error { - return connect.NewError(connect.CodeUnimplemented, fmt.Errorf("fleet: %s is not available in this build", method)) +func ocspModeOf(m fleetv1.OcspMode) string { + switch m { + case fleetv1.OcspMode_OCSP_MODE_OFF: + return store.OCSPModeOff + case fleetv1.OcspMode_OCSP_MODE_URL: + return store.OCSPModeURL + default: + return store.OCSPModeAIA + } +} + +// uploadUnderHard refuses an upload CRL source with a hard revocation +// policy: an expired CRL would lock out the admins who upload the next one, +// and the manager would refuse to start with it. +func (s *Service) uploadUnderHard(source string) error { + if source == store.CRLSourceUpload && s.revocations.Policy() == operatorca.PolicyHard { + return failedPrecondition("operatorRevocationPolicy is hard, so a CRL can't come by upload: an expired CRL would lock out the admins who upload the next one; use a CRL URL") + } + return nil +} + +// ListOperatorCAs returns every operator CA: active first, then retiring, +// then retired, newest retirement first. A config-file CA is listed +// read-only. Operator-readable. +func (s *Service) ListOperatorCAs(ctx context.Context, _ *connect.Request[fleetv1.ListOperatorCAsRequest]) (*connect.Response[fleetv1.ListOperatorCAsResponse], error) { + id, err := operatorLevel(ctx) + if err != nil { + return nil, err + } + if id.Level < authz.LevelOperator { + return nil, connect.NewError(connect.CodePermissionDenied, errors.New("fleet: operator level required")) + } + if !s.hasOperatorCA() { + return nil, errOperatorCAUnconfigured("ListOperatorCAs") + } + if s.trust.Source().Kind == operatorca.KindFile { + return connect.NewResponse(&fleetv1.ListOperatorCAsResponse{Items: s.fileCAs()}), nil + } + ot, ok := s.store.(store.OperatorTrust) + if !ok { + return nil, errOperatorCAUnconfigured("ListOperatorCAs") + } + rows, err := ot.OperatorCAs(ctx) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + stored := s.storedCRLs(ctx, ot) + rank := map[string]int{store.OperatorCAActive: 0, store.OperatorCARetiring: 1, store.OperatorCARetired: 2} + slices.SortStableFunc(rows, func(a, b store.OperatorCA) int { + if d := rank[a.State] - rank[b.State]; d != 0 { + return d + } + return b.RetiredAt.Compare(a.RetiredAt) + }) + items := make([]*fleetv1.OperatorCA, 0, len(rows)) + for _, r := range rows { + oc, err := s.rowToProto(r, stored) + if err != nil { + log.Printf("fleet: ListOperatorCAs: %v", err) + continue + } + items = append(items, oc) + } + return connect.NewResponse(&fleetv1.ListOperatorCAsResponse{Items: items}), nil +} + +func (s *Service) storedCRLs(ctx context.Context, ot store.OperatorTrust) map[string]store.OperatorCRL { + crls, err := ot.OperatorCRLs(ctx) + if err != nil { + log.Printf("fleet: can't read stored operator CRLs: %v", err) + } + out := make(map[string]store.OperatorCRL, len(crls)) + for _, c := range crls { + out[c.IssuerSHA256] = c + } + return out +} + +func (s *Service) fileCAs() []*fleetv1.OperatorCA { + var locations []string + for _, t := range s.trust.Source().CRLTargets { + locations = append(locations, t.Location) + } + anchors := s.trust.Anchors() + slices.SortFunc(anchors, func(a, b operatorca.Anchor) int { return strings.Compare(a.SHA256, b.SHA256) }) + items := make([]*fleetv1.OperatorCA, 0, len(anchors)) + for _, a := range anchors { + oc := describeCA(a.Cert, nil, nil, a.CRLSource, strings.Join(locations, ", "), a.OCSPMode, a.OCSPURL) + oc.State = fleetv1.OperatorCAState_OPERATOR_CA_STATE_ACTIVE + oc.ManagedByConfig = true + s.addRuntimeState(oc, a.Cert, nil) + items = append(items, oc) + } + return items +} + +func (s *Service) rowToProto(r store.OperatorCA, stored map[string]store.OperatorCRL) (*fleetv1.OperatorCA, error) { + cert, err := x509.ParseCertificate(r.CertDER) + if err != nil { + return nil, fmt.Errorf("the stored operator CA %s doesn't parse: %w", r.SHA256, err) + } + oc := describeCA(cert, r.Warnings, r.Acknowledgements, r.CRLSource, r.CRLURL, r.OCSPMode, r.OCSPURL) + oc.State = stateEnum(r.State) + oc.RegisteredAt = rfc3339(r.RegisteredAt) + oc.RetiredAt = rfc3339(r.RetiredAt) + oc.RetiredReason = r.RetiredReason + var rec *store.OperatorCRL + if c, ok := stored[r.SHA256]; ok { + rec = &c + } + s.addRuntimeState(oc, cert, rec) + return oc, nil +} + +// addRuntimeState adds what this replica knows about the CA: its CRL, the +// last CRL and OCSP errors. +func (s *Service) addRuntimeState(oc *fleetv1.OperatorCA, cert *x509.Certificate, rec *store.OperatorCRL) { + now := time.Now() + sha := oc.GetSha256() + v, ok := s.revocations.CRL(sha) + if !ok && rec != nil && rec.DER != nil { + if verified, err := operatorca.VerifyCRL(rec.DER, cert, now); err == nil { + v, ok = verified, true + } + } + lastError := s.revocations.Status(sha).LastError + var fetched time.Time + if rec != nil { + fetched = rec.FetchedAt + if lastError == "" { + lastError = rec.LastError + } + } + if ok { + oc.Crl = crlStatus(v, fetched, now, lastError) + } else if lastError != "" { + oc.Crl = &fleetv1.CrlStatus{LastError: lastError} + } + if c := s.revocations.OCSP(); c != nil { + oc.OcspLastError = c.LastError(sha) + } +} + +func describeCA(cert *x509.Certificate, warnings, acks []string, crlSource, crlLocation, 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: crlLocation, + OcspMode: ocspModeEnum(ocspMode), + OcspUrl: ocspURL, + Warnings: slices.Clone(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)), + FetchedAt: rfc3339(fetched), + LastError: lastError, + Stale: !v.Fresh(now), + } + if v.Number != nil { + st.CrlNumber = v.Number.String() + } + return st +} + +func probeToProto(anchor *x509.Certificate, r *operatorca.OCSPResult) *fleetv1.OcspProbeResult { + if r == nil { + return nil + } + out := &fleetv1.OcspProbeResult{CertStatus: r.Status.String(), Signer: fleetv1.OcspSigner_OCSP_SIGNER_ANCHOR} + signer := r.Signer + if signer == nil { + signer = anchor + } + if !signer.Equal(anchor) { + out.Signer = fleetv1.OcspSigner_OCSP_SIGNER_DELEGATED + } + out.SignerSubject = signer.Subject.String() + out.SignerNotAfter = signer.NotAfter.UTC().Format(time.RFC3339) + return out +} + +func stateEnum(s string) fleetv1.OperatorCAState { + switch s { + case store.OperatorCAActive: + return fleetv1.OperatorCAState_OPERATOR_CA_STATE_ACTIVE + case store.OperatorCARetiring: + return fleetv1.OperatorCAState_OPERATOR_CA_STATE_RETIRING + case store.OperatorCARetired: + return fleetv1.OperatorCAState_OPERATOR_CA_STATE_RETIRED + } + return fleetv1.OperatorCAState_OPERATOR_CA_STATE_UNSPECIFIED +} + +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 +} + +// RegisterOperatorCA previews, then with confirm_sha256 registers, a new +// operator CA after first run, with the first-run checks. The new CA becomes +// active and the previous active CA retiring: still trusted and still +// revocation-checked until it is retired. Refused with 1605 +// ROTATION_IN_PROGRESS while a CA is retiring. Admin-gated and audited. +func (s *Service) RegisterOperatorCA(ctx context.Context, req *connect.Request[fleetv1.RegisterOperatorCARequest]) (*connect.Response[fleetv1.RegisterOperatorCAResponse], error) { + id, ot, err := s.operatorCAWrite(ctx, "RegisterOperatorCA") + if err != nil { + return nil, err + } + m := req.Msg + cert, err := operatorca.ParseAnchorUpload(m.GetCaCertDer()) + if err != nil { + return nil, caErr(err) + } + rows, err := ot.OperatorCAs(ctx) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + for _, r := range rows { + if r.State == store.OperatorCARetiring { + return nil, caReject(connect.CodeFailedPrecondition, fleetv1.ErrorReason_ERROR_REASON_ROTATION_IN_PROGRESS, + "operator CA %s is still retiring; retire it before registering another CA", r.SHA256) + } + } + + choice := operatorca.CRLChoice{Source: store.CRLSourceNone, Acknowledgements: acksOf(m.GetAcknowledgements())} + switch src := m.GetCrlSource().(type) { + case *fleetv1.RegisterOperatorCARequest_Url: + choice.Source, choice.URL = store.CRLSourceURL, src.Url + case *fleetv1.RegisterOperatorCARequest_CrlDer: + choice.Source, choice.DER = store.CRLSourceUpload, src.CrlDer + } + if err := s.uploadUnderHard(choice.Source); err != nil { + return nil, err + } + now := time.Now() + warnings, err := operatorca.ValidateAnchor(cert, operatorca.AnchorOptions{ + Now: now, NodeCAs: s.nodeCAs(), CRLSource: choice.Source != store.CRLSourceNone, + }) + if err != nil { + return nil, caErr(err) + } + crl, err := operatorca.CheckCRLChoice(ctx, cert, choice, now, s.caAdmin.FetchCRL) + if err != nil { + return nil, caErr(err) + } + mode := ocspModeOf(m.GetOcspMode()) + probe, err := operatorca.CheckOCSPChoice(ctx, cert, mode, m.GetOcspUrl(), s.probe()) + if err != nil { + if errors.Is(err, operatorca.ErrOCSPURL) { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + return nil, caErr(err) + } + extfile, err := operatorca.ExtfileSection("admin") + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + oc := describeCA(cert, warnings, choice.Acknowledgements, choice.Source, choice.URL, mode, m.GetOcspUrl()) + if crl != nil { + oc.Crl = crlStatus(crl, time.Time{}, now, "") + } + resp := &fleetv1.RegisterOperatorCAResponse{OperatorCa: oc, AdminExtfile: extfile, OcspProbe: probeToProto(cert, probe)} + if m.GetConfirmSha256() == "" { + return connect.NewResponse(resp), nil + } + fp := oc.GetSha256() + if normalizeFingerprint(m.GetConfirmSha256()) != fp { + return nil, caReject(connect.CodeInvalidArgument, fleetv1.ErrorReason_ERROR_REASON_NOT_CONFIRMED, + "confirm_sha256 doesn't match the uploaded operator CA") + } + + at := now.UTC() + row := store.OperatorCA{ + SHA256: fp, CertDER: cert.Raw, State: store.OperatorCAActive, + CRLSource: choice.Source, CRLURL: choice.URL, OCSPMode: mode, OCSPURL: m.GetOcspUrl(), + Acknowledgements: choice.Acknowledgements, Warnings: warnings, RegisteredAt: at, RegisteredBy: id.CN, + } + crlRow, decide := crlWrite(fp, choice.Source, crl, at) + if err := ot.RotateOperatorCA(ctx, row, crlRow, decide); err != nil { + switch { + case errors.Is(err, store.ErrRotationInProgress): + return nil, caReject(connect.CodeFailedPrecondition, fleetv1.ErrorReason_ERROR_REASON_ROTATION_IN_PROGRESS, + "an operator CA is still retiring; retire it before registering another CA") + case errors.Is(err, store.ErrOperatorCATrusted): + return nil, failedPrecondition("operator CA %s is already trusted", fp) + default: + return nil, caErr(err) + } + } + if err := s.applyTrustChange(ctx); err != nil { + return nil, err + } + + previous := "none" + for _, r := range rows { + if r.State == store.OperatorCAActive { + previous = r.SHA256 + } + } + log.Printf("fleet: operator CA registered %s, SHA-256 %s, by %s; the previous active CA %s is now retiring", + cert.Subject, operatorca.ColonFingerprint(cert.Raw), id.CN, previous) + for _, w := range warnings { + log.Printf("fleet: WARNING operator CA %s: %s", fp, w) + } + s.audit(ctx, "operator-ca-registered", fp, fmt.Sprintf( + "Registered operator CA %s (SHA-256 %s) as active; previous active CA %s is now retiring. CRL source %s%s, OCSP mode %s%s, acknowledgements [%s], warnings [%s]", + cert.Subject, fp, previous, choice.Source, prefixed(choice.URL), mode, prefixed(m.GetOcspUrl()), + strings.Join(choice.Acknowledgements, ", "), strings.Join(warnings, "; "))) + + resp.Confirmed = true + oc.State = fleetv1.OperatorCAState_OPERATOR_CA_STATE_ACTIVE + oc.RegisteredAt = at.Format(time.RFC3339) + if crl != nil { + oc.Crl = crlStatus(crl, at, now, "") + } + return connect.NewResponse(resp), nil +} + +// crlWrite is the CRL row to store with a change, and the anti-rollback +// decision for it; both nil when there is no CRL. +func crlWrite(sha, source string, v *operatorca.VerifiedCRL, at time.Time) (*store.OperatorCRL, store.DecideCRL) { + if v == nil { + return nil, nil + } + row := &store.OperatorCRL{ + IssuerSHA256: sha, DER: v.DER, Number: v.Number, ThisUpdate: v.List.ThisUpdate, NextUpdate: v.List.NextUpdate, + FetchedAt: at, Source: source, + } + return row, func(cur store.OperatorCRL, has bool) (bool, error) { return operatorca.AcceptNewer(cur, has, v) } +} + +func prefixed(s string) string { + if s == "" { + return "" + } + return " " + s +} + +// RetireOperatorCA stops trusting a retiring operator CA; its certificates +// are refused from their next request. The active CA can't be retired (1609: +// register its replacement first), and neither can the caller's own CA +// unless i_understand_self_lockout is set. Admin-gated and audited. +func (s *Service) RetireOperatorCA(ctx context.Context, req *connect.Request[fleetv1.RetireOperatorCARequest]) (*connect.Response[fleetv1.RetireOperatorCAResponse], error) { + id, ot, err := s.operatorCAWrite(ctx, "RetireOperatorCA") + if err != nil { + return nil, err + } + row, err := findRow(ctx, ot, req.Msg.GetSha256()) + if err != nil { + return nil, err + } + switch row.State { + case store.OperatorCARetired: + return nil, failedPrecondition("operator CA %s is already retired", row.SHA256) + case store.OperatorCAActive: + return nil, connect.NewError(connect.CodeFailedPrecondition, apperr.Coded(apperr.CodeOperatorCAInUse, + fmt.Errorf("fleet: operator CA %s is the active CA; retiring it would leave no active operator CA, so register its replacement first", row.SHA256))) + } + selfLockout := id.IssuerSHA256 != "" && id.IssuerSHA256 == row.SHA256 + if selfLockout && !req.Msg.GetIUnderstandSelfLockout() { + return nil, failedPrecondition("your own certificate chains to operator CA %s, so retiring it signs you out; "+ + "sign in with a certificate from the active CA, or set i_understand_self_lockout to retire it anyway", row.SHA256) + } + + if err := ot.SetOperatorCAState(ctx, row.SHA256, store.OperatorCARetired, retiredByAdmin, time.Now().UTC()); err != nil { + if errors.Is(err, store.ErrOperatorCANotFound) { + return nil, connect.NewError(connect.CodeNotFound, err) + } + return nil, connect.NewError(connect.CodeInternal, err) + } + if c := s.revocations.OCSP(); c != nil { + c.ClearAnchor(row.SHA256) + } + if err := s.applyTrustChange(ctx); err != nil { + return nil, err + } + log.Printf("fleet: operator CA %s retired by %s; its certificates are refused from their next request", row.SHA256, id.CN) + summary := fmt.Sprintf("Retired operator CA %s; certificates under it are no longer trusted", row.SHA256) + if selfLockout { + summary += " (the caller's own CA, retired with i_understand_self_lockout)" + } + s.audit(ctx, "operator-ca-retired", row.SHA256, summary) + + oc, err := s.rowProto(ctx, ot, row.SHA256) + if err != nil { + return nil, err + } + return connect.NewResponse(&fleetv1.RetireOperatorCAResponse{OperatorCa: oc}), nil +} + +// rowProto renders the stored row with the fingerprint, as it is now. +func (s *Service) rowProto(ctx context.Context, ot store.OperatorTrust, sha string) (*fleetv1.OperatorCA, error) { + row, err := findRow(ctx, ot, sha) + if err != nil { + return nil, err + } + oc, err := s.rowToProto(row, s.storedCRLs(ctx, ot)) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + return oc, nil +} + +// SetOperatorCACRLSource changes a trusted operator CA's CRL source to a +// URL, an uploaded CRL or none. A new URL or CRL must verify against the CA +// first, and none needs the NO_CRL acknowledgement (it also cuts off MCP +// under the CA). Admin-gated and audited. +func (s *Service) SetOperatorCACRLSource(ctx context.Context, req *connect.Request[fleetv1.SetOperatorCACRLSourceRequest]) (*connect.Response[fleetv1.SetOperatorCACRLSourceResponse], error) { + id, ot, err := s.operatorCAWrite(ctx, "SetOperatorCACRLSource") + if err != nil { + return nil, err + } + m := req.Msg + choice := operatorca.CRLChoice{Acknowledgements: acksOf(m.GetAcknowledgements())} + switch src := m.GetCrlSource().(type) { + case *fleetv1.SetOperatorCACRLSourceRequest_Url: + choice.Source, choice.URL = store.CRLSourceURL, src.Url + case *fleetv1.SetOperatorCACRLSourceRequest_CrlDer: + choice.Source, choice.DER = store.CRLSourceUpload, src.CrlDer + case *fleetv1.SetOperatorCACRLSourceRequest_None: + choice.Source = store.CRLSourceNone + default: + return nil, invalidArg("crl_source is required: url, crl_der or none") + } + row, cert, err := trustedRow(ctx, ot, m.GetSha256()) + if err != nil { + return nil, err + } + if err := s.uploadUnderHard(choice.Source); err != nil { + return nil, err + } + now := time.Now() + crl, err := operatorca.CheckCRLChoice(ctx, cert, choice, now, s.caAdmin.FetchCRL) + if err != nil { + return nil, caErr(err) + } + crlRow, decide := crlWrite(row.SHA256, choice.Source, crl, now.UTC()) + if err := ot.SetOperatorCACRLSource(ctx, row.SHA256, choice.Source, choice.URL, choice.Acknowledgements, crlRow, decide); err != nil { + if errors.Is(err, store.ErrOperatorCANotFound) { + return nil, connect.NewError(connect.CodeNotFound, err) + } + return nil, caErr(err) + } + if err := s.applyTrustChange(ctx); err != nil { + return nil, err + } + log.Printf("fleet: operator CA %s CRL source changed from %s to %s by %s", row.SHA256, row.CRLSource, choice.Source, id.CN) + s.audit(ctx, "operator-ca-crl-source-changed", row.SHA256, fmt.Sprintf( + "Changed the CRL source of operator CA %s from %s%s to %s%s, acknowledgements [%s]", + row.SHA256, row.CRLSource, prefixed(row.CRLURL), choice.Source, prefixed(choice.URL), strings.Join(choice.Acknowledgements, ", "))) + + oc, err := s.rowProto(ctx, ot, row.SHA256) + if err != nil { + return nil, err + } + return connect.NewResponse(&fleetv1.SetOperatorCACRLSourceResponse{OperatorCa: oc}), nil +} + +// UploadOperatorCRL stores a new CRL for an operator CA whose CRL source is +// upload. It must verify against the CA and be newer than the stored CRL; +// storing it moves the revocation epoch so every replica enforces it. +// Admin-gated and audited. +func (s *Service) UploadOperatorCRL(ctx context.Context, req *connect.Request[fleetv1.UploadOperatorCRLRequest]) (*connect.Response[fleetv1.UploadOperatorCRLResponse], error) { + id, ot, err := s.operatorCAWrite(ctx, "UploadOperatorCRL") + if err != nil { + return nil, err + } + row, _, err := trustedRow(ctx, ot, req.Msg.GetSha256()) + if err != nil { + return nil, err + } + if row.CRLSource != store.CRLSourceUpload { + return nil, failedPrecondition("operator CA %s takes its CRL from %s, not by upload; switch its CRL source to upload first", row.SHA256, row.CRLSource) + } + stored, err := s.revocations.StoreCRL(ctx, row.SHA256, req.Msg.GetCrlDer(), store.CRLSourceUpload) + if err != nil { + if errors.Is(err, operatorca.ErrUnknownAnchor) { + return nil, failedPrecondition("operator CA %s isn't trusted on this replica yet; try again", row.SHA256) + } + return nil, caErr(err) + } + if stored { + v, _ := s.revocations.CRL(row.SHA256) + log.Printf("fleet: CRL uploaded for operator CA %s by %s", row.SHA256, id.CN) + s.audit(ctx, "operator-crl-uploaded", row.SHA256, fmt.Sprintf( + "Uploaded a CRL for operator CA %s: number %v, %d revoked, next update %s", + row.SHA256, v.Number, len(v.Revoked), v.List.NextUpdate.UTC().Format(time.RFC3339))) + } else { + log.Printf("fleet: CRL upload for operator CA %s by %s is the CRL already stored; nothing changed", row.SHA256, id.CN) + } + + oc, err := s.rowProto(ctx, ot, row.SHA256) + if err != nil { + return nil, err + } + return connect.NewResponse(&fleetv1.UploadOperatorCRLResponse{OperatorCa: oc}), nil +} + +// SetOperatorCAOCSP changes a trusted operator CA's OCSP mode. Mode url +// must pass a probe of the responder first. The CA's cached OCSP answers are +// dropped here and on each replica as it takes the change. Admin-gated and +// audited. +func (s *Service) SetOperatorCAOCSP(ctx context.Context, req *connect.Request[fleetv1.SetOperatorCAOCSPRequest]) (*connect.Response[fleetv1.SetOperatorCAOCSPResponse], error) { + id, ot, err := s.operatorCAWrite(ctx, "SetOperatorCAOCSP") + if err != nil { + return nil, err + } + m := req.Msg + mode := ocspModeOf(m.GetOcspMode()) + if mode == store.OCSPModeURL && m.GetOcspUrl() == "" { + return nil, invalidArg("ocsp_url is required for OCSP mode url") + } + row, cert, err := trustedRow(ctx, ot, m.GetSha256()) + if err != nil { + return nil, err + } + probe, err := operatorca.CheckOCSPChoice(ctx, cert, mode, m.GetOcspUrl(), s.probe()) + if err != nil { + if errors.Is(err, operatorca.ErrOCSPURL) { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + return nil, caErr(err) + } + if err := ot.SetOperatorCAOCSP(ctx, row.SHA256, mode, m.GetOcspUrl()); err != nil { + if errors.Is(err, store.ErrOperatorCANotFound) { + return nil, connect.NewError(connect.CodeNotFound, err) + } + return nil, connect.NewError(connect.CodeInternal, err) + } + if c := s.revocations.OCSP(); c != nil { + c.ClearAnchor(row.SHA256) + } + if err := s.applyTrustChange(ctx); err != nil { + return nil, err + } + log.Printf("fleet: operator CA %s OCSP mode changed from %s to %s by %s", row.SHA256, row.OCSPMode, mode, id.CN) + s.audit(ctx, "operator-ca-ocsp-changed", row.SHA256, fmt.Sprintf( + "Changed the OCSP mode of operator CA %s from %s%s to %s%s", row.SHA256, row.OCSPMode, prefixed(row.OCSPURL), mode, prefixed(m.GetOcspUrl()))) + + oc, err := s.rowProto(ctx, ot, row.SHA256) + if err != nil { + return nil, err + } + return connect.NewResponse(&fleetv1.SetOperatorCAOCSPResponse{OperatorCa: oc, OcspProbe: probeToProto(cert, probe)}), nil } diff --git a/internal/fleet/operatorca_rpcs_test.go b/internal/fleet/operatorca_rpcs_test.go new file mode 100644 index 0000000..660ea89 --- /dev/null +++ b/internal/fleet/operatorca_rpcs_test.go @@ -0,0 +1,757 @@ +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/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "errors" + "math/big" + "strings" + "sync" + "testing" + "time" + + connect "connectrpc.com/connect" + fleetv1 "github.com/CryptOS-PKI/cryptos-manager/gen/go/cryptos/fleet/v1" + "github.com/CryptOS-PKI/cryptos-manager/internal/apperr" + "github.com/CryptOS-PKI/cryptos-manager/internal/authz" + "github.com/CryptOS-PKI/cryptos-manager/internal/operatorca" + "github.com/CryptOS-PKI/cryptos-manager/internal/store" + "github.com/CryptOS-PKI/cryptos-manager/internal/store/memory" + "github.com/CryptOS-PKI/cryptos-manager/internal/store/storetest" +) + +// rotationStore is the in-memory inventory with the shared operator trust +// fake, which behaves like Postgres, so the registered source can run here. +type rotationStore struct { + *memory.Store + trust *storetest.MemoryTrust +} + +func (r *rotationStore) OperatorCAs(ctx context.Context) ([]store.OperatorCA, error) { + return r.trust.OperatorCAs(ctx) +} +func (r *rotationStore) AddOperatorCA(ctx context.Context, ca store.OperatorCA) error { + return r.trust.AddOperatorCA(ctx, ca) +} +func (r *rotationStore) SetOperatorCAState(ctx context.Context, sha, state, reason string, at time.Time) error { + return r.trust.SetOperatorCAState(ctx, sha, state, reason, at) +} +func (r *rotationStore) RotateOperatorCA(ctx context.Context, ca store.OperatorCA, crl *store.OperatorCRL, decide store.DecideCRL) error { + return r.trust.RotateOperatorCA(ctx, ca, crl, decide) +} +func (r *rotationStore) SetOperatorCACRLSource(ctx context.Context, sha, source, url string, acks []string, crl *store.OperatorCRL, decide store.DecideCRL) error { + return r.trust.SetOperatorCACRLSource(ctx, sha, source, url, acks, crl, decide) +} +func (r *rotationStore) SetOperatorCAOCSP(ctx context.Context, sha, mode, url string) error { + return r.trust.SetOperatorCAOCSP(ctx, sha, mode, url) +} +func (r *rotationStore) OperatorCRLs(ctx context.Context) ([]store.OperatorCRL, error) { + return r.trust.OperatorCRLs(ctx) +} +func (r *rotationStore) PutOperatorCRL(ctx context.Context, c store.OperatorCRL, decide store.DecideCRL) (bool, error) { + return r.trust.PutOperatorCRL(ctx, c, decide) +} +func (r *rotationStore) RecordOperatorCRLAttempt(ctx context.Context, issuer, msg string, at time.Time) error { + return r.trust.RecordOperatorCRLAttempt(ctx, issuer, msg, at) +} +func (r *rotationStore) OperatorDenylist(ctx context.Context) ([]store.DenylistEntry, error) { + return r.trust.OperatorDenylist(ctx) +} +func (r *rotationStore) AddOperatorDenylistEntry(ctx context.Context, e store.DenylistEntry) (bool, error) { + return r.trust.AddOperatorDenylistEntry(ctx, e) +} +func (r *rotationStore) OperatorTrustVersion(ctx context.Context) (store.TrustVersion, error) { + return r.trust.OperatorTrustVersion(ctx) +} +func (r *rotationStore) TryAdvisoryLock(ctx context.Context, name string) (func(), bool, error) { + return r.trust.TryAdvisoryLock(ctx, name) +} + +// replica is one manager process over the shared store. +type replica struct { + svc *Service + trust *operatorca.TrustStore + rev *operatorca.Revocations + auth operatorca.PeerAuthorizer + poller *operatorca.Poller +} + +// rotationFixture is a registered-source manager whose first operator CA, +// ca, is active with no CRL and OCSP off. CRL URLs are served from crls and +// OCSP probes answered by probe. +type rotationFixture struct { + st *rotationStore + ca testCA + policy string + + mu sync.Mutex + crls map[string][]byte + probeErr error + probeURLs []string + + replica +} + +func newRotationFixture(t *testing.T, policy string) *rotationFixture { + t.Helper() + f := &rotationFixture{ + st: &rotationStore{Store: memory.New(nil), trust: storetest.NewMemoryTrust()}, + ca: newTestCA(t, "Example Operator CA G1"), + policy: policy, + crls: map[string][]byte{}, + } + if err := f.st.AddOperatorCA(context.Background(), store.OperatorCA{ + SHA256: operatorca.Fingerprint(f.ca.cert), CertDER: f.ca.cert.Raw, State: store.OperatorCAActive, + CRLSource: store.CRLSourceNone, OCSPMode: store.OCSPModeOff, Acknowledgements: []string{"NO_CRL"}, + }); err != nil { + t.Fatal(err) + } + f.replica = f.newReplica(t) + return f +} + +func (f *rotationFixture) newReplica(t *testing.T) replica { + t.Helper() + ctx := context.Background() + rev := operatorca.NewRevocations(operatorca.RevocationOptions{Store: f.st, Policy: f.policy, OCSPFetcher: operatorca.NewOCSPFetcher()}) + trust, err := operatorca.NewTrustStore(ctx, operatorca.Source{Kind: operatorca.KindRegistered, Policy: f.policy}, f.st, rev, &tls.Config{}, nil) + if err != nil { + t.Fatal(err) + } + if err := rev.Reload(ctx); err != nil { + t.Fatal(err) + } + svc := New(f.st, noDial(t)).WithOperatorTrust(trust, rev).WithOperatorCAAdmin(OperatorCAAdmin{ + FetchCRL: f.fetch, + Probe: f.probe, + }) + return replica{svc: svc, trust: trust, rev: rev, auth: operatorca.PeerAuthorizer{Trust: trust, Rev: rev}, + poller: operatorca.NewPoller(f.st, trust, rev, nil)} +} + +func (f *rotationFixture) fetch(_ context.Context, url string) ([]byte, error) { + f.mu.Lock() + defer f.mu.Unlock() + b, ok := f.crls[url] + if !ok { + return nil, errors.New("connection refused") + } + return b, nil +} + +func (f *rotationFixture) probe(_ context.Context, anchor *x509.Certificate, url string) (operatorca.OCSPResult, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.probeURLs = append(f.probeURLs, url) + if f.probeErr != nil { + return operatorca.OCSPResult{}, f.probeErr + } + return operatorca.OCSPResult{Status: operatorca.OCSPUnknown, Signer: anchor}, nil +} + +func (f *rotationFixture) serveCRL(url string, der []byte) { + f.mu.Lock() + defer f.mu.Unlock() + f.crls[url] = der +} + +func (f *rotationFixture) states(t *testing.T) map[string]string { + t.Helper() + cas, err := f.st.OperatorCAs(context.Background()) + if err != nil { + t.Fatal(err) + } + out := map[string]string{} + for _, c := range cas { + out[c.SHA256] = c.State + } + return out +} + +func (f *rotationFixture) row(t *testing.T, sha string) store.OperatorCA { + t.Helper() + cas, _ := f.st.OperatorCAs(context.Background()) + for _, c := range cas { + if c.SHA256 == sha { + return c + } + } + t.Fatalf("no operator CA %s", sha) + return store.OperatorCA{} +} + +func (f *rotationFixture) version(t *testing.T) store.TrustVersion { + t.Helper() + v, err := f.st.OperatorTrustVersion(context.Background()) + if err != nil { + t.Fatal(err) + } + return v +} + +func (f *rotationFixture) auditKinds() []string { + var out []string + for _, e := range f.st.Audit() { + out = append(out, e.Kind) + } + return out +} + +// register previews and then confirms ca as the next operator CA. +func (f *rotationFixture) register(t *testing.T, ca testCA, req *fleetv1.RegisterOperatorCARequest) *fleetv1.RegisterOperatorCAResponse { + t.Helper() + req.CaCertDer = ca.cert.Raw + preview, err := f.svc.RegisterOperatorCA(adminCtx(), connect.NewRequest(req)) + if err != nil { + t.Fatalf("RegisterOperatorCA preview: %v", err) + } + req.ConfirmSha256 = preview.Msg.GetOperatorCa().GetSha256() + resp, err := f.svc.RegisterOperatorCA(adminCtx(), connect.NewRequest(req)) + if err != nil { + t.Fatalf("RegisterOperatorCA confirm: %v", err) + } + return resp.Msg +} + +func noCRL() *fleetv1.RegisterOperatorCARequest { + return &fleetv1.RegisterOperatorCARequest{ + CrlSource: &fleetv1.RegisterOperatorCARequest_None{None: true}, + OcspMode: fleetv1.OcspMode_OCSP_MODE_OFF, + Acknowledgements: []fleetv1.OperatorCAAcknowledgement{fleetv1.OperatorCAAcknowledgement_OPERATOR_CA_ACKNOWLEDGEMENT_NO_CRL}, + } +} + +// crl signs a CRL from ca with the given number, listing revoked. +func (ca testCA) crl(t *testing.T, number int64, revoked ...int64) []byte { + t.Helper() + tmpl := &x509.RevocationList{ + Number: big.NewInt(number), ThisUpdate: time.Now().Add(-time.Hour), NextUpdate: time.Now().Add(24 * time.Hour), + } + for _, s := range revoked { + tmpl.RevokedCertificateEntries = append(tmpl.RevokedCertificateEntries, x509.RevocationListEntry{SerialNumber: big.NewInt(s), RevocationTime: time.Now().Add(-time.Minute)}) + } + der, err := x509.CreateRevocationList(rand.Reader, tmpl, ca.cert, ca.key) + if err != nil { + t.Fatal(err) + } + return der +} + +func leafKey(t *testing.T) *ecdsa.PrivateKey { + t.Helper() + k, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + if err != nil { + t.Fatal(err) + } + return k +} + +func wantRefusal(t *testing.T, err error, code int, reason fleetv1.ErrorReason) { + t.Helper() + if err == nil { + t.Fatalf("error = nil, want %d/%s", code, apperr.ReasonName(reason)) + } + if got, ok := apperr.Code(err); !ok || got != code { + t.Fatalf("code = %d (ok %v), want %d; error %v", got, ok, code, err) + } + if reason == fleetv1.ErrorReason_ERROR_REASON_UNSPECIFIED { + return + } + if got, ok := apperr.ReasonOf(err); !ok || got != reason { + t.Fatalf("reason = %s, want %s; error %v", apperr.ReasonName(got), apperr.ReasonName(reason), err) + } +} + +// An admin registration after first run makes the new CA active and the old +// one retiring. Both stay trusted, without a restart, and both stay +// revocation-checked: the old CA's denylist and the new CA's CRL apply. +func TestRegisterOperatorCA_RotatesTheActiveCAToRetiring(t *testing.T) { + f := newRotationFixture(t, operatorca.PolicySoft) + next := newTestCA(t, "Example Operator CA G2") + f.serveCRL("http://pki.example.org/g2.crl", next.crl(t, 10, 99)) + oldFP, newFP := operatorca.Fingerprint(f.ca.cert), operatorca.Fingerprint(next.cert) + + preview, err := f.svc.RegisterOperatorCA(adminCtx(), connect.NewRequest(&fleetv1.RegisterOperatorCARequest{ + CaCertDer: next.cert.Raw, + CrlSource: &fleetv1.RegisterOperatorCARequest_Url{Url: "http://pki.example.org/g2.crl"}, + })) + if err != nil { + t.Fatalf("RegisterOperatorCA preview: %v", err) + } + p := preview.Msg + if p.GetConfirmed() || p.GetOperatorCa().GetSha256() != newFP || p.GetOperatorCa().GetState() != fleetv1.OperatorCAState_OPERATOR_CA_STATE_UNSPECIFIED || + p.GetOperatorCa().GetCrl().GetCrlNumber() != "10" || p.GetOperatorCa().GetOcspMode() != fleetv1.OcspMode_OCSP_MODE_AIA || + !strings.Contains(p.GetAdminExtfile(), "1.3.6.1.4.1.59999.1.1") { + t.Fatalf("preview = %+v", p) + } + if states := f.states(t); len(states) != 1 { + t.Fatalf("a preview stored something: %v", states) + } + + resp := f.register(t, next, &fleetv1.RegisterOperatorCARequest{CrlSource: &fleetv1.RegisterOperatorCARequest_Url{Url: "http://pki.example.org/g2.crl"}}) + if !resp.GetConfirmed() || resp.GetOperatorCa().GetState() != fleetv1.OperatorCAState_OPERATOR_CA_STATE_ACTIVE || resp.GetOperatorCa().GetRegisteredAt() == "" { + t.Fatalf("confirm = %+v", resp) + } + if states := f.states(t); states[oldFP] != store.OperatorCARetiring || states[newFP] != store.OperatorCAActive { + t.Fatalf("states = %v", states) + } + if got := f.row(t, newFP).RegisteredBy; got != "admin@example.org" { + t.Fatalf("registered_by = %q", got) + } + + oldLeaf := f.ca.leaf(t, &leafKey(t).PublicKey, "old@example.org", "admin", 5) + newLeaf := next.leaf(t, &leafKey(t).PublicKey, "new@example.org", "admin", 6) + listed := next.leaf(t, &leafKey(t).PublicKey, "gone@example.org", "admin", 99) + if _, err := f.auth.AuthorizePeer(oldLeaf, nil); err != nil { + t.Fatalf("a certificate from the retiring CA = %v, want trusted", err) + } + if _, err := f.auth.AuthorizePeer(newLeaf, nil); err != nil { + t.Fatalf("a certificate from the new CA = %v, want trusted", err) + } + if _, err := f.auth.AuthorizePeer(listed, nil); err == nil { + t.Fatal("a certificate in the new CA's CRL was accepted") + } + if _, err := f.svc.RevokeOperatorCredential(adminCtx(), connect.NewRequest(&fleetv1.RevokeOperatorCredentialRequest{ + SerialHex: "05", IssuerSha256: oldFP, ReasonCode: 4, + })); err != nil { + t.Fatalf("deny under the retiring CA: %v", err) + } + if _, err := f.auth.AuthorizePeer(oldLeaf, nil); err == nil { + t.Fatal("a denied certificate from the retiring CA was accepted") + } + if kinds := strings.Join(f.auditKinds(), ","); !strings.Contains(kinds, "operator-ca-registered") { + t.Fatalf("audit = %s", kinds) + } +} + +// At most one CA can be retiring, so a second rotation waits until the +// first one's old CA is retired. +func TestRegisterOperatorCA_SecondRotationIsRefused(t *testing.T) { + f := newRotationFixture(t, operatorca.PolicySoft) + f.register(t, newTestCA(t, "Example Operator CA G2"), noCRL()) + before := f.version(t) + + req := noCRL() + req.CaCertDer = newTestCA(t, "Example Operator CA G3").cert.Raw + _, err := f.svc.RegisterOperatorCA(adminCtx(), connect.NewRequest(req)) + wantRefusal(t, err, apperr.CodeOperatorCARejected, fleetv1.ErrorReason_ERROR_REASON_ROTATION_IN_PROGRESS) + if after := f.version(t); after != before { + t.Fatalf("trust version moved: %+v -> %+v", before, after) + } +} + +// The admin registration runs the first-run checks: the fingerprint must be +// confirmed, no CRL needs NO_CRL, a CRL must fetch and verify against the +// CA, and an OCSP url must answer the probe, which the preview reports. +func TestRegisterOperatorCA_ChecksLikeFirstRun(t *testing.T) { + f := newRotationFixture(t, operatorca.PolicySoft) + next := newTestCA(t, "Example Operator CA G2") + other := newTestCA(t, "Example Other CA") + call := func(req *fleetv1.RegisterOperatorCARequest) (*fleetv1.RegisterOperatorCAResponse, error) { + req.CaCertDer = next.cert.Raw + resp, err := f.svc.RegisterOperatorCA(adminCtx(), connect.NewRequest(req)) + if err != nil { + return nil, err + } + return resp.Msg, nil + } + + req := noCRL() + req.ConfirmSha256 = strings.Repeat("ab", 32) + _, err := call(req) + wantRefusal(t, err, apperr.CodeOperatorCARejected, fleetv1.ErrorReason_ERROR_REASON_NOT_CONFIRMED) + + req = noCRL() + req.Acknowledgements = nil + _, err = call(req) + wantRefusal(t, err, apperr.CodeOperatorCARejected, fleetv1.ErrorReason_ERROR_REASON_NO_CRL_NOT_ACKNOWLEDGED) + + _, err = call(&fleetv1.RegisterOperatorCARequest{CrlSource: &fleetv1.RegisterOperatorCARequest_Url{Url: "http://pki.example.org/missing.crl"}}) + wantRefusal(t, err, apperr.CodeOperatorCARejected, fleetv1.ErrorReason_ERROR_REASON_CRL_UNREACHABLE) + + _, err = call(&fleetv1.RegisterOperatorCARequest{CrlSource: &fleetv1.RegisterOperatorCARequest_CrlDer{CrlDer: other.crl(t, 1)}}) + wantRefusal(t, err, apperr.CodeOperatorCARejected, fleetv1.ErrorReason_ERROR_REASON_CRL_INVALID) + + req = noCRL() + req.OcspMode, req.OcspUrl = fleetv1.OcspMode_OCSP_MODE_URL, "http://ocsp.example.org/" + resp, err := call(req) + if err != nil { + t.Fatalf("preview with an OCSP url: %v", err) + } + if pr := resp.GetOcspProbe(); pr.GetSigner() != fleetv1.OcspSigner_OCSP_SIGNER_ANCHOR || pr.GetCertStatus() != "unknown" || pr.GetSignerSubject() == "" { + t.Fatalf("probe = %+v", pr) + } + f.probeErr = apperr.Reasoned(apperr.CodeOperatorCARejected, fleetv1.ErrorReason_ERROR_REASON_OCSP_UNREACHABLE, errors.New("down")) + _, err = call(req) + wantRefusal(t, err, apperr.CodeOperatorCARejected, fleetv1.ErrorReason_ERROR_REASON_OCSP_UNREACHABLE) + + if states := f.states(t); len(states) != 1 { + t.Fatalf("a refused registration stored something: %v", states) + } +} + +// Retiring refuses the last active CA and the caller's own CA unless the +// caller says they understand. A retired CA's certificates stop on their +// next request. +func TestRetireOperatorCA_GuardsAndStopsTrust(t *testing.T) { + f := newRotationFixture(t, operatorca.PolicySoft) + oldFP := operatorca.Fingerprint(f.ca.cert) + retire := func(ctx context.Context, sha string, flag bool) (*fleetv1.RetireOperatorCAResponse, error) { + resp, err := f.svc.RetireOperatorCA(ctx, connect.NewRequest(&fleetv1.RetireOperatorCARequest{Sha256: sha, IUnderstandSelfLockout: flag})) + if err != nil { + return nil, err + } + return resp.Msg, nil + } + + _, err := retire(adminCtx(), oldFP, true) + wantRefusal(t, err, apperr.CodeOperatorCAInUse, fleetv1.ErrorReason_ERROR_REASON_UNSPECIFIED) + + next := newTestCA(t, "Example Operator CA G2") + f.register(t, next, noCRL()) + newFP := operatorca.Fingerprint(next.cert) + _, err = retire(adminCtx(), newFP, true) + wantRefusal(t, err, apperr.CodeOperatorCAInUse, fleetv1.ErrorReason_ERROR_REASON_UNSPECIFIED) + + mine := authz.NewContext(context.Background(), authz.Identity{CN: "admin@example.org", Level: authz.LevelAdmin, IssuerSHA256: oldFP}) + _, err = retire(mine, oldFP, false) + requireConnectCode(t, err, connect.CodeFailedPrecondition) + if !strings.Contains(err.Error(), "i_understand_self_lockout") { + t.Fatalf("self-lockout refusal = %v, want it to name the flag", err) + } + if f.states(t)[oldFP] != store.OperatorCARetiring { + t.Fatal("a refused retirement changed the CA") + } + + oldLeaf := f.ca.leaf(t, &leafKey(t).PublicKey, "old@example.org", "admin", 5) + if _, err := f.auth.AuthorizePeer(oldLeaf, nil); err != nil { + t.Fatalf("before retiring: %v", err) + } + resp, err := retire(mine, oldFP, true) + if err != nil { + t.Fatalf("RetireOperatorCA: %v", err) + } + if ca := resp.GetOperatorCa(); ca.GetState() != fleetv1.OperatorCAState_OPERATOR_CA_STATE_RETIRED || ca.GetRetiredReason() != "retired" || ca.GetRetiredAt() == "" { + t.Fatalf("response = %+v", ca) + } + if _, err := f.auth.AuthorizePeer(oldLeaf, nil); err == nil { + t.Fatal("a certificate from the retired CA was accepted on its next request") + } + if kinds := strings.Join(f.auditKinds(), ","); !strings.Contains(kinds, "operator-ca-retired") { + t.Fatalf("audit = %s", kinds) + } + + _, err = retire(adminCtx(), oldFP, true) + requireConnectCode(t, err, connect.CodeFailedPrecondition) + _, err = retire(adminCtx(), strings.Repeat("0", 64), true) + requireConnectCode(t, err, connect.CodeNotFound) +} + +// A new CRL source must verify first, switching to none needs NO_CRL, and +// a change moves the revocation epoch and is audited. +func TestSetOperatorCACRLSource(t *testing.T) { + f := newRotationFixture(t, operatorca.PolicySoft) + fp := operatorca.Fingerprint(f.ca.cert) + set := func(req *fleetv1.SetOperatorCACRLSourceRequest) (*fleetv1.SetOperatorCACRLSourceResponse, error) { + req.Sha256 = fp + resp, err := f.svc.SetOperatorCACRLSource(adminCtx(), connect.NewRequest(req)) + if err != nil { + return nil, err + } + return resp.Msg, nil + } + + f.serveCRL("http://pki.example.org/bad.crl", newTestCA(t, "Example Other CA").crl(t, 1)) + _, err := set(&fleetv1.SetOperatorCACRLSourceRequest{CrlSource: &fleetv1.SetOperatorCACRLSourceRequest_Url{Url: "http://pki.example.org/bad.crl"}}) + wantRefusal(t, err, apperr.CodeOperatorCARejected, fleetv1.ErrorReason_ERROR_REASON_CRL_INVALID) + if f.row(t, fp).CRLSource != store.CRLSourceNone { + t.Fatal("a refused CRL source was stored") + } + + before := f.version(t) + f.serveCRL("http://pki.example.org/g1.crl", f.ca.crl(t, 3, 77)) + resp, err := set(&fleetv1.SetOperatorCACRLSourceRequest{CrlSource: &fleetv1.SetOperatorCACRLSourceRequest_Url{Url: "http://pki.example.org/g1.crl"}}) + if err != nil { + t.Fatalf("SetOperatorCACRLSource(url): %v", err) + } + if ca := resp.GetOperatorCa(); ca.GetCrlSource() != fleetv1.CrlSource_CRL_SOURCE_URL || ca.GetCrlLocation() != "http://pki.example.org/g1.crl" || + ca.GetCrl().GetCrlNumber() != "3" || ca.GetCrl().GetRevokedCount() != 1 { + t.Fatalf("response = %+v", ca) + } + if after := f.version(t); after.Epoch <= before.Epoch || after.CAs == before.CAs { + t.Fatalf("trust version %+v -> %+v", before, after) + } + if _, err := f.auth.AuthorizePeer(f.ca.leaf(t, &leafKey(t).PublicKey, "x@example.org", "admin", 77), nil); err == nil { + t.Fatal("a certificate in the new CRL was accepted") + } + + _, err = set(&fleetv1.SetOperatorCACRLSourceRequest{CrlSource: &fleetv1.SetOperatorCACRLSourceRequest_None{None: true}}) + wantRefusal(t, err, apperr.CodeOperatorCARejected, fleetv1.ErrorReason_ERROR_REASON_NO_CRL_NOT_ACKNOWLEDGED) + resp, err = set(&fleetv1.SetOperatorCACRLSourceRequest{ + CrlSource: &fleetv1.SetOperatorCACRLSourceRequest_None{None: true}, + Acknowledgements: []fleetv1.OperatorCAAcknowledgement{fleetv1.OperatorCAAcknowledgement_OPERATOR_CA_ACKNOWLEDGEMENT_NO_CRL}, + }) + if err != nil { + t.Fatalf("SetOperatorCACRLSource(none): %v", err) + } + if resp.GetOperatorCa().GetCrlSource() != fleetv1.CrlSource_CRL_SOURCE_NONE { + t.Fatalf("response = %+v", resp.GetOperatorCa()) + } + if kinds := strings.Join(f.auditKinds(), ","); strings.Count(kinds, "operator-ca-crl-source-changed") != 2 { + t.Fatalf("audit = %s", kinds) + } + + _, err = set(&fleetv1.SetOperatorCACRLSourceRequest{}) + requireConnectCode(t, err, connect.CodeInvalidArgument) +} + +// With a hard revocation policy an upload source is refused: an expired CRL +// would lock out the admins who upload the next one. +func TestSetOperatorCACRLSource_HardPolicyRefusesUpload(t *testing.T) { + f := newRotationFixture(t, operatorca.PolicyHard) + _, err := f.svc.SetOperatorCACRLSource(adminCtx(), connect.NewRequest(&fleetv1.SetOperatorCACRLSourceRequest{ + Sha256: operatorca.Fingerprint(f.ca.cert), CrlSource: &fleetv1.SetOperatorCACRLSourceRequest_CrlDer{CrlDer: f.ca.crl(t, 1)}, + })) + requireConnectCode(t, err, connect.CodeFailedPrecondition) + + req := noCRL() + req.CrlSource = &fleetv1.RegisterOperatorCARequest_CrlDer{CrlDer: f.ca.crl(t, 1)} + req.CaCertDer = newTestCA(t, "Example Operator CA G2").cert.Raw + _, err = f.svc.RegisterOperatorCA(adminCtx(), connect.NewRequest(req)) + requireConnectCode(t, err, connect.CodeFailedPrecondition) +} + +// An uploaded CRL is verified against the CA, refused when older than the +// stored one, stored with an epoch bump, and audited. +func TestUploadOperatorCRL(t *testing.T) { + f := newRotationFixture(t, operatorca.PolicySoft) + fp := operatorca.Fingerprint(f.ca.cert) + upload := func(der []byte) (*fleetv1.UploadOperatorCRLResponse, error) { + resp, err := f.svc.UploadOperatorCRL(adminCtx(), connect.NewRequest(&fleetv1.UploadOperatorCRLRequest{Sha256: fp, CrlDer: der})) + if err != nil { + return nil, err + } + return resp.Msg, nil + } + + _, err := upload(f.ca.crl(t, 1)) + requireConnectCode(t, err, connect.CodeFailedPrecondition) + + if _, err := f.svc.SetOperatorCACRLSource(adminCtx(), connect.NewRequest(&fleetv1.SetOperatorCACRLSourceRequest{ + Sha256: fp, CrlSource: &fleetv1.SetOperatorCACRLSourceRequest_CrlDer{CrlDer: f.ca.crl(t, 5)}, + })); err != nil { + t.Fatalf("switch to upload: %v", err) + } + + before := f.version(t) + resp, err := upload(f.ca.crl(t, 6, 42)) + if err != nil { + t.Fatalf("UploadOperatorCRL: %v", err) + } + if c := resp.GetOperatorCa().GetCrl(); c.GetCrlNumber() != "6" || c.GetRevokedCount() != 1 { + t.Fatalf("CRL status = %+v", c) + } + if after := f.version(t); after.Epoch <= before.Epoch { + t.Fatalf("epoch %d -> %d, want a bump", before.Epoch, after.Epoch) + } + if _, err := f.auth.AuthorizePeer(f.ca.leaf(t, &leafKey(t).PublicKey, "x@example.org", "admin", 42), nil); err == nil { + t.Fatal("a certificate in the uploaded CRL was accepted") + } + + _, err = upload(f.ca.crl(t, 4)) + wantRefusal(t, err, apperr.CodeOperatorCARejected, fleetv1.ErrorReason_ERROR_REASON_CRL_ROLLBACK) + _, err = upload(newTestCA(t, "Example Other CA").crl(t, 9)) + wantRefusal(t, err, apperr.CodeOperatorCARejected, fleetv1.ErrorReason_ERROR_REASON_CRL_INVALID) + + if kinds := strings.Join(f.auditKinds(), ","); strings.Count(kinds, "operator-crl-uploaded") != 1 { + t.Fatalf("audit = %s", kinds) + } +} + +// An OCSP change probes a url responder first, and another replica takes +// the new mode within one poll. +func TestSetOperatorCAOCSP(t *testing.T) { + f := newRotationFixture(t, operatorca.PolicySoft) + other := f.newReplica(t) + fp := operatorca.Fingerprint(f.ca.cert) + set := func(mode fleetv1.OcspMode, url string) (*fleetv1.SetOperatorCAOCSPResponse, error) { + resp, err := f.svc.SetOperatorCAOCSP(adminCtx(), connect.NewRequest(&fleetv1.SetOperatorCAOCSPRequest{Sha256: fp, OcspMode: mode, OcspUrl: url})) + if err != nil { + return nil, err + } + return resp.Msg, nil + } + + _, err := set(fleetv1.OcspMode_OCSP_MODE_URL, "") + requireConnectCode(t, err, connect.CodeInvalidArgument) + + f.probeErr = apperr.Reasoned(apperr.CodeOperatorCARejected, fleetv1.ErrorReason_ERROR_REASON_OCSP_INVALID, errors.New("bad signature")) + _, err = set(fleetv1.OcspMode_OCSP_MODE_URL, "http://ocsp.example.org/") + wantRefusal(t, err, apperr.CodeOperatorCARejected, fleetv1.ErrorReason_ERROR_REASON_OCSP_INVALID) + if f.row(t, fp).OCSPMode != store.OCSPModeOff { + t.Fatal("a refused OCSP change was stored") + } + + f.probeErr = nil + resp, err := set(fleetv1.OcspMode_OCSP_MODE_URL, "http://ocsp.example.org/") + if err != nil { + t.Fatalf("SetOperatorCAOCSP: %v", err) + } + if resp.GetOcspProbe().GetSigner() != fleetv1.OcspSigner_OCSP_SIGNER_ANCHOR || resp.GetOperatorCa().GetOcspMode() != fleetv1.OcspMode_OCSP_MODE_URL || + resp.GetOperatorCa().GetOcspUrl() != "http://ocsp.example.org/" { + t.Fatalf("response = %+v", resp) + } + if got := f.probeURLs; len(got) != 2 || got[1] != "http://ocsp.example.org/" { + t.Fatalf("probes = %v", got) + } + if err := other.poller.PollOnce(context.Background()); err != nil { + t.Fatal(err) + } + for _, a := range other.trust.Anchors() { + if a.SHA256 == fp && (a.OCSPMode != store.OCSPModeURL || a.OCSPURL != "http://ocsp.example.org/") { + t.Fatalf("the other replica has %+v after one poll", a) + } + } + + resp, err = set(fleetv1.OcspMode_OCSP_MODE_AIA, "") + if err != nil { + t.Fatalf("SetOperatorCAOCSP(aia): %v", err) + } + if resp.GetOcspProbe() != nil || len(f.probeURLs) != 2 { + t.Fatalf("aia mode probed: %+v, %v", resp.GetOcspProbe(), f.probeURLs) + } + if kinds := strings.Join(f.auditKinds(), ","); strings.Count(kinds, "operator-ca-ocsp-changed") != 2 { + t.Fatalf("audit = %s", kinds) + } +} + +// The list shows every row, trusted ones first, with its CRL and OCSP +// state; it is operator-readable. +func TestListOperatorCAs_Registered(t *testing.T) { + f := newRotationFixture(t, operatorca.PolicySoft) + g2 := newTestCA(t, "Example Operator CA G2") + f.register(t, g2, &fleetv1.RegisterOperatorCARequest{CrlSource: &fleetv1.RegisterOperatorCARequest_CrlDer{CrlDer: g2.crl(t, 2)}}) + if _, err := f.svc.RetireOperatorCA(adminCtx(), connect.NewRequest(&fleetv1.RetireOperatorCARequest{Sha256: operatorca.Fingerprint(f.ca.cert)})); err != nil { + t.Fatal(err) + } + g3 := newTestCA(t, "Example Operator CA G3") + f.register(t, g3, noCRL()) + + resp, err := f.svc.ListOperatorCAs(operatorCtx("op@example.org", authz.LevelOperator), connect.NewRequest(&fleetv1.ListOperatorCAsRequest{})) + if err != nil { + t.Fatalf("ListOperatorCAs: %v", err) + } + items := resp.Msg.GetItems() + if len(items) != 3 { + t.Fatalf("items = %+v", items) + } + want := []struct { + sha string + state fleetv1.OperatorCAState + }{ + {operatorca.Fingerprint(g3.cert), fleetv1.OperatorCAState_OPERATOR_CA_STATE_ACTIVE}, + {operatorca.Fingerprint(g2.cert), fleetv1.OperatorCAState_OPERATOR_CA_STATE_RETIRING}, + {operatorca.Fingerprint(f.ca.cert), fleetv1.OperatorCAState_OPERATOR_CA_STATE_RETIRED}, + } + for i, w := range want { + if items[i].GetSha256() != w.sha || items[i].GetState() != w.state || items[i].GetManagedByConfig() { + t.Fatalf("items[%d] = %+v, want %s %v", i, items[i], w.sha, w.state) + } + } + if c := items[1].GetCrl(); items[1].GetCrlSource() != fleetv1.CrlSource_CRL_SOURCE_UPLOAD || c.GetCrlNumber() != "2" || c.GetStale() || c.GetNextUpdate() == "" { + t.Fatalf("the retiring CA's CRL = %+v", items[1]) + } + if items[0].GetCrlSource() != fleetv1.CrlSource_CRL_SOURCE_NONE || items[0].GetOcspMode() != fleetv1.OcspMode_OCSP_MODE_OFF || + len(items[0].GetAcknowledgements()) != 1 || items[0].GetSubject() == "" || items[0].GetNotAfter() == "" { + t.Fatalf("the active CA = %+v", items[0]) + } + if items[2].GetRetiredReason() != "retired" || items[2].GetRetiredAt() == "" { + t.Fatalf("the retired CA = %+v", items[2]) + } + + _, err = f.svc.ListOperatorCAs(operatorCtx("v@example.org", authz.LevelViewer), connect.NewRequest(&fleetv1.ListOperatorCAsRequest{})) + requireConnectCode(t, err, connect.CodePermissionDenied) +} + +// A config-file operator CA is listed read-only, and every write answers +// 1607 so the UI can say to change the config instead. +func TestOperatorCAs_FileSourceIsManagedByConfig(t *testing.T) { + st := newCredStore() + ca := newTestCA(t, "Example Operator CA") + svc, _ := withAnchors(t, New(st, noDial(t)), st, ca.anchor(store.OperatorCAActive)) + fp := operatorca.Fingerprint(ca.cert) + + resp, err := svc.ListOperatorCAs(adminCtx(), connect.NewRequest(&fleetv1.ListOperatorCAsRequest{})) + if err != nil { + t.Fatalf("ListOperatorCAs: %v", err) + } + if items := resp.Msg.GetItems(); len(items) != 1 || !items[0].GetManagedByConfig() || items[0].GetSha256() != fp || + items[0].GetState() != fleetv1.OperatorCAState_OPERATOR_CA_STATE_ACTIVE { + t.Fatalf("items = %+v", items) + } + + for name, call := range writeCalls(svc, fp, ca.cert.Raw) { + wantRefusal(t, call(adminCtx()), apperr.CodeOperatorCAManagedByConfig, fleetv1.ErrorReason_ERROR_REASON_UNSPECIFIED) + _ = name + } +} + +// Every write is admin-only. +func TestOperatorCAs_WritesNeedAdmin(t *testing.T) { + f := newRotationFixture(t, operatorca.PolicySoft) + for name, call := range writeCalls(f.svc, operatorca.Fingerprint(f.ca.cert), newTestCA(t, "Example Operator CA G2").cert.Raw) { + if got := connect.CodeOf(call(operatorCtx("op@example.org", authz.LevelOperator))); got != connect.CodePermissionDenied { + t.Errorf("%s as an operator: code = %v, want PermissionDenied", name, got) + } + } +} + +func writeCalls(svc *Service, sha string, certDER []byte) map[string]func(context.Context) error { + return map[string]func(context.Context) error{ + "RegisterOperatorCA": func(ctx context.Context) error { + req := noCRL() + req.CaCertDer = certDER + _, err := svc.RegisterOperatorCA(ctx, connect.NewRequest(req)) + return err + }, + "RetireOperatorCA": func(ctx context.Context) error { + _, err := svc.RetireOperatorCA(ctx, connect.NewRequest(&fleetv1.RetireOperatorCARequest{Sha256: sha})) + return err + }, + "SetOperatorCACRLSource": func(ctx context.Context) error { + _, err := svc.SetOperatorCACRLSource(ctx, connect.NewRequest(&fleetv1.SetOperatorCACRLSourceRequest{ + Sha256: sha, CrlSource: &fleetv1.SetOperatorCACRLSourceRequest_None{None: true}, + Acknowledgements: []fleetv1.OperatorCAAcknowledgement{fleetv1.OperatorCAAcknowledgement_OPERATOR_CA_ACKNOWLEDGEMENT_NO_CRL}, + })) + return err + }, + "UploadOperatorCRL": func(ctx context.Context) error { + _, err := svc.UploadOperatorCRL(ctx, connect.NewRequest(&fleetv1.UploadOperatorCRLRequest{Sha256: sha, CrlDer: []byte{1}})) + return err + }, + "SetOperatorCAOCSP": func(ctx context.Context) error { + _, err := svc.SetOperatorCAOCSP(ctx, connect.NewRequest(&fleetv1.SetOperatorCAOCSPRequest{Sha256: sha, OcspMode: fleetv1.OcspMode_OCSP_MODE_OFF})) + return err + }, + } +} diff --git a/internal/fleet/service.go b/internal/fleet/service.go index 74a67e8..e29f634 100644 --- a/internal/fleet/service.go +++ b/internal/fleet/service.go @@ -75,6 +75,9 @@ type Service struct { trust *operatorca.TrustStore revocations *operatorca.Revocations + // caAdmin backs the operator CA admin RPCs. + caAdmin OperatorCAAdmin + // previewCert fetches a not-yet-adopted node's maintenance cert // fingerprint + subject (TOFU preview). dialMaintenance opens a // TOFU-pinned maintenance connection. Both are seams so tests inject fakes diff --git a/internal/operatorca/registration.go b/internal/operatorca/registration.go new file mode 100644 index 0000000..b45a97b --- /dev/null +++ b/internal/operatorca/registration.go @@ -0,0 +1,121 @@ +package operatorca + +/* +Copyright The CryptOS Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import ( + "context" + "crypto/x509" + "errors" + "fmt" + "slices" + "time" + + fleetv1 "github.com/CryptOS-PKI/cryptos-manager/gen/go/cryptos/fleet/v1" + "github.com/CryptOS-PKI/cryptos-manager/internal/apperr" + "github.com/CryptOS-PKI/cryptos-manager/internal/store" +) + +// AckNoCRL is the acknowledgement that an operator CA has no CRL source: +// revocations made at the CA aren't seen, and MCP is refused under it. +const AckNoCRL = "NO_CRL" + +// CRLChoice is the CRL source chosen for an operator CA when it is +// registered or changed: none, url or upload. +type CRLChoice struct { + Source string + URL string + DER []byte + Acknowledgements []string +} + +// CheckCRLChoice checks a CRL source against anchor before it is stored: +// none needs the NO_CRL acknowledgement; url and upload need cRLSign on the +// anchor, and the CRL, fetched with fetch for url, must verify against the +// anchor. It returns the verified CRL, nil for none. +func CheckCRLChoice(ctx context.Context, anchor *x509.Certificate, c CRLChoice, now time.Time, fetch func(context.Context, string) ([]byte, error)) (*VerifiedCRL, error) { + switch c.Source { + case store.CRLSourceNone: + if !slices.Contains(c.Acknowledgements, AckNoCRL) { + return nil, rejectAnchor(fleetv1.ErrorReason_ERROR_REASON_NO_CRL_NOT_ACKNOWLEDGED, + "no CRL source was chosen and NO_CRL wasn't acknowledged") + } + return nil, nil + case store.CRLSourceURL, store.CRLSourceUpload: + default: + return nil, fmt.Errorf("operatorca: unknown CRL source %q", c.Source) + } + if anchor.KeyUsage&x509.KeyUsageCRLSign == 0 { + return nil, rejectAnchor(fleetv1.ErrorReason_ERROR_REASON_CRL_SIGN_MISSING, + "%s has no cRLSign key usage, so no CRL can be verified against it", anchor.Subject) + } + der := c.DER + if c.Source == store.CRLSourceURL { + if err := ValidateFetchURL(c.URL); err != nil { + return nil, crlUnreachable("refused the URL: %v", err) + } + if fetch == nil { + return nil, crlUnreachable("isn't available in this process") + } + b, err := fetch(ctx, c.URL) + if err != nil { + if _, ok := apperr.ReasonOf(err); ok { + return nil, err + } + return nil, crlUnreachable("from %s failed: %v", redact(c.URL), err) + } + der = b + } + if len(der) == 0 { + return nil, crlInvalid("is empty") + } + return VerifyCRL(der, anchor, now) +} + +// ErrOCSPURL is returned by CheckOCSPChoice for a URL that doesn't fit the +// mode: missing in url mode, or set in any other. +var ErrOCSPURL = errors.New("operatorca: ocsp_url is required for OCSP mode url and only allowed with it") + +// CheckOCSPChoice checks an OCSP mode and URL before they are stored. In url +// mode the URL must be an http or https URL and the responder must answer +// probe with a validly signed response, which is returned; the other modes +// send nothing. probe may be nil when OCSP is off in this process. +func CheckOCSPChoice(ctx context.Context, anchor *x509.Certificate, mode, url string, probe func(context.Context, *x509.Certificate, string) (OCSPResult, error)) (*OCSPResult, error) { + switch mode { + case store.OCSPModeOff, store.OCSPModeAIA: + if url != "" { + return nil, ErrOCSPURL + } + return nil, nil + case store.OCSPModeURL: + default: + return nil, fmt.Errorf("operatorca: unknown OCSP mode %q", mode) + } + if url == "" { + return nil, ErrOCSPURL + } + if err := ValidateFetchURL(url); err != nil { + return nil, ocspUnreachable("URL refused: %v", err) + } + if probe == nil { + return nil, nil + } + res, err := probe(ctx, anchor, url) + if err != nil { + return nil, err + } + return &res, nil +} diff --git a/internal/operatorca/rotation_test.go b/internal/operatorca/rotation_test.go index 27e2631..bc72c3a 100644 --- a/internal/operatorca/rotation_test.go +++ b/internal/operatorca/rotation_test.go @@ -22,7 +22,7 @@ import ( "testing" "time" - "github.com/CryptOS-PKI/manager/internal/store" + "github.com/CryptOS-PKI/cryptos-manager/internal/store" "golang.org/x/crypto/ocsp" ) diff --git a/internal/store/storetest/memorytrust.go b/internal/store/storetest/memorytrust.go index b8658cb..57c995e 100644 --- a/internal/store/storetest/memorytrust.go +++ b/internal/store/storetest/memorytrust.go @@ -24,7 +24,7 @@ import ( "sync" "time" - "github.com/CryptOS-PKI/manager/internal/store" + "github.com/CryptOS-PKI/cryptos-manager/internal/store" ) var _ store.OperatorTrust = (*MemoryTrust)(nil) diff --git a/internal/store/storetest/memorytrust_test.go b/internal/store/storetest/memorytrust_test.go index 08582fb..bd11e38 100644 --- a/internal/store/storetest/memorytrust_test.go +++ b/internal/store/storetest/memorytrust_test.go @@ -19,8 +19,8 @@ limitations under the License. import ( "testing" - "github.com/CryptOS-PKI/manager/internal/store" - "github.com/CryptOS-PKI/manager/internal/store/storetest" + "github.com/CryptOS-PKI/cryptos-manager/internal/store" + "github.com/CryptOS-PKI/cryptos-manager/internal/store/storetest" ) // The fake other packages' tests use must keep the Postgres store's From 83f366aee7b131e275b9d6e9daab80fe915af7b3 Mon Sep 17 00:00:00 2001 From: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:35:10 -0400 Subject: [PATCH 3/4] docs(operator-ca): rotating a registered operator CA and changing its CRL and OCSP settings Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> --- docs/operator-ca.md | 76 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/docs/operator-ca.md b/docs/operator-ca.md index ffd5f37..067a138 100644 --- a/docs/operator-ca.md +++ b/docs/operator-ca.md @@ -8,7 +8,8 @@ certificate (the trust anchor). It never holds the CA's key and never signs an operator credential. This page covers where the manager gets the anchor, how it checks revocation, -the config keys, and how to move off `operator_ca_node`. +how admins change and rotate registered CAs, the config keys, and how to move +off `operator_ca_node`. ## 🧭 Where the anchor comes from @@ -158,6 +159,79 @@ available, it must be `good`: `revoked` or `unknown` refuse with [1610](error-codes.md) `REVOKED_OCSP` or `OCSP_UNKNOWN`. An OCSP failure is allowed, because the fresh CRL covers it. +## 🔄 Changing and rotating a registered operator CA + +After first run, admins change registered operator CAs on the **Operator CAs** +page, or through these FleetService RPCs. Every change is audited and takes +effect on the replica that made it at once and on every other replica within +one trust poll (about 5 seconds), with no restart. + +| RPC | Who | What it does | +| --- | --- | --- | +| `ListOperatorCAs` | operator | Every CA: active first, then retiring, then retired. Each row has its fingerprint, notAfter, CRL source and the CRL the manager holds (thisUpdate, nextUpdate, revoked count, last error), OCSP mode and URL, this replica's last OCSP error, and warnings. A config-file CA is listed with `managed_by_config` set. | +| `RegisterOperatorCA` | admin | Registers the next CA with the first-run checks and the preview then `confirm_sha256` step. The new CA becomes **active** and the old active CA becomes **retiring**. Audited `operator-ca-registered`. | +| `RetireOperatorCA` | admin | Stops trusting a retiring CA. Audited `operator-ca-retired`. | +| `SetOperatorCACRLSource` | admin | Switches a CA to a CRL URL, an uploaded CRL, or none. Audited `operator-ca-crl-source-changed`. | +| `UploadOperatorCRL` | admin | Stores a new CRL for a CA whose source is upload. Audited `operator-crl-uploaded`. | +| `SetOperatorCAOCSP` | admin | Sets the OCSP mode to `off`, `aia` or `url`. Audited `operator-ca-ocsp-changed`. | + +> [!CAUTION] +> While `operatorCAPath` is set, every write above is refused with +> [1607 OPERATOR_CA_MANAGED_BY_CONFIG](error-codes.md). Change the config file +> instead. + +### Rotation: register, overlap, retire + +1. Create the new operator CA and register it. Compare the fingerprint the + preview shows with the new CA certificate before you confirm: + + ```sh + openssl x509 -in operator-ca-g2.crt -noout -fingerprint -sha256 + ``` + +2. **Overlap.** Both CAs are trusted and both are revocation-checked: the old + CA's denylist, CRL and OCSP still apply to its certificates. New credentials + are recorded only under the active CA (a certificate from the retiring CA + gives [1610 NOT_ACTIVE_ANCHOR](error-codes.md)). Issue new certificates from + the new CA and install them. +3. **Retire** the old CA. Its certificates, and the MCP keys bound to them, are + refused from their next request, even over an open connection. Its denylist + entries are kept in case it is registered again. + +Only one CA can be retiring at a time: registering another one before the old +CA is retired is refused with [1605 ROTATION_IN_PROGRESS](error-codes.md). + +> [!WARNING] +> The active CA can't be retired ([1609 OPERATOR_CA_IN_USE](error-codes.md)); +> register its replacement first. Retiring the CA your own certificate comes +> from is refused unless you set `i_understand_self_lockout`: sign in with a +> certificate from the new CA first, or you are signed out on your next request. + +### CRL source, CRL upload and OCSP mode + +- **A new CRL URL or CRL must verify first.** The manager fetches the URL (or + takes the uploaded CRL) and verifies it against the CA, so a wrong URL is + refused with [1605 CRL_UNREACHABLE or CRL_INVALID](error-codes.md) instead of + being stored. The CA needs the `cRLSign` key usage (CRL_SIGN_MISSING). +- **Switching to none** needs the `NO_CRL` acknowledgement + (NO_CRL_NOT_ACKNOWLEDGED). The last CRL the manager stored keeps applying, but + revocations made at the CA afterwards aren't seen, and + [MCP is refused](#mcp-needs-a-fresh-crl) for certificates under that CA from + their next call. +- **Uploads** are refused when older than the stored CRL + ([1605 CRL_ROLLBACK](error-codes.md)); uploading the same CRL again changes + nothing. A CA with a URL source takes no uploads: switch it to upload first. +- **OCSP mode `url`** sends the responder a probe for a random serial first and + needs a validly signed answer, whatever its status + ([1605 OCSP_UNREACHABLE or OCSP_INVALID](error-codes.md)). The response says + who signed it: the CA, or a delegated responder certificate. Changing the + mode drops the answers each replica cached for that CA. + +> [!WARNING] +> With `operatorRevocationPolicy: hard`, an upload CRL source is refused, both +> when registering and when changing the source: an expired CRL would lock out +> the admins who upload the next one. Use a CRL URL. + ## ⚙️ Config keys ```yaml From a0ea99e3340397cbe771ace1d1f31ab7f47101f9 Mon Sep 17 00:00:00 2001 From: Bugs5382 Date: Thu, 1 Oct 2026 17:22:07 -0400 Subject: [PATCH 4/4] test(bootstrap): give the first-run fake store the operator CA admin writes Rebased onto the restructured main: the first-run fake store now has to implement the RotateOperatorCA, SetOperatorCACRLSource and SetOperatorCAOCSP methods this change adds to store.OperatorTrust. First run never calls them, so they refuse. Signed-off-by: Bugs5382 --- internal/bootstrap/fake_test.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/internal/bootstrap/fake_test.go b/internal/bootstrap/fake_test.go index aff4ecd..7160611 100644 --- a/internal/bootstrap/fake_test.go +++ b/internal/bootstrap/fake_test.go @@ -18,6 +18,7 @@ limitations under the License. import ( "context" + "errors" "fmt" "strings" "sync" @@ -470,3 +471,19 @@ func (l *logSink) count(sub string) int { } return n } + +// The operator CA admin writes are not reached by first run; the fake refuses +// them so a test that strays onto one fails loudly. +var errNotFirstRun = errors.New("fakeStore: not used by first run") + +func (f *fakeStore) RotateOperatorCA(context.Context, store.OperatorCA, *store.OperatorCRL, store.DecideCRL) error { + return errNotFirstRun +} + +func (f *fakeStore) SetOperatorCACRLSource(context.Context, string, string, string, []string, *store.OperatorCRL, store.DecideCRL) error { + return errNotFirstRun +} + +func (f *fakeStore) SetOperatorCAOCSP(context.Context, string, string, string) error { + return errNotFirstRun +}