From ef26df33fb8d923afdc25d285e05ed991baba12e Mon Sep 17 00:00:00 2001 From: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:20:17 -0400 Subject: [PATCH] feat(adopt): confirm the installed node's fingerprint before trusting it After a node installs and reboots during adoption it presents a new management certificate that nothing links to the maintenance certificate the operator confirmed. Adoption no longer pins it on first use. It captures the certificate, streams the awaiting-fingerprint-confirmation phase with its SHA-256 and the adoption ID, and waits for ConfirmAdoptionFingerprint. A matching fingerprint (case, colons and spaces ignored) resumes the adoption, and only then is the certificate pinned and the node dialed against it. A mismatch, no confirmation within 15 minutes, or the client going away fails the adoption with nothing pinned, recorded or registered. Confirmations and refusals are audited with the operator. The new RPC is excluded from the MCP catalog. Closes #144 Refs #135 Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> --- README.md | 2 +- docs/mcp.md | 1 + docs/node-trust.md | 47 +++- go.mod | 2 +- go.sum | 4 +- internal/fleet/adopt.go | 150 ++++++----- internal/fleet/adopt_confirm.go | 200 ++++++++++++++ internal/fleet/adopt_confirm_test.go | 376 +++++++++++++++++++++++++++ internal/fleet/nodetrust_test.go | 33 ++- internal/fleet/service.go | 4 + internal/mcpserver/catalog.go | 1 + 11 files changed, 732 insertions(+), 88 deletions(-) create mode 100644 internal/fleet/adopt_confirm.go create mode 100644 internal/fleet/adopt_confirm_test.go diff --git a/README.md b/README.md index 45132f7..22fa9ff 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/docs/mcp.md b/docs/mcp.md index 65a94de..ea30257 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -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 diff --git a/docs/node-trust.md b/docs/node-trust.md index 6046f63..d9fb706 100644 --- a/docs/node-trust.md +++ b/docs/node-trust.md @@ -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 diff --git a/go.mod b/go.mod index 4381f91..6aa5586 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index d7a094a..c602257 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/fleet/adopt.go b/internal/fleet/adopt.go index 6ad94fd..1b944ed 100644 --- a/internal/fleet/adopt.go +++ b/internal/fleet/adopt.go @@ -19,6 +19,7 @@ limitations under the License. import ( "context" "crypto/sha256" + "crypto/x509" "encoding/hex" "encoding/json" "errors" @@ -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 @@ -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")) } @@ -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 @@ -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 @@ -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 { diff --git a/internal/fleet/adopt_confirm.go b/internal/fleet/adopt_confirm.go new file mode 100644 index 0000000..6231dfc --- /dev/null +++ b/internal/fleet/adopt_confirm.go @@ -0,0 +1,200 @@ +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/rand" + "encoding/hex" + "errors" + "fmt" + "log" + "strings" + "sync" + "time" + "unicode" + + connect "connectrpc.com/connect" + fleetv1 "github.com/CryptOS-PKI/api/go/cryptos/fleet/v1" + "github.com/CryptOS-PKI/manager/internal/auditlog" + "github.com/CryptOS-PKI/manager/internal/store" +) + +// phaseAwaitingFingerprint is streamed, with the presented SHA-256, while an +// adoption waits for the operator to confirm the installed node's certificate. +const phaseAwaitingFingerprint = "awaiting-fingerprint-confirmation" + +// confirmWait bounds how long an adoption waits for the operator to confirm +// the installed node's fingerprint. It is a var so tests can shrink it. +var confirmWait = 15 * time.Minute + +// errFingerprintMismatch is what a waiting adoption receives when the +// operator confirms a fingerprint the node does not present. +var errFingerprintMismatch = errors.New("fleet: the confirmed fingerprint does not match the certificate the installed node presents; nothing was trusted or recorded. Find out what answered on the node's address before adopting again") + +// pendingConfirm is one adoption waiting for its fingerprint confirmation. +type pendingConfirm struct { + node string + endpoint string + presented string + result chan error +} + +// adoptionWaits holds the adoptions waiting for a confirmation, keyed by +// adoption ID. It lives in the replica that runs the adoption stream. +type adoptionWaits struct { + mu sync.Mutex + m map[string]*pendingConfirm +} + +func (w *adoptionWaits) add(id string, p *pendingConfirm) { + w.mu.Lock() + defer w.mu.Unlock() + if w.m == nil { + w.m = map[string]*pendingConfirm{} + } + w.m[id] = p +} + +// take removes and returns the adoption waiting under id. +func (w *adoptionWaits) take(id string) (*pendingConfirm, bool) { + w.mu.Lock() + defer w.mu.Unlock() + p, ok := w.m[id] + delete(w.m, id) + return p, ok +} + +// presented returns the fingerprint the adoption under id waits on, or "". +func (w *adoptionWaits) presented(id string) string { + w.mu.Lock() + defer w.mu.Unlock() + if p, ok := w.m[id]; ok { + return p.presented + } + return "" +} + +func newAdoptionID() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic(fmt.Sprintf("fleet: newAdoptionID: %v", err)) + } + return "adp-" + hex.EncodeToString(b) +} + +// normalizeFingerprint drops colons and whitespace and lowercases, so a +// fingerprint copied from the console or openssl compares equal to hex. +func normalizeFingerprint(s string) string { + return strings.Map(func(r rune) rune { + if r == ':' || unicode.IsSpace(r) { + return -1 + } + return unicode.ToLower(r) + }, s) +} + +// adoptSink builds the phase sink the AdoptNode handler streams through: +// every message carries the adoption ID, and the awaiting phase carries the +// presented fingerprint. +func (s *Service) adoptSink(id string, msg *fleetv1.AdoptNodeRequest, send func(*fleetv1.AdoptNodeResponse) error) phaseSink { + return func(phase, detail string, done bool) error { + resp := s.adoptResponse(msg, phase, detail, done) + resp.AdoptionId = id + if phase == phaseAwaitingFingerprint { + resp.PresentedCertSha256 = s.adoptions.presented(id) + } + return send(resp) + } +} + +// awaitFingerprintConfirm streams the awaiting phase with presented and +// blocks until the operator confirms it through ConfirmAdoptionFingerprint, +// the wait expires, or the client goes away. Only a matching confirmation +// returns nil. +func (s *Service) awaitFingerprintConfirm(ctx context.Context, id, nodeName, endpoint, presented string, send phaseSink) error { + p := &pendingConfirm{node: nodeName, endpoint: endpoint, presented: presented, result: make(chan error, 1)} + s.adoptions.add(id, p) + defer s.adoptions.take(id) + log.Printf("fleet: adopt %s (%s): waiting up to %s for the operator to confirm the installed node's certificate sha256 %s", nodeName, id, confirmWait, presented) + + if err := send(phaseAwaitingFingerprint, fmt.Sprintf( + "the node is back in running mode and presents certificate sha256 %s. Compare it with the Mgmt SHA-256 line on the node's console and confirm it to continue", presented), false); err != nil { + return err + } + + timer := time.NewTimer(confirmWait) + defer timer.Stop() + select { + case err := <-p.result: + if err != nil { + log.Printf("fleet: adopt %s (%s): fingerprint refused: %v", nodeName, id, err) + return connect.NewError(connect.CodeInvalidArgument, err) + } + log.Printf("fleet: adopt %s (%s): fingerprint confirmed", nodeName, id) + return nil + case <-timer.C: + log.Printf("fleet: adopt %s (%s): no fingerprint confirmation within %s", nodeName, id, confirmWait) + return connect.NewError(connect.CodeDeadlineExceeded, fmt.Errorf( + "fleet: the installed node's fingerprint was not confirmed within %s; nothing was trusted or recorded. Adopt the node again to resume", confirmWait)) + case <-ctx.Done(): + log.Printf("fleet: adopt %s (%s): the client went away while waiting for the fingerprint confirmation", nodeName, id) + return connect.NewError(connect.CodeCanceled, fmt.Errorf("fleet: adoption stopped before the fingerprint was confirmed: %w", ctx.Err())) + } +} + +// ConfirmAdoptionFingerprint confirms the certificate an installed node +// presents, so the adoption waiting under adoption_id continues. The +// fingerprint must equal the presented one (case, colons and whitespace +// ignored); a mismatch fails the adoption and nothing is trusted or recorded. +// Admin-gated; both outcomes are audited. +func (s *Service) ConfirmAdoptionFingerprint(ctx context.Context, req *connect.Request[fleetv1.ConfirmAdoptionFingerprintRequest]) (*connect.Response[fleetv1.ConfirmAdoptionFingerprintResponse], error) { + if err := requireAdmin(ctx); err != nil { + return nil, err + } + id := req.Msg.GetAdoptionId() + confirmed := normalizeFingerprint(req.Msg.GetCertSha256()) + if id == "" || confirmed == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("fleet: adoption_id and cert_sha256 are required")) + } + p, ok := s.adoptions.take(id) + if !ok { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("fleet: no adoption %s is waiting for a fingerprint confirmation", id)) + } + + if confirmed != p.presented { + p.result <- errFingerprintMismatch + auditlog.Record(ctx, s.store, store.AuditEvent{ + ID: newAuditID(), + Kind: "node-adoption-fingerprint-rejected", + Outcome: auditlog.OutcomeDenied, + Summary: fmt.Sprintf("Refused adoption of node %s at %s: confirmed sha256 %s, but the node presents %s", p.node, p.endpoint, confirmed, p.presented), + TargetKind: "adoption", + TargetPath: "/adoptions/" + id, + }) + return nil, connect.NewError(connect.CodeInvalidArgument, errFingerprintMismatch) + } + + p.result <- nil + auditlog.Record(ctx, s.store, store.AuditEvent{ + ID: newAuditID(), + Kind: "node-adoption-fingerprint-confirmed", + Summary: fmt.Sprintf("Confirmed certificate sha256 %s for the adoption of node %s at %s", p.presented, p.node, p.endpoint), + TargetKind: "adoption", + TargetPath: "/adoptions/" + id, + }) + return connect.NewResponse(&fleetv1.ConfirmAdoptionFingerprintResponse{}), nil +} diff --git a/internal/fleet/adopt_confirm_test.go b/internal/fleet/adopt_confirm_test.go new file mode 100644 index 0000000..a7e02c5 --- /dev/null +++ b/internal/fleet/adopt_confirm_test.go @@ -0,0 +1,376 @@ +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/sha256" + "crypto/x509" + "encoding/hex" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "connectrpc.com/connect" + fleetv1 "github.com/CryptOS-PKI/api/go/cryptos/fleet/v1" + cryptosv1 "github.com/CryptOS-PKI/api/go/cryptos/v1" + "github.com/CryptOS-PKI/manager/internal/auditlog" + "github.com/CryptOS-PKI/manager/internal/authz" + "github.com/CryptOS-PKI/manager/internal/nodeclient" + "github.com/CryptOS-PKI/manager/internal/store" + "github.com/CryptOS-PKI/manager/internal/store/memory" +) + +// pauseSink records phases and signals when the adoption waits for the +// installed node's fingerprint. +type pauseSink struct { + mu sync.Mutex + phases []string + details []string + paused chan string +} + +func newPauseSink() *pauseSink { return &pauseSink{paused: make(chan string, 1)} } + +func (p *pauseSink) send(phase, detail string, _ bool) error { + p.mu.Lock() + p.phases = append(p.phases, phase) + p.details = append(p.details, detail) + p.mu.Unlock() + if phase == phaseAwaitingFingerprint { + p.paused <- detail + } + return nil +} + +func (p *pauseSink) seen() []string { + p.mu.Lock() + defer p.mu.Unlock() + return append([]string(nil), p.phases...) +} + +// confirmFixture is a root adoption whose installed node presents running. +type confirmFixture struct { + svc *Service + st *memory.Store + running *x509.Certificate + dials *int + mu *sync.Mutex +} + +func newConfirmFixture(t *testing.T) confirmFixture { + t.Helper() + adoptCredsBaseDir = t.TempDir() + st := memory.New(nil) + running := fakeRunningCert(t) + mconn := &fakeConn{applyConfigResp: &cryptosv1.ApplyConfigResponse{RequiresReboot: true, Generation: 1}} + runningConn := &fakeConn{ + status: &cryptosv1.GetStatusResponse{}, + identity: rootIdentity(t), + ceremonyStream: &scriptedCeremony{kinds: []cryptosv1.CeremonyEventKind{ + cryptosv1.CeremonyEventKind_CEREMONY_EVENT_KIND_COMPLETE, + }}, + } + dials := 0 + var mu sync.Mutex + svc := New(st, func(store.Node) (NodeConn, error) { + mu.Lock() + dials++ + mu.Unlock() + return runningConn, nil + }). + WithAdoption(nil, func(string, string, string, string) (NodeConn, error) { return mconn, nil }). + WithServerCertCapture(func(store.Node) (*x509.Certificate, error) { return running, nil }) + t.Cleanup(setRebootTiming(5*time.Millisecond, time.Millisecond, time.Millisecond)) + return confirmFixture{svc: svc, st: st, running: running, dials: &dials, mu: &mu} +} + +func (f confirmFixture) dialCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return *f.dials +} + +func sha256Hex(cert *x509.Certificate) string { + sum := sha256.Sum256(cert.Raw) + return hex.EncodeToString(sum[:]) +} + +// colonUpper renders a hex fingerprint the way a console or openssl shows it. +func colonUpper(fp string) string { + var parts []string + for i := 0; i < len(fp); i += 2 { + parts = append(parts, strings.ToUpper(fp[i:i+2])) + } + return strings.Join(parts, ":") +} + +func startAdoption(f confirmFixture, id string, sink *pauseSink) chan error { + done := make(chan error, 1) + go func() { + done <- f.svc.runAdoptionAs(context.Background(), id, &fleetv1.AdoptNodeRequest{ + Endpoint: "192.0.2.30:4443", PinnedCertSha256: "abc", Config: adoptConfig(), + }, sink.send) + }() + return done +} + +func waitPaused(t *testing.T, sink *pauseSink) string { + t.Helper() + select { + case detail := <-sink.paused: + return detail + case <-time.After(2 * time.Second): + t.Fatalf("adoption never paused for the fingerprint; phases = %v", sink.seen()) + return "" + } +} + +func waitDone(t *testing.T, done chan error) error { + t.Helper() + select { + case err := <-done: + return err + case <-time.After(2 * time.Second): + t.Fatal("adoption did not finish") + return nil + } +} + +func confirm(svc *Service, ctx context.Context, id, sha string) error { + _, err := svc.ConfirmAdoptionFingerprint(ctx, connect.NewRequest(&fleetv1.ConfirmAdoptionFingerprintRequest{ + AdoptionId: id, CertSha256: sha, + })) + return err +} + +func pinPath() string { + return filepath.Join(adoptCredsBaseDir, "new-node", nodeclient.ServerCertFile) +} + +func auditByKind(st *memory.Store) map[string]store.AuditEvent { + out := map[string]store.AuditEvent{} + for _, e := range st.Audit() { + out[e.Kind] = e + } + return out +} + +func TestRunAdoption_PausesUntilTheInstalledFingerprintIsConfirmed(t *testing.T) { + f := newConfirmFixture(t) + sink := newPauseSink() + done := startAdoption(f, "adopt-1", sink) + + detail := waitPaused(t, sink) + want := sha256Hex(f.running) + if !strings.Contains(detail, want) { + t.Errorf("paused detail = %q, want the presented fingerprint %s", detail, want) + } + if got := f.svc.adoptions.presented("adopt-1"); got != want { + t.Errorf("presented fingerprint = %q, want %q", got, want) + } + time.Sleep(20 * time.Millisecond) + if _, err := os.Stat(pinPath()); !errors.Is(err, os.ErrNotExist) { + t.Errorf("the running certificate was pinned before the operator confirmed it (stat err = %v)", err) + } + if n := f.dialCount(); n != 0 { + t.Errorf("the node was dialed %d time(s) before its fingerprint was confirmed", n) + } + if _, ok := f.st.Node("new-node"); ok { + t.Error("the node was registered before its fingerprint was confirmed") + } + + ctx := operatorCtx("admin@example.org", authz.LevelAdmin) + if err := confirm(f.svc, ctx, "adopt-1", " "+colonUpper(want)+" "); err != nil { + t.Fatalf("ConfirmAdoptionFingerprint() error = %v", err) + } + if err := waitDone(t, done); err != nil { + t.Fatalf("runAdoption() error = %v", err) + } + + pins := readPEMCerts(t, pinPath()) + if len(pins) != 1 || string(pins[0]) != string(f.running.Raw) { + t.Errorf("server.crt = %d cert(s), want exactly the confirmed certificate", len(pins)) + } + if !containsPhase(sink.seen(), phaseEstablished) { + t.Errorf("phases = %v, want established", sink.seen()) + } + kinds := auditByKind(f.st) + ev, ok := kinds["node-adoption-fingerprint-confirmed"] + if !ok { + t.Fatalf("audit = %+v, want a node-adoption-fingerprint-confirmed event", f.st.Audit()) + } + if ev.ActorCN != "admin@example.org" || ev.Outcome != auditlog.OutcomeOK || !strings.Contains(ev.Summary, want) { + t.Errorf("confirm audit = %+v, want the admin, outcome ok and the fingerprint", ev) + } + if _, ok := kinds["node-adopted"]; !ok { + t.Error("no node-adopted audit event after a confirmed adoption") + } +} + +func TestRunAdoption_MismatchedFingerprint_FailsAndRecordsNothing(t *testing.T) { + f := newConfirmFixture(t) + sink := newPauseSink() + done := startAdoption(f, "adopt-2", sink) + waitPaused(t, sink) + + ctx := operatorCtx("admin@example.org", authz.LevelAdmin) + wrong := strings.Repeat("0", 64) + requireConnectCode(t, confirm(f.svc, ctx, "adopt-2", wrong), connect.CodeInvalidArgument) + + err := waitDone(t, done) + requireConnectCode(t, err, connect.CodeInvalidArgument) + if !strings.Contains(err.Error(), "does not match") { + t.Errorf("error = %q, want it to say the fingerprint does not match", err) + } + if !containsPhase(sink.seen(), phaseError) { + t.Errorf("phases = %v, want a terminal error phase", sink.seen()) + } + if _, err := os.Stat(pinPath()); !errors.Is(err, os.ErrNotExist) { + t.Errorf("a refused fingerprint was pinned (stat err = %v)", err) + } + if _, err := os.Stat(filepath.Join(adoptCredsBaseDir, "new-node", caChainFile)); !errors.Is(err, os.ErrNotExist) { + t.Errorf("a CA chain was recorded for a refused node (stat err = %v)", err) + } + if _, ok := f.st.Node("new-node"); ok { + t.Error("a refused node was registered") + } + if n := f.dialCount(); n != 0 { + t.Errorf("a refused node was dialed %d time(s)", n) + } + kinds := auditByKind(f.st) + if _, ok := kinds["node-adopted"]; ok { + t.Error("a refused adoption wrote node-adopted") + } + ev, ok := kinds["node-adoption-fingerprint-rejected"] + if !ok || ev.Outcome != auditlog.OutcomeDenied { + t.Errorf("audit = %+v, want a denied node-adoption-fingerprint-rejected event", f.st.Audit()) + } + requireConnectCode(t, confirm(f.svc, ctx, "adopt-2", sha256Hex(f.running)), connect.CodeNotFound) +} + +func TestRunAdoption_NoConfirmation_TimesOutAndRecordsNothing(t *testing.T) { + f := newConfirmFixture(t) + old := confirmWait + confirmWait = 20 * time.Millisecond + t.Cleanup(func() { confirmWait = old }) + sink := newPauseSink() + done := startAdoption(f, "adopt-3", sink) + waitPaused(t, sink) + + requireConnectCode(t, waitDone(t, done), connect.CodeDeadlineExceeded) + if _, err := os.Stat(pinPath()); !errors.Is(err, os.ErrNotExist) { + t.Errorf("an unconfirmed certificate was pinned (stat err = %v)", err) + } + if _, ok := f.st.Node("new-node"); ok { + t.Error("an unconfirmed node was registered") + } + ctx := operatorCtx("admin@example.org", authz.LevelAdmin) + requireConnectCode(t, confirm(f.svc, ctx, "adopt-3", sha256Hex(f.running)), connect.CodeNotFound) +} + +func TestRunAdoption_ClientGoneWhileWaiting_RecordsNothing(t *testing.T) { + f := newConfirmFixture(t) + sink := newPauseSink() + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- f.svc.runAdoptionAs(ctx, "adopt-4", &fleetv1.AdoptNodeRequest{ + Endpoint: "192.0.2.30:4443", PinnedCertSha256: "abc", Config: adoptConfig(), + }, sink.send) + }() + waitPaused(t, sink) + cancel() + + if err := waitDone(t, done); err == nil { + t.Fatal("runAdoption() = nil after the client went away, want an error") + } + if _, err := os.Stat(pinPath()); !errors.Is(err, os.ErrNotExist) { + t.Errorf("a cancelled adoption pinned the certificate (stat err = %v)", err) + } + if _, ok := f.st.Node("new-node"); ok { + t.Error("a cancelled adoption registered the node") + } + if got := f.svc.adoptions.presented("adopt-4"); got != "" { + t.Error("a cancelled adoption is still waiting for a confirmation") + } +} + +func TestConfirmAdoptionFingerprint_Refusals(t *testing.T) { + svc := New(memory.New(nil), dialFor(nil)) + admin := operatorCtx("admin@example.org", authz.LevelAdmin) + requireConnectCode(t, confirm(svc, operatorCtx("op@example.org", authz.LevelOperator), "adopt-x", "ab"), connect.CodePermissionDenied) + requireConnectCode(t, confirm(svc, admin, "", "ab"), connect.CodeInvalidArgument) + requireConnectCode(t, confirm(svc, admin, "adopt-x", " : "), connect.CodeInvalidArgument) + requireConnectCode(t, confirm(svc, admin, "adopt-x", strings.Repeat("a", 64)), connect.CodeNotFound) +} + +// Every streamed message names the adoption, and the paused one carries the +// fingerprint the operator compares with the console. +func TestAdoptStreamMessages_CarryAdoptionIDAndPresentedFingerprint(t *testing.T) { + f := newConfirmFixture(t) + var got []*fleetv1.AdoptNodeResponse + var mu sync.Mutex + paused := make(chan struct{}, 1) + sink := f.svc.adoptSink("adopt-5", &fleetv1.AdoptNodeRequest{Config: adoptConfig()}, func(m *fleetv1.AdoptNodeResponse) error { + mu.Lock() + got = append(got, m) + mu.Unlock() + if m.GetPhase() == phaseAwaitingFingerprint { + paused <- struct{}{} + } + return nil + }) + done := make(chan error, 1) + go func() { + done <- f.svc.runAdoptionAs(context.Background(), "adopt-5", &fleetv1.AdoptNodeRequest{ + Endpoint: "192.0.2.30:4443", PinnedCertSha256: "abc", Config: adoptConfig(), + }, sink) + }() + select { + case <-paused: + case <-time.After(2 * time.Second): + t.Fatal("adoption never paused") + } + if err := confirm(f.svc, operatorCtx("admin@example.org", authz.LevelAdmin), "adopt-5", sha256Hex(f.running)); err != nil { + t.Fatal(err) + } + if err := waitDone(t, done); err != nil { + t.Fatal(err) + } + + mu.Lock() + defer mu.Unlock() + sawPresented := false + for _, m := range got { + if m.GetAdoptionId() != "adopt-5" { + t.Errorf("phase %s adoption_id = %q, want adopt-5", m.GetPhase(), m.GetAdoptionId()) + } + switch { + case m.GetPhase() == phaseAwaitingFingerprint: + sawPresented = m.GetPresentedCertSha256() == sha256Hex(f.running) + case m.GetPresentedCertSha256() != "": + t.Errorf("phase %s carries presented_cert_sha256, want it only on %s", m.GetPhase(), phaseAwaitingFingerprint) + } + } + if !sawPresented { + t.Error("the awaiting phase did not carry the presented fingerprint") + } +} diff --git a/internal/fleet/nodetrust_test.go b/internal/fleet/nodetrust_test.go index 10925c7..84fe556 100644 --- a/internal/fleet/nodetrust_test.go +++ b/internal/fleet/nodetrust_test.go @@ -87,10 +87,27 @@ func fakeRunningCert(t *testing.T) *x509.Certificate { return cert } -// Adoption pins the certificate the installed node presents, streams its -// fingerprint for the operator to compare with the console, and records the -// root's CA chain so the node is verified by its CA once its management -// certificate is CA-signed. +// confirmingSink wraps send and confirms the presented fingerprint as soon as +// the adoption under id asks for it, as an operator whose console matches. +func confirmingSink(t *testing.T, svc *Service, id string, send phaseSink) phaseSink { + return func(phase, detail string, done bool) error { + if err := send(phase, detail, done); err != nil { + return err + } + if phase == phaseAwaitingFingerprint { + if _, err := svc.ConfirmAdoptionFingerprint(operatorCtx("admin@example.org", authz.LevelAdmin), + connect.NewRequest(&fleetv1.ConfirmAdoptionFingerprintRequest{AdoptionId: id, CertSha256: svc.adoptions.presented(id)})); err != nil { + t.Errorf("ConfirmAdoptionFingerprint() error = %v", err) + } + } + return nil + } +} + +// Adoption pins the certificate the installed node presents once the operator +// confirms its fingerprint against the console, and records the root's CA +// chain so the node is verified by its CA once its management certificate is +// CA-signed. func TestRunAdoption_Root_PinsRunningCertAndRecordsCAChain(t *testing.T) { adoptCredsBaseDir = t.TempDir() st := memory.New(nil) @@ -115,9 +132,9 @@ func TestRunAdoption_Root_PinsRunningCertAndRecordsCAChain(t *testing.T) { defer setRebootTiming(5*time.Millisecond, time.Millisecond, time.Millisecond)() sink := &detailSink{} - if err := svc.runAdoption(context.Background(), &fleetv1.AdoptNodeRequest{ + if err := svc.runAdoptionAs(context.Background(), "adopt-root", &fleetv1.AdoptNodeRequest{ Endpoint: "192.0.2.30:4443", PinnedCertSha256: "abc", Config: adoptConfig(), - }, sink.send); err != nil { + }, confirmingSink(t, svc, "adopt-root", sink.send)); err != nil { t.Fatalf("runAdoption() error = %v", err) } @@ -160,9 +177,9 @@ func TestRunAdoption_Subordinate_PinsRunningCertWithoutChain(t *testing.T) { cfg := adoptConfig() cfg.Metadata.Name = "sub-node" cfg.Role = &cryptosv1.Role{Kind: "issuing"} - if err := svc.runAdoption(context.Background(), &fleetv1.AdoptNodeRequest{ + if err := svc.runAdoptionAs(context.Background(), "adopt-sub", &fleetv1.AdoptNodeRequest{ Endpoint: "192.0.2.31:4443", PinnedCertSha256: "abc", Config: cfg, - }, (&detailSink{}).send); err != nil { + }, confirmingSink(t, svc, "adopt-sub", (&detailSink{}).send)); err != nil { t.Fatalf("runAdoption() error = %v", err) } diff --git a/internal/fleet/service.go b/internal/fleet/service.go index 5a059b4..aaff066 100644 --- a/internal/fleet/service.go +++ b/internal/fleet/service.go @@ -101,6 +101,10 @@ type Service struct { // until the node reports them running. reboots *rebootTracker + // adoptions holds the adoptions waiting for the operator to confirm the + // installed node's fingerprint. + adoptions adoptionWaits + log log.Logger } diff --git a/internal/mcpserver/catalog.go b/internal/mcpserver/catalog.go index cb48ebb..4793937 100644 --- a/internal/mcpserver/catalog.go +++ b/internal/mcpserver/catalog.go @@ -91,6 +91,7 @@ var Catalog = []Spec{ {Name: "adopt_node", RPC: "AdoptNode", Policy: Excluded, Reason: "wipes and provisions a disk"}, {Name: "preview_adoption", RPC: "PreviewAdoption", Policy: Excluded, Reason: "part of disk provisioning"}, {Name: "list_install_disks", RPC: "ListInstallDisks", Policy: Excluded, Reason: "part of disk provisioning"}, + {Name: "confirm_adoption_fingerprint", RPC: "ConfirmAdoptionFingerprint", Policy: Excluded, Reason: "a person confirms a new node's identity from its console"}, {Name: "apply_node_config", RPC: "ApplyNodeConfig", Policy: Excluded, Reason: "replaces a node's network, revocation and trust configuration"}, {Name: "set_node_protocol", RPC: "SetNodeProtocol", Policy: Excluded, Reason: "opens or closes a node's enrolment endpoint at its next reboot"}, {Name: "rekey_node", RPC: "RekeyNode", Policy: Excluded, Reason: "rotates a CA key"},