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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cmd/manager/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
76 changes: 75 additions & 1 deletion docs/operator-ca.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions internal/bootstrap/fake_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ limitations under the License.

import (
"context"
"errors"
"fmt"
"strings"
"sync"
Expand Down Expand Up @@ -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
}
42 changes: 12 additions & 30 deletions internal/fleet/contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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)
}
}
}
Loading
Loading