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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Adopting a fresh node from its maintenance endpoint is safe to retry. The manage

The node always checks the manager's admin credential, and the manager always checks the node's server certificate: against the node's recorded CA chain once the node signs its management certificate with its CA, or against a pinned `server.crt` before that. A node it can't verify is refused. `manager -check-node-trust` lists how each node is verified. See [`docs/node-trust.md`](docs/node-trust.md).

Operators sign in with a client certificate from an **external operator CA**, never a CryptOS node; the manager never signs an operator credential. It re-checks the certificate on every request and refuses revoked ones from its own denylist and the CA's CRL. See [`docs/operator-ca.md`](docs/operator-ca.md).
Operators sign in with a client certificate from an **external operator CA**, never a CryptOS node; the manager never signs an operator credential. It re-checks the certificate on every request and refuses revoked ones from its own denylist, the CA's CRL and, where configured, the CA's OCSP responder. See [`docs/operator-ca.md`](docs/operator-ca.md).

Once linked, the node's embedded operator surface becomes read-only and FM owns day-to-day operations. Unlinking is a config change + reboot. A node that has never been linked is managed via [`cryptosctl`](https://github.com/CryptOS-PKI/cryptos) only — no web UI in that case (by design — there's no web frontend on the CA image).

Expand Down Expand Up @@ -214,7 +214,7 @@ The MCP endpoint is off in the chart too. `mcp.enabled` and `mcp.publicURL` turn
The manager can serve a [Model Context Protocol](https://modelcontextprotocol.io) endpoint at `/mcp` for AI agents. It is off by default.

- 🔑 **Logged in with your operator certificate.** An MCP client such as the `claude` CLI runs a one-time OAuth login; the consent page in the web UI needs your operator certificate, and the client receives a long-lived `fos_mcp_` key bound to that certificate's serial. Clients without OAuth use a key from the Agent keys page.
- 🧮 **Checked live on every call.** The key's certificate is re-validated against the operator CAs trusted now, the denylist and its CA's CRL each time, and its CA must have a fresh CRL, and the key never acts above the certificate's level or its own ceiling.
- 🧮 **Checked live on every call.** The key's certificate is re-validated against the operator CAs trusted now, the denylist, its CA's CRL and OCSP responder (where configured) each time, and its CA must have a fresh CRL, and the key never acts above the certificate's level or its own ceiling.
- 🛑 **Narrow by design.** Agents get read tools and non-CA leaf issuance on intermediate or issuing nodes. CA key material, node provisioning and operator credentials are not exposed.
- ✋ **A person approves the risky calls.** Revocation, profile and adapter changes, and CA or root issuance only raise an approval; they run when the agent calls again after a person approves it in the web UI with their operator certificate. An approval covers one exact request, runs once and lapses after 15 minutes.
- 🧾 **Audited.** Every MCP call, reads included, is in the hash-chained audit log with the operator, key and tool.
Expand Down
3 changes: 3 additions & 0 deletions cmd/manager/trust.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ func setupOperatorTrust(ctx context.Context, cfg config.Config, st store.Store,
Policy: src.Policy,
Logf: logf,
Audit: func(e store.AuditEvent) { auditlog.Record(context.Background(), st, e) },
// OCSP per anchor (off, aia or url); the file source takes the mode
// from operatorOCSP, a registered CA from its row.
OCSPFetcher: operatorca.NewOCSPFetcher(),
})
trust, err := operatorca.NewTrustStore(ctx, src, ot, rev, base, logf)
if err != nil {
Expand Down
4 changes: 3 additions & 1 deletion docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ because a key could not be re-validated on each request:
A key also needs its operator certificate's CA to have a **CRL source with a fresh CRL**.
That is checked when the key is created and on every call, not at start, so MCP can be
enabled before the CRL is set up; until then keys are refused with
[1608](error-codes.md) `NO_CRL` or `STALE_CRL`. See [operator-ca.md](operator-ca.md#mcp-needs-a-fresh-crl).
[1608](error-codes.md) `NO_CRL` or `STALE_CRL`. Where OCSP is configured for the
certificate, a fresh `revoked` or `unknown` answer refuses the key as well. See
[operator-ca.md](operator-ca.md#mcp-needs-a-fresh-crl).

Keys and login state live in Postgres, so every replica shares them and a login can
start on one replica and finish on another.
Expand Down
92 changes: 77 additions & 15 deletions docs/operator-ca.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ The source is chosen only by what is configured:

| Source | When | Trust | Revocation |
| --- | --- | --- | --- |
| Config file | `operatorCAPath` set | The certificates in that PEM file | The denylist (needs Postgres), plus the CRLs from `operatorCRL` |
| Registered | No `operatorCAPath`, Postgres configured, `firstRun` not `disabled` | The active and retiring operator CAs stored in Postgres | The denylist, plus each CA's own CRL source |
| Config file | `operatorCAPath` set | The certificates in that PEM file | The denylist (needs Postgres), plus the CRLs from `operatorCRL`, plus OCSP per `operatorOCSP` |
| Registered | No `operatorCAPath`, Postgres configured, `firstRun` not `disabled` | The active and retiring operator CAs stored in Postgres | The denylist, plus each CA's own CRL source, plus OCSP per the CA's OCSP mode |
| None | No `operatorCAPath`, and no Postgres or `firstRun: disabled` | Nothing | Nothing; every API caller is refused |
| Dev | `authBypass: true` | No certificates at all | n/a |

Expand Down Expand Up @@ -53,6 +53,11 @@ so every source is keyed by the CA as well as the serial.
library, and refuses a CRL older than the one it holds. It refreshes every 15
minutes and at the CRL's nextUpdate, and retries a failure after a minute,
backing off to 15 minutes.
- **OCSP** is your CA's live answer about one certificate, optional per CA. It
can only add a refusal: `revoked` refuses with
[1610 REVOKED_OCSP](error-codes.md), and `unknown` counts as revoked
([1610 OCSP_UNKNOWN](error-codes.md), audited `operator-ocsp-unknown`). A
`good` answer never overrides the denylist or the CRL.

> [!WARNING]
> The denylist stops a credential at the Fleet Manager only. Other systems that
Expand All @@ -63,31 +68,92 @@ operator CA itself, with a nextUpdate. Delta CRLs, indirect CRLs, CRLs limited t
CA certificates or to some reasons, and CRLs with an unknown critical extension
are refused. Entries on hold count as revoked while they are listed.

### When no fresh CRL is available
### OCSP

Each operator CA has an OCSP mode (`operatorOCSP.mode` for the config file, the
CA's own setting when registered):

| Mode | Responder |
| --- | --- |
| `aia` (default) | The OCSP URI in the operator certificate's authorityInfoAccess. A certificate without one gets no OCSP check. The URI is read only after the certificate has been verified to chain to the CA, so only the CA can choose it. |
| `url` | The URL you set (`operatorOCSP.url`, or the CA's OCSP URL). The certificate's own URI is ignored. |
| `off` | No OCSP. |

The manager asks with an HTTP POST carrying a 32-byte nonce, and falls back to
GET only when the responder answers POST with 405 and the base64 request is
under 255 bytes. Fetches follow the CRL limits: http or https only, a 10 second
timeout, at most 3 redirects, no proxy from the environment, and a 64 KiB
response cap.

A response is used only if it is a successful basic response for exactly the
requested certificate (hash algorithm, CA name and key hashes, serial), its
thisUpdate is at most 5 minutes ahead and its nextUpdate hasn't passed, and it
is signed by one of:

- the operator CA itself;
- a delegated responder certificate issued **directly** by the operator CA, with
the `OCSPSigning` extended key usage, valid now, and named by the response's
responder ID. With `noCheck` it isn't revocation-checked; without it, it must
not be on the CA's denylist or CRL.

A nonce echoed in the response must match; a response without one is accepted,
because responders for offline CAs often pre-sign. Anything else counts as no
response, and is logged with the responder URL and `OCSP_INVALID` or
`OCSP_UNREACHABLE`, never with the response body.

> [!CAUTION]
> The manager refuses responses from an expired delegated responder
> certificate. Renew it before it expires, or every check falls back to the CRL
> and then to `operatorRevocationPolicy`.

Each replica caches answers in memory for the response's nextUpdate, at most 1
hour, or 5 minutes when the response has no nextUpdate. A certificate in use is
re-checked in the background half way through that time; idle entries just
expire. Concurrent requests for the same certificate share one fetch, and a
failed fetch is remembered for 30 seconds so a dead responder doesn't slow every
request. A denylist entry still refuses a certificate at once, whatever the
cache holds.

When OCSP is configured for a certificate but there is no fresh response, the
manager falls back to the CA's CRL: with a CRL within nextUpdate, the
certificate is allowed unless the CRL lists it. With no fresh CRL either,
`operatorRevocationPolicy` decides (below).

### When no fresh revocation data is available

`operatorRevocationPolicy` decides what the web UI and API do when a CA's CRL is
past its nextUpdate, or a configured CRL has never been fetched:
past its nextUpdate, a configured CRL has never been fetched, or OCSP is
configured for the certificate with no fresh response and no fresh CRL to fall
back on:

| | `soft` (default) | `hard` |
| --- | --- | --- |
| Web UI and API | Keep enforcing the last good CRL and the denylist. Log hourly and audit `operator-crl-expired` once. | Refuse certificates under that CA with [1608 STALE_CRL](error-codes.md) until a fresh CRL loads. |
| Web UI and API | Keep enforcing the last good CRL and the denylist. Log hourly and audit `operator-crl-expired` once per expiry; for OCSP, show the `OCSP responder unreachable` banner, log hourly and audit `operator-ocsp-unavailable` once per outage. | Refuse certificates under that CA with [1608 STALE_OCSP](error-codes.md) when OCSP was configured for the certificate, otherwise [1608 STALE_CRL](error-codes.md). A certificate with a fresh OCSP `good` is still allowed with a stale CRL. |
| MCP | Refused ([1608 STALE_CRL](error-codes.md)) | Refused |

> [!WARNING]
> With `hard`, an unreachable CRL URL locks every operator under that CA out of
> the web UI until the CRL publication point is fixed. Use it only with an
> automated, highly available CRL publication point.
> With `hard`, an unreachable CRL URL or OCSP responder locks every operator
> under that CA out of the web UI until the CRL publication point or the
> responder is fixed. Use it only with an automated, highly available CRL
> publication point and responder.

A CA with no CRL source is checked against the denylist only; revocations made
at the CA are not seen.
A CA with no CRL source and OCSP off (or `aia` and certificates without an OCSP
URI) is checked against the denylist only; revocations made at the CA are not
seen. With OCSP on, the CA's revocations are seen through OCSP only.

### MCP needs a fresh CRL

MCP keys are long-lived, so they are held to a stricter rule. On every call, and
before a key is created, the bound certificate's CA must have a CRL source with a
CRL within nextUpdate, and this replica's last denylist poll must be under 5
minutes old. Otherwise the call is refused with
[1608](error-codes.md) `NO_CRL`, `STALE_CRL` or `STALE_DENYLIST`.
[1608](error-codes.md) `NO_CRL`, `STALE_CRL` or `STALE_DENYLIST`. A fresh OCSP
`good` doesn't replace the CRL here.

When OCSP is configured for the bound certificate and a fresh response is
available, it must be `good`: `revoked` or `unknown` refuse with
[1610](error-codes.md) `REVOKED_OCSP` or `OCSP_UNKNOWN`. An OCSP failure is
allowed, because the fresh CRL covers it.

## ⚙️ Config keys

Expand Down Expand Up @@ -122,10 +188,6 @@ firstRun: auto
| `operatorOCSP.url` | unset | Required with mode `url`, http or https; refused with any other mode. |
| `firstRun` | `auto` | `auto` or `disabled`. With `disabled` and no `operatorCAPath`, no operator CA is trusted. |

> [!NOTE]
> `operatorOCSP` is validated, but this version doesn't query OCSP responders
> yet. Revocation comes from the denylist and the CRL.

Unknown keys are refused at load, so a misspelt key fails loudly rather than
being ignored.

Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ require (
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.10.0
github.com/modelcontextprotocol/go-sdk v1.8.0
golang.org/x/crypto v0.55.0
golang.org/x/sync v0.22.0
golang.org/x/time v0.15.0
)

Expand All @@ -33,7 +35,6 @@ require (
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.22.0 // indirect
)

require (
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRk
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
Expand Down
15 changes: 14 additions & 1 deletion internal/mcpauth/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ type MCPChecker interface {
CheckMCP(anchorSHA256, serial string) error
}

// CertMCPChecker is an MCPChecker that takes the whole certificate. The
// resolver prefers it: the OCSP check reads the responder URI from the
// certificate's authorityInfoAccess, which a serial alone doesn't carry.
type CertMCPChecker interface {
CheckMCPCert(anchorSHA256 string, cert *x509.Certificate) error
}

// Resolver turns a bearer key into the live identity of the operator
// certificate it is bound to. Nothing is cached: every request re-checks the
// key row and re-validates the certificate against the operator CAs trusted
Expand Down Expand Up @@ -143,7 +150,13 @@ func (r *Resolver) validate(key store.McpKey, now time.Time) (authz.Identity, er
anchor := chains[0][len(chains[0])-1]
sum := sha256.Sum256(anchor.Raw)
id.IssuerSHA256 = hex.EncodeToString(sum[:])
if err := r.Revoked.CheckMCP(id.IssuerSHA256, fmt.Sprintf("%x", cert.SerialNumber)); err != nil {
var revErr error
if cc, ok := r.Revoked.(CertMCPChecker); ok {
revErr = cc.CheckMCPCert(id.IssuerSHA256, cert)
} else {
revErr = r.Revoked.CheckMCP(id.IssuerSHA256, fmt.Sprintf("%x", cert.SerialNumber))
}
if err := revErr; err != nil {
if code, _ := apperr.Code(err); code == apperr.CodeNoRevocationSource {
return authz.Identity{}, fmt.Errorf("%w: %w", ErrRevocationStale, err)
}
Expand Down
35 changes: 35 additions & 0 deletions internal/mcpauth/revocation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,38 @@ func TestMint_RefusesACertificateTheAdmissionCheckRefuses(t *testing.T) {
t.Fatal("a key was stored for a refused certificate")
}
}

// certChecker also takes the whole certificate, which the OCSP check needs
// for the responder URI in the certificate's authorityInfoAccess.
type certChecker struct {
fakeChecker
gotCert *x509.Certificate
}

func (c *certChecker) CheckMCPCert(anchor string, cert *x509.Certificate) error {
c.gotAnchor, c.gotCert = anchor, cert
c.callsCount++
return c.errs[anchor]
}

// A checker that takes the certificate gets the bound certificate itself.
func TestResolve_PassesTheCertificateToACertChecker(t *testing.T) {
ca := newTestCA(t, "Operator CA")
st := memory.New(nil)
cert := ca.validOperator(t, 0x0abd, authz.LevelOperator)
plain, _ := mintFor(t, st, cert, "")
revokedOCSP := apperr.Reasoned(apperr.CodeCertRejected, fleetv1.ErrorReason_ERROR_REASON_REVOKED_OCSP, errors.New("revoked"))
checker := &certChecker{fakeChecker: fakeChecker{errs: map[string]error{fp(ca.cert): revokedOCSP}}}
r := &Resolver{Store: st, Roots: ca.pool, Revoked: checker}

_, err := r.Resolve(context.Background(), plain)
if !errors.Is(err, ErrCertRevoked) {
t.Fatalf("Resolve = %v, want ErrCertRevoked", err)
}
if checker.gotCert == nil || !checker.gotCert.Equal(cert) || checker.gotAnchor != fp(ca.cert) {
t.Fatalf("CheckMCPCert got anchor %q cert %v, want the bound certificate", checker.gotAnchor, checker.gotCert)
}
if checker.gotSerial != "" {
t.Fatal("the serial-only CheckMCP was called as well")
}
}
4 changes: 3 additions & 1 deletion internal/operatorca/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
// over registered rows). TrustStore holds the current anchors as numbered
// generations, serves them to the TLS handshake and re-verifies the peer on
// every request. Revocations combines the manager's denylist with each
// anchor's CRL, keyed by anchor, because serials are unique per issuer only.
// anchor's CRL, keyed by anchor, because serials are unique per issuer only,
// and asks the anchor's OCSP responder about each certificate through
// OCSPClient, which can only add a refusal.
package operatorca

/*
Expand Down
29 changes: 23 additions & 6 deletions internal/operatorca/fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,30 +112,47 @@ func (f *Fetcher) Fetch(ctx context.Context, rawURL string) ([]byte, error) {
}

func (f *Fetcher) do(ctx context.Context, method, rawURL, contentType string, body io.Reader) ([]byte, error) {
b, err := f.request(ctx, method, rawURL, contentType, body)
if err != nil {
return nil, crlUnreachable("%s %s: %v", method, redact(rawURL), err)
}
return b, nil
}

// httpStatusError is a response other than 200 OK.
type httpStatusError struct{ code int }

func (e *httpStatusError) Error() string { return fmt.Sprintf("HTTP %d", e.code) }

var errBodyTooLarge = errors.New("the response is over the size cap")

// request does one fetch with the limits. Its errors carry no response
// body and no query string.
func (f *Fetcher) request(ctx context.Context, method, rawURL, contentType string, body io.Reader) ([]byte, error) {
if err := ValidateFetchURL(rawURL); err != nil {
return nil, crlUnreachable("refused: %v", err)
return nil, fmt.Errorf("refused: %w", err)
}
req, err := http.NewRequestWithContext(ctx, method, rawURL, body)
if err != nil {
return nil, crlUnreachable("refused: %v", err)
return nil, fmt.Errorf("refused: %w", err)
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
resp, err := f.client.Do(req)
if err != nil {
return nil, crlUnreachable("%s %s: %v", method, redact(rawURL), unwrapURLError(err))
return nil, unwrapURLError(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, crlUnreachable("%s %s: HTTP %d", method, redact(rawURL), resp.StatusCode)
return nil, &httpStatusError{code: resp.StatusCode}
}
b, err := io.ReadAll(io.LimitReader(resp.Body, f.maxBytes+1))
if err != nil {
return nil, crlUnreachable("%s %s: read: %v", method, redact(rawURL), err)
return nil, fmt.Errorf("read: %w", err)
}
if int64(len(b)) > f.maxBytes {
return nil, crlUnreachable("%s %s: the response is more than %d bytes", method, redact(rawURL), f.maxBytes)
return nil, fmt.Errorf("%w (%d bytes)", errBodyTooLarge, f.maxBytes)
}
return b, nil
}
Expand Down
Loading
Loading