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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

A node is linked to the FM via **mutual consent**: the operator declares the FM's URL + trust anchor in the node's machine config and reboots; the node calls the FM's `EnrollNode` with a TPM EK attestation; the FM operator approves in the UI; the FM issues the node a 90-day peer cert (EKU `clientAuth` only). Either side can revoke or unlink.

Adopting a fresh node from its maintenance endpoint is safe to retry. The manager mints a bootstrap admin credential per node name and stores it under `MANAGER_NODE_CREDS_DIR` (default `/var/lib/cryptos-manager/node-creds`). A retry reuses it, because a node that got as far as installing trusts only that credential. When the node reports it has already booted its installed system, the retry skips the install, runs the first-boot ceremony only if a Root has not finished it, and registers the node. If the stored credential is gone, the node refuses the handshake and the adoption fails with a hint to reset the node from its console and adopt it again.
Adopting a fresh node from its maintenance endpoint is safe to retry. The manager mints a bootstrap admin credential per node name and stores it under `MANAGER_NODE_CREDS_DIR` (default `/var/lib/cryptos-manager/node-creds`). A retry reuses it, because a node that got as far as installing trusts only that credential. When the node reports it has already booted its installed system, the retry skips the install, runs the first-boot ceremony only if a Root has not finished it, and registers the node. If the stored credential is gone, the node refuses the handshake and the adoption fails with a hint to reset the node from its console and adopt it again. After the node reboots into its installed system, the adoption pauses until an admin confirms the certificate fingerprint it presents against the `Mgmt SHA-256` line on its console; see [docs/node-trust.md](docs/node-trust.md#-what-adoption-records).

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).

Expand Down
1 change: 1 addition & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ Not registered as tools at all:

- `ExportCAKey` and `ImportCAKey` (CA key material); `DecommissionNode`; `AdoptNode`,
`PreviewAdoption` and `ListInstallDisks` (disk wipe and provisioning);
`ConfirmAdoptionFingerprint` (a person confirms a new node's identity from its console);
`ApplyNodeConfig` and `SetNodeProtocol` (a node's configuration and its enrolment
endpoints); `RekeyNode`; `CreateEnrollment` and `ApproveEnrollment` (node admin
credentials, subordinate signing); `RenameNode`; `RevokeOperatorCredential`, the
Expand Down
47 changes: 36 additions & 11 deletions docs/node-trust.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,28 +102,53 @@ Adoption records what the manager needs to verify the node afterwards:

1. The fingerprint you confirm in the adopt wizard pins the node's
maintenance-mode certificate for the install steps.
2. The installed node boots with a new management certificate. When it comes
back, the manager saves that certificate as the node's `server.crt` and
shows its fingerprint in the adoption progress:
2. The installed node boots with a new management certificate. Nothing links
it to the maintenance certificate you confirmed, so adoption pauses on the
`awaiting-fingerprint-confirmation` phase and shows the fingerprint the
node presents:

```text
node is back in running mode; pinned its management certificate sha256 5a0e...c3. Check it against the Mgmt SHA-256 line on the node's console
the node is back in running mode and presents certificate sha256 5a0e...c3. Compare it with the Mgmt SHA-256 line on the node's console and confirm it to continue
```

> [!CAUTION]
> This pin is trust on first use: nothing links the installed node's new
> certificate to the maintenance certificate you confirmed. Compare the
> fingerprint with the `Mgmt SHA-256` line on the node's console. If they
> differ, something other than your node answered on its address; stop and
> find out what before you use the node.
> Compare the fingerprint with the `Mgmt SHA-256` line on the node's
> console before you confirm it. If they differ, something other than your
> node answered on its address: choose "Does not match" in the wizard and
> find out what answered before you adopt again.

Confirming (the adopt wizard, or `ConfirmAdoptionFingerprint` with the
stream's `adoption_id`) resumes the adoption. Only then does the manager
save that certificate as the node's `server.crt` and dial the node,
verified against it. The comparison ignores case, colons and spaces.

The adoption stops, and nothing is pinned, recorded or registered, when:

- the confirmed fingerprint differs from the presented one (the call and
the adoption fail with `InvalidArgument`);
- nobody confirms within 15 minutes (`DeadlineExceeded`);
- the wizard cancels or the adoption stream is closed.

The manager admin credential minted for the adoption stays, so adopting
the node again resumes from here. Both a confirmation and a refused
fingerprint are audited (`node-adoption-fingerprint-confirmed`,
`node-adoption-fingerprint-rejected`) with the operator who sent it.

> [!WARNING]
> The waiting adoption lives in the manager replica that runs the adoption
> stream. With `replicaCount` above 1, a confirmation that reaches another
> replica returns `NotFound`; send it again so it reaches the replica that
> holds the stream, or adopt with a single replica.

3. A root's ceremony gives it its CA. The manager reads the CA chain from the
node and saves it as `ca.crt` next to the admin certificate. An intermediate
or issuing node gets its CA when its subordinate enrollment is approved, and
the manager saves the signed chain the same way.

A retried adoption replaces the node's old `server.crt` with the certificate
the re-installed node presents.
A retried adoption asks you to confirm the certificate the node presents
again, then replaces the node's old `server.crt` with it. A root that has
already run its ceremony presents a CA-signed certificate by then; confirm that
one the same way.

## 🔄 When the node gets its CA

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ require (
)

require (
github.com/CryptOS-PKI/api v0.0.0-20260930170156-1de16da02cdf
github.com/CryptOS-PKI/api v0.0.0-20260930184803-29b56ecd3e46
golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ github.com/Bugs5382/go-apperr v1.0.0 h1:YfepdvZm+ctIt5DDJ9XVbwPRk+lo9aE95btYqaSf
github.com/Bugs5382/go-apperr v1.0.0/go.mod h1:4kzGmhaC5tcPDskwAi8HIRDKSkMrJdvOUxik9Uuij1k=
github.com/Bugs5382/go-log v1.3.0 h1:lJv2iv6yL9l1Eqii62ArcJ1eoKbpLFdrY7L9gPNSqcI=
github.com/Bugs5382/go-log v1.3.0/go.mod h1:OGgIWWKKNg2AXVJlbVJZb/s8dnqqsrKwWznYXik547E=
github.com/CryptOS-PKI/api v0.0.0-20260930170156-1de16da02cdf h1:ZnVAluaEprpxQA8AXLafPoip1J4swQloeBOhJFnHcw4=
github.com/CryptOS-PKI/api v0.0.0-20260930170156-1de16da02cdf/go.mod h1:tmeaYhNoUblqPYYNtTbGBGIUsZ7xLATgLdKq4S33rmE=
github.com/CryptOS-PKI/api v0.0.0-20260930184803-29b56ecd3e46 h1:vLGhyS1TdPo3CVVZsOHXrD0TxICVhtoHW3x5XlKEwX8=
github.com/CryptOS-PKI/api v0.0.0-20260930184803-29b56ecd3e46/go.mod h1:/x//qWOzXOQ3lQDa3xy4ncsVZWv1KHEgvV+IcxAGSAQ=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand Down
150 changes: 85 additions & 65 deletions internal/fleet/adopt.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ limitations under the License.
import (
"context"
"crypto/sha256"
"crypto/x509"
"encoding/hex"
"encoding/json"
"errors"
Expand Down Expand Up @@ -143,10 +144,8 @@ func (s *Service) AdoptNode(ctx context.Context, req *connect.Request[fleetv1.Ad
if err := requireAdmin(ctx); err != nil {
return err
}
sink := func(phase, detail string, done bool) error {
return stream.Send(s.adoptResponse(req.Msg, phase, detail, done))
}
return s.runAdoption(ctx, req.Msg, sink)
id := newAdoptionID()
return s.runAdoptionAs(ctx, id, req.Msg, s.adoptSink(id, req.Msg, stream.Send))
}

// adoptResponse builds one streamed adoption message. The final message of a
Expand All @@ -161,11 +160,17 @@ func (s *Service) adoptResponse(msg *fleetv1.AdoptNodeRequest, phase, detail str
return resp
}

// runAdoption is the transport-independent adoption orchestration, driving the
// phaseSink so the Connect handler and tests share one code path. A step that
// fails streams an error phase (done) and returns a Connect error; it never
// hangs — the reboot wait is bounded.
// runAdoption runs an adoption under a new adoption ID.
func (s *Service) runAdoption(ctx context.Context, msg *fleetv1.AdoptNodeRequest, send phaseSink) error {
return s.runAdoptionAs(ctx, newAdoptionID(), msg, send)
}

// runAdoptionAs is the transport-independent adoption orchestration, driving
// the phaseSink so the Connect handler and tests share one code path. id names
// the adoption for ConfirmAdoptionFingerprint. A step that fails streams an
// error phase (done) and returns a Connect error; it never hangs — the reboot
// and confirmation waits are bounded.
func (s *Service) runAdoptionAs(ctx context.Context, id string, msg *fleetv1.AdoptNodeRequest, send phaseSink) error {
if s.dialMaintenance == nil {
return connect.NewError(connect.CodeUnimplemented, errors.New("fleet: adoption not configured"))
}
Expand Down Expand Up @@ -244,20 +249,33 @@ func (s *Service) runAdoption(ctx context.Context, msg *fleetv1.AdoptNodeRequest
}
}

// Wait for the running node, dialing with the admin cert it trusts (a
// managed dial, no maintenance pin: the running server cert differs). The
// certificate it comes back with is pinned first, so the dial verifies it.
conn, pinnedSHA256, err := s.awaitRunningNode(ctx, nodeName, endpoint, adminCertPath, adminKeyPath)
// Wait for the running node. The certificate it comes back with has no
// link to the maintenance certificate the operator confirmed, so the
// adoption pauses until the operator confirms it from the node's console;
// only then is it pinned and the node dialed with the admin cert it trusts
// (a managed dial, verified against the pin).
node := store.Node{Name: nodeName, Endpoint: endpoint, AdminCert: adminCertPath, AdminKey: adminKeyPath}
running, err := s.awaitRunningCert(ctx, node)
if err != nil {
return s.adoptFail(send, connect.CodeDeadlineExceeded, err)
}
defer func() { _ = conn.Close() }()
if pinnedSHA256 != "" {
if err := send(phaseAwaitingReboot, fmt.Sprintf(
"node is back in running mode; pinned its management certificate sha256 %s. Check it against the Mgmt SHA-256 line on the node's console", pinnedSHA256), false); err != nil {
return err
if running != nil {
sum := sha256.Sum256(running.Raw)
presented := hex.EncodeToString(sum[:])
if err := s.awaitFingerprintConfirm(ctx, id, nodeName, endpoint, presented, send); err != nil {
return s.adoptFailErr(send, err)
}
path, err := nodeclient.WritePin(node, running)
if err != nil {
return s.adoptFail(send, connect.CodeInternal, err)
}
log.Printf("fleet: adopt %s: pinned the confirmed management certificate sha256 %s at %s", nodeName, presented, path)
}
conn, err = s.awaitRunningNode(ctx, node)
if err != nil {
return s.adoptFail(send, connect.CodeDeadlineExceeded, err)
}
defer func() { _ = conn.Close() }()

// A root self-signs its CA via the first-boot ceremony. A subordinate
// (intermediate/issuing) node cannot: the node has already staged its own
Expand Down Expand Up @@ -332,71 +350,62 @@ func (s *Service) runAdoption(ctx context.Context, msg *fleetv1.AdoptNodeRequest
return send(phaseEstablished, "node adopted and established", true)
}

// awaitRunningNode waits for the node to finish installing, self-reboot, and
// come back in RUNNING mode: serving mTLS with the bootstrap admin trust,
// awaiting the first-boot ceremony. It dials with a managed admin-cert dial
// (no maintenance pin — the running server cert differs from the maintenance
// one) and confirms a cheap RPC before returning, bounded by rebootWait so a
// node that never returns streams an error rather than hanging. A grace period
// lets the node begin rebooting before the first poll.
//
// With a capture seam, every poll first reads the certificate the node
// presents and pins it (replacing any pin from an earlier adoption), so the
// dial verifies the node against it. The node's new certificate has no link
// to the maintenance certificate the operator confirmed, so this is trust on
// first use; the returned SHA-256 is streamed for the operator to check
// against the node's console.
func (s *Service) awaitRunningNode(ctx context.Context, name, endpoint, adminCertPath, adminKeyPath string) (NodeConn, string, error) {
// awaitRunningCert waits for the node to finish installing, self-reboot and
// come back in RUNNING mode, and returns the certificate it presents there.
// Nothing is pinned: the caller has the operator confirm it first. Without a
// capture seam it returns nil and the caller dials as before. Bounded by
// rebootWait after a grace period that lets the node begin rebooting.
func (s *Service) awaitRunningCert(ctx context.Context, node store.Node) (*x509.Certificate, error) {
select {
case <-ctx.Done():
return nil, "", ctx.Err()
return nil, ctx.Err()
case <-time.After(rebootPollGrace):
}

node := store.Node{Name: name, Endpoint: endpoint, AdminCert: adminCertPath, AdminKey: adminKeyPath}
if s.captureServerCert == nil {
return nil, nil
}
deadline := time.Now().Add(rebootWait)
for {
pinned, perr := s.pinRunningCert(node)
if perr == nil {
conn, err := s.dial(node)
if err == nil {
// A lazy gRPC client dial can succeed before the server is up;
// confirm the node answers a cheap RPC before proceeding.
if _, serr := conn.GetStatus(ctx); serr == nil {
return conn, pinned, nil
}
_ = conn.Close()
}
cert, err := s.captureServerCert(node)
if err == nil {
return cert, nil
}
if time.Now().After(deadline) {
return nil, "", fmt.Errorf("fleet: node did not come back in running mode on %s within %s of install", endpoint, rebootWait)
return nil, fmt.Errorf("fleet: node did not come back in running mode on %s within %s of install: %w", node.Endpoint, rebootWait, err)
}
select {
case <-ctx.Done():
return nil, "", ctx.Err()
return nil, ctx.Err()
case <-time.After(rebootPollGap):
}
}
}

// pinRunningCert captures the certificate node presents and pins it,
// returning its SHA-256. Without a capture seam it pins nothing.
func (s *Service) pinRunningCert(node store.Node) (string, error) {
if s.captureServerCert == nil {
return "", nil
}
cert, err := s.captureServerCert(node)
if err != nil {
return "", err
}
path, err := nodeclient.WritePin(node, cert)
if err != nil {
return "", err
// awaitRunningNode dials the running node with a managed admin-cert dial
// (verified against the confirmed pin) and confirms a cheap RPC before
// returning, bounded by rebootWait so a node that never answers streams an
// error rather than hanging.
func (s *Service) awaitRunningNode(ctx context.Context, node store.Node) (NodeConn, error) {
deadline := time.Now().Add(rebootWait)
for {
conn, err := s.dial(node)
if err == nil {
// A lazy gRPC client dial can succeed before the server is up;
// confirm the node answers a cheap RPC before proceeding.
if _, serr := conn.GetStatus(ctx); serr == nil {
return conn, nil
}
_ = conn.Close()
}
if time.Now().After(deadline) {
return nil, fmt.Errorf("fleet: node did not come back in running mode on %s within %s of install", node.Endpoint, rebootWait)
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(rebootPollGap):
}
}
sum := sha256.Sum256(cert.Raw)
fp := hex.EncodeToString(sum[:])
log.Printf("fleet: adopt %s: pinned the running node's management certificate sha256 %s at %s (trust on first use; check it against the node's console)", node.Name, fp, path)
return fp, nil
}

// adoptFail streams a terminal error phase and returns the Connect error, so a
Expand All @@ -407,6 +416,17 @@ func (s *Service) adoptFail(send phaseSink, code connect.Code, cause error) erro
return connect.NewError(code, cause)
}

// adoptFailErr is adoptFail for an error that already carries its Connect
// code.
func (s *Service) adoptFailErr(send phaseSink, err error) error {
var cerr *connect.Error
if errors.As(err, &cerr) {
_ = send(phaseError, cerr.Message(), true)
return err
}
return s.adoptFail(send, connect.CodeInternal, err)
}

// relayCeremony forwards every ceremony event as a ceremony phase and reports
// whether the stream reached COMPLETE. io.EOF ends the stream cleanly.
func relayCeremony(stream interface {
Expand Down
Loading
Loading