Skip to content

feat(bootstrap): first-run token, session, operator CA registration and first admin - #160

Merged
Bugs5382 merged 12 commits into
mainfrom
feat/148-bootstrap-first-run
Oct 1, 2026
Merged

Bugs5382 merged 12 commits into
mainfrom
feat/148-bootstrap-first-run

Conversation

@Bugs5382

@Bugs5382 Bugs5382 commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

What and why

First run: a Fleet Manager started with Postgres and no operator CA prints a single-use bootstrap token in its log, and the token holder registers the external operator CA's certificate and checks the first admin certificate that CA signed. The manager signs nothing; the session only teaches it which anchor to trust. First run closes for good the first time an admin certificate signs in.

Closes #148
Refs #78

Merge order: independent of open PRs; based on main after #149, #151, #152, #150, #158 and #159. Merge together with the docs companion CryptOS-PKI/docs#79.

What changes

  • Tokens and sessions (internal/bootstrap):
    • fos_boot_ tokens: 256 random bits in Crockford base32, grouped in fours, typed forgivingly (case, dashes, O/I/L).
    • The token is printed once in a banner next to the server certificate's SHA-256, and only its hash is stored. It is single use: starting a session prints the next one at once. It expires after 60 minutes, and only one is live.
    • The replica holding the fleetos.bootstrap_token advisory lock prints at start and on expiry.
    • fos_bsess_ sessions travel in the Fleetos-Bootstrap-Session header and are stored as hashes. They end after 15 minutes idle or 60 minutes in all, and there is one live session.
  • BootstrapService:
    • GetBootstrapState reports NOT_APPLICABLE for the file source, UNAVAILABLE without Postgres or with firstRun: disabled, and otherwise OPEN, OPEN_IN_PROGRESS or CLOSED.
    • StartBootstrapSession.
    • RegisterOperatorCA:
      • preview, then confirm with confirm_sha256; colons and case don't matter;
      • CRL source url (fetched now), crl_der (checked now) or none (needs NO_CRL);
      • OCSP aia, url or off, with the url-mode probe from feat(operatorca): check operator certificates with the CA's OCSP responder #151;
      • the anchor rules from ValidateAnchor.
      • Confirming stores the CA, its CRL and the epoch bump in one transaction, retires an earlier first-run registration at once, and rebuilds trust with no restart.
      • A new session confirms the registration again: an empty ca_cert_der returns its preview, which is empty, and not counted as a failure, when nothing is registered.
    • SubmitFirstAdminCertificate takes a CSR, or no CSR as a path B pre-flight, and runs the shared profile check. A different later certificate puts the earlier first-admin certificate on the denylist (reason superseded, D14).
  • OCSP at acceptance: CertCheck.CheckRevocation runs the live revocation decision (Revocations.CheckWebCert) inside CheckOperatorCert. First-admin submission and RecordOperatorCredential therefore both refuse REVOKED_OCSP and OCSP_UNKNOWN, and return 1608 under hard with no fresh data.
  • Latch: a new authz.AuthHook runs after a request passes the certificate and revocation checks. The first admin request from either source closes first run. Closing ends sessions, deletes tokens, records an unrecorded certificate as first_admin (path B without a pre-flight), logs the close and audits it. Viewer, operator and revoked admin certificates don't close it, and nothing reopens it. A closed first run that trusts no CA logs why every caller is refused.
  • Rate limits: mcpauth's failure limiter moves to internal/ratelimit; MCP keeps its numbers.
    • Per client address: 5 failures, then 1602, with one back per minute.
    • Globally: 50 failures in an hour rotate the token and end the session.
    • X-Forwarded-For isn't trusted.
  • Transport:
    • Mounted outside the certificate middleware and on the TLS server only; rootMounts keeps it off the authBypass plaintext listener.
    • Every procedure refuses non-TLS requests with 1603.
    • Cross-origin writes are refused unless the origin is in corsOrigins.
    • Bodies are capped at 16 KiB, or 4 MiB plus 64 KiB for RegisterOperatorCA.
    • withCORS allows the session header.
  • Audit: actor kind bootstrap_session on bootstrap-session-started, operator-ca-registered, operator-first-admin-recorded, operator-first-admin-superseded and bootstrap-token-rotated. bootstrap-closed names the admin certificate. Neither secret appears in any error, audit row or log line other than the token's own banner.
  • Node CA found at runtime: operatorca.NodeCAWatch sees the chains nodes report through GetIdentity (node list and detail). When a chain holds a trusted anchor (by certificate or key) it logs an ERROR once and raises a banner flag, keeping the anchor until the next start.
  • Store: store.Bootstrap over the v7 tables, implemented by Postgres with a conformance suite. The in-memory store doesn't implement it, so first run is unavailable there. PutOperatorCRL's transaction body is shared with registration.

Notes

  • full_name errors return 1610 with no sub-reason for now. CryptOS-PKI/api#138 adds FULL_NAME; the manager switches to it in a follow-up once that merges and api is re-pinned.
  • The preview's ocsp_probe stays empty: feat(operatorca): check operator certificates with the CA's OCSP responder #151's probe reports only pass or fail. A confirmed registration means the probe passed.
  • The banner flag from the runtime node-CA check isn't shown by any RPC yet; ListOperatorCAs (operator CA rotation) is the place for it.
  • No new dependency.

Tests

  • 59 new test functions, written first and seen failing for the right reason, plus a 12-case Postgres conformance suite for store.Bootstrap.
  • They cover: tokens and sessions, including a concurrent double consume against Postgres; registration and first admin; the latch; per-client and global limits; plaintext refusal; secrets never leaking; OCSP at acceptance on both paths; and the TLS-only mount.
  • One existing test changed on purpose: TestRegister_NothingRegisteredToPreview now expects an empty preview instead of 1605.
  • Removed with the code it covered: TestValidFullName, since operatorca.ValidateFullName from feat(operators): credential requests, recording and denylist revoke #152 replaces the local check.

Verification

  • Lint clean
  • Tests pass
  • Build succeeds
  • Documentation updated (if behavior or API changed)

How this was verified

  • task ci (gofmt, vet, golangci-lint, the full test suite with a throwaway Postgres for the store tests) passes on this tree, and task license changes nothing.
  • A manager built from this branch ran locally against Postgres. Using an OpenSSL operator CA, curl and PowerShell 7, the whole flow was driven: the banner and fingerprint, a session, a refused reused token (1600), the preview and confirm with an uploaded CRL, path A and path B submissions, the first admin request closing the latch (first run CLOSED by ..., then 1601 everywhere), and a restart printing no token with the same server fingerprint.
  • Docs: docs/first-run.md (new), links from the README and docs/operator-ca.md, and the OCSP refusals in docs/operator-credentials.md. Docs site companion: docs(fleet-manager): first run, from the operator CA to the first admin docs#79.

Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
…first admin and latch

Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
…ts own CA

Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
… latch from the API path

Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
…en accepting an operator certificate

Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
…keep first run off the plaintext listener

Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
…ot a refusal

Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
… revokes

Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
@Bugs5382
Bugs5382 force-pushed the feat/148-bootstrap-first-run branch from 1f4ee9f to 748e8cc Compare October 1, 2026 14:29
@Bugs5382
Bugs5382 marked this pull request as ready for review October 1, 2026 14:29
@github-actions github-actions Bot added the enhancement New feature (feat). Minor version bump. label Oct 1, 2026
@Bugs5382

Bugs5382 commented Oct 1, 2026

Copy link
Copy Markdown
Contributor Author

Merging for the v0.1.0 test run: CI green and mergeable at merge time.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature (feat). Minor version bump.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(bootstrap): first-run token, session, operator CA registration and first admin

1 participant