diff --git a/go.mod b/go.mod index 6372c23..9f823d1 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( ) require ( - github.com/CryptOS-PKI/api v0.0.0-20260720184550-a3c6ceb82bea + github.com/CryptOS-PKI/api v0.0.0-20260720194735-e45e67c7e01f golang.org/x/net v0.51.0 golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.34.0 // indirect diff --git a/go.sum b/go.sum index d223d7b..3345115 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ= connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4= -github.com/CryptOS-PKI/api v0.0.0-20260720184550-a3c6ceb82bea h1:WqtNr+rimg2Q/+Tf7HHXHwGCTZWrrG5jO7qBsGsozH4= -github.com/CryptOS-PKI/api v0.0.0-20260720184550-a3c6ceb82bea/go.mod h1:tmeaYhNoUblqPYYNtTbGBGIUsZ7xLATgLdKq4S33rmE= +github.com/CryptOS-PKI/api v0.0.0-20260720194735-e45e67c7e01f h1:5b97hNxJruEar4BHFkx68US7FimJ8uTLzBqSlr8UAUA= +github.com/CryptOS-PKI/api v0.0.0-20260720194735-e45e67c7e01f/go.mod h1:tmeaYhNoUblqPYYNtTbGBGIUsZ7xLATgLdKq4S33rmE= 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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= diff --git a/internal/fleet/enrollment_test.go b/internal/fleet/enrollment_test.go index 98bc880..cf024d6 100644 --- a/internal/fleet/enrollment_test.go +++ b/internal/fleet/enrollment_test.go @@ -618,6 +618,14 @@ func (r *routingConn) CompleteKeyRotation(ctx context.Context, chainDER [][]byte return r.ferry.CompleteKeyRotation(ctx, chainDER, chainPEM) } +func (r *routingConn) ExportCAKey(ctx context.Context, passphrase []byte) (*cryptosv1.ExportCAKeyResponse, error) { + return r.ferry.ExportCAKey(ctx, passphrase) +} + +func (r *routingConn) ImportCAKey(ctx context.Context, envelope, passphrase []byte) (*cryptosv1.ImportCAKeyResponse, error) { + return r.ferry.ImportCAKey(ctx, envelope, passphrase) +} + func (r *routingConn) Close() error { _ = r.ferry.Close() return r.identity.Close() diff --git a/internal/fleet/escrow.go b/internal/fleet/escrow.go new file mode 100644 index 0000000..a662de8 --- /dev/null +++ b/internal/fleet/escrow.go @@ -0,0 +1,175 @@ +package fleet + +/* +Apache License 2.0 + +Copyright 2026 Shane + +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" + "errors" + "fmt" + "time" + + connect "connectrpc.com/connect" + fleetv1 "github.com/CryptOS-PKI/api/go/cryptos/fleet/v1" + "github.com/CryptOS-PKI/manager/internal/store" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// minPassphraseLen is the minimum operator passphrase length the manager +// enforces for CA key escrow, matching the web's client-side guard. It is a +// defense-in-depth floor: the node performs the actual sealing/unsealing, but +// the manager rejects a too-short passphrase before it ever dials a node. +const minPassphraseLen = 18 + +// ExportCAKey backs up a managed node's CA key to an encrypted envelope. It is +// admin-gated and rejects a passphrase shorter than minPassphraseLen before any +// dial or audit; the rejection never echoes the passphrase. On success it +// relays the node's encrypted envelope straight back to the caller and appends +// a single "ca-key-exported" audit event that names the node only. A TPM-backed +// node refuses export (FailedPrecondition); that is mapped through with a +// secret-free message and writes no audit event. The passphrase and envelope +// are never logged. +func (s *Service) ExportCAKey(ctx context.Context, req *connect.Request[fleetv1.ExportCAKeyRequest]) (*connect.Response[fleetv1.ExportCAKeyResponse], error) { + if err := requireAdmin(ctx); err != nil { + return nil, err + } + if err := checkPassphrase(req.Msg.GetPassphrase()); err != nil { + return nil, err + } + + name := req.Msg.GetNodeName() + node, ok := s.store.Node(name) + if !ok { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("fleet: node %q not found", name)) + } + + conn, err := s.dial(node) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("fleet: dial node: %w", err)) + } + defer func() { _ = conn.Close() }() + + // Do not log the passphrase or the resulting envelope anywhere. + resp, err := conn.ExportCAKey(ctx, req.Msg.GetPassphrase()) + if err != nil { + return nil, mapNodeEscrowError(name, "export", err) + } + + s.store.AddAuditEvent(store.AuditEvent{ + ID: newAuditID(), + At: time.Now().UTC().Format(time.RFC3339), + Kind: "ca-key-exported", + Summary: fmt.Sprintf("Exported CA key from %s", name), + TargetKind: "node", + TargetPath: "/nodes/" + name, + }) + + return connect.NewResponse(&fleetv1.ExportCAKeyResponse{Envelope: resp.GetEnvelope()}), nil +} + +// ImportCAKey restores a CA identity onto a fresh managed node from an encrypted +// envelope. It is admin-gated and rejects an empty envelope or a passphrase +// shorter than minPassphraseLen before any dial or audit; the rejection never +// echoes the passphrase. On success it returns the restored identity's +// subject/issuer CN summary and appends a single "ca-key-imported" audit event +// naming the node and restored subject. A node that already holds an identity +// refuses the import (FailedPrecondition); that is mapped through with a clear, +// secret-free message and writes no audit event. The passphrase and envelope +// are never logged. +func (s *Service) ImportCAKey(ctx context.Context, req *connect.Request[fleetv1.ImportCAKeyRequest]) (*connect.Response[fleetv1.ImportCAKeyResponse], error) { + if err := requireAdmin(ctx); err != nil { + return nil, err + } + if len(req.Msg.GetEnvelope()) == 0 { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("fleet: envelope is required")) + } + if err := checkPassphrase(req.Msg.GetPassphrase()); err != nil { + return nil, err + } + + name := req.Msg.GetNodeName() + node, ok := s.store.Node(name) + if !ok { + return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("fleet: node %q not found", name)) + } + + conn, err := s.dial(node) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("fleet: dial node: %w", err)) + } + defer func() { _ = conn.Close() }() + + // Do not log the passphrase or the envelope anywhere. + resp, err := conn.ImportCAKey(ctx, req.Msg.GetEnvelope(), req.Msg.GetPassphrase()) + if err != nil { + return nil, mapNodeEscrowError(name, "import", err) + } + + subjectCN, issuerCN := leafCNs(resp.GetIdentity()) + + s.store.AddAuditEvent(store.AuditEvent{ + ID: newAuditID(), + At: time.Now().UTC().Format(time.RFC3339), + Kind: "ca-key-imported", + Summary: fmt.Sprintf("Imported CA key onto %s (%s)", name, subjectCN), + TargetKind: "node", + TargetPath: "/nodes/" + name, + }) + + return connect.NewResponse(&fleetv1.ImportCAKeyResponse{ + SubjectCn: subjectCN, + IssuerCn: issuerCN, + }), nil +} + +// checkPassphrase enforces the minimum passphrase length without ever echoing +// the passphrase in the error message. +func checkPassphrase(passphrase []byte) error { + if len(passphrase) < minPassphraseLen { + return connect.NewError(connect.CodeInvalidArgument, + fmt.Errorf("fleet: passphrase must be at least %d bytes", minPassphraseLen)) + } + return nil +} + +// mapNodeEscrowError maps a node's escrow error to a clear, secret-free Connect +// error. A node's FailedPrecondition means the operation is not allowed in the +// node's current state (a TPM node cannot export; a node that already holds an +// identity cannot import), so it maps to a guidance message that never contains +// the passphrase or envelope. Other node errors surface as Internal without the +// node's raw message so no secret can leak through it. +func mapNodeEscrowError(node, op string, err error) *connect.Error { + if status.Code(err) == codes.FailedPrecondition { + switch op { + case "export": + return connect.NewError(connect.CodeFailedPrecondition, + fmt.Errorf("fleet: node %q refused export; its CA key is non-exportable (for example a TPM-backed key)", node)) + case "import": + return connect.NewError(connect.CodeFailedPrecondition, + fmt.Errorf("fleet: node %q already has a CA identity; import only onto a fresh node", node)) + } + } + if status.Code(err) == codes.InvalidArgument { + // The node validated the request (for example a passphrase that does + // not unseal the envelope). Do not relay the node's raw message. + return connect.NewError(connect.CodeInvalidArgument, + fmt.Errorf("fleet: node %q rejected the %s request", node, op)) + } + return connect.NewError(connect.CodeInternal, fmt.Errorf("fleet: %s on node %q failed", op, node)) +} diff --git a/internal/fleet/escrow_test.go b/internal/fleet/escrow_test.go new file mode 100644 index 0000000..12cac75 --- /dev/null +++ b/internal/fleet/escrow_test.go @@ -0,0 +1,444 @@ +package fleet + +/* +Apache License 2.0 + +Copyright 2026 Shane + +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 ( + "errors" + "strings" + "testing" + + connect "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/authz" + "github.com/CryptOS-PKI/manager/internal/store" + "github.com/CryptOS-PKI/manager/internal/store/memory" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// escrowTestStore is a two-node inventory for the escrow handler tests. +func escrowTestStore() store.Store { + return memory.New([]store.Node{ + {Name: "A", Endpoint: "a.acme.com:4443", Role: "root"}, + {Name: "B", Endpoint: "b.acme.com:4444", Role: "intermediate"}, + }) +} + +// strongPassphrase is an example passphrase at the enforced minimum length. +const strongPassphrase = "correct-horse-battery-staple" // >= 18 bytes + +func connErr(t *testing.T, err error) *connect.Error { + t.Helper() + var ce *connect.Error + if !errors.As(err, &ce) { + t.Fatalf("error is not a *connect.Error: %v", err) + } + return ce +} + +// --- ExportCAKey --- + +func TestExportCAKey_ViewerDenied_NoDialNoAudit(t *testing.T) { + st := escrowTestStore() + connA := &fakeConn{} + svc := New(st, dialFor(map[string]*fakeConn{"A": connA})) + + before := len(st.Audit()) + ctx := operatorCtx("viewer@acme.example", authz.LevelViewer) + _, err := svc.ExportCAKey(ctx, connect.NewRequest(&fleetv1.ExportCAKeyRequest{ + NodeName: "A", + Passphrase: []byte(strongPassphrase), + })) + if err == nil { + t.Fatal("ExportCAKey(viewer) error = nil, want PermissionDenied") + } + if code := connErr(t, err).Code(); code != connect.CodePermissionDenied { + t.Errorf("code = %v, want CodePermissionDenied", code) + } + if connA.gotExportPassphrase != nil { + t.Error("node was dialed and export called, want no call on denial") + } + if connA.closed { + t.Error("node connection was opened, want no dial") + } + if len(st.Audit()) != before { + t.Errorf("audit len = %d, want %d (no event on denial)", len(st.Audit()), before) + } +} + +func TestExportCAKey_OperatorDenied(t *testing.T) { + st := escrowTestStore() + connA := &fakeConn{} + svc := New(st, dialFor(map[string]*fakeConn{"A": connA})) + + ctx := operatorCtx("op@acme.example", authz.LevelOperator) + _, err := svc.ExportCAKey(ctx, connect.NewRequest(&fleetv1.ExportCAKeyRequest{ + NodeName: "A", + Passphrase: []byte(strongPassphrase), + })) + if err == nil { + t.Fatal("ExportCAKey(operator) error = nil, want PermissionDenied (admin required)") + } + if code := connErr(t, err).Code(); code != connect.CodePermissionDenied { + t.Errorf("code = %v, want CodePermissionDenied", code) + } + if connA.gotExportPassphrase != nil { + t.Error("node was dialed, want no call on denial") + } +} + +func TestExportCAKey_ShortPassphrase_InvalidArgument_NoDialNoAudit(t *testing.T) { + st := escrowTestStore() + connA := &fakeConn{} + svc := New(st, dialFor(map[string]*fakeConn{"A": connA})) + + before := len(st.Audit()) + short := []byte("short-secret") // < 18 bytes + ctx := operatorCtx("admin@acme.example", authz.LevelAdmin) + _, err := svc.ExportCAKey(ctx, connect.NewRequest(&fleetv1.ExportCAKeyRequest{ + NodeName: "A", + Passphrase: short, + })) + if err == nil { + t.Fatal("ExportCAKey(short passphrase) error = nil, want InvalidArgument") + } + ce := connErr(t, err) + if ce.Code() != connect.CodeInvalidArgument { + t.Errorf("code = %v, want CodeInvalidArgument", ce.Code()) + } + if strings.Contains(ce.Message(), string(short)) { + t.Errorf("error message %q echoes the passphrase", ce.Message()) + } + if connA.gotExportPassphrase != nil { + t.Error("node was dialed on a short passphrase, want no dial before validation") + } + if len(st.Audit()) != before { + t.Errorf("audit len = %d, want %d (no event on validation failure)", len(st.Audit()), before) + } +} + +func TestExportCAKey_UnknownNode_NotFound(t *testing.T) { + st := escrowTestStore() + svc := New(st, dialFor(map[string]*fakeConn{})) + + before := len(st.Audit()) + ctx := operatorCtx("admin@acme.example", authz.LevelAdmin) + _, err := svc.ExportCAKey(ctx, connect.NewRequest(&fleetv1.ExportCAKeyRequest{ + NodeName: "missing", + Passphrase: []byte(strongPassphrase), + })) + if err == nil { + t.Fatal("ExportCAKey(unknown node) error = nil, want NotFound") + } + if code := connErr(t, err).Code(); code != connect.CodeNotFound { + t.Errorf("code = %v, want CodeNotFound", code) + } + if len(st.Audit()) != before { + t.Errorf("audit len = %d, want %d (no event when node unknown)", len(st.Audit()), before) + } +} + +func TestExportCAKey_Admin_ReturnsEnvelopeAndAudits(t *testing.T) { + st := escrowTestStore() + connA := &fakeConn{ + exportResp: &cryptosv1.ExportCAKeyResponse{Envelope: []byte("ENCRYPTED-ENVELOPE-BYTES")}, + } + svc := New(st, dialFor(map[string]*fakeConn{"A": connA})) + + ctx := operatorCtx("admin@acme.example", authz.LevelAdmin) + resp, err := svc.ExportCAKey(ctx, connect.NewRequest(&fleetv1.ExportCAKeyRequest{ + NodeName: "A", + Passphrase: []byte(strongPassphrase), + })) + if err != nil { + t.Fatalf("ExportCAKey(admin) error = %v, want nil", err) + } + + if string(connA.gotExportPassphrase) != strongPassphrase { + t.Errorf("node received passphrase %q, want it relayed unchanged", connA.gotExportPassphrase) + } + if got := string(resp.Msg.GetEnvelope()); got != "ENCRYPTED-ENVELOPE-BYTES" { + t.Errorf("response envelope = %q, want the node's envelope relayed", got) + } + if !connA.closed { + t.Error("node connection was not closed") + } + + audit := st.Audit() + if len(audit) != 1 { + t.Fatalf("audit len = %d, want 1", len(audit)) + } + ev := audit[0] + if ev.Kind != "ca-key-exported" { + t.Errorf("audit kind = %q, want ca-key-exported", ev.Kind) + } + if !strings.Contains(ev.Summary, "A") { + t.Errorf("audit summary %q does not name the node", ev.Summary) + } + if strings.Contains(ev.Summary, strongPassphrase) || strings.Contains(ev.Summary, "ENCRYPTED-ENVELOPE-BYTES") { + t.Errorf("audit summary %q leaks the passphrase or envelope", ev.Summary) + } + if ev.TargetKind != "node" || ev.TargetPath != "/nodes/A" { + t.Errorf("audit target = (%q, %q), want (node, /nodes/A)", ev.TargetKind, ev.TargetPath) + } +} + +func TestExportCAKey_TPMNonExportable_MappedNoAudit(t *testing.T) { + st := escrowTestStore() + connA := &fakeConn{ + err: status.Error(codes.FailedPrecondition, "node: CA key is TPM-backed and non-exportable"), + } + svc := New(st, dialFor(map[string]*fakeConn{"A": connA})) + + before := len(st.Audit()) + ctx := operatorCtx("admin@acme.example", authz.LevelAdmin) + _, err := svc.ExportCAKey(ctx, connect.NewRequest(&fleetv1.ExportCAKeyRequest{ + NodeName: "A", + Passphrase: []byte(strongPassphrase), + })) + if err == nil { + t.Fatal("ExportCAKey(TPM node) error = nil, want FailedPrecondition") + } + ce := connErr(t, err) + if ce.Code() != connect.CodeFailedPrecondition { + t.Errorf("code = %v, want CodeFailedPrecondition", ce.Code()) + } + if strings.Contains(ce.Message(), strongPassphrase) { + t.Errorf("error message %q leaks the passphrase", ce.Message()) + } + if len(st.Audit()) != before { + t.Errorf("audit len = %d, want %d (no event when export refused)", len(st.Audit()), before) + } +} + +// --- ImportCAKey --- + +func TestImportCAKey_ViewerDenied_NoDialNoAudit(t *testing.T) { + st := escrowTestStore() + connA := &fakeConn{} + svc := New(st, dialFor(map[string]*fakeConn{"A": connA})) + + before := len(st.Audit()) + ctx := operatorCtx("viewer@acme.example", authz.LevelViewer) + _, err := svc.ImportCAKey(ctx, connect.NewRequest(&fleetv1.ImportCAKeyRequest{ + NodeName: "A", + Envelope: []byte("env"), + Passphrase: []byte(strongPassphrase), + })) + if err == nil { + t.Fatal("ImportCAKey(viewer) error = nil, want PermissionDenied") + } + if code := connErr(t, err).Code(); code != connect.CodePermissionDenied { + t.Errorf("code = %v, want CodePermissionDenied", code) + } + if connA.gotImportEnvelope != nil { + t.Error("node was dialed, want no call on denial") + } + if len(st.Audit()) != before { + t.Errorf("audit len = %d, want %d (no event on denial)", len(st.Audit()), before) + } +} + +func TestImportCAKey_EmptyEnvelope_InvalidArgument(t *testing.T) { + st := escrowTestStore() + connA := &fakeConn{} + svc := New(st, dialFor(map[string]*fakeConn{"A": connA})) + + ctx := operatorCtx("admin@acme.example", authz.LevelAdmin) + _, err := svc.ImportCAKey(ctx, connect.NewRequest(&fleetv1.ImportCAKeyRequest{ + NodeName: "A", + Envelope: nil, + Passphrase: []byte(strongPassphrase), + })) + if err == nil { + t.Fatal("ImportCAKey(empty envelope) error = nil, want InvalidArgument") + } + if code := connErr(t, err).Code(); code != connect.CodeInvalidArgument { + t.Errorf("code = %v, want CodeInvalidArgument", code) + } + if connA.gotImportEnvelope != nil { + t.Error("node was dialed on empty envelope, want no dial") + } +} + +func TestImportCAKey_ShortPassphrase_InvalidArgument(t *testing.T) { + st := escrowTestStore() + connA := &fakeConn{} + svc := New(st, dialFor(map[string]*fakeConn{"A": connA})) + + short := []byte("short-secret") + ctx := operatorCtx("admin@acme.example", authz.LevelAdmin) + _, err := svc.ImportCAKey(ctx, connect.NewRequest(&fleetv1.ImportCAKeyRequest{ + NodeName: "A", + Envelope: []byte("env"), + Passphrase: short, + })) + if err == nil { + t.Fatal("ImportCAKey(short passphrase) error = nil, want InvalidArgument") + } + ce := connErr(t, err) + if ce.Code() != connect.CodeInvalidArgument { + t.Errorf("code = %v, want CodeInvalidArgument", ce.Code()) + } + if strings.Contains(ce.Message(), string(short)) { + t.Errorf("error message %q echoes the passphrase", ce.Message()) + } + if connA.gotImportEnvelope != nil { + t.Error("node was dialed on a short passphrase, want no dial") + } +} + +func TestImportCAKey_UnknownNode_NotFound(t *testing.T) { + st := escrowTestStore() + svc := New(st, dialFor(map[string]*fakeConn{})) + + ctx := operatorCtx("admin@acme.example", authz.LevelAdmin) + _, err := svc.ImportCAKey(ctx, connect.NewRequest(&fleetv1.ImportCAKeyRequest{ + NodeName: "missing", + Envelope: []byte("env"), + Passphrase: []byte(strongPassphrase), + })) + if err == nil { + t.Fatal("ImportCAKey(unknown node) error = nil, want NotFound") + } + if code := connErr(t, err).Code(); code != connect.CodeNotFound { + t.Errorf("code = %v, want CodeNotFound", code) + } +} + +func TestImportCAKey_Admin_ReturnsCNsAndAudits(t *testing.T) { + rootDER, rootCert, rootKey := signCert(t, "ACME Root CA", nil, nil) + interDER, _, _ := signCert(t, "ACME Intermediate CA", rootCert, rootKey) + + st := escrowTestStore() + connA := &fakeConn{ + importResp: &cryptosv1.ImportCAKeyResponse{ + Identity: &cryptosv1.Identity{ChainDer: [][]byte{interDER, rootDER}}, + }, + } + svc := New(st, dialFor(map[string]*fakeConn{"A": connA})) + + ctx := operatorCtx("admin@acme.example", authz.LevelAdmin) + resp, err := svc.ImportCAKey(ctx, connect.NewRequest(&fleetv1.ImportCAKeyRequest{ + NodeName: "A", + Envelope: []byte("ENCRYPTED-ENVELOPE-BYTES"), + Passphrase: []byte(strongPassphrase), + })) + if err != nil { + t.Fatalf("ImportCAKey(admin) error = %v, want nil", err) + } + + if string(connA.gotImportEnvelope) != "ENCRYPTED-ENVELOPE-BYTES" { + t.Errorf("node received envelope %q, want it relayed unchanged", connA.gotImportEnvelope) + } + if string(connA.gotImportPassphrase) != strongPassphrase { + t.Errorf("node received passphrase %q, want it relayed unchanged", connA.gotImportPassphrase) + } + if resp.Msg.GetSubjectCn() != "ACME Intermediate CA" { + t.Errorf("response subject CN = %q, want ACME Intermediate CA", resp.Msg.GetSubjectCn()) + } + if resp.Msg.GetIssuerCn() != "ACME Root CA" { + t.Errorf("response issuer CN = %q, want ACME Root CA", resp.Msg.GetIssuerCn()) + } + if !connA.closed { + t.Error("node connection was not closed") + } + + audit := st.Audit() + if len(audit) != 1 { + t.Fatalf("audit len = %d, want 1", len(audit)) + } + ev := audit[0] + if ev.Kind != "ca-key-imported" { + t.Errorf("audit kind = %q, want ca-key-imported", ev.Kind) + } + if !strings.Contains(ev.Summary, "A") || !strings.Contains(ev.Summary, "ACME Intermediate CA") { + t.Errorf("audit summary %q should name the node and restored subject", ev.Summary) + } + if strings.Contains(ev.Summary, strongPassphrase) || strings.Contains(ev.Summary, "ENCRYPTED-ENVELOPE-BYTES") { + t.Errorf("audit summary %q leaks the passphrase or envelope", ev.Summary) + } + if ev.TargetKind != "node" || ev.TargetPath != "/nodes/A" { + t.Errorf("audit target = (%q, %q), want (node, /nodes/A)", ev.TargetKind, ev.TargetPath) + } +} + +func TestImportCAKey_IdentityExists_MappedNoAudit(t *testing.T) { + st := escrowTestStore() + connA := &fakeConn{ + err: status.Error(codes.FailedPrecondition, "node: target already holds a CA identity"), + } + svc := New(st, dialFor(map[string]*fakeConn{"A": connA})) + + before := len(st.Audit()) + ctx := operatorCtx("admin@acme.example", authz.LevelAdmin) + _, err := svc.ImportCAKey(ctx, connect.NewRequest(&fleetv1.ImportCAKeyRequest{ + NodeName: "A", + Envelope: []byte("env"), + Passphrase: []byte(strongPassphrase), + })) + if err == nil { + t.Fatal("ImportCAKey(identity exists) error = nil, want FailedPrecondition") + } + ce := connErr(t, err) + if ce.Code() != connect.CodeFailedPrecondition { + t.Errorf("code = %v, want CodeFailedPrecondition", ce.Code()) + } + if !strings.Contains(ce.Message(), "fresh") { + t.Errorf("error message %q should guide the operator to a fresh node", ce.Message()) + } + if strings.Contains(ce.Message(), strongPassphrase) { + t.Errorf("error message %q leaks the passphrase", ce.Message()) + } + if len(st.Audit()) != before { + t.Errorf("audit len = %d, want %d (no event when import refused)", len(st.Audit()), before) + } +} + +// TestEscrow_AuditNeverContainsPassphrase drives both operations end to end and +// asserts no audit summary contains the passphrase. +func TestEscrow_AuditNeverContainsPassphrase(t *testing.T) { + rootDER, _, _ := signCert(t, "ACME Root CA", nil, nil) + + st := escrowTestStore() + connA := &fakeConn{exportResp: &cryptosv1.ExportCAKeyResponse{Envelope: []byte("env")}} + connB := &fakeConn{importResp: &cryptosv1.ImportCAKeyResponse{ + Identity: &cryptosv1.Identity{ChainDer: [][]byte{rootDER}}, + }} + svc := New(st, dialFor(map[string]*fakeConn{"A": connA, "B": connB})) + + ctx := operatorCtx("admin@acme.example", authz.LevelAdmin) + if _, err := svc.ExportCAKey(ctx, connect.NewRequest(&fleetv1.ExportCAKeyRequest{ + NodeName: "A", Passphrase: []byte(strongPassphrase), + })); err != nil { + t.Fatalf("ExportCAKey error = %v", err) + } + if _, err := svc.ImportCAKey(ctx, connect.NewRequest(&fleetv1.ImportCAKeyRequest{ + NodeName: "B", Envelope: []byte("env"), Passphrase: []byte(strongPassphrase), + })); err != nil { + t.Fatalf("ImportCAKey error = %v", err) + } + + for _, ev := range st.Audit() { + if strings.Contains(ev.Summary, strongPassphrase) { + t.Errorf("audit summary %q contains the passphrase", ev.Summary) + } + } +} diff --git a/internal/fleet/nodes_test.go b/internal/fleet/nodes_test.go index f3b5e75..0267248 100644 --- a/internal/fleet/nodes_test.go +++ b/internal/fleet/nodes_test.go @@ -100,6 +100,23 @@ type fakeConn struct { // instead of the zero-value response (it carries the adopted identity). completeRotationResp *cryptosv1.CompleteKeyRotationResponse + // gotExportPassphrase records the passphrase ExportCAKey was called with, + // so an escrow test can assert the handler relayed it to the node and that + // the audit never contains it. + gotExportPassphrase []byte + // exportResp, when set, is returned by ExportCAKey instead of the + // zero-value response (it carries the encrypted envelope). + exportResp *cryptosv1.ExportCAKeyResponse + + // gotImportEnvelope and gotImportPassphrase record the envelope and + // passphrase ImportCAKey was called with, so an escrow test can assert the + // handler relayed them to the node unchanged. + gotImportEnvelope []byte + gotImportPassphrase []byte + // importResp, when set, is returned by ImportCAKey instead of the + // zero-value response (it carries the restored identity). + importResp *cryptosv1.ImportCAKeyResponse + // getConfigResp, when set, is returned by GetConfig instead of the // zero-value response (the config-push flow fetches the node's baseline). getConfigResp *cryptosv1.GetConfigResponse @@ -273,6 +290,29 @@ func (f *fakeConn) IssueLeaf(_ context.Context, csrDER []byte, profileName strin return &cryptosv1.IssueLeafResponse{}, nil } +func (f *fakeConn) ExportCAKey(_ context.Context, passphrase []byte) (*cryptosv1.ExportCAKeyResponse, error) { + f.gotExportPassphrase = passphrase + if f.err != nil { + return nil, f.err + } + if f.exportResp != nil { + return f.exportResp, nil + } + return &cryptosv1.ExportCAKeyResponse{}, nil +} + +func (f *fakeConn) ImportCAKey(_ context.Context, envelope, passphrase []byte) (*cryptosv1.ImportCAKeyResponse, error) { + f.gotImportEnvelope = envelope + f.gotImportPassphrase = passphrase + if f.err != nil { + return nil, f.err + } + if f.importResp != nil { + return f.importResp, nil + } + return &cryptosv1.ImportCAKeyResponse{}, nil +} + // record appends name to the shared call log, if this fake was given one. // Used by the SUBORDINATE ferry tests to assert child/parent call order. func (f *fakeConn) record(name string) { diff --git a/internal/fleet/service.go b/internal/fleet/service.go index 86dad64..abe12d1 100644 --- a/internal/fleet/service.go +++ b/internal/fleet/service.go @@ -49,6 +49,8 @@ type NodeConn interface { IssueLeaf(ctx context.Context, csrDER []byte, profileName string) (*cryptosv1.IssueLeafResponse, error) BeginKeyRotation(ctx context.Context) (*cryptosv1.BeginKeyRotationResponse, error) CompleteKeyRotation(ctx context.Context, chainDER [][]byte, chainPEM string) (*cryptosv1.CompleteKeyRotationResponse, error) + ExportCAKey(ctx context.Context, passphrase []byte) (*cryptosv1.ExportCAKeyResponse, error) + ImportCAKey(ctx context.Context, envelope, passphrase []byte) (*cryptosv1.ImportCAKeyResponse, error) Close() error } diff --git a/internal/nodeclient/nodeclient.go b/internal/nodeclient/nodeclient.go index 0ca4266..64b5614 100644 --- a/internal/nodeclient/nodeclient.go +++ b/internal/nodeclient/nodeclient.go @@ -205,6 +205,21 @@ func (c *Client) CompleteKeyRotation(ctx context.Context, chainDER [][]byte, cha }) } +// ExportCAKey asks the dialed node to seal its CA private key into an +// encrypted backup envelope using the operator passphrase. The node performs +// the encryption; the passphrase is relayed in transit only and never +// persisted by the manager. +func (c *Client) ExportCAKey(ctx context.Context, passphrase []byte) (*cryptosv1.ExportCAKeyResponse, error) { + return c.node.ExportCAKey(ctx, &cryptosv1.ExportCAKeyRequest{Passphrase: passphrase}) +} + +// ImportCAKey delivers an encrypted backup envelope and its passphrase to the +// dialed node so it can decrypt and adopt the restored CA identity. The +// passphrase is relayed in transit only and never persisted by the manager. +func (c *Client) ImportCAKey(ctx context.Context, envelope, passphrase []byte) (*cryptosv1.ImportCAKeyResponse, error) { + return c.node.ImportCAKey(ctx, &cryptosv1.ImportCAKeyRequest{Envelope: envelope, Passphrase: passphrase}) +} + // Close releases the underlying gRPC connection. func (c *Client) Close() error { return c.conn.Close() diff --git a/internal/nodeclient/nodeclient_test.go b/internal/nodeclient/nodeclient_test.go index d870b80..d26fa40 100644 --- a/internal/nodeclient/nodeclient_test.go +++ b/internal/nodeclient/nodeclient_test.go @@ -89,6 +89,22 @@ func (fakeNodeService) ListRevocations(context.Context, *cryptosv1.ListRevocatio }, nil } +func (fakeNodeService) ExportCAKey(_ context.Context, req *cryptosv1.ExportCAKeyRequest) (*cryptosv1.ExportCAKeyResponse, error) { + // Echo the passphrase back inside the envelope so the test can assert the + // Client relayed it unchanged. + return &cryptosv1.ExportCAKeyResponse{ + Envelope: append([]byte("envelope-for-"), req.GetPassphrase()...), + }, nil +} + +func (fakeNodeService) ImportCAKey(_ context.Context, req *cryptosv1.ImportCAKeyRequest) (*cryptosv1.ImportCAKeyResponse, error) { + return &cryptosv1.ImportCAKeyResponse{ + Identity: &cryptosv1.Identity{ + ChainPem: "imported:" + string(req.GetEnvelope()) + ":" + string(req.GetPassphrase()), + }, + }, nil +} + // testCA is a minimal self-signed CA used to mint both the fake node's // server cert and the test admin client cert. type testCA struct { @@ -302,6 +318,22 @@ func TestDial_GetStatus_GetIdentity(t *testing.T) { if got := configResp.GetConfig().GetRole().GetKind(); got != "fake-config-test-marker" { t.Errorf("GetConfig().Config.Role.Kind = %q, want fake-config-test-marker", got) } + + exportResp, err := client.ExportCAKey(ctx, []byte("relayed-passphrase")) + if err != nil { + t.Fatalf("ExportCAKey() error = %v, want nil", err) + } + if got := string(exportResp.GetEnvelope()); got != "envelope-for-relayed-passphrase" { + t.Errorf("ExportCAKey().Envelope = %q, want envelope-for-relayed-passphrase (passphrase not relayed)", got) + } + + importResp, err := client.ImportCAKey(ctx, []byte("env-bytes"), []byte("relayed-passphrase")) + if err != nil { + t.Fatalf("ImportCAKey() error = %v, want nil", err) + } + if got := importResp.GetIdentity().GetChainPem(); got != "imported:env-bytes:relayed-passphrase" { + t.Errorf("ImportCAKey().Identity.ChainPem = %q, want imported:env-bytes:relayed-passphrase (envelope/passphrase not relayed)", got) + } } func TestDial_BadCertPath(t *testing.T) {