From 7bebfdce926bba51866fc897cee0672886fda1bf Mon Sep 17 00:00:00 2001 From: Tiago Silva Date: Thu, 25 Jul 2024 16:20:29 +0100 Subject: [PATCH] [sec_scan][19] add `tsh scan keys` implementation (#44220) * [sec_scan][19] add `tsh scan keys` implementation This PR introduces the required code to transverse a directory(es), finding all the SSH private keys and report them back to the cluster using the device security enclave as authentication mechanism. This PR is part of https://github.com/gravitational/access-graph/issues/637. Signed-off-by: Tiago Silva * handle code review * fix message * handle code review * fork ssh private keys * add skip dirs support * handle code review --------- Signed-off-by: Tiago Silva --- api/types/accessgraph/private_key.go | 15 + lib/secretsscanner/reporter/env_test.go | 199 ++++++++++++ lib/secretsscanner/reporter/report.go | 195 ++++++++++++ lib/secretsscanner/reporter/report_test.go | 157 +++++++++ lib/secretsscanner/scaner/scan.go | 298 ++++++++++++++++++ lib/secretsscanner/scaner/scan_test.go | 237 ++++++++++++++ .../scaner/testdata/invalid_keys.go | 50 +++ .../scaner/testdata/ssh_keys.go | 290 +++++++++++++++++ tool/tsh/common/scan.go | 170 ++++++++++ tool/tsh/common/tsh.go | 5 +- 10 files changed, 1615 insertions(+), 1 deletion(-) create mode 100644 lib/secretsscanner/reporter/env_test.go create mode 100644 lib/secretsscanner/reporter/report.go create mode 100644 lib/secretsscanner/reporter/report_test.go create mode 100644 lib/secretsscanner/scaner/scan.go create mode 100644 lib/secretsscanner/scaner/scan_test.go create mode 100644 lib/secretsscanner/scaner/testdata/invalid_keys.go create mode 100644 lib/secretsscanner/scaner/testdata/ssh_keys.go create mode 100644 tool/tsh/common/scan.go diff --git a/api/types/accessgraph/private_key.go b/api/types/accessgraph/private_key.go index 57e0874ed040a..a8730685f8d3c 100644 --- a/api/types/accessgraph/private_key.go +++ b/api/types/accessgraph/private_key.go @@ -106,3 +106,18 @@ func hashComp(values ...string) string { } return hex.EncodeToString(h.Sum(nil)) } + +// DescribePublicKeyMode returns a human-readable description of the public key mode. +func DescribePublicKeyMode(mode accessgraphv1pb.PublicKeyMode) string { + switch mode { + case accessgraphv1pb.PublicKeyMode_PUBLIC_KEY_MODE_PUB_FILE: + return "used public key file" + case accessgraphv1pb.PublicKeyMode_PUBLIC_KEY_MODE_PROTECTED: + return "protected private key" + case accessgraphv1pb.PublicKeyMode_PUBLIC_KEY_MODE_DERIVED: + return "derived from private key" + default: + return "unknown" + } + +} diff --git a/lib/secretsscanner/reporter/env_test.go b/lib/secretsscanner/reporter/env_test.go new file mode 100644 index 0000000000000..04ffebb5c6711 --- /dev/null +++ b/lib/secretsscanner/reporter/env_test.go @@ -0,0 +1,199 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package reporter_test + +import ( + "errors" + "io" + "net" + "testing" + + "github.com/gravitational/trace" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + + accessgraphsecretsv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/accessgraph/v1" + devicepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/devicetrust/v1" + dttestenv "github.com/gravitational/teleport/lib/devicetrust/testenv" + "github.com/gravitational/teleport/lib/fixtures" +) + +type env struct { + secretsScannerAddr string + service *serviceFake +} + +type opts struct { + device *device + preReconcileError error +} + +type device struct { + device dttestenv.FakeDevice + id string +} + +type option func(*opts) + +func withDevice(deviceID string, dev dttestenv.FakeDevice) option { + return func(o *opts) { + o.device = &device{ + device: dev, + id: deviceID, + } + } +} + +func withPreReconcileError(err error) option { + return func(o *opts) { + o.preReconcileError = err + } +} + +func setup(t *testing.T, ops ...option) env { + t.Helper() + + o := opts{} + for _, op := range ops { + op(&o) + } + + var opts []dttestenv.Opt + if o.device != nil { + dev, pubKey, err := dttestenv.CreateEnrolledDevice(o.device.id, o.device.device) + require.NoError(t, err) + opts = append(opts, dttestenv.WithPreEnrolledDevice(dev, pubKey)) + } + dtFakeSvc, err := dttestenv.New(opts...) + require.NoError(t, err) + t.Cleanup(func() { + err := dtFakeSvc.Close() + assert.NoError(t, err) + }) + + svc := newServiceFake(dtFakeSvc.Service) + svc.preReconcileError = o.preReconcileError + + tlsConfig, err := fixtures.LocalTLSConfig() + require.NoError(t, err) + + grpcServer := grpc.NewServer( + grpc.Creds( + credentials.NewTLS(tlsConfig.TLS), + ), + ) + accessgraphsecretsv1pb.RegisterSecretsScannerServiceServer(grpcServer, svc) + + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + go func() { + err := grpcServer.Serve(lis) + assert.NoError(t, err) + }() + t.Cleanup(func() { + grpcServer.Stop() + _ = lis.Close() + }) + + return env{ + service: svc, + secretsScannerAddr: lis.Addr().String(), + } +} + +func newServiceFake(deviceTrustSvc *dttestenv.FakeDeviceService) *serviceFake { + return &serviceFake{ + deviceTrustSvc: deviceTrustSvc, + } +} + +type serviceFake struct { + accessgraphsecretsv1pb.UnimplementedSecretsScannerServiceServer + privateKeysReported []*accessgraphsecretsv1pb.PrivateKey + deviceTrustSvc *dttestenv.FakeDeviceService + preReconcileError error +} + +func (s *serviceFake) ReportSecrets(in accessgraphsecretsv1pb.SecretsScannerService_ReportSecretsServer) error { + // Step 1. Assert the device. + if _, err := s.deviceTrustSvc.AssertDevice(in.Context(), streamAdapter{stream: in}); err != nil { + return trace.Wrap(err) + } + // Step 2. Collect the private keys into a temporary slice. + var collectedKeys []*accessgraphsecretsv1pb.PrivateKey + for { + msg, err := in.Recv() + // Step 4. When the client closes his side of the stream, we break the loop + // and collect the private keys. + if errors.Is(err, io.EOF) { + break + } else if err != nil { + return trace.Wrap(err) + } + + if msg.GetPrivateKeys() == nil { + return trace.BadParameter("unexpected assert request payload: %T", msg.GetPayload()) + } + // Step 3. Collect the private keys into a temporary slice. + collectedKeys = append(collectedKeys, msg.GetPrivateKeys().GetKeys()...) + + } + + if s.preReconcileError != nil { + return s.preReconcileError + } + + // Step 5. Store the collected private keys. + // This only happens when the client closes his side of the stream. + s.privateKeysReported = collectedKeys + return nil +} + +// streamAdapter is a helper struct that adapts the [accessgraphsecretsv1pb.SecretsScannerService_ReportSecretsServer] +// stream to the device trust assertion stream [assertserver.AssertDeviceServerStream]. +// This is needed because we need to extract the [*devicepb.AssertDeviceRequest] from the stream +// and return the [*devicepb.AssertDeviceResponse] to the stream. +type streamAdapter struct { + stream accessgraphsecretsv1pb.SecretsScannerService_ReportSecretsServer +} + +func (s streamAdapter) Send(rsp *devicepb.AssertDeviceResponse) error { + msg := &accessgraphsecretsv1pb.ReportSecretsResponse{ + Payload: &accessgraphsecretsv1pb.ReportSecretsResponse_DeviceAssertion{ + DeviceAssertion: rsp, + }, + } + err := s.stream.Send(msg) + return trace.Wrap(err) +} + +func (s streamAdapter) Recv() (*devicepb.AssertDeviceRequest, error) { + msg, err := s.stream.Recv() + if err != nil { + return nil, trace.Wrap(err) + } + + if msg.GetDeviceAssertion() == nil { + return nil, trace.BadParameter("unexpected assert request payload: %T", msg.GetPayload()) + } + + return msg.GetDeviceAssertion(), nil +} diff --git a/lib/secretsscanner/reporter/report.go b/lib/secretsscanner/reporter/report.go new file mode 100644 index 0000000000000..22dd8c22b9d3b --- /dev/null +++ b/lib/secretsscanner/reporter/report.go @@ -0,0 +1,195 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package reporter + +import ( + "context" + "errors" + "io" + "log/slog" + + "github.com/gravitational/trace" + + accessgraphsecretsv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/accessgraph/v1" + devicepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/devicetrust/v1" + dtassert "github.com/gravitational/teleport/lib/devicetrust/assert" + secretsscannerclient "github.com/gravitational/teleport/lib/secretsscanner/client" +) + +// AssertCeremonyBuilderFunc is a function that builds the device authentication ceremony. +type AssertCeremonyBuilderFunc func() (*dtassert.Ceremony, error) + +// Config specifies the configuration for the reporter. +type Config struct { + // Client is a client for the SecretsScannerService. + Client secretsscannerclient.Client + // Log is the logger. + Log *slog.Logger + // BatchSize is the number of secrets to send in a single batch. Defaults to [defaultBatchSize] if not set. + BatchSize int + // AssertCeremonyBuilder is the device authentication ceremony builder. + // If not set, the default device authentication ceremony will be used. + // Used for testing, avoid in production code. + AssertCeremonyBuilder AssertCeremonyBuilderFunc +} + +// Reporter reports secrets to the Teleport Proxy. +type Reporter struct { + client secretsscannerclient.Client + log *slog.Logger + batchSize int + assertCeremonyBuilder AssertCeremonyBuilderFunc +} + +// New creates a new reporter instance. +func New(cfg Config) (*Reporter, error) { + if cfg.Client == nil { + return nil, trace.BadParameter("missing client") + } + if cfg.Log == nil { + cfg.Log = slog.Default() + } + if cfg.BatchSize == 0 { + const defaultBatchSize = 100 + cfg.BatchSize = defaultBatchSize + } + if cfg.AssertCeremonyBuilder == nil { + cfg.AssertCeremonyBuilder = func() (*dtassert.Ceremony, error) { + return dtassert.NewCeremony() + } + } + return &Reporter{ + client: cfg.Client, + log: cfg.Log, + batchSize: cfg.BatchSize, + assertCeremonyBuilder: cfg.AssertCeremonyBuilder, + }, nil +} + +// ReportPrivateKeys reports the private keys to the Teleport server. +// This function performs the following steps: +// 1. Create a new gRPC client to the Teleport Proxy. +// 2. Run the device assertion ceremony. +// 3. Report the private keys to the Teleport cluster. +// 4. Wait for the server to acknowledge the report. +func (r *Reporter) ReportPrivateKeys(ctx context.Context, pks []*accessgraphsecretsv1pb.PrivateKey) error { + + stream, err := r.client.ReportSecrets(ctx) + if err != nil { + return trace.Wrap(err, "failed to create client") + } + + if err := r.runAssertionCeremony(ctx, stream); err != nil { + return trace.Wrap(err, "failed to run assertion ceremony") + } + + if err := r.reportPrivateKeys(stream, pks); err != nil { + return trace.Wrap(err, "failed to report private keys") + } + + return trace.Wrap(r.terminateAndWaitAcknowledge(stream), "server failed to acknowledge the report") +} + +// runAssertionCeremony runs the device assertion ceremony. +func (r *Reporter) runAssertionCeremony(ctx context.Context, stream accessgraphsecretsv1pb.SecretsScannerService_ReportSecretsClient) error { + // Create a new device authentication ceremony. + assertCeremony, err := r.assertCeremonyBuilder() + if err != nil { + return trace.Wrap(err, "failed to create assertCeremony") + } + + // Run the device authentication ceremony. + // If successful, the device will be authenticated and the device can report its secrets. + err = assertCeremony.Run( + ctx, + reportToAssertStreamAdapter{stream}, + ) + return trace.Wrap(err, "failed to run device authentication ceremony") +} + +// reportPrivateKeys reports the private keys to the Teleport server in batches of size [r.batchSize] using the given stream. +func (r *Reporter) reportPrivateKeys(stream accessgraphsecretsv1pb.SecretsScannerService_ReportSecretsClient, privateKeys []*accessgraphsecretsv1pb.PrivateKey) error { + batchSize := r.batchSize + for i := 0; len(privateKeys) > i; i += batchSize { + start := i + end := i + batchSize + if end > len(privateKeys) { + end = len(privateKeys) + } + if err := stream.Send(&accessgraphsecretsv1pb.ReportSecretsRequest{ + Payload: &accessgraphsecretsv1pb.ReportSecretsRequest_PrivateKeys{ + PrivateKeys: &accessgraphsecretsv1pb.ReportPrivateKeys{ + Keys: privateKeys[start:end], + }, + }, + }); err != nil { + return trace.Wrap(err, "failed to send private keys") + } + } + return nil +} + +// terminateAndWaitAcknowledge terminates the client side of the stream and waits for the server to acknowledge the report. +func (r *Reporter) terminateAndWaitAcknowledge(stream accessgraphsecretsv1pb.SecretsScannerService_ReportSecretsClient) error { + // Inform the server that there are no more private keys to report. + if err := stream.CloseSend(); err != nil { + return trace.Wrap(err, "failed to close send") + } + + // Wait for the server to acknowledge the report. + if _, err := stream.Recv(); err != nil && !errors.Is(err, io.EOF) { + return trace.Wrap(err, "error closing the stream") + } + return nil +} + +// reportToAssertStreamAdapter is a wrapper for the [accessgraphsecretsv1pb.SecretsScannerService_ReportSecretsClient] that implements the +// [assert.AssertDeviceClientStream] interface. +// +// This adapter allows the [accessgraphsecretsv1pb.SecretsScannerService_ReportSecretsClient] to be used with the [assert.AssertDeviceClientStream] +// interface, which is essential for the [assert.Ceremony] in executing the device authentication process. It handles the extraction and insertion +// of device assertion messages from and into the [accessgraphsecretsv1pb.ReportSecretsRequest] and [accessgraphsecretsv1pb.ReportSecretsResponse] messages. +type reportToAssertStreamAdapter struct { + stream accessgraphsecretsv1pb.SecretsScannerService_ReportSecretsClient +} + +func (s reportToAssertStreamAdapter) Send(request *devicepb.AssertDeviceRequest) error { + return trace.Wrap( + s.stream.Send( + &accessgraphsecretsv1pb.ReportSecretsRequest{ + Payload: &accessgraphsecretsv1pb.ReportSecretsRequest_DeviceAssertion{ + DeviceAssertion: request, + }, + }, + ), + ) +} + +func (s reportToAssertStreamAdapter) Recv() (*devicepb.AssertDeviceResponse, error) { + in, err := s.stream.Recv() + if err != nil { + return nil, trace.Wrap(err) + } + + if in.GetDeviceAssertion() == nil { + return nil, trace.BadParameter("unsupported response type: expected DeviceAssertion, got %T", in.Payload) + } + + return in.GetDeviceAssertion(), nil +} diff --git a/lib/secretsscanner/reporter/report_test.go b/lib/secretsscanner/reporter/report_test.go new file mode 100644 index 0000000000000..76c2d661cd116 --- /dev/null +++ b/lib/secretsscanner/reporter/report_test.go @@ -0,0 +1,157 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package reporter_test + +import ( + "context" + "errors" + "log/slog" + "sort" + "strconv" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/testing/protocmp" + + "github.com/gravitational/teleport/api/defaults" + accessgraphsecretsv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/accessgraph/v1" + devicepb "github.com/gravitational/teleport/api/gen/proto/go/teleport/devicetrust/v1" + "github.com/gravitational/teleport/api/types/accessgraph" + dtassert "github.com/gravitational/teleport/lib/devicetrust/assert" + dtauthn "github.com/gravitational/teleport/lib/devicetrust/authn" + dttestenv "github.com/gravitational/teleport/lib/devicetrust/testenv" + secretsscannerclient "github.com/gravitational/teleport/lib/secretsscanner/client" + "github.com/gravitational/teleport/lib/secretsscanner/reporter" +) + +func TestReporter(t *testing.T) { + // disable TLS routing check for tests + t.Setenv(defaults.TLSRoutingConnUpgradeEnvVar, "false") + deviceID := uuid.NewString() + device, err := dttestenv.NewFakeMacOSDevice() + require.NoError(t, err) + + tests := []struct { + name string + preReconcileError error + assertErr require.ErrorAssertionFunc + report []*accessgraphsecretsv1pb.PrivateKey + want []*accessgraphsecretsv1pb.PrivateKey + }{ + { + name: "success", + report: newPrivateKeys(t, deviceID), + want: newPrivateKeys(t, deviceID), + assertErr: require.NoError, + }, + { + name: "pre-reconcile error", + preReconcileError: errors.New("pre-reconcile error"), + report: newPrivateKeys(t, deviceID), + assertErr: func(t require.TestingT, err error, _ ...any) { + require.ErrorContains(t, err, "pre-reconcile error") + }, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + e := setup( + t, + withDevice(deviceID, device), + withPreReconcileError(tt.preReconcileError), + ) + + ctx := context.Background() + + client, err := secretsscannerclient.NewSecretsScannerServiceClient(ctx, + secretsscannerclient.ClientConfig{ + ProxyServer: e.secretsScannerAddr, + Insecure: true, + }, + ) + require.NoError(t, err) + + r, err := reporter.New( + reporter.Config{ + Log: slog.Default(), + Client: client, + BatchSize: 1, /* batch size for tests */ + AssertCeremonyBuilder: func() (*dtassert.Ceremony, error) { + return dtassert.NewCeremony( + dtassert.WithNewAuthnCeremonyFunc( + func() *dtauthn.Ceremony { + return &dtauthn.Ceremony{ + GetDeviceCredential: func() (*devicepb.DeviceCredential, error) { + return device.GetDeviceCredential(), nil + }, + CollectDeviceData: device.CollectDeviceData, + SignChallenge: device.SignChallenge, + SolveTPMAuthnDeviceChallenge: device.SolveTPMAuthnDeviceChallenge, + GetDeviceOSType: device.GetDeviceOSType, + } + }, + ), + ) + }, + }, + ) + require.NoError(t, err) + + err = r.ReportPrivateKeys(ctx, tt.report) + tt.assertErr(t, err) + + got := e.service.privateKeysReported + sortPrivateKeys(got) + sortPrivateKeys(tt.want) + + diff := cmp.Diff(tt.want, got, protocmp.Transform()) + require.Empty(t, diff, "ReportPrivateKeys keys mismatch (-got +want)") + + }) + } +} + +func sortPrivateKeys(keys []*accessgraphsecretsv1pb.PrivateKey) { + sort.Slice(keys, func(i, j int) bool { + return keys[i].Metadata.Name < keys[j].Metadata.Name + }) +} + +func newPrivateKeys(t *testing.T, deviceID string) []*accessgraphsecretsv1pb.PrivateKey { + t.Helper() + var pks []*accessgraphsecretsv1pb.PrivateKey + for i := 0; i < 10; i++ { + pk, err := accessgraph.NewPrivateKey( + &accessgraphsecretsv1pb.PrivateKeySpec{ + PublicKeyFingerprint: "key" + strconv.Itoa(i), + DeviceId: deviceID, + PublicKeyMode: accessgraphsecretsv1pb.PublicKeyMode_PUBLIC_KEY_MODE_DERIVED, + }, + ) + require.NoError(t, err) + pks = append(pks, pk) + } + + return pks +} diff --git a/lib/secretsscanner/scaner/scan.go b/lib/secretsscanner/scaner/scan.go new file mode 100644 index 0000000000000..664b6fcef8e67 --- /dev/null +++ b/lib/secretsscanner/scaner/scan.go @@ -0,0 +1,298 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package scanner + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "io" + "io/fs" + "log/slog" + "os" + "path/filepath" + + "github.com/gravitational/trace" + "golang.org/x/crypto/ssh" + + accessgraphsecretsv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/accessgraph/v1" + "github.com/gravitational/teleport/api/types/accessgraph" +) + +// Config specifies parameters for the scanner. +type Config struct { + // Dirs is a list of directories to scan. + Dirs []string + // SkipPaths is a list of paths to skip. + // It supports glob patterns (e.g. "/etc/*/"). + // Please refer to the [filepath.Match] documentation for more information. + SkipPaths []string + // Log is the logger. + Log *slog.Logger +} + +// New creates a new scanner. +func New(cfg Config) (*Scanner, error) { + if len(cfg.Dirs) == 0 { + return nil, trace.BadParameter("missing dirs") + } + if cfg.Log == nil { + cfg.Log = slog.Default() + } + + // expand the glob patterns in the skipPaths list. + // we expand the glob patterns here to avoid expanding them for each file during the scan. + // only the directories matched by the glob patterns will be skipped. + skippedPaths, err := expandSkipPaths(cfg.SkipPaths) + if err != nil { + return nil, trace.Wrap(err) + } + + return &Scanner{ + dirs: cfg.Dirs, + log: cfg.Log, + skippedPaths: skippedPaths, + }, nil +} + +// Scanner is a scanner that scans directories for secrets. +type Scanner struct { + dirs []string + log *slog.Logger + skippedPaths map[string]struct{} +} + +// ScanPrivateKeys scans directories for SSH private keys. +func (s *Scanner) ScanPrivateKeys(ctx context.Context, deviceID string) []SSHPrivateKey { + // privateKeys is a map of private keys found during the scan. + // The key is the path to the private key file and the value is the private key representation. + privateKeysMap := make(map[string]*accessgraphsecretsv1pb.PrivateKey) + for _, dir := range s.dirs { + s.findPrivateKeys(ctx, dir, deviceID, privateKeysMap) + } + + keys := make([]SSHPrivateKey, 0, len(privateKeysMap)) + for path, key := range privateKeysMap { + keys = append(keys, SSHPrivateKey{ + Path: path, + Key: key, + }) + } + return keys +} + +// SSHPrivateKey represents an SSH private key found during the scan. +type SSHPrivateKey struct { + // Path is the absolute path to the private key file. + Path string + // Key is the private key representation. + Key *accessgraphsecretsv1pb.PrivateKey +} + +// findPrivateKeys walks through all files in a directory and its subdirectories +// and checks if they are SSH private keys. +func (s *Scanner) findPrivateKeys(ctx context.Context, root, deviceID string, privateKeysMap map[string]*accessgraphsecretsv1pb.PrivateKey) { + logger := s.log.With("root", root) + + err := filepath.WalkDir(root, func(path string, info fs.DirEntry, err error) error { + if err != nil { + logger.DebugContext(ctx, "error walking directory", "path", path, "error", err) + return fs.SkipDir + } + if info.IsDir() { + if _, ok := s.skippedPaths[path]; ok { + logger.DebugContext(ctx, "skipping directory", "path", path) + return fs.SkipDir + } + return nil + } + + if _, ok := s.skippedPaths[path]; ok { + logger.DebugContext(ctx, "skipping file", "path", path) + return nil + } + + switch fileData, isKey, err := s.readFileIfSSHPrivateKey(ctx, path); { + case err != nil: + logger.DebugContext(ctx, "error reading file", "path", path, "error", err) + case isKey: + key, err := extractSSHKey(ctx, path, deviceID, fileData) + if err != nil { + logger.DebugContext(ctx, "error extracting private key", "path", path, "error", err) + } else { + privateKeysMap[path] = key + } + } + return nil + }) + + if err != nil { + logger.WarnContext(ctx, "error walking directory", "root", root, "error", err) + } +} + +var ( + supportedPrivateKeyHeaders = [][]byte{ + []byte("RSA PRIVATE KEY"), + []byte("PRIVATE KEY"), + []byte("EC PRIVATE KEY"), + []byte("DSA PRIVATE KEY"), + []byte("OPENSSH PRIVATE KEY"), + } +) + +// readFileIfSSHPrivateKey checks if a file is an OpenSSH private key +func (s *Scanner) readFileIfSSHPrivateKey(ctx context.Context, filePath string) ([]byte, bool, error) { + file, err := os.Open(filePath) + if err != nil { + return nil, false, err + } + defer func() { + if err = file.Close(); err != nil { + s.log.DebugContext(ctx, "failed to close file", "path", filePath, "error", err) + } + }() + + // read the first bytes of the file to check if it's an OpenSSH private key. + // 40 bytes is the maximum length of the header of an OpenSSH private key. + var buf [40]byte + n, err := file.Read(buf[:]) + if errors.Is(err, io.EOF) || n < len(buf) { + return nil, false, nil + } else if err != nil { + return nil, false, trace.Wrap(err, "failed to read file") + } + + isPrivateKey := false + for _, header := range supportedPrivateKeyHeaders { + if bytes.Contains(buf[:], header) { + isPrivateKey = true + break + } + } + if !isPrivateKey { + return nil, false, nil + } + + // read the entire file + data, err := io.ReadAll(file) + if err != nil { + return nil, false, trace.Wrap(err, "failed to read file") + } + return append(buf[:], data...), true, nil +} + +func extractSSHKey(ctx context.Context, path, deviceID string, fileData []byte) (*accessgraphsecretsv1pb.PrivateKey, error) { + logger := slog.Default().With("private_key_file", path, "device_id", deviceID) + + var publicKey ssh.PublicKey + var mode accessgraphsecretsv1pb.PublicKeyMode + var pme *ssh.PassphraseMissingError + switch pk, err := ssh.ParsePrivateKey(fileData); { + case errors.As(err, &pme): + if pme.PublicKey != nil { + // If the key is a OpenSSH private key whose public key is embedded in the header, it will return the public key. This is + // a special case for OpenSSH private keys that have the public key embedded in the header, for more information see + // OpenSSH's ssh key format: https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.key + publicKey = pme.PublicKey + mode = accessgraphsecretsv1pb.PublicKeyMode_PUBLIC_KEY_MODE_DERIVED + break + } + const pubKeyFileSuffix = ".pub" + publicKey, mode = tryParsingPublicKeyFromPublicFilePath(ctx, logger, path+pubKeyFileSuffix) + case err != nil: + return nil, trace.Wrap(err) + default: + publicKey = pk.PublicKey() + mode = accessgraphsecretsv1pb.PublicKeyMode_PUBLIC_KEY_MODE_DERIVED + } + var fingerprint string + if publicKey != nil { + fingerprint = ssh.FingerprintSHA256(publicKey) + } + + key, err := accessgraph.NewPrivateKeyWithName( + privateKeyNameGen(path, deviceID, fingerprint), + &accessgraphsecretsv1pb.PrivateKeySpec{ + PublicKeyFingerprint: fingerprint, + DeviceId: deviceID, + PublicKeyMode: mode, + }, + ) + return key, trace.Wrap(err) +} + +// tryParsingPublicKeyFromPublicFilePath tries to read the public key from the public key file if the private key is password protected. +// If the public key file doesn't exist, it will return mode accessgraphsecretsv1pb.PublicKeyMode_PUBLIC_KEY_MODE_PROTECTED +// identifying that the private key is password protected and the public key could not be extracted. +func tryParsingPublicKeyFromPublicFilePath(ctx context.Context, logger *slog.Logger, pubPath string) (ssh.PublicKey, accessgraphsecretsv1pb.PublicKeyMode) { + logger = logger.With("public_key_file", pubPath) + logger.DebugContext(ctx, "PrivateKey is password protected. Fallback to public key file.") + + pubData, err := os.ReadFile(pubPath) + if err != nil { + logger.DebugContext(ctx, "Unable to read public key file.", "err", err) + return nil, accessgraphsecretsv1pb.PublicKeyMode_PUBLIC_KEY_MODE_PROTECTED + } + + logger.DebugContext(ctx, "Trying to parse public key as authorized key data.") + pub, _, _, _, err := ssh.ParseAuthorizedKey(pubData) + if err == nil { + return pub, accessgraphsecretsv1pb.PublicKeyMode_PUBLIC_KEY_MODE_PUB_FILE + } + logger.DebugContext(ctx, "Unable to parse ssh public key file.", "err", err) + + logger.DebugContext(ctx, "Trying to parse public key directly.") + + pub, err = ssh.ParsePublicKey(pubData) + if err == nil { + return pub, accessgraphsecretsv1pb.PublicKeyMode_PUBLIC_KEY_MODE_PUB_FILE + } + + logger.DebugContext(ctx, "Unable to parse ssh public key file.", "err", err) + + return nil, accessgraphsecretsv1pb.PublicKeyMode_PUBLIC_KEY_MODE_PROTECTED + +} + +func privateKeyNameGen(path, deviceID, fingerprint string) string { + sha := sha256.New() + sha.Write([]byte(path)) + sha.Write([]byte(deviceID)) + sha.Write([]byte(fingerprint)) + return hex.EncodeToString(sha.Sum(nil)) +} + +// expandSkipPaths expands the glob patterns in the skipPaths list and returns a set of the +// paths matched by the glob patterns to be skipped. +func expandSkipPaths(skipPaths []string) (map[string]struct{}, error) { + set := make(map[string]struct{}) + for _, glob := range skipPaths { + matches, err := filepath.Glob(glob) + if err != nil { + return nil, trace.Wrap(err, "glob pattern %q is invalid", glob) + } + for _, match := range matches { + set[match] = struct{}{} + } + } + return set, nil +} diff --git a/lib/secretsscanner/scaner/scan_test.go b/lib/secretsscanner/scaner/scan_test.go new file mode 100644 index 0000000000000..df82e08eb0ea7 --- /dev/null +++ b/lib/secretsscanner/scaner/scan_test.go @@ -0,0 +1,237 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package scanner + +import ( + "context" + "os" + "path/filepath" + "sort" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" + "google.golang.org/protobuf/testing/protocmp" + + accessgraphsecretsv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/accessgraph/v1" + "github.com/gravitational/teleport/api/types/accessgraph" + scantestdata "github.com/gravitational/teleport/lib/secretsscanner/scaner/testdata" +) + +var ( + deviceID = uuid.NewString() +) + +func TestNewScanner(t *testing.T) { + tests := []struct { + name string + keysGen func(t *testing.T, path string) []*accessgraphsecretsv1pb.PrivateKey + skipTestDir bool + assertResult func(t *testing.T, got []*accessgraphsecretsv1pb.PrivateKey) + }{ + { + name: "encrypted keys", + keysGen: writeEncryptedKeys, + }, + { + name: "unencrypted keys", + keysGen: writeUnEncryptedKeys, + }, + { + name: "encryptedKey without public key file", + keysGen: writeEncryptedKeyWithoutPubFile, + }, + { + name: "invalid keys", + keysGen: writeInvalidKeys, + }, + { + name: "skip test dir keys", + keysGen: writeUnEncryptedKeys, + skipTestDir: true, + assertResult: func(t *testing.T, got []*accessgraphsecretsv1pb.PrivateKey) { + require.Empty(t, got, "ScanPrivateKeys with skip test dir should return empty keys") + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + + expect := tt.keysGen(t, dir) + + var skipPaths []string + if tt.skipTestDir { + // skip the test directory. + skipPaths = []string{filepath.Join(dir, "*")} + // the expected keys should be nil since the test directory is skipped. + expect = nil + } + + s, err := New(Config{ + Dirs: []string{dir}, + SkipPaths: skipPaths, + }) + require.NoError(t, err) + + keys := s.ScanPrivateKeys(context.Background(), deviceID) + var got []*accessgraphsecretsv1pb.PrivateKey + for _, key := range keys { + got = append(got, key.Key) + } + + // Sort the keys by name for comparison. + sortPrivateKeys(expect) + sortPrivateKeys(got) + + if tt.assertResult != nil { + tt.assertResult(t, got) + } + + diff := cmp.Diff(expect, got, protocmp.Transform()) + require.Empty(t, diff, "ScanPrivateKeys keys mismatch (-got +want)") + }) + } +} + +func sortPrivateKeys(keys []*accessgraphsecretsv1pb.PrivateKey) { + sort.Slice(keys, func(i, j int) bool { + return keys[i].Metadata.Name < keys[j].Metadata.Name + }) +} + +func writeEncryptedKeys(t *testing.T, dir string) []*accessgraphsecretsv1pb.PrivateKey { + t.Helper() + var expectedKeys []*accessgraphsecretsv1pb.PrivateKey + // Write encrypted keys to the directory. + for _, key := range scantestdata.PEMEncryptedKeys { + err := os.Mkdir(filepath.Join(dir, key.Name), 0o777) + require.NoError(t, err) + + filePath := filepath.Join(dir, key.Name, key.Name) + err = os.WriteFile(filePath, key.PEMBytes, 0o666) + require.NoError(t, err) + + s, err := ssh.ParsePrivateKeyWithPassphrase(key.PEMBytes, []byte(key.EncryptionKey)) + require.NoError(t, err) + + if !key.IncludesPublicKey { + pubFilePath := filePath + ".pub" + authorizedKeyBytes := ssh.MarshalAuthorizedKey(s.PublicKey()) + require.NoError(t, os.WriteFile(pubFilePath, authorizedKeyBytes, 0o666)) + } + + fingerprint := ssh.FingerprintSHA256(s.PublicKey()) + + mode := accessgraphsecretsv1pb.PublicKeyMode_PUBLIC_KEY_MODE_DERIVED + if !key.IncludesPublicKey { + mode = accessgraphsecretsv1pb.PublicKeyMode_PUBLIC_KEY_MODE_PUB_FILE + } + + key, err := accessgraph.NewPrivateKeyWithName( + privateKeyNameGen(filePath, deviceID, fingerprint), + &accessgraphsecretsv1pb.PrivateKeySpec{ + PublicKeyFingerprint: fingerprint, + DeviceId: deviceID, + PublicKeyMode: mode, + }, + ) + require.NoError(t, err) + + expectedKeys = append(expectedKeys, key) + } + + return expectedKeys +} + +func writeUnEncryptedKeys(t *testing.T, dir string) []*accessgraphsecretsv1pb.PrivateKey { + t.Helper() + var expectedKeys []*accessgraphsecretsv1pb.PrivateKey + + for name, key := range scantestdata.PEMBytes { + err := os.Mkdir(filepath.Join(dir, name), 0o777) + require.NoError(t, err) + + filePath := filepath.Join(dir, name, name) + err = os.WriteFile(filePath, key, 0o666) + require.NoError(t, err) + + s, err := ssh.ParsePrivateKey(key) + require.NoError(t, err) + + fingerprint := ssh.FingerprintSHA256(s.PublicKey()) + + key, err := accessgraph.NewPrivateKeyWithName( + privateKeyNameGen(filePath, deviceID, fingerprint), + &accessgraphsecretsv1pb.PrivateKeySpec{ + PublicKeyFingerprint: fingerprint, + DeviceId: deviceID, + PublicKeyMode: accessgraphsecretsv1pb.PublicKeyMode_PUBLIC_KEY_MODE_DERIVED, + }, + ) + require.NoError(t, err) + + expectedKeys = append(expectedKeys, key) + } + + return expectedKeys +} + +func writeEncryptedKeyWithoutPubFile(t *testing.T, dir string) []*accessgraphsecretsv1pb.PrivateKey { + t.Helper() + + // Write encrypted keys to the directory. + rawKey := scantestdata.PEMEncryptedKeys[0] + err := os.Mkdir(filepath.Join(dir, rawKey.Name), 0o777) + require.NoError(t, err) + + filePath := filepath.Join(dir, rawKey.Name, rawKey.Name) + err = os.WriteFile(filePath, rawKey.PEMBytes, 0o666) + require.NoError(t, err) + + key, err := accessgraph.NewPrivateKeyWithName( + privateKeyNameGen(filePath, deviceID, ""), + &accessgraphsecretsv1pb.PrivateKeySpec{ + PublicKeyFingerprint: "", + DeviceId: deviceID, + PublicKeyMode: accessgraphsecretsv1pb.PublicKeyMode_PUBLIC_KEY_MODE_PROTECTED, + }, + ) + require.NoError(t, err) + + return []*accessgraphsecretsv1pb.PrivateKey{key} +} + +func writeInvalidKeys(t *testing.T, dir string) []*accessgraphsecretsv1pb.PrivateKey { + t.Helper() + + // Write invalid keys to the directory. + for path, keyBytes := range scantestdata.InvalidKeysBytes { + err := os.Mkdir(filepath.Join(dir, path), 0o777) + require.NoError(t, err) + + filePath := filepath.Join(dir, path, path) + err = os.WriteFile(filePath, keyBytes, 0o666) + require.NoError(t, err) + } + + return nil +} diff --git a/lib/secretsscanner/scaner/testdata/invalid_keys.go b/lib/secretsscanner/scaner/testdata/invalid_keys.go new file mode 100644 index 0000000000000..b2e1b18732770 --- /dev/null +++ b/lib/secretsscanner/scaner/testdata/invalid_keys.go @@ -0,0 +1,50 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package testdata + +// InvalidKeysBytes is a map of invalid keys to their byte representation. +var InvalidKeysBytes = map[string][]byte{ + "short-file": []byte("short file"), + + "empty-file": []byte(""), + + "invalid-key": []byte(`-----BEGIN PRIVATE +KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDQ7z7z7z7z7z7z +-----END OPENSSH PRIVATE KEY----- +`), + + "invalid-key-valid-headers": []byte( + `-----BEGIN OPENSSH PRIVATE KEY----- +trash +-----END OPENSSH PRIVATE KEY----- +`), + + "invalid-key-invalid-header": []byte( + `abcefg-----BEGIN OPENSSH PRIVATE KEY----- +-----END OPENSSH PRIVATE KEY----- +`), + + "valid-key-not-supported-header": []byte(`-----BEGIN RANDOM PRIVATE KEY----- +MHcCAQEEINGWx0zo6fhJ/0EAfrPzVFyFC9s18lBt3cRoEDhS3ARooAoGCCqGSM49 +AwEHoUQDQgAEi9Hdw6KvZcWxfg2IDhA7UkpDtzzt6ZqJXSsFdLd+Kx4S3Sx4cVO+ +6/ZOXRnPmNAlLUqjShUsUBBngG0u2fqEqA== +-----END EC PRIVATE KEY----- +`), +} diff --git a/lib/secretsscanner/scaner/testdata/ssh_keys.go b/lib/secretsscanner/scaner/testdata/ssh_keys.go new file mode 100644 index 0000000000000..ff35441f77581 --- /dev/null +++ b/lib/secretsscanner/scaner/testdata/ssh_keys.go @@ -0,0 +1,290 @@ +// Copyright 2014 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +// forked from golang.org/x/crypto@v0.24.0/ssh/testdata/keys.go + +package testdata + +var PEMBytes = map[string][]byte{ + "dsa": []byte(`-----BEGIN DSA PRIVATE KEY----- +MIIBuwIBAAKBgQD6PDSEyXiI9jfNs97WuM46MSDCYlOqWw80ajN16AohtBncs1YB +lHk//dQOvCYOsYaE+gNix2jtoRjwXhDsc25/IqQbU1ahb7mB8/rsaILRGIbA5WH3 +EgFtJmXFovDz3if6F6TzvhFpHgJRmLYVR8cqsezL3hEZOvvs2iH7MorkxwIVAJHD +nD82+lxh2fb4PMsIiaXudAsBAoGAQRf7Q/iaPRn43ZquUhd6WwvirqUj+tkIu6eV +2nZWYmXLlqFQKEy4Tejl7Wkyzr2OSYvbXLzo7TNxLKoWor6ips0phYPPMyXld14r +juhT24CrhOzuLMhDduMDi032wDIZG4Y+K7ElU8Oufn8Sj5Wge8r6ANmmVgmFfynr +FhdYCngCgYEA3ucGJ93/Mx4q4eKRDxcWD3QzWyqpbRVRRV1Vmih9Ha/qC994nJFz +DQIdjxDIT2Rk2AGzMqFEB68Zc3O+Wcsmz5eWWzEwFxaTwOGWTyDqsDRLm3fD+QYj +nOwuxb0Kce+gWI8voWcqC9cyRm09jGzu2Ab3Bhtpg8JJ8L7gS3MRZK4CFEx4UAfY +Fmsr0W6fHB9nhS4/UXM8 +-----END DSA PRIVATE KEY----- +`), + "ecdsa": []byte(`-----BEGIN EC PRIVATE KEY----- +MHcCAQEEINGWx0zo6fhJ/0EAfrPzVFyFC9s18lBt3cRoEDhS3ARooAoGCCqGSM49 +AwEHoUQDQgAEi9Hdw6KvZcWxfg2IDhA7UkpDtzzt6ZqJXSsFdLd+Kx4S3Sx4cVO+ +6/ZOXRnPmNAlLUqjShUsUBBngG0u2fqEqA== +-----END EC PRIVATE KEY----- +`), + "ecdsap256": []byte(`-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIAPCE25zK0PQSnsgVcEbM1mbKTASH4pqb5QJajplDwDZoAoGCCqGSM49 +AwEHoUQDQgAEWy8TxGcIHRh5XGpO4dFVfDjeNY+VkgubQrf/eyFJZHxAn1SKraXU +qJUjTKj1z622OxYtJ5P7s9CfAEVsTzLCzg== +-----END EC PRIVATE KEY----- +`), + "ecdsap384": []byte(`-----BEGIN EC PRIVATE KEY----- +MIGkAgEBBDBWfSnMuNKq8J9rQLzzEkx3KAoEohSXqhE/4CdjEYtoU2i22HW80DDS +qQhYNHRAduygBwYFK4EEACKhZANiAAQWaDMAd0HUd8ZiXCX7mYDDnC54gwH/nG43 +VhCUEYmF7HMZm/B9Yn3GjFk3qYEDEvuF/52+NvUKBKKaLbh32AWxMv0ibcoba4cz +hL9+hWYhUD9XIUlzMWiZ2y6eBE9PdRI= +-----END EC PRIVATE KEY----- +`), + "ecdsap521": []byte(`-----BEGIN EC PRIVATE KEY----- +MIHcAgEBBEIBrkYpQcy8KTVHNiAkjlFZwee90224Bu6wz94R4OBo+Ts0eoAQG7SF +iaygEDMUbx6kTgXTBcKZ0jrWPKakayNZ/kigBwYFK4EEACOhgYkDgYYABADFuvLV +UoaCDGHcw5uNfdRIsvaLKuWSpLsl48eWGZAwdNG432GDVKduO+pceuE+8XzcyJb+ +uMv+D2b11Q/LQUcHJwE6fqbm8m3EtDKPsoKs0u/XUJb0JsH4J8lkZzbUTjvGYamn +FFlRjzoB3Oxu8UQgb+MWPedtH9XYBbg9biz4jJLkXQ== +-----END EC PRIVATE KEY----- +`), + "rsa": []byte(`-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAnuozKMtcQkIImZGSe4IujS4+Lkas9jmlBivziWGU3waivkpU +vYspgJbh7vSvnHOPtKscdIJ+3UUyViDUoM73GumsmHvfeRCoA9YROZK4fQR9G0a1 +wfoRqsrJXGToCzTvr/I2KIwpUG0bRE9rUvsW+JN9xgri+cIJWtu/dGYDkILO4bkF +IxtEvHNVvhGLenyOHFhPw3hAZ7/bKq8kvKzm9D2zOllHe1wWncMkhVmEFF9Houeh +jbddmeIAAxBpRUFfzp1dD7503ADBlJdK306D4CeI4KIFiqE1VrmfcMgP8fti0S0b +4JtmvevYoPd+/wB9ItFqvhc6nyuxF0PfWH+SvwIDAQABAoIBAQCCcpNeSFjKVvRC +Q1nwIrPd1njaec+fK0CIqWl3e2++B+9trwySrvp5gOGjyp2hGsd7Mf7gsQI81oF0 +a+y+uEXlhK3WWdDey0pwI7ft/7+LeDTOQCQRQBpijaXvPzGviVu7nWLRtARx7a41 +S9A4xL5dfI0BFYyuIpaVS8+EV/1TEJIbceZ5q5RBlARA1rc+nBvjygNzYdRq9Rao +yyehvnXZ7pQrATnwofPolbZNseW2Q9sRMmtm1E60XJJ433P7nFbxXtsMAYgxWSQc +V/92iRPYeD7sN/b7qulLEgC6e8el2gLGIB9aQyG7B6KFqloqvx/ymYs07+bWIQCU +6i9y7LABAoGBANKB2Rs0lF5c+gpEE3w0AWoxGyL04TZECtl2hQ/b2jc2n8hLqLhv +zNIKN+xzEP6j0ijXajjEMLiQH10qQ6+Plv8C7o8GJC1V/Oj4u4kbPqfVV1kSxuWm +FBjz6+c8VPbEBgXq5lCMEgC2Ii8XVRoyd3iSSOh+LIMBu/Br3JEsjJq/AoGBAMFC +CvODTiThrZo8v765dRSHrLOvB4jZOPrEKLWECLaQDQpuhgzZhyWm9zFfxGB+LWE9 +R9pU6ZCFPtPfd4cRZ9cezrp+lgdrqjcUX/2ZLLMk21WXFuRaw/4KTlKNsMY6lbAK +qVkUFWIZWaCQ8bdVCP48polipRNmN9mAHsIhIwgBAoGBAM6yn1qeS11I0GAKLlPT +wNvjsfCmIQmm0DxtqwRCbUevxD7pQ5cueCB51iW/ap2OgEqIEo4A3pIrOhDB8kpN +pQdrepFHh3hYqYicy5A6B1DHJAibbl+Krss9n5KjZA4VtpBS8al/kCHQtUomD/M0 +QKlMgnh/g/dzWXYegyqtYraDAoGAGbeBH5B8iJnjcR/eYDHrq5S2XZ7QANzvISeT +RzxPsIOQyK+WdQVJX7BNOqvExRZlUYhHFH2yKwIgLy+Qh0/Aora9ycFok4o3N2cl +suh8M0aXTVdyu2Z8qESU0ZV7TZWkL63rhSgQBGLdM2m2ULAnJzXI74VJ9D/o9K+A +6FJiiAECgYEAujJ/hKxVKEUvxwloGSDhKCUH86+7UOkb/EM2zZFrlPYAz1VcCwr3 +K14r8BtmLFXuLXOlACpoH0Wf4uia+t6n8m9JK3mvpJ7fempAsptP3AdZMQFe7xUm +SXEGQBYxcyS5Q+ncwWZuPgby5wJ9D4Fd6TQH+wwG52sFugt/fGxbPug= +-----END RSA PRIVATE KEY----- +`), + "pkcs8": []byte(`-----BEGIN PRIVATE KEY----- +MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCitzS2KiRQTccf +VApb0mbPpo1lt29JjeLBYAehXHWfQ+w8sXpd8e04n/020spx1R94yg+v0NjXyh2R +NFXNBYdhNei33VJxUeKNlExaecvW2yxfuZqka+ZxT1aI8zrAsjh3Rwc6wayAJS4R +wZuzlDv4jZitWqwD+mb/22Zwq/WSs4YX5dUHDklfdWSVnoBfue8K/00n8f5yMTdJ +vFF0qAJwf9spPEHla0lYcozJk64CO5lRkqfLor4UnsXXOiA7aRIoaUSKa+rlhiqt +1EMGYiBjblPt4SwMelGGU2UfywPb4d85gpQ/s8SBARbpPxNVs2IbHDMwj70P3uZc +74M3c4VJAgMBAAECggEAFIzY3mziGzZHgMBncoNXMsCRORh6uKpvygZr0EhSHqRA +cMXlc3n7gNxL6aGjqc7F48Z5RrY0vMQtCcq3T2Z0W6WoV5hfMiqqV0E0h3S8ds1F +hG13h26NMyBXCILXl8Cqev4Afr45IBISCHIQTRTaoiCX+MTr1rDIU2YNQQumvzkz +fMw2XiFTFTgxAtJUAgKoTqLtm7/T+az7TKw+Hesgbx7yaJoMh9DWGBh4Y61DnIDA +fcxJboAfxxnFiXvdBVmzo72pCsRXrWOsjW6WxQmCKuXHvyB1FZTmMaEFNCGSJDa6 +U+OCzA3m65loAZAE7ffFHhYgssz/h9TBaOjKO0BX1QKBgQDZiCBvu+bFh9pEodcS +VxaI+ATlsYcmGdLtnZw5pxuEdr60iNWhpEcV6lGkbdiv5aL43QaGFDLagqeHI77b ++ITFbPPdCiYNaqlk6wyiXv4pdN7V683EDmGWSQlPeC9IhUilt2c+fChK2EB/XlkO +q8c3Vk1MsC6JOxDXNgJxylNpswKBgQC/fYBTb9iD+uM2n3SzJlct/ZlPaONKnNDR +pbTOdxBFHsu2VkfY858tfnEPkmSRX0yKmjHni6e8/qIzfzLwWBY4NmxhNZE5v+qJ +qZF26ULFdrZB4oWXAOliy/1S473OpQnp2MZp2asd0LPcg/BNaMuQrz44hxHb76R7 +qWD0ebIfEwKBgQCRCIiP1pjbVGN7ZOgPS080DSC+wClahtcyI+ZYLglTvRQTLDQ7 +LFtUykCav748MIADKuJBnM/3DiuCF5wV71EejDDfS/fo9BdyuKBY1brhixFTUX+E +Ww5Hc/SoLnpgALVZ/7jvWTpIBHykLxRziqYtR/YLzl+IkX/97P2ePoZ0rwKBgHNC +/7M5Z4JJyepfIMeVFHTCaT27TNTkf20x6Rs937U7TDN8y9JzEiU4LqXI4HAAhPoI +xnExRs4kF04YCnlRDE7Zs3Lv43J3ap1iTATfcymYwyv1RaQXEGQ/lUQHgYCZJtZz +fTrJoo5XyWu6nzJ5Gc8FLNaptr5ECSXGVm3Rsr2xAoGBAJWqEEQS/ejhO05QcPqh +y4cUdLr0269ILVsvic4Ot6zgfPIntXAK6IsHGKcg57kYm6W9k1CmmlA4ENGryJnR +vxyyqA9eyTFc1CQNuc2frKFA9It49JzjXahKc0aDHEHmTR787Tmk1LbuT0/gm9kA +L4INU6g+WqF0fatJxd+IJPrp +-----END PRIVATE KEY----- +`), + "ed25519": []byte(`-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACA+3f7hS7g5UWwXOGVTrMfhmxyrjqz7Sxxbx7I1j8DvvwAAAJhAFfkOQBX5 +DgAAAAtzc2gtZWQyNTUxOQAAACA+3f7hS7g5UWwXOGVTrMfhmxyrjqz7Sxxbx7I1j8Dvvw +AAAEAaYmXltfW6nhRo3iWGglRB48lYq0z0Q3I3KyrdutEr6j7d/uFLuDlRbBc4ZVOsx+Gb +HKuOrPtLHFvHsjWPwO+/AAAAE2dhcnRvbm1AZ2FydG9ubS14cHMBAg== +-----END OPENSSH PRIVATE KEY----- +`), + "rsa-openssh-format": []byte(`-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAlwAAAAdzc2gtcn +NhAAAAAwEAAQAAAIEAwa48yfWFi3uIdqzuf9X7C2Zxfea/Iaaw0zIwHudpF8U92WVIiC5l +oEuW1+OaVi3UWfIEjWMV1tHGysrHOwtwc34BPCJqJknUQO/KtDTBTJ4Pryhw1bWPC999Lz +a+yrCTdNQYBzoROXKExZgPFh9pTMi5wqpHDuOQ2qZFIEI3lT0AAAIQWL0H31i9B98AAAAH +c3NoLXJzYQAAAIEAwa48yfWFi3uIdqzuf9X7C2Zxfea/Iaaw0zIwHudpF8U92WVIiC5loE +uW1+OaVi3UWfIEjWMV1tHGysrHOwtwc34BPCJqJknUQO/KtDTBTJ4Pryhw1bWPC999Lza+ +yrCTdNQYBzoROXKExZgPFh9pTMi5wqpHDuOQ2qZFIEI3lT0AAAADAQABAAAAgCThyTGsT4 +IARDxVMhWl6eiB2ZrgFgWSeJm/NOqtppWgOebsIqPMMg4UVuVFsl422/lE3RkPhVkjGXgE +pWvZAdCnmLmApK8wK12vF334lZhZT7t3Z9EzJps88PWEHo7kguf285HcnUM7FlFeissJdk +kXly34y7/3X/a6Tclm+iABAAAAQE0xR/KxZ39slwfMv64Rz7WKk1PPskaryI29aHE3mKHk +pY2QA+P3QlrKxT/VWUMjHUbNNdYfJm48xu0SGNMRdKMAAABBAORh2NP/06JUV3J9W/2Hju +X1ViJuqqcQnJPVzpgSL826EC2xwOECTqoY8uvFpUdD7CtpksIxNVqRIhuNOlz0lqEAAABB +ANkaHTTaPojClO0dKJ/Zjs7pWOCGliebBYprQ/Y4r9QLBkC/XaWMS26gFIrjgC7D2Rv+rZ +wSD0v0RcmkITP1ZR0AAAAYcHF1ZXJuYUBMdWNreUh5ZHJvLmxvY2FsAQID +-----END OPENSSH PRIVATE KEY-----`), + "p256-openssh-format": []byte(`-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS +1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQSN5Ld/DFy8LJK0yrWg+Ryhq4/ifHry +QyCQeT4UXSB+UGdRct7kWA0hARbTaSCh+8U/Gs5O+IkDNoTKVsgxKUMQAAAAsO3C7nPtwu +5zAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBI3kt38MXLwskrTK +taD5HKGrj+J8evJDIJB5PhRdIH5QZ1Fy3uRYDSEBFtNpIKH7xT8azk74iQM2hMpWyDEpQx +AAAAAhAIHB48R+goZaiXndfYTrwk4BT1+MeLPC2/dwe0J5d1QDAAAAE21hcmlhbm9AZW5k +b3IubG9jYWwBAgME +-----END OPENSSH PRIVATE KEY-----`), + "p384-openssh-format": []byte(`-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAiAAAABNlY2RzYS +1zaGEyLW5pc3RwMzg0AAAACG5pc3RwMzg0AAAAYQTZb2VzEPs2NN/i1qHddKTVfwoIq3Tf +PeQ/kcWBvuCVJfIygvpm9MeusawEPuLSEXwiNDew+YHZ9xHIvFjCmZsLuEOzuh9t9KotwM +57H+7N+RDFzhM2j8hAaOuT5XDLKfUAAADgn/Sny5/0p8sAAAATZWNkc2Etc2hhMi1uaXN0 +cDM4NAAAAAhuaXN0cDM4NAAAAGEE2W9lcxD7NjTf4tah3XSk1X8KCKt03z3kP5HFgb7glS +XyMoL6ZvTHrrGsBD7i0hF8IjQ3sPmB2fcRyLxYwpmbC7hDs7ofbfSqLcDOex/uzfkQxc4T +No/IQGjrk+Vwyyn1AAAAMQDg0hwGKB/9Eq+e2FeTspi8QHW5xTD6prqsHDFx4cKk0ccgFV +61dhFhD/8SEbYlHzEAAAATbWFyaWFub0BlbmRvci5sb2NhbAECAwQ= +-----END OPENSSH PRIVATE KEY-----`), + "p521-openssh-format": []byte(`-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAArAAAABNlY2RzYS +1zaGEyLW5pc3RwNTIxAAAACG5pc3RwNTIxAAAAhQQBKzI3QSp1a2e1zMulZl1uFF1Y2Dnv +LSIwEu837hOV1epYEgNveAhGNm57TuBqYtnZeVfd2pzaz7CKX6N4B33N1XABQ5Ngji7lF2 +dUbmhNqJoMh43ioIsQNBaBenhmRpYP6f5k8P/7JZMIsLhkJk2hykb8maSZ+B3PYwPMNBdS +vP+0sHQAAAEYIsr2CCLK9ggAAAATZWNkc2Etc2hhMi1uaXN0cDUyMQAAAAhuaXN0cDUyMQ +AAAIUEASsyN0EqdWtntczLpWZdbhRdWNg57y0iMBLvN+4TldXqWBIDb3gIRjZue07gamLZ +2XlX3dqc2s+wil+jeAd9zdVwAUOTYI4u5RdnVG5oTaiaDIeN4qCLEDQWgXp4ZkaWD+n+ZP +D/+yWTCLC4ZCZNocpG/Jmkmfgdz2MDzDQXUrz/tLB0AAAAQgEdeH+im6iRcP/juTAoeSHo +ExLtWhgL4JYqRwcOnzCKuLOPjEY/HSOuc+HRrbN9rbjsq+PcPHYe1NnkzXk0IW8hxQAAAB +NtYXJpYW5vQGVuZG9yLmxvY2FsAQIDBAUGBw== +-----END OPENSSH PRIVATE KEY-----`), + "user": []byte(`-----BEGIN EC PRIVATE KEY----- +MHcCAQEEILYCAeq8f7V4vSSypRw7pxy8yz3V5W4qg8kSC3zJhqpQoAoGCCqGSM49 +AwEHoUQDQgAEYcO2xNKiRUYOLEHM7VYAp57HNyKbOdYtHD83Z4hzNPVC4tM5mdGD +PLL8IEwvYu2wq+lpXfGQnNMbzYf9gspG0w== +-----END EC PRIVATE KEY----- +`), + "ca": []byte(`-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAvg9dQ9IRG59lYJb+GESfKWTch4yBpr7Ydw1jkK6vvtrx9jLo +5hkA8X6+ElRPRqTAZSlN5cBm6YCAcQIOsmXDUn6Oj1lVPQAoOjTBTvsjM3NjGhvv +52kHTY0nsMsBeY9q5DTtlzmlYkVUq2a6Htgf2mNi01dIw5fJ7uTTo8EbNf7O0i3u +c9a8P19HaZl5NKiWN4EIZkfB2WdXYRJCVBsGgQj3dE/GrEmH9QINq1A+GkNvK96u +vZm8H1jjmuqzHplWa7lFeXcx8FTVTbVb/iJrZ2Lc/JvIPitKZWhqbR59yrGjpwEp +Id7bo4WhO5L3OB0fSIJYvfu+o4WYnt4f3UzecwIDAQABAoIBABRD9yHgKErVuC2Q +bA+SYZY8VvdtF/X7q4EmQFORDNRA7EPgMc03JU6awRGbQ8i4kHs46EFzPoXvWcKz +AXYsO6N0Myc900Tp22A5d9NAHATEbPC/wdje7hRq1KyZONMJY9BphFv3nZbY5apR +Dc90JBFZP5RhXjTc3n9GjvqLAKfFEKVmPRCvqxCOZunw6XR+SgIQLJo36nsIsbhW +QUXIVaCI6cXMN8bRPm8EITdBNZu06Fpu4ZHm6VaxlXN9smERCDkgBSNXNWHKxmmA +c3Glo2DByUr2/JFBOrLEe9fkYgr24KNCQkHVcSaFxEcZvTggr7StjKISVHlCNEaB +7Q+kPoECgYEA3zE9FmvFGoQCU4g4Nl3dpQHs6kaAW8vJlrmq3xsireIuaJoa2HMe +wYdIvgCnK9DIjyxd5OWnE4jXtAEYPsyGD32B5rSLQrRO96lgb3f4bESCLUb3Bsn/ +sdgeE3p1xZMA0B59htqCrvVgN9k8WxyevBxYl3/gSBm/p8OVH1RTW/ECgYEA2f9Z +95OLj0KQHQtxQXf+I3VjhCw3LkLW39QZOXVI0QrCJfqqP7uxsJXH9NYX0l0GFTcR +kRrlyoaSU1EGQosZh+n1MvplGBTkTSV47/bPsTzFpgK2NfEZuFm9RoWgltS+nYeH +Y2k4mnAN3PhReCMwuprmJz8GRLsO3Cs2s2YylKMCgYEA2UX+uO/q7jgqZ5UJW+ue +1H5+W0aMuFA3i7JtZEnvRaUVFqFGlwXin/WJ2+WY1++k/rPrJ+Rk9IBXtBUIvEGw +FC5TIfsKQsJyyWgqx/jbbtJ2g4s8+W/1qfTAuqeRNOg5d2DnRDs90wJuS4//0JaY +9HkHyVwkQyxFxhSA/AHEMJECgYA2MvyFR1O9bIk0D3I7GsA+xKLXa77Ua53MzIjw +9i4CezBGDQpjCiFli/fI8am+jY5DnAtsDknvjoG24UAzLy5L0mk6IXMdB6SzYYut +7ak5oahqW+Y9hxIj+XvLmtGQbphtxhJtLu35x75KoBpxSh6FZpmuTEccs31AVCYn +eFM/DQKBgQDOPUwbLKqVi6ddFGgrV9MrWw+SWsDa43bPuyvYppMM3oqesvyaX1Dt +qDvN7owaNxNM4OnfKcZr91z8YPVCFo4RbBif3DXRzjNNBlxEjHBtuMOikwvsmucN +vIrbeEpjTiUMTEAr6PoTiVHjsfS8WAM6MDlF5M+2PNswDsBpa2yLgA== +-----END RSA PRIVATE KEY----- +`), +} + +var PEMEncryptedKeys = []struct { + Name string + EncryptionKey string + IncludesPublicKey bool + PEMBytes []byte +}{ + 0: { + Name: "rsa-encrypted", + EncryptionKey: "r54-G0pher_t3st$", + PEMBytes: []byte(`-----BEGIN RSA PRIVATE KEY----- +Proc-Type: 4,ENCRYPTED +DEK-Info: AES-128-CBC,3E1714DE130BC5E81327F36564B05462 + +MqW88sud4fnWk/Jk3fkjh7ydu51ZkHLN5qlQgA4SkAXORPPMj2XvqZOv1v2LOgUV +dUevUn8PZK7a9zbZg4QShUSzwE5k6wdB7XKPyBgI39mJ79GBd2U4W3h6KT6jIdWA +goQpluxkrzr2/X602IaxLEre97FT9mpKC6zxKCLvyFWVIP9n3OSFS47cTTXyFr+l +7PdRhe60nn6jSBgUNk/Q1lAvEQ9fufdPwDYY93F1wyJ6lOr0F1+mzRrMbH67NyKs +rG8J1Fa7cIIre7ueKIAXTIne7OAWqpU9UDgQatDtZTbvA7ciqGsSFgiwwW13N+Rr +hN8MkODKs9cjtONxSKi05s206A3NDU6STtZ3KuPDjFE1gMJODotOuqSM+cxKfyFq +wxpk/CHYCDdMAVBSwxb/vraOHamylL4uCHpJdBHypzf2HABt+lS8Su23uAmL87DR +yvyCS/lmpuNTndef6qHPRkoW2EV3xqD3ovosGf7kgwGJUk2ZpCLVteqmYehKlZDK +r/Jy+J26ooI2jIg9bjvD1PZq+Mv+2dQ1RlDrPG3PB+rEixw6vBaL9x3jatCd4ej7 +XG7lb3qO9xFpLsx89tkEcvpGR+broSpUJ6Mu5LBCVmrvqHjvnDhrZVz1brMiQtU9 +iMZbgXqDLXHd6ERWygk7OTU03u+l1gs+KGMfmS0h0ZYw6KGVLgMnsoxqd6cFSKNB +8Ohk9ZTZGCiovlXBUepyu8wKat1k8YlHSfIHoRUJRhhcd7DrmojC+bcbMIZBU22T +Pl2ftVRGtcQY23lYd0NNKfebF7ncjuLWQGy+vZW+7cgfI6wPIbfYfP6g7QAutk6W +KQx0AoX5woZ6cNxtpIrymaVjSMRRBkKQrJKmRp3pC/lul5E5P2cueMs1fj4OHTbJ +lAUv88ywr+R+mRgYQlFW/XQ653f6DT4t6+njfO9oBcPrQDASZel3LjXLpjjYG/N5 ++BWnVexuJX9ika8HJiFl55oqaKb+WknfNhk5cPY+x7SDV9ywQeMiDZpr0ffeYAEP +LlwwiWRDYpO+uwXHSFF3+JjWwjhs8m8g99iFb7U93yKgBB12dCEPPa2ZeH9wUHMJ +sreYhNuq6f4iWWSXpzN45inQqtTi8jrJhuNLTT543ErW7DtntBO2rWMhff3aiXbn +Uy3qzZM1nPbuCGuBmP9L2dJ3Z5ifDWB4JmOyWY4swTZGt9AVmUxMIKdZpRONx8vz +I9u9nbVPGZBcou50Pa0qTLbkWsSL94MNXrARBxzhHC9Zs6XNEtwN7mOuii7uMkVc +adrxgknBH1J1N+NX/eTKzUwJuPvDtA+Z5ILWNN9wpZT/7ed8zEnKHPNUexyeT5g3 +uw9z9jH7ffGxFYlx87oiVPHGOrCXYZYW5uoZE31SCBkbtNuffNRJRKIFeipmpJ3P +7bpAG+kGHMelQH6b+5K1Qgsv4tpuSyKeTKpPFH9Av5nN4P1ZBm9N80tzbNWqjSJm +S7rYdHnuNEVnUGnRmEUMmVuYZnNBEVN/fP2m2SEwXcP3Uh7TiYlcWw10ygaGmOr7 +MvMLGkYgQ4Utwnd98mtqa0jr0hK2TcOSFir3AqVvXN3XJj4cVULkrXe4Im1laWgp +-----END RSA PRIVATE KEY----- +`), + }, + + 1: { + Name: "dsa-encrypted", + EncryptionKey: "qG0pher-dsa_t3st$", + PEMBytes: []byte(`-----BEGIN DSA PRIVATE KEY----- +Proc-Type: 4,ENCRYPTED +DEK-Info: AES-128-CBC,7CE7A6E4A647DC01AF860210B15ADE3E + +hvnBpI99Hceq/55pYRdOzBLntIEis02JFNXuLEydWL+RJBFDn7tA+vXec0ERJd6J +G8JXlSOAhmC2H4uK3q2xR8/Y3yL95n6OIcjvCBiLsV+o3jj1MYJmErxP6zRtq4w3 +JjIjGHWmaYFSxPKQ6e8fs74HEqaeMV9ONUoTtB+aISmgaBL15Fcoayg245dkBvVl +h5Kqspe7yvOBmzA3zjRuxmSCqKJmasXM7mqs3vIrMxZE3XPo1/fWKcPuExgpVQoT +HkJZEoIEIIPnPMwT2uYbFJSGgPJVMDT84xz7yvjCdhLmqrsXgs5Qw7Pw0i0c0BUJ +b7fDJ2UhdiwSckWGmIhTLlJZzr8K+JpjCDlP+REYBI5meB7kosBnlvCEHdw2EJkH +0QDc/2F4xlVrHOLbPRFyu1Oi2Gvbeoo9EsM/DThpd1hKAlb0sF5Y0y0d+owv0PnE +R/4X3HWfIdOHsDUvJ8xVWZ4BZk9Zk9qol045DcFCehpr/3hslCrKSZHakLt9GI58 +vVQJ4L0aYp5nloLfzhViZtKJXRLkySMKdzYkIlNmW1oVGl7tce5UCNI8Nok4j6yn +IiHM7GBn+0nJoKTXsOGMIBe3ulKlKVxLjEuk9yivh/8= +-----END DSA PRIVATE KEY----- +`), + }, + + 2: { + Name: "ed25519-encrypted", + EncryptionKey: "password", + IncludesPublicKey: true, + PEMBytes: []byte(`-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jdHIAAAAGYmNyeXB0AAAAGAAAABDKj29BlC +ocEWuVhQ94/RjoAAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIIw1gSurPTDwZidA +2AIjQZgoQi3IFn9jBtFdP10/Jj7DAAAAoFGkQbB2teSU7ikUsnc7ct2aH3pitM359lNVUh +7DQbJWMjbQFbrBYyDJP+ALj1/RZmP2yoIf7/wr99q53/pm28Xp1gGP5V2RGRJYCA6kgFIH +xdB6KEw1Ce7Bz8JaDIeagAGd3xtQTH3cuuleVxCZZnk9NspsPxigADKCls/RUiK7F+z3Qf +Lvs9+PH8nIuhFMYZgo3liqZbVS5z4Fqhyzyq4= +-----END OPENSSH PRIVATE KEY----- +`), + }, + + 3: { + Name: "ed25519-encrypted-cbc", + EncryptionKey: "password", + IncludesPublicKey: true, + PEMBytes: []byte(`-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAACmFlczI1Ni1jYmMAAAAGYmNyeXB0AAAAGAAAABDzGKF3uX +G1gXALZKFd6Ir4AAAAEAAAAAEAAAAzAAAAC3NzaC1lZDI1NTE5AAAAIDne4/teO42zTDdj +NwxUMNpbfmp/dxgU4ZNkC3ydgcugAAAAoJ3J/oA7+iqVOz0CIUUk9ufdP1VP4jDf2um+0s +Sgs7x6Gpyjq67Ps7wLRdSmxr/G5b+Z8dRGFYS/wUCQEe3whwuImvLyPwWjXLzkAyMzc01f +ywBGSrHnvP82ppenc2HuTI+E05Xc02i6JVyI1ShiekQL5twoqtR6pEBZnD17UonIx7cRzZ +gbDGyT3bXMQtagvCwoW+/oMTKXiZP5jCJpEO8= +-----END OPENSSH PRIVATE KEY----- +`), + }, +} diff --git a/tool/tsh/common/scan.go b/tool/tsh/common/scan.go new file mode 100644 index 0000000000000..f96eff417b468 --- /dev/null +++ b/tool/tsh/common/scan.go @@ -0,0 +1,170 @@ +/* + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package common + +import ( + "fmt" + "log/slog" + "runtime" + "strings" + + "github.com/alecthomas/kingpin/v2" + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/api/constants" + accessgraphsecretsv1pb "github.com/gravitational/teleport/api/gen/proto/go/teleport/accessgraph/v1" + "github.com/gravitational/teleport/api/types/accessgraph" + "github.com/gravitational/teleport/lib/devicetrust/assert" + dtnative "github.com/gravitational/teleport/lib/devicetrust/native" + secretsscannerclient "github.com/gravitational/teleport/lib/secretsscanner/client" + secretsreporter "github.com/gravitational/teleport/lib/secretsscanner/reporter" + secretsscanner "github.com/gravitational/teleport/lib/secretsscanner/scaner" +) + +type scanCommand struct { + keys *scanKeysCommand +} + +func newScanCommand(app *kingpin.Application) scanCommand { + scan := app.Command("scan", "Scan the local machine for Secrets and report findings to Teleport") + cmd := scanCommand{ + keys: newScanKeysCommand(scan), + } + return cmd +} + +type scanKeysCommand struct { + *kingpin.CmdClause + dirs []string + skipPaths []string +} + +func newScanKeysCommand(parent *kingpin.CmdClause) *scanKeysCommand { + c := &scanKeysCommand{CmdClause: parent.Command("keys", "Scan the local machine for SSH private keys and report findings to Teleport")} + c.Flag("dirs", "Directories to scan.").Default(defaultDirValues()).StringsVar(&c.dirs) + c.Flag("skip-paths", "Paths to directories or files to skip. Supports for matching patterns.").StringsVar(&c.skipPaths) + return c +} + +func defaultDirValues() string { + switch runtime.GOOS { + case constants.LinuxOS: + return "/home/" + case constants.DarwinOS: + return "/Users/" + case constants.WindowsOS: + return "C:\\Users\\" + default: + return "/" + } +} + +func (c *scanKeysCommand) run(cf *CLIConf) error { + if len(c.dirs) == 0 { + return trace.BadParameter("no directories to scan") + } + + if cf.Proxy == "" { + return trace.BadParameter("proxy address is required") + } + + ctx := cf.Context + + deviceCred, err := dtnative.GetDeviceCredential() + if err != nil { + return trace.Wrap(err, "device not enrolled") + } + + fmt.Printf("Device trust credentials found.\nScanning %s.\n", strings.Join(c.dirs, ", ")) + + scanner, err := secretsscanner.New(secretsscanner.Config{ + Dirs: c.dirs, + SkipPaths: c.skipPaths, + Log: slog.Default(), + }) + if err != nil { + return trace.Wrap(err, "failed to create scanner") + } + + privateKeys := scanner.ScanPrivateKeys( + ctx, + deviceCred.Id, + ) + + printPrivateKeys(privateKeys) + + client, err := secretsscannerclient.NewSecretsScannerServiceClient( + ctx, + secretsscannerclient.ClientConfig{ + ProxyServer: cf.Proxy, + Insecure: cf.InsecureSkipVerify, + Log: slog.Default(), + }) + if err != nil { + return trace.Wrap(err, "failed to create client") + } + + reporter, err := secretsreporter.New( + secretsreporter.Config{ + Client: client, + Log: slog.Default(), + AssertCeremonyBuilder: func() (*assert.Ceremony, error) { + return assert.NewCeremony() + }, + }, + ) + if err != nil { + return trace.Wrap(err, "failed to create reporter") + } + + if err := reporter.ReportPrivateKeys(ctx, collectPrivateKeys(privateKeys)); trace.IsNotImplemented(err) { + return handleUnimplementedError(ctx, err, *cf) + } else if err != nil { + return trace.Wrap(err, "failed to report private keys") + } + + fmt.Printf("Reported %d SSH fingerprints to Teleport.\n", len(privateKeys)) + + return nil +} + +func printPrivateKeys(privateKeys []secretsscanner.SSHPrivateKey) { + if len(privateKeys) == 0 { + fmt.Println("No SSH private keys found.") + return + } + + fmt.Println("SSH private keys found:") + for _, pk := range privateKeys { + path, key := pk.Path, pk.Key + fmt.Printf("- SHA256 fingerprint: %q (mode: %s) at %s\n", + key.Spec.PublicKeyFingerprint, + accessgraph.DescribePublicKeyMode(key.Spec.PublicKeyMode), + path, + ) + } +} + +func collectPrivateKeys(privateKeys []secretsscanner.SSHPrivateKey) []*accessgraphsecretsv1pb.PrivateKey { + keys := make([]*accessgraphsecretsv1pb.PrivateKey, 0, len(privateKeys)) + for _, pk := range privateKeys { + keys = append(keys, pk.Key) + } + return keys +} diff --git a/tool/tsh/common/tsh.go b/tool/tsh/common/tsh.go index b88d502d84b10..215b043c6c732 100644 --- a/tool/tsh/common/tsh.go +++ b/tool/tsh/common/tsh.go @@ -1173,6 +1173,8 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error { kube := newKubeCommand(app) // MFA subcommands. mfa := newMFACommand(app) + // SCAN subcommands. + scan := newScanCommand(app) config := app.Command("config", "Print OpenSSH configuration details.") config.Flag("port", "SSH port on a remote host").Short('p').Int32Var(&cf.NodePort) @@ -1471,7 +1473,8 @@ func Run(ctx context.Context, args []string, opts ...CliOption) error { err = kube.exec.run(&cf) case kube.join.FullCommand(): err = kube.join.run(&cf) - + case scan.keys.FullCommand(): + err = scan.keys.run(&cf) case proxySSH.FullCommand(): err = onProxyCommandSSH(&cf) case proxyDB.FullCommand():