From 488af75797326e92145497955fdadbd97711171b Mon Sep 17 00:00:00 2001 From: Brian Joerger Date: Thu, 5 Jan 2023 17:34:04 -0800 Subject: [PATCH] Client store generalization (#19420) - Add a generalized client store made up of a key, profile, and trusted certs store. Each sub store can support different backends (~/.tsh, identity_file, in-memory). - Replace custom identity file handling with in-memory client store. - Fix issues with trusted certs handling. --- api/identityfile/identityfile.go | 6 +- api/identityfile/identityfile_test.go | 9 +- api/profile/profile.go | 20 +- api/utils/keypaths/keypaths.go | 21 + api/utils/slices.go | 31 +- api/utils/slices_test.go | 17 + api/utils/sshutils/ssh.go | 51 +- api/utils/sshutils/ssh_test.go | 23 + fixtures/certs/identities/key-cert-ca.pem | 2 +- integration/helpers/helpers.go | 2 +- integration/helpers/kube.go | 3 +- integration/proxy/teleterm_test.go | 2 +- lib/auth/methods.go | 18 +- lib/auth/testauthority/testauthority.go | 2 + lib/benchmark/benchmark.go | 7 +- lib/client/api.go | 784 +++--------------- lib/client/ca_export.go | 18 +- lib/client/client_store.go | 216 +++++ lib/client/client_store_test.go | 387 +++++++++ lib/client/conntest/ssh.go | 2 +- lib/client/db/database_certificates.go | 5 +- lib/client/identityfile/identity.go | 126 ++- lib/client/identityfile/identity_test.go | 178 +++- lib/client/interfaces.go | 236 ++---- lib/client/keyagent.go | 175 ++-- lib/client/keyagent_test.go | 77 +- lib/client/keystore.go | 725 ++++------------ lib/client/keystore_test.go | 757 ++++------------- lib/client/local_proxy_middleware.go | 2 +- lib/client/profile.go | 511 ++++++++++++ lib/client/profile_test.go | 86 ++ lib/client/trusted_certs_store.go | 524 ++++++++++++ lib/client/trusted_certs_store_test.go | 233 ++++++ lib/kube/kubeconfig/kubeconfig_test.go | 6 +- lib/sshutils/marshal.go | 99 ++- lib/sshutils/marshal_test.go | 69 ++ lib/tbot/config/configtemplate.go | 8 +- .../config/configtemplate_ssh_host_cert.go | 6 +- .../clusters/cluster_access_requests.go | 2 +- lib/teleterm/clusters/cluster_auth.go | 6 +- lib/teleterm/clusters/storage.go | 8 +- tool/tctl/common/auth_command.go | 6 +- tool/tctl/common/tctl.go | 132 +-- tool/tsh/app.go | 10 +- tool/tsh/aws.go | 2 +- tool/tsh/azure.go | 2 +- tool/tsh/db.go | 19 +- tool/tsh/db_test.go | 4 +- tool/tsh/proxy.go | 2 +- tool/tsh/tsh.go | 312 ++----- tool/tsh/tsh_test.go | 66 +- 51 files changed, 3372 insertions(+), 2643 deletions(-) create mode 100644 lib/client/client_store.go create mode 100644 lib/client/client_store_test.go create mode 100644 lib/client/profile.go create mode 100644 lib/client/profile_test.go create mode 100644 lib/client/trusted_certs_store.go create mode 100644 lib/client/trusted_certs_store_test.go create mode 100644 lib/sshutils/marshal_test.go diff --git a/api/identityfile/identityfile.go b/api/identityfile/identityfile.go index c3ec58f05e10..cbbd09d94598 100644 --- a/api/identityfile/identityfile.go +++ b/api/identityfile/identityfile.go @@ -68,7 +68,7 @@ type Certs struct { // CACerts contains PEM encoded CA certificates. type CACerts struct { - // SSH are CA certs used for SSH. + // SSH are CA certs used for SSH in known_hosts format. SSH [][]byte // TLS are CA certs used for TLS. TLS [][]byte @@ -262,9 +262,9 @@ func decodeIdentityFile(idFile io.Reader) (*IdentityFile, error) { for scanln() { switch { case isSSHCert(line): - ident.Certs.SSH = cloneln() + ident.Certs.SSH = append(cloneln(), '\n') case hasPrefix("@cert-authority"): - ident.CACerts.SSH = append(ident.CACerts.SSH, cloneln()) + ident.CACerts.SSH = append(ident.CACerts.SSH, append(cloneln(), '\n')) case hasPrefix("-----BEGIN"): // Current line marks the beginning of a PEM block. Consume all // lines until a corresponding END is found. diff --git a/api/identityfile/identityfile_test.go b/api/identityfile/identityfile_test.go index dbea91944f9e..5160c6cdea78 100644 --- a/api/identityfile/identityfile_test.go +++ b/api/identityfile/identityfile_test.go @@ -34,11 +34,11 @@ func TestIdentityFileBasics(t *testing.T) { writeIDFile := &IdentityFile{ PrivateKey: []byte("-----BEGIN RSA PRIVATE KEY-----\nkey\n-----END RSA PRIVATE KEY-----\n"), Certs: Certs{ - SSH: []byte(ssh.CertAlgoRSAv01), + SSH: append([]byte(ssh.CertAlgoRSAv01), '\n'), TLS: []byte("-----BEGIN CERTIFICATE-----\ntls-cert\n-----END CERTIFICATE-----\n"), }, CACerts: CACerts{ - SSH: [][]byte{[]byte("@cert-authority ssh-cacerts")}, + SSH: [][]byte{[]byte("@cert-authority ssh-cacerts\n")}, TLS: [][]byte{[]byte("-----BEGIN CERTIFICATE-----\ntls-cacerts\n-----END CERTIFICATE-----\n")}, }, } @@ -50,15 +50,13 @@ func TestIdentityFileBasics(t *testing.T) { // Read identity file from file readIDFile, err := ReadFile(path) require.NoError(t, err) + require.Equal(t, writeIDFile, readIDFile) // Read identity file from string s, err := os.ReadFile(path) require.NoError(t, err) fromStringIDFile, err := FromString(string(s)) require.NoError(t, err) - - // Check that read and write values are equal - require.Equal(t, writeIDFile, readIDFile) require.Equal(t, writeIDFile, fromStringIDFile) } @@ -87,5 +85,4 @@ func TestIsSSHCert(t *testing.T) { require.Equal(t, tc.expectBool, isSSHCert) }) } - } diff --git a/api/profile/profile.go b/api/profile/profile.go index 0f8c7265d161..58a213265935 100644 --- a/api/profile/profile.go +++ b/api/profile/profile.go @@ -39,9 +39,6 @@ import ( const ( // profileDir is the default root directory where tsh stores profiles. profileDir = ".tsh" - // currentProfileFilename is a file which stores the name of the - // currently active profile. - currentProfileFilename = "current-profile" ) // Profile is a collection of most frequently used CLI flags @@ -101,6 +98,15 @@ type Profile struct { MFAMode string `yaml:"mfa_mode,omitempty"` } +// Copy returns a shallow copy of p, or nil if p is nil. +func (p *Profile) Copy() *Profile { + if p == nil { + return nil + } + copy := *p + return © +} + // Name returns the name of the profile. func (p *Profile) Name() string { addr, _, err := net.SplitHostPort(p.WebProxyAddr) @@ -221,7 +227,7 @@ func SetCurrentProfileName(dir string, name string) error { return trace.BadParameter("cannot set current profile: missing dir") } - path := filepath.Join(dir, currentProfileFilename) + path := keypaths.CurrentProfileFilePath(dir) if err := os.WriteFile(path, []byte(strings.TrimSpace(name)+"\n"), 0660); err != nil { return trace.Wrap(err) } @@ -244,7 +250,7 @@ func GetCurrentProfileName(dir string) (name string, err error) { return "", trace.BadParameter("cannot get current profile: missing dir") } - data, err := os.ReadFile(filepath.Join(dir, currentProfileFilename)) + data, err := os.ReadFile(keypaths.CurrentProfileFilePath(dir)) if err != nil { if os.IsNotExist(err) { return "", trace.NotFound("current-profile is not set") @@ -316,7 +322,7 @@ func FromDir(dir string, name string) (*Profile, error) { return nil, trace.Wrap(err) } } - p, err := profileFromFile(filepath.Join(dir, name+".yaml")) + p, err := profileFromFile(keypaths.ProfileFilePath(dir, name)) if err != nil { return nil, trace.Wrap(err) } @@ -350,7 +356,7 @@ func (p *Profile) SaveToDir(dir string, makeCurrent bool) error { if dir == "" { return trace.BadParameter("cannot save profile: missing dir") } - if err := p.saveToFile(filepath.Join(dir, p.Name()+".yaml")); err != nil { + if err := p.saveToFile(keypaths.ProfileFilePath(dir, p.Name())); err != nil { return trace.Wrap(err) } if makeCurrent { diff --git a/api/utils/keypaths/keypaths.go b/api/utils/keypaths/keypaths.go index 6b1be414633e..43dedabd2c4e 100644 --- a/api/utils/keypaths/keypaths.go +++ b/api/utils/keypaths/keypaths.go @@ -56,10 +56,17 @@ const ( casDir = "cas" // fileExtPem is the extension of a file where a public certificate is stored. fileExtPem = ".pem" + // currentProfileFileName is a file containing the name of the current profile + currentProfileFilename = "current-profile" + // profileFileExt is the suffix of a profile file. + profileFileExt = ".yaml" ) // Here's the file layout of all these keypaths. // ~/.tsh/ --> default base directory +// ├── current-profile --> file containing the name of the currently active profile +// ├── one.example.com.yaml --> file containing profile details for proxy "one.example.com" +// ├── two.example.com.yaml --> file containing profile details for proxy "two.example.com" // ├── known_hosts --> trusted certificate authorities (their keys) in a format similar to known_hosts // └── keys --> session keys directory // ├── one.example.com --> Proxy hostname @@ -107,6 +114,20 @@ func KeyDir(baseDir string) string { return filepath.Join(baseDir, sessionKeyDir) } +// CurrentProfile returns the path to the current profile file. +// +// /current-profile +func CurrentProfileFilePath(baseDir string) string { + return filepath.Join(baseDir, currentProfileFilename) +} + +// ProfileFilePath returns the path to the profile file for the given profile. +// +// /.yaml +func ProfileFilePath(baseDir, profileName string) string { + return filepath.Join(baseDir, profileName+profileFileExt) +} + // KnownHostsPath returns the path to the known hosts file. // // /known_hosts diff --git a/api/utils/slices.go b/api/utils/slices.go index 6d607f2eece9..ef0bdc08cf2b 100644 --- a/api/utils/slices.go +++ b/api/utils/slices.go @@ -45,17 +45,38 @@ func JoinStrings[T ~string](elems []T, sep string) T { return T(b.String()) } -// Deduplicate deduplicates list of strings -func Deduplicate(in []string) []string { +// Deduplicate deduplicates list of comparable values. +func Deduplicate[T comparable](in []T) []T { if len(in) == 0 { return in } - out := make([]string, 0, len(in)) - seen := make(map[string]bool, len(in)) + out := make([]T, 0, len(in)) + seen := make(map[T]struct{}, len(in)) for _, val := range in { if _, ok := seen[val]; !ok { out = append(out, val) - seen[val] = true + seen[val] = struct{}{} + } + } + return out +} + +// DeduplicateAny deduplicates list of any values with compare function. +func DeduplicateAny[T any](in []T, compare func(T, T) bool) []T { + if len(in) == 0 { + return in + } + out := make([]T, 0, len(in)) + for _, val := range in { + var seen bool + for _, outVal := range out { + if compare(val, outVal) { + seen = true + break + } + } + if !seen { + out = append(out, val) } } return out diff --git a/api/utils/slices_test.go b/api/utils/slices_test.go index 848ba222775b..e733ca3b1995 100644 --- a/api/utils/slices_test.go +++ b/api/utils/slices_test.go @@ -17,6 +17,7 @@ limitations under the License. package utils import ( + "bytes" "testing" "github.com/stretchr/testify/require" @@ -37,3 +38,19 @@ func TestDeduplicate(t *testing.T) { }) } } + +func TestDeduplicateAny(t *testing.T) { + tests := []struct { + name string + in, expected [][]byte + }{ + {name: "empty slice", in: [][]byte{}, expected: [][]byte{}}, + {name: "slice with unique elements", in: [][]byte{{0}, {1}}, expected: [][]byte{{0}, {1}}}, + {name: "slice with duplicate elements", in: [][]byte{{0}, {1}, {1}, {0}, {2}}, expected: [][]byte{{0}, {1}, {2}}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.expected, DeduplicateAny(tc.in, bytes.Equal)) + }) + } +} diff --git a/api/utils/sshutils/ssh.go b/api/utils/sshutils/ssh.go index e88896c4d01e..6286d4d52dcd 100644 --- a/api/utils/sshutils/ssh.go +++ b/api/utils/sshutils/ssh.go @@ -24,6 +24,7 @@ import ( "io" "net" "regexp" + "strings" "github.com/gravitational/trace" "golang.org/x/crypto/ssh" @@ -68,23 +69,67 @@ func ParseCertificate(buf []byte) (*ssh.Certificate, error) { } // ParseKnownHosts parses provided known_hosts entries into ssh.PublicKey list. -func ParseKnownHosts(knownHosts [][]byte) ([]ssh.PublicKey, error) { +// If one or more hostnames are provided, only keys that have at least one match +// will be returned. +func ParseKnownHosts(knownHosts [][]byte, matchHostnames ...string) ([]ssh.PublicKey, error) { var keys []ssh.PublicKey for _, line := range knownHosts { for { - _, _, publicKey, _, bytes, err := ssh.ParseKnownHosts(line) + _, hosts, publicKey, _, bytes, err := ssh.ParseKnownHosts(line) if err == io.EOF { break } else if err != nil { return nil, trace.Wrap(err, "failed parsing known hosts: %v; raw line: %q", err, line) } - keys = append(keys, publicKey) + + if len(matchHostnames) == 0 || HostNameMatch(matchHostnames, hosts) { + keys = append(keys, publicKey) + } + line = bytes } } return keys, nil } +// HostNameMatch returns whether at least one of the given hosts matches one +// of the given matchHosts. If a host has a wildcard prefix "*.", it will be +// used to match. Ex: "*.example.com" will match "proxy.example.com". +func HostNameMatch(matchHosts []string, hosts []string) bool { + for _, matchHost := range matchHosts { + for _, host := range hosts { + if host == matchHost || matchesWildcard(matchHost, host) { + return true + } + } + } + return false +} + +// matchesWildcard ensures the given `hostname` matches the given `pattern`. +// The `pattern` should be prefixed with `*.` which will match exactly one domain +// segment, meaning `*.example.com` will match `foo.example.com` but not +// `foo.bar.example.com`. +func matchesWildcard(hostname, pattern string) bool { + pattern = strings.TrimSpace(pattern) + + // Don't allow non-wildcard or empty patterns. + if !strings.HasPrefix(pattern, "*.") || len(pattern) < 3 { + return false + } + matchHost := pattern[2:] + + // Trim any trailing "." in case of an absolute domain. + hostname = strings.TrimSuffix(hostname, ".") + + _, hostnameRoot, found := strings.Cut(hostname, ".") + if !found { + return false + } + + return hostnameRoot == matchHost +} + // ParseAuthorizedKeys parses provided authorized_keys entries into ssh.PublicKey list. func ParseAuthorizedKeys(authorizedKeys [][]byte) ([]ssh.PublicKey, error) { var keys []ssh.PublicKey diff --git a/api/utils/sshutils/ssh_test.go b/api/utils/sshutils/ssh_test.go index 342c5a250d14..3f0a57e35302 100644 --- a/api/utils/sshutils/ssh_test.go +++ b/api/utils/sshutils/ssh_test.go @@ -125,3 +125,26 @@ func TestSSHMarshalEd25519(t *testing.T) { result := KeysEqual(ak, bk) require.True(t, result) } + +func TestMatchesWildcard(t *testing.T) { + t.Parallel() + t.Run("Wildcard match", func(t *testing.T) { + require.True(t, matchesWildcard("foo.example.com", "*.example.com")) + require.True(t, matchesWildcard("bar.example.com", "*.example.com")) + require.True(t, matchesWildcard("bar.example.com.", "*.example.com")) + require.True(t, matchesWildcard("bar.foo", "*.foo")) + }) + + t.Run("Wildcard mismatch", func(t *testing.T) { + require.False(t, matchesWildcard("foo.example.com", "example.com"), "Not a wildcard pattern") + require.False(t, matchesWildcard("foo.example.org", "*.example.com"), "Wildcard pattern shouldn't match different suffix") + require.False(t, matchesWildcard("a.b.example.com", "*.example.com"), "Wildcard pattern shouldn't match multiple prefixes") + + t.Run("Single part hostname", func(t *testing.T) { + require.False(t, matchesWildcard("example", "*.example.com")) + require.False(t, matchesWildcard("example", "*.example")) + require.False(t, matchesWildcard("example", "example")) + require.False(t, matchesWildcard("example", "*.")) + }) + }) +} diff --git a/fixtures/certs/identities/key-cert-ca.pem b/fixtures/certs/identities/key-cert-ca.pem index 4ca709957470..5b927f651015 100644 --- a/fixtures/certs/identities/key-cert-ca.pem +++ b/fixtures/certs/identities/key-cert-ca.pem @@ -26,4 +26,4 @@ w+VQYlOF3Nz0IrAwEWxKg4GxAoGBANlBOHShukF/qSMXqRer59ExgBuTG0KZ8QT0 rBjbUpA16Fi8NSro/mXDLCh8mTzu0tPG+e1jqcEVc5JDLYIau12j6jw= -----END RSA PRIVATE KEY----- ssh-rsa-cert-v01@openssh.com AAAAHHNzaC1yc2EtY2VydC12MDFAb3BlbnNzaC5jb20AAAAg0NeCnpO5ZAzWmMX6XwjrFyDi+JRrPLNb0vrEYqJp+bEAAAADAQABAAABAQC96eyjDJkj80k2JJ2imXQTXb4VfjEXHxPClX4uw0Th7dJ6NxvKb+AfAbaFdYu3xJjyUhFkHg0hPtMhe/ubq0wrejkTtYwd87iWj8wu+aiSziRexphXClxNt8RWv+1mgAVZuBSPHg4jykrYzpaQOqmIiOcMBpumFpwA2cXNRgLbEdZ4uwpNjBYwxGigh1m50OiFvcXFvrwvGkkqDwExIaCqSoK+E3NmTLt6I5eTVvjdhxSzKqwF65vY9XWqh4w1JP2NHCQSkyh2rlC4WM0mpkyL4ZmJdIsRFj1DxN7Ovma6HS8AKJeiDyShuWounwCsoK33onjr+ib9cYUAvsKTdB9pAAAAAAAAAAAAAAABAAAACmVrb250c2V2b3kAAAAOAAAACmVrb250c2V2b3kAAAAAAAAAAAAAAABZPPDKAAAAAAAAAHYAAAAWcGVybWl0LXBvcnQtZm9yd2FyZGluZwAAAAAAAAAKcGVybWl0LXB0eQAAAAAAAAAOdGVsZXBvcnQtcm9sZXMAAAAwAAAALHsidmVyc2lvbiI6InYxIiwicm9sZXMiOlsidXNlcjpla29udHNldm95Il19AAAAAAAAARcAAAAHc3NoLXJzYQAAAAMBAAEAAAEBAMA/0pVkfFhDPUDosZpM9nP/r/t6tORkmhXCxMkLZiS7+kg0htYSDLmwFGfzgSbYAH6Bryu9BZOxv1W23WW9oW7IdJpwfCpuyzFoRN0/2mHhAAHETtxucksTgYNwN+dDXF/IzG/QGVYswP4ENte4ZuNsd3bquBu+opK7CXU4B+UtsY0JUcV7gU5TzCZBdFpzLgB2VUpiHlFg0PUuV74aZmwzHlwoONBSIn2FZpHmvN2ZUdqTHSjof1vgH20cScMWGk05dFuM5gWjHEYC1gwdPpmTGcgN93SAQwKiAUQ6ZnJ+lVhzSp+/vxVz/aecDnrza+xI26DnB/nEEiCMu92WYxEAAAEPAAAAB3NzaC1yc2EAAAEAQRUml83QKsEeWB0WswfR1rvEzzumYRn/CAMTSGsF99bNzHmZ0lJbwCzNdl0hlJ3tGVPhANL5WwWuiLN1q6O8qrUU4cGJK3L8eNFUXmJIVc1xH2bIaws+nHikqPHnxbtAzJBbHeCngBX7eVT69bW6AdgWSHSzlPRaAaApMoEwVIMKOLiedjy7D9s/Cd+GtOtxTMoG/LmFBnvUiuXWwiQ658MRrg65ATl0x24ErJqz2cnj52Sy5G6SNUrENRqkP8TxRtp6a+FT1oJQ/2LqLwnlPQpb41j6fDqLRa47NU2TRnYRv0rhCMHO5tIhA51qhRlU9R2m5BH5o1JswN/phMgbjg== -@cert-authority *.turing.local ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDEk4cVIiydp9xSPIb8UqXpShY8zPlk/lpR69UL+0+RnNXtQl7GcQUZsrXDB2gOCfj+doKZj8Pt8oQVSDJF/vKhr+KS2Z+LC2Gyt8D5IY/acyyhSN5VoIo0JzIOr5CPGJNpLChREFuveV30hLihSfY52cqSvu7N5u34BlZ29WTLeBD9WssAG5HZUES8Xo3neHBl4SOck+mdiUvOIPhcnPiYRmYltOI3GJRu5y1xGemoPU3MnMziQMqnKCc2+To6IC8CkeQqa8D//BxLjenjSgn1K/SLUHraMb5qCmf77fyshj6A9jamgo0UOaOqem+jyg8idnz6JbVfXwW0nEaSyPzX type=host +@cert-authority proxy.example.com,turing.local,*.turing.local ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDEk4cVIiydp9xSPIb8UqXpShY8zPlk/lpR69UL+0+RnNXtQl7GcQUZsrXDB2gOCfj+doKZj8Pt8oQVSDJF/vKhr+KS2Z+LC2Gyt8D5IY/acyyhSN5VoIo0JzIOr5CPGJNpLChREFuveV30hLihSfY52cqSvu7N5u34BlZ29WTLeBD9WssAG5HZUES8Xo3neHBl4SOck+mdiUvOIPhcnPiYRmYltOI3GJRu5y1xGemoPU3MnMziQMqnKCc2+To6IC8CkeQqa8D//BxLjenjSgn1K/SLUHraMb5qCmf77fyshj6A9jamgo0UOaOqem+jyg8idnz6JbVfXwW0nEaSyPzX type=host diff --git a/integration/helpers/helpers.go b/integration/helpers/helpers.go index 5ba245d7fa57..2fa045909a0a 100644 --- a/integration/helpers/helpers.go +++ b/integration/helpers/helpers.go @@ -216,7 +216,7 @@ func MustCreateUserIdentityFile(t *testing.T, tc *TeleInstance, username string, hostCAs, err := tc.Process.GetAuthServer().GetCertAuthorities(context.Background(), types.HostCA, false) require.NoError(t, err) - key.TrustedCA = auth.AuthoritiesToTrustedCerts(hostCAs) + key.TrustedCerts = auth.AuthoritiesToTrustedCerts(hostCAs) idPath := filepath.Join(t.TempDir(), "user_identity") _, err = identityfile.Write(identityfile.WriteConfig{ diff --git a/integration/helpers/kube.go b/integration/helpers/kube.go index 78052776bdd6..e506c11e6333 100644 --- a/integration/helpers/kube.go +++ b/integration/helpers/kube.go @@ -119,7 +119,8 @@ func genUserKey() (*client.Key, error) { return &client.Key{ PrivateKey: priv, TLSCert: tlsCert, - TrustedCA: []auth.TrustedCerts{{ + TrustedCerts: []auth.TrustedCerts{{ + ClusterName: "localhost", TLSCertificates: [][]byte{caCert}, }}, }, nil diff --git a/integration/proxy/teleterm_test.go b/integration/proxy/teleterm_test.go index 4b53331de89a..3736f28d7ca6 100644 --- a/integration/proxy/teleterm_test.go +++ b/integration/proxy/teleterm_test.go @@ -77,7 +77,7 @@ func testGatewayCertRenewal(t *testing.T, pack *dbhelpers.DatabasePack, creds *h require.NoError(t, err) // The profile on disk created by NewClientWithCreds doesn't have WebProxyAddr set. tc.WebProxyAddr = pack.Root.Cluster.Web - tc.SaveProfile(tc.KeysDir, false /* makeCurrent */) + tc.SaveProfile(false /* makeCurrent */) fakeClock := clockwork.NewFakeClockAt(time.Now()) diff --git a/lib/auth/methods.go b/lib/auth/methods.go index b7e979e4d591..b2e26b118642 100644 --- a/lib/auth/methods.go +++ b/lib/auth/methods.go @@ -412,18 +412,18 @@ type TrustedCerts struct { // for host authorities that means base hostname of all servers, // for user authorities that means organization name ClusterName string `json:"domain_name"` - // HostCertificates is a list of SSH public keys that can be used to check - // host certificate signatures - HostCertificates [][]byte `json:"checking_keys"` - // TLSCertificates is a list of TLS certificates of the certificate authority + // AuthorizedKeys is a list of SSH public keys in authorized_keys format + // that can be used to check host key signatures. + AuthorizedKeys [][]byte `json:"checking_keys"` + // TLSCertificates is a list of TLS certificates of the certificate authority // of the authentication server TLSCertificates [][]byte `json:"tls_certs"` } // SSHCertPublicKeys returns a list of trusted host SSH certificate authority public keys func (c *TrustedCerts) SSHCertPublicKeys() ([]ssh.PublicKey, error) { - out := make([]ssh.PublicKey, 0, len(c.HostCertificates)) - for _, keyBytes := range c.HostCertificates { + out := make([]ssh.PublicKey, 0, len(c.AuthorizedKeys)) + for _, keyBytes := range c.AuthorizedKeys { publicKey, _, _, _, err := ssh.ParseAuthorizedKey(keyBytes) if err != nil { return nil, trace.Wrap(err) @@ -438,9 +438,9 @@ func AuthoritiesToTrustedCerts(authorities []types.CertAuthority) []TrustedCerts out := make([]TrustedCerts, len(authorities)) for i, ca := range authorities { out[i] = TrustedCerts{ - ClusterName: ca.GetClusterName(), - HostCertificates: services.GetSSHCheckingKeys(ca), - TLSCertificates: services.GetTLSCerts(ca), + ClusterName: ca.GetClusterName(), + AuthorizedKeys: services.GetSSHCheckingKeys(ca), + TLSCertificates: services.GetTLSCerts(ca), } } return out diff --git a/lib/auth/testauthority/testauthority.go b/lib/auth/testauthority/testauthority.go index 1180c68dd987..55c147101b7c 100644 --- a/lib/auth/testauthority/testauthority.go +++ b/lib/auth/testauthority/testauthority.go @@ -59,6 +59,8 @@ func (n *Keygen) GetNewKeyPairFromPool() (priv []byte, pub []byte, err error) { return n.GenerateKeyPair() } +// GenerateKeyPair returns a new private key in PEM format and an ssh +// public key in authorized_key format. func (n *Keygen) GenerateKeyPair() (priv []byte, pub []byte, err error) { return native.GenerateKeyPair() } diff --git a/lib/benchmark/benchmark.go b/lib/benchmark/benchmark.go index a92f848c1446..565d9dc86606 100644 --- a/lib/benchmark/benchmark.go +++ b/lib/benchmark/benchmark.go @@ -32,7 +32,6 @@ import ( "github.com/gravitational/trace" "github.com/sirupsen/logrus" - "github.com/gravitational/teleport/api/profile" "github.com/gravitational/teleport/lib/client" "github.com/gravitational/teleport/lib/observability/tracing" "github.com/gravitational/teleport/lib/utils" @@ -275,7 +274,7 @@ func makeTeleportClient(host, login, proxy string) (*client.TeleportClient, erro Host: host, Tracer: tracing.NoopProvider().Tracer("test"), } - path := profile.FullProfilePath("") + if login != "" { c.HostLogin = login c.Username = login @@ -283,7 +282,9 @@ func makeTeleportClient(host, login, proxy string) (*client.TeleportClient, erro if proxy != "" { c.SSHProxyAddr = proxy } - if err := c.LoadProfile(path, proxy); err != nil { + + profileStore := client.NewFSProfileStore("") + if err := c.LoadProfile(profileStore, proxy); err != nil { return nil, trace.Wrap(err) } tc, err := client.NewClient(&c) diff --git a/lib/client/api.go b/lib/client/api.go index f10be6954a73..913b3b43688a 100644 --- a/lib/client/api.go +++ b/lib/client/api.go @@ -22,25 +22,20 @@ import ( "crypto/x509" "encoding/json" "encoding/pem" - "errors" "fmt" "io" "net" - "net/url" "os" "os/exec" "os/user" "path/filepath" "runtime" - "sort" "strconv" "strings" - "sync" "time" "unicode/utf8" "github.com/gravitational/trace" - "github.com/jonboulle/clockwork" "github.com/sirupsen/logrus" "go.opentelemetry.io/otel/attribute" oteltrace "go.opentelemetry.io/otel/trace" @@ -59,9 +54,7 @@ import ( "github.com/gravitational/teleport/api/profile" "github.com/gravitational/teleport/api/types" apievents "github.com/gravitational/teleport/api/types/events" - "github.com/gravitational/teleport/api/types/wrappers" apiutils "github.com/gravitational/teleport/api/utils" - "github.com/gravitational/teleport/api/utils/keypaths" "github.com/gravitational/teleport/api/utils/keys" "github.com/gravitational/teleport/lib/auth" "github.com/gravitational/teleport/lib/auth/native" @@ -245,8 +238,7 @@ type Config struct { // Agent is used when SkipLocalAuth is true Agent agent.ExtendedAgent - // PreloadKey is a key with which to initialize a local in-memory keystore. - PreloadKey *Key + ClientStore *Store // ForwardAgent is used by the client to request agent forwarding from the server. ForwardAgent AgentForwardingMode @@ -503,233 +495,6 @@ func VirtualPathEnvNames(kind VirtualPathKind, params VirtualPathParams) []strin return vars } -// ProfileStatus combines metadata from the logged in profile and associated -// SSH certificate. -type ProfileStatus struct { - // Name is the profile name. - Name string - - // Dir is the directory where profile is located. - Dir string - - // ProxyURL is the URL the web client is accessible at. - ProxyURL url.URL - - // Username is the Teleport username. - Username string - - // Roles is a list of Teleport Roles this user has been assigned. - Roles []string - - // Logins are the Linux accounts, also known as principals in OpenSSH terminology. - Logins []string - - // KubeEnabled is true when this profile is configured to connect to a - // kubernetes cluster. - KubeEnabled bool - - // KubeUsers are the kubernetes users used by this profile. - KubeUsers []string - - // KubeGroups are the kubernetes groups used by this profile. - KubeGroups []string - - // Databases is a list of database services this profile is logged into. - Databases []tlsca.RouteToDatabase - - // Apps is a list of apps this profile is logged into. - Apps []tlsca.RouteToApp - - // ValidUntil is the time at which this SSH certificate will expire. - ValidUntil time.Time - - // Extensions is a list of enabled SSH features for the certificate. - Extensions []string - - // CriticalOptions is a map of SSH critical options for the certificate. - CriticalOptions map[string]string - - // Cluster is a selected cluster - Cluster string - - // Traits hold claim data used to populate a role at runtime. - Traits wrappers.Traits - - // ActiveRequests tracks the privilege escalation requests applied - // during certificate construction. - ActiveRequests services.RequestIDs - - // AWSRoleARNs is a list of allowed AWS role ARNs user can assume. - AWSRolesARNs []string - - // AzureIdentities is a list of allowed Azure identities user can assume. - AzureIdentities []string - - // AllowedResourceIDs is a list of resources the user can access. An empty - // list means there are no resource-specific restrictions. - AllowedResourceIDs []types.ResourceID - - // IsVirtual is set when this profile does not actually exist on disk, - // probably because it was constructed from an identity file. When set, - // certain profile functions - particularly those that return paths to - // files on disk - must be accompanied by fallback logic when those paths - // do not exist. - IsVirtual bool -} - -// IsExpired returns true if profile is not expired yet -func (p *ProfileStatus) IsExpired(clock clockwork.Clock) bool { - return p.ValidUntil.Sub(clock.Now()) <= 0 -} - -// virtualPathWarnOnce is used to ensure warnings about missing virtual path -// environment variables are consolidated into a single message and not spammed -// to the console. -var virtualPathWarnOnce sync.Once - -// virtualPathFromEnv attempts to retrieve the path as defined by the given -// formatter from the environment. -func (p *ProfileStatus) virtualPathFromEnv(kind VirtualPathKind, params VirtualPathParams) (string, bool) { - if !p.IsVirtual { - return "", false - } - - for _, envName := range VirtualPathEnvNames(kind, params) { - if val, ok := os.LookupEnv(envName); ok { - return val, true - } - } - - // If we can't resolve any env vars, this will return garbage which we - // should at least warn about. As ugly as this is, arguably making every - // profile path lookup fallible is even uglier. - log.Debugf("Could not resolve path to virtual profile entry of type %s "+ - "with parameters %+v.", kind, params) - - virtualPathWarnOnce.Do(func() { - log.Errorf("A virtual profile is in use due to an identity file " + - "(`-i ...`) but this functionality requires additional files on " + - "disk and may fail. Consider using a compatible wrapper " + - "application (e.g. Machine ID) for this command.") - }) - - return "", false -} - -// CACertPathForCluster returns path to the cluster CA certificate for this profile. -// -// It's stored in /keys//cas/.pem by default. -func (p *ProfileStatus) CACertPathForCluster(cluster string) string { - // Return an env var override if both valid and present for this identity. - if path, ok := p.virtualPathFromEnv(VirtualPathCA, VirtualPathCAParams(types.HostCA)); ok { - return path - } - - return filepath.Join(keypaths.ProxyKeyDir(p.Dir, p.Name), "cas", cluster+".pem") -} - -// KeyPath returns path to the private key for this profile. -// -// It's kept in /keys//. -func (p *ProfileStatus) KeyPath() string { - // Return an env var override if both valid and present for this identity. - if path, ok := p.virtualPathFromEnv(VirtualPathKey, nil); ok { - return path - } - - return keypaths.UserKeyPath(p.Dir, p.Name, p.Username) -} - -// DatabaseCertPathForCluster returns path to the specified database access -// certificate for this profile, for the specified cluster. -// -// It's kept in /keys//-db//-x509.pem -// -// If the input cluster name is an empty string, the selected cluster in the -// profile will be used. -func (p *ProfileStatus) DatabaseCertPathForCluster(clusterName string, databaseName string) string { - if clusterName == "" { - clusterName = p.Cluster - } - - if path, ok := p.virtualPathFromEnv(VirtualPathDatabase, VirtualPathDatabaseParams(databaseName)); ok { - return path - } - - return keypaths.DatabaseCertPath(p.Dir, p.Name, p.Username, clusterName, databaseName) -} - -// AppCertPath returns path to the specified app access certificate -// for this profile. -// -// It's kept in /keys//-app//-x509.pem -func (p *ProfileStatus) AppCertPath(name string) string { - if path, ok := p.virtualPathFromEnv(VirtualPathApp, VirtualPathAppParams(name)); ok { - return path - } - - return keypaths.AppCertPath(p.Dir, p.Name, p.Username, p.Cluster, name) -} - -// AppLocalCAPath returns the specified app's self-signed localhost CA path for -// this profile. -// -// It's kept in /keys//-app//-localca.pem -func (p *ProfileStatus) AppLocalCAPath(name string) string { - return keypaths.AppLocalCAPath(p.Dir, p.Name, p.Username, p.Cluster, name) -} - -// KubeConfigPath returns path to the specified kubeconfig for this profile. -// -// It's kept in /keys//-kube//-kubeconfig -func (p *ProfileStatus) KubeConfigPath(name string) string { - if path, ok := p.virtualPathFromEnv(VirtualPathKubernetes, VirtualPathKubernetesParams(name)); ok { - return path - } - - return keypaths.KubeConfigPath(p.Dir, p.Name, p.Username, p.Cluster, name) -} - -// DatabaseServices returns a list of database service names for this profile. -func (p *ProfileStatus) DatabaseServices() (result []string) { - for _, db := range p.Databases { - result = append(result, db.ServiceName) - } - return result -} - -// DatabasesForCluster returns a list of databases for this profile, for the -// specified cluster name. -func (p *ProfileStatus) DatabasesForCluster(clusterName string) ([]tlsca.RouteToDatabase, error) { - if clusterName == "" || clusterName == p.Cluster { - return p.Databases, nil - } - - idx := KeyIndex{ - ProxyHost: p.Name, - Username: p.Username, - ClusterName: clusterName, - } - - store, err := NewFSLocalKeyStore(p.Dir) - if err != nil { - return nil, trace.Wrap(err) - } - key, err := store.GetKey(idx, WithDBCerts{}) - if err != nil { - return nil, trace.Wrap(err) - } - return findActiveDatabases(key) -} - -// AppNames returns a list of app names this profile is logged into. -func (p *ProfileStatus) AppNames() (result []string) { - for _, app := range p.Apps { - result = append(result, app.Name) - } - return result -} - // RetryWithRelogin is a helper error handling method, attempts to relogin and // retry the function once. func RetryWithRelogin(ctx context.Context, tc *TeleportClient, fn func() error) error { @@ -746,10 +511,6 @@ func RetryWithRelogin(ctx context.Context, tc *TeleportClient, fn func() error) return trace.Wrap(err) } - // Don't try to login when using an identity file. - if tc.SkipLocalAuth { - return trace.Wrap(err) - } log.Debugf("Activating relogin on %v.", err) // check if the error is a private key policy error. @@ -780,7 +541,7 @@ func RetryWithRelogin(ctx context.Context, tc *TeleportClient, fn func() error) } // Save profile to record proxy credentials - if err := tc.SaveProfile(tc.HomePath, true); err != nil { + if err := tc.SaveProfile(true); err != nil { log.Warningf("Failed to save profile: %v", err) return trace.Wrap(err) } @@ -791,376 +552,55 @@ func RetryWithRelogin(ctx context.Context, tc *TeleportClient, fn func() error) func IsErrorResolvableWithRelogin(err error) bool { // Assume that failed handshake is a result of expired credentials. return utils.IsHandshakeFailedError(err) || utils.IsCertExpiredError(err) || - trace.IsBadParameter(err) || trace.IsTrustError(err) || keys.IsPrivateKeyPolicyError(err) -} - -// ProfileOptions contains fields needed to initialize a profile beyond those -// derived directly from a Key. -type ProfileOptions struct { - ProfileName string - ProfileDir string - WebProxyAddr string - Username string - SiteName string - KubeProxyAddr string - IsVirtual bool -} - -// profileFromkey returns a ProfileStatus for the given key and options. -func profileFromKey(key *Key, opts ProfileOptions) (*ProfileStatus, error) { - sshCert, err := key.SSHCert() - if err != nil { - return nil, trace.Wrap(err) - } - - // Extract from the certificate how much longer it will be valid for. - validUntil := time.Unix(int64(sshCert.ValidBefore), 0) - - // Extract roles from certificate. Note, if the certificate is in old format, - // this will be empty. - var roles []string - rawRoles, ok := sshCert.Extensions[teleport.CertExtensionTeleportRoles] - if ok { - roles, err = services.UnmarshalCertRoles(rawRoles) - if err != nil { - return nil, trace.Wrap(err) - } - } - sort.Strings(roles) - - // Extract traits from the certificate. Note if the certificate is in the - // old format, this will be empty. - var traits wrappers.Traits - rawTraits, ok := sshCert.Extensions[teleport.CertExtensionTeleportTraits] - if ok { - err = wrappers.UnmarshalTraits([]byte(rawTraits), &traits) - if err != nil { - return nil, trace.Wrap(err) - } - } - - var activeRequests services.RequestIDs - rawRequests, ok := sshCert.Extensions[teleport.CertExtensionTeleportActiveRequests] - if ok { - if err := activeRequests.Unmarshal([]byte(rawRequests)); err != nil { - return nil, trace.Wrap(err) - } - } - - allowedResourcesStr := sshCert.Extensions[teleport.CertExtensionAllowedResources] - allowedResourceIDs, err := types.ResourceIDsFromString(allowedResourcesStr) - if err != nil { - return nil, trace.Wrap(err) - } - - // Extract extensions from certificate. This lists the abilities of the - // certificate (like can the user request a PTY, port forwarding, etc.) - var extensions []string - for ext := range sshCert.Extensions { - if ext == teleport.CertExtensionTeleportRoles || - ext == teleport.CertExtensionTeleportTraits || - ext == teleport.CertExtensionTeleportRouteToCluster || - ext == teleport.CertExtensionTeleportActiveRequests || - ext == teleport.CertExtensionAllowedResources { - continue - } - extensions = append(extensions, ext) - } - sort.Strings(extensions) - - tlsCert, err := key.TeleportTLSCertificate() - if err != nil { - return nil, trace.Wrap(err) - } - tlsID, err := tlsca.FromSubject(tlsCert.Subject, time.Time{}) - if err != nil { - return nil, trace.Wrap(err) - } - - databases, err := findActiveDatabases(key) - if err != nil { - return nil, trace.Wrap(err) - } - - appCerts, err := key.AppTLSCertificates() - if err != nil { - return nil, trace.Wrap(err) - } - var apps []tlsca.RouteToApp - for _, cert := range appCerts { - tlsID, err := tlsca.FromSubject(cert.Subject, time.Time{}) - if err != nil { - return nil, trace.Wrap(err) - } - if tlsID.RouteToApp.PublicAddr != "" { - apps = append(apps, tlsID.RouteToApp) - } - } - - return &ProfileStatus{ - Name: opts.ProfileName, - Dir: opts.ProfileDir, - ProxyURL: url.URL{ - Scheme: "https", - Host: opts.WebProxyAddr, - }, - Username: opts.Username, - Logins: sshCert.ValidPrincipals, - ValidUntil: validUntil, - Extensions: extensions, - CriticalOptions: sshCert.CriticalOptions, - Roles: roles, - Cluster: opts.SiteName, - Traits: traits, - ActiveRequests: activeRequests, - KubeEnabled: opts.KubeProxyAddr != "", - KubeUsers: tlsID.KubernetesUsers, - KubeGroups: tlsID.KubernetesGroups, - Databases: databases, - Apps: apps, - AWSRolesARNs: tlsID.AWSRoleARNs, - AzureIdentities: tlsID.AzureIdentities, - IsVirtual: opts.IsVirtual, - AllowedResourceIDs: allowedResourceIDs, - }, nil -} - -// ReadProfileFromIdentity creates a "fake" profile from only an identity file, -// allowing the various profile-using subcommands to use identity files as if -// they were profiles. It will set the `username` and `siteName` fields of -// the profileOptions to certificate-provided values if they are unset. -func ReadProfileFromIdentity(key *Key, opts ProfileOptions) (*ProfileStatus, error) { - // Note: these profile options are largely derived from tsh's makeClient() - if opts.Username == "" { - username, err := key.CertUsername() - if err != nil { - return nil, trace.Wrap(err) - } - - opts.Username = username - } - - if opts.SiteName == "" { - rootCluster, err := key.RootClusterName() - if err != nil { - return nil, trace.Wrap(err) - } - - opts.SiteName = rootCluster - } - - opts.IsVirtual = true - - return profileFromKey(key, opts) -} - -// ReadProfileStatus reads in the profile as well as the associated certificate -// and returns a *ProfileStatus which can be used to print the status of the -// profile. -func ReadProfileStatus(profileDir string, profileName string) (*ProfileStatus, error) { - if profileDir == "" { - return nil, trace.BadParameter("profileDir cannot be empty") - } - - // Read in the profile for this proxy. - profile, err := profile.FromDir(profileDir, profileName) - if err != nil { - return nil, trace.Wrap(err) - } - - // Read in the SSH certificate for the user logged into this proxy. - store, err := NewFSLocalKeyStore(profileDir) - if err != nil { - return nil, trace.Wrap(err) - } - idx := KeyIndex{ - ProxyHost: profile.Name(), - Username: profile.Username, - ClusterName: profile.SiteName, - } - key, err := store.GetKey(idx, WithAllCerts...) - if err != nil { - return nil, trace.Wrap(err) - } - - return profileFromKey(key, ProfileOptions{ - ProfileName: profileName, - ProfileDir: profileDir, - WebProxyAddr: profile.WebProxyAddr, - Username: profile.Username, - SiteName: profile.SiteName, - KubeProxyAddr: profile.KubeProxyAddr, - IsVirtual: false, - }) + trace.IsBadParameter(err) || trace.IsTrustError(err) || keys.IsPrivateKeyPolicyError(err) || trace.IsNotFound(err) } -// StatusCurrent returns the active profile status. -func StatusCurrent(profileDir, proxyHost, identityFilePath string) (*ProfileStatus, error) { - if identityFilePath != "" { - key, err := KeyFromIdentityFile(identityFilePath) - if err != nil { - return nil, trace.Wrap(err) - } - - profile, err := ReadProfileFromIdentity(key, ProfileOptions{ - ProfileName: "identity", - WebProxyAddr: proxyHost, - }) - if err != nil { - return nil, trace.Wrap(err) - } - - return profile, nil - } - - active, _, err := Status(profileDir, proxyHost) - if err != nil { - return nil, trace.Wrap(err) - } - if active == nil { - return nil, trace.NotFound("not logged in") - } - return active, nil -} - -// StatusFor returns profile for the specified proxy/user. -func StatusFor(profileDir, proxyHost, username string) (*ProfileStatus, error) { - active, others, err := Status(profileDir, proxyHost) - if err != nil { - return nil, trace.Wrap(err) - } - for _, profile := range append(others, active) { - if profile != nil && profile.Username == username { - return profile, nil - } - } - return nil, trace.NotFound("no profile for proxy %v and user %v found", - proxyHost, username) -} - -// Status returns the active profile as well as a list of available profiles. -// If no profile is active, Status returns a nil error and nil profile. -func Status(profileDir, proxyHost string) (*ProfileStatus, []*ProfileStatus, error) { +// LoadProfile populates Config with the values stored in the given +// profiles directory. If profileDir is an empty string, the default profile +// directory ~/.tsh is used. +func (c *Config) LoadProfile(ps ProfileStore, proxyAddr string) error { + var proxyHost string var err error - var profileStatus *ProfileStatus - var others []*ProfileStatus - - // remove ports from proxy host, because profile name is stored - // by host name - if proxyHost != "" { - proxyHost, err = utils.Host(proxyHost) + if proxyAddr == "" { + proxyHost, err = ps.CurrentProfile() if err != nil { - return nil, nil, trace.Wrap(err) - } - } - - // Construct the full path to the profile requested and make sure it exists. - profileDir = profile.FullProfilePath(profileDir) - stat, err := os.Stat(profileDir) - if err != nil { - log.Debugf("Failed to stat file: %v.", err) - if os.IsNotExist(err) { - return nil, nil, trace.NotFound(err.Error()) - } else if os.IsPermission(err) { - return nil, nil, trace.AccessDenied(err.Error()) - } else { - return nil, nil, trace.Wrap(err) - } - } - if !stat.IsDir() { - return nil, nil, trace.BadParameter("profile path not a directory") - } - - // use proxyHost as default profile name, or the current profile if - // no proxyHost was supplied. - profileName := proxyHost - if profileName == "" { - profileName, err = profile.GetCurrentProfileName(profileDir) - if err != nil { - if trace.IsNotFound(err) { - return nil, nil, trace.NotFound("not logged in") - } - return nil, nil, trace.Wrap(err) - } - } - - // Read in the target profile first. If readProfile returns trace.NotFound - // or trace.CompareFailed, that means the profile may have been corrupted - // (for example keys were deleted or modified, but profile exists), treat - // this as the user not being logged in. - profileStatus, err = ReadProfileStatus(profileDir, profileName) - if err != nil { - log.Debug(err) - if !trace.IsNotFound(err) && !trace.IsCompareFailed(err) { - return nil, nil, trace.Wrap(err) - } - // Make sure the profile is nil, which tsh uses to detect that no - // active profile exists. - profileStatus = nil - } - - // load the rest of the profiles - profiles, err := profile.ListProfileNames(profileDir) - if err != nil { - return nil, nil, trace.Wrap(err) - } - for _, name := range profiles { - if name == profileName { - // already loaded this one - continue + return trace.Wrap(err) } - ps, err := ReadProfileStatus(profileDir, name) + } else { + proxyHost, err = utils.Host(proxyAddr) if err != nil { - log.Debug(err) - // parts of profile are missing? - // status skips these files - if trace.IsNotFound(err) { - continue - } - return nil, nil, trace.Wrap(err) + return trace.Wrap(err) } - others = append(others, ps) } - return profileStatus, others, nil -} - -// LoadProfile populates Config with the values stored in the given -// profiles directory. If profileDir is an empty string, the default profile -// directory ~/.tsh is used. -func (c *Config) LoadProfile(profileDir string, proxyName string) error { - // read the profile: - cp, err := profile.FromDir(profileDir, ProxyHost(proxyName)) + profile, err := ps.GetProfile(proxyHost) if err != nil { - if trace.IsNotFound(err) { - return nil - } return trace.Wrap(err) } - c.Username = cp.Username - c.SiteName = cp.SiteName - c.KubeProxyAddr = cp.KubeProxyAddr - c.WebProxyAddr = cp.WebProxyAddr - c.SSHProxyAddr = cp.SSHProxyAddr - c.PostgresProxyAddr = cp.PostgresProxyAddr - c.MySQLProxyAddr = cp.MySQLProxyAddr - c.MongoProxyAddr = cp.MongoProxyAddr - c.TLSRoutingEnabled = cp.TLSRoutingEnabled - c.KeysDir = profileDir - c.AuthConnector = cp.AuthConnector - c.LoadAllCAs = cp.LoadAllCAs - c.AuthenticatorAttachment, err = parseMFAMode(cp.MFAMode) + c.Username = profile.Username + c.SiteName = profile.SiteName + c.KubeProxyAddr = profile.KubeProxyAddr + c.WebProxyAddr = profile.WebProxyAddr + c.SSHProxyAddr = profile.SSHProxyAddr + c.PostgresProxyAddr = profile.PostgresProxyAddr + c.MySQLProxyAddr = profile.MySQLProxyAddr + c.MongoProxyAddr = profile.MongoProxyAddr + c.TLSRoutingEnabled = profile.TLSRoutingEnabled + c.KeysDir = profile.Dir + c.AuthConnector = profile.AuthConnector + c.LoadAllCAs = profile.LoadAllCAs + c.AuthenticatorAttachment, err = parseMFAMode(profile.MFAMode) if err != nil { return trace.BadParameter("unable to parse mfa mode in user profile: %v.", err) } - c.LocalForwardPorts, err = ParsePortForwardSpec(cp.ForwardedPorts) + c.LocalForwardPorts, err = ParsePortForwardSpec(profile.ForwardedPorts) if err != nil { log.Warnf("Unable to parse port forwarding in user profile: %v.", err) } - c.DynamicForwardedPorts, err = ParseDynamicPortForwardSpec(cp.DynamicForwardedPorts) + c.DynamicForwardedPorts, err = ParseDynamicPortForwardSpec(profile.DynamicForwardedPorts) if err != nil { log.Warnf("Unable to parse dynamic port forwarding in user profile: %v.", err) } @@ -1170,29 +610,28 @@ func (c *Config) LoadProfile(profileDir string, proxyName string) error { // SaveProfile updates the given profiles directory with the current configuration // If profileDir is an empty string, the default ~/.tsh is used -func (c *Config) SaveProfile(dir string, makeCurrent bool) error { +func (c *Config) SaveProfile(makeCurrent bool) error { if c.WebProxyAddr == "" { return nil } - dir = profile.FullProfilePath(dir) - - var cp profile.Profile - cp.Username = c.Username - cp.WebProxyAddr = c.WebProxyAddr - cp.SSHProxyAddr = c.SSHProxyAddr - cp.KubeProxyAddr = c.KubeProxyAddr - cp.PostgresProxyAddr = c.PostgresProxyAddr - cp.MySQLProxyAddr = c.MySQLProxyAddr - cp.MongoProxyAddr = c.MongoProxyAddr - cp.ForwardedPorts = c.LocalForwardPorts.String() - cp.SiteName = c.SiteName - cp.TLSRoutingEnabled = c.TLSRoutingEnabled - cp.AuthConnector = c.AuthConnector - cp.MFAMode = c.AuthenticatorAttachment.String() - cp.LoadAllCAs = c.LoadAllCAs - - if err := cp.SaveToDir(dir, makeCurrent); err != nil { + p := &profile.Profile{ + Username: c.Username, + WebProxyAddr: c.WebProxyAddr, + SSHProxyAddr: c.SSHProxyAddr, + KubeProxyAddr: c.KubeProxyAddr, + PostgresProxyAddr: c.PostgresProxyAddr, + MySQLProxyAddr: c.MySQLProxyAddr, + MongoProxyAddr: c.MongoProxyAddr, + ForwardedPorts: c.LocalForwardPorts.String(), + SiteName: c.SiteName, + TLSRoutingEnabled: c.TLSRoutingEnabled, + AuthConnector: c.AuthConnector, + MFAMode: c.AuthenticatorAttachment.String(), + LoadAllCAs: c.LoadAllCAs, + } + + if err := c.ClientStore.SaveProfile(p, makeCurrent); err != nil { return trace.Wrap(err) } return nil @@ -1506,47 +945,40 @@ func NewClient(c *Config) (tc *TeleportClient, err error) { tc.Stdin = os.Stdin } - // Create a buffered channel to hold events that occurred during this session. - // This channel must be buffered because the SSH connection directly feeds - // into it. Delays in pulling messages off the global SSH request channel - // could lead to the connection hanging. - tc.eventsCh = make(chan events.EventFields, 1024) - - localAgentCfg := LocalAgentConfig{ - Agent: c.Agent, - ProxyHost: tc.WebProxyHost(), - Username: c.Username, - KeysOption: c.AddKeysToAgent, - Insecure: c.InsecureSkipVerify, - Site: tc.SiteName, - LoadAllCAs: tc.LoadAllCAs, - } - // sometimes we need to use external auth without using local auth // methods, e.g. in automation daemons. if c.SkipLocalAuth { if len(c.AuthMethods) == 0 { return nil, trace.BadParameter("SkipLocalAuth is true but no AuthMethods provided") } - localAgentCfg.Keystore = noLocalKeyStore{} - if c.PreloadKey != nil { - localAgentCfg.Keystore, err = NewMemLocalKeyStore(c.KeysDir) - if err != nil { - return nil, trace.Wrap(err) - } - } - } else if c.AddKeysToAgent == AddKeysToAgentOnly { - localAgentCfg.Keystore, err = NewMemLocalKeyStore(c.KeysDir) - if err != nil { - return nil, trace.Wrap(err) - } - } else { - localAgentCfg.Keystore, err = NewFSLocalKeyStore(c.KeysDir) - if err != nil { - return nil, trace.Wrap(err) + tc.ClientStore = NewMemClientStore() + } + + if tc.ClientStore == nil { + tc.ClientStore = NewFSClientStore(c.KeysDir) + if c.AddKeysToAgent == AddKeysToAgentOnly { + // Store client keys in memory, but still save trusted certs and profile to disk. + tc.ClientStore.KeyStore = NewMemKeyStore() } } + // Create a buffered channel to hold events that occurred during this session. + // This channel must be buffered because the SSH connection directly feeds + // into it. Delays in pulling messages off the global SSH request channel + // could lead to the connection hanging. + tc.eventsCh = make(chan events.EventFields, 1024) + + localAgentCfg := LocalAgentConfig{ + ClientStore: tc.ClientStore, + Agent: c.Agent, + ProxyHost: tc.WebProxyHost(), + Username: c.Username, + KeysOption: c.AddKeysToAgent, + Insecure: c.InsecureSkipVerify, + Site: tc.SiteName, + LoadAllCAs: tc.LoadAllCAs, + } + // initialize the local agent (auth agent which uses local SSH keys signed by the CA): tc.localAgent, err = NewLocalAgent(localAgentCfg) if err != nil { @@ -1554,24 +986,23 @@ func NewClient(c *Config) (tc *TeleportClient, err error) { } if tc.HostKeyCallback == nil { - tc.HostKeyCallback = tc.localAgent.CheckHostSignature + tc.HostKeyCallback = tc.localAgent.CheckHostKey } - if c.PreloadKey != nil { - // Extract the username from the key - it's needed for GetKey() - // to function properly. - tc.localAgent.username, err = c.PreloadKey.CertUsername() - if err != nil { - return nil, trace.Wrap(err) - } + return tc, nil +} - // Add the key to the agent and keystore. - if err := tc.AddKey(c.PreloadKey); err != nil { - return nil, trace.Wrap(err) - } +func (tc *TeleportClient) ProfileStatus() (*ProfileStatus, error) { + status, err := tc.ClientStore.ReadProfileStatus(tc.WebProxyAddr) + if err != nil { + return nil, trace.Wrap(err) } - - return tc, nil + // If the profile has a different username than the current client, don't return + // the profile. This is used for login and logout logic. + if status.Username != tc.Username { + return nil, trace.NotFound("no profile for proxy %v and user %v found", tc.WebProxyAddr, tc.Username) + } + return status, nil } // LoadKeyForCluster fetches a cluster-specific SSH key and loads it into the @@ -3266,7 +2697,7 @@ func (tc *TeleportClient) connectToProxy(ctx context.Context) (*ProxyClient, err signers, err := tc.localAgent.Signers() // errNoLocalKeyStore is returned when running in the proxy. The proxy // should be passing auth methods via tc.Config.AuthMethods. - if err != nil && !errors.Is(err, errNoLocalKeyStore) && !trace.IsNotFound(err) { + if err != nil && !trace.IsNotFound(err) { return nil, trace.Wrap(err) } if len(signers) > 0 { @@ -3756,8 +3187,9 @@ func (tc *TeleportClient) SSHLogin(ctx context.Context, sshLoginFunc SSHLoginFun key := NewKey(priv) key.Cert = response.Cert key.TLSCert = response.TLSCert - key.TrustedCA = response.HostSigners + key.TrustedCerts = response.HostSigners key.Username = response.Username + key.ProxyHost = tc.WebProxyHost() if tc.KubernetesCluster != "" { key.KubeTLSCerts[tc.KubernetesCluster] = response.TLSCert @@ -3769,7 +3201,7 @@ func (tc *TeleportClient) SSHLogin(ctx context.Context, sshLoginFunc SSHLoginFun // Store the requested cluster name in the key. key.ClusterName = tc.SiteName if key.ClusterName == "" { - rootClusterName := key.TrustedCA[0].ClusterName + rootClusterName := key.TrustedCerts[0].ClusterName key.ClusterName = rootClusterName tc.SiteName = rootClusterName } @@ -3979,25 +3411,14 @@ func (tc *TeleportClient) ActivateKey(ctx context.Context, key *Key) error { // skip activation if no local agent is present return nil } - // save the list of CAs client trusts to ~/.tsh/known_hosts - err := tc.localAgent.AddHostSignersToCache(key.TrustedCA) - if err != nil { - return trace.Wrap(err) - } - - // save the list of TLS CAs client trusts - err = tc.localAgent.SaveTrustedCerts(key.TrustedCA) - if err != nil { - return trace.Wrap(err) - } // save the cert to the local storage (~/.tsh usually): - if err = tc.localAgent.AddKey(key); err != nil { + if err := tc.localAgent.AddKey(key); err != nil { return trace.Wrap(err) } // Connect to the Auth Server of the root cluster and fetch the known hosts. - rootClusterName := key.TrustedCA[0].ClusterName + rootClusterName := key.TrustedCerts[0].ClusterName if err := tc.UpdateTrustedCA(ctx, rootClusterName); err != nil { if len(tc.JumpHosts) == 0 { return trace.Wrap(err) @@ -4162,14 +3583,7 @@ func (tc *TeleportClient) UpdateTrustedCA(ctx context.Context, clusterName strin } trustedCerts := auth.AuthoritiesToTrustedCerts(hostCerts) - // Update the ~/.tsh/known_hosts file to include all the CA the cluster - // knows about. - err = tc.localAgent.AddHostSignersToCache(trustedCerts) - if err != nil { - return trace.Wrap(err) - } - - // Update the CA pool with all the CA the cluster knows about. + // Update the CA pool and known hosts for all CAs the cluster knows about. err = tc.localAgent.SaveTrustedCerts(trustedCerts) if err != nil { return trace.Wrap(err) @@ -4348,20 +3762,12 @@ func (tc *TeleportClient) AddTrustedCA(ctx context.Context, ca types.CertAuthori if tc.localAgent == nil { return trace.BadParameter("TeleportClient.AddTrustedCA called on a client without localAgent") } - err := tc.localAgent.AddHostSignersToCache(auth.AuthoritiesToTrustedCerts([]types.CertAuthority{ca})) + + err := tc.localAgent.SaveTrustedCerts(auth.AuthoritiesToTrustedCerts([]types.CertAuthority{ca})) if err != nil { return trace.Wrap(err) } - // only host CA has TLS certificates, user CA will overwrite trusted certs - // to empty file if called - if ca.GetType() == types.HostCA { - err = tc.localAgent.SaveTrustedCerts(auth.AuthoritiesToTrustedCerts([]types.CertAuthority{ca})) - if err != nil { - return trace.Wrap(err) - } - } - return nil } diff --git a/lib/client/ca_export.go b/lib/client/ca_export.go index b66383ec8a15..f096371331e6 100644 --- a/lib/client/ca_export.go +++ b/lib/client/ca_export.go @@ -66,8 +66,8 @@ type ExportAuthoritiesRequest struct { // // Exporting using "host" AuthType: // Returns the certificate authority public key exported as a single line -// that can be placed in ~/.ssh/authorized_hosts. The format adheres to the man sshd (8) -// authorized_hosts format, a space-separated list of: marker, hosts, key, and comment. +// that can be placed in ~/.ssh/known_hosts. The format adheres to the man sshd (8) +// known_hosts format, a space-separated list of: marker, hosts, key, and comment. // For example: // > @cert-authority *.cluster-a ssh-rsa AAA... type=host // URL encoding is used to pass the CA type and allowed logins into the comment field. @@ -193,7 +193,6 @@ func exportAuth(ctx context.Context, client auth.ClientI, req ExportAuthoritiesR } ret.WriteString(castr) - ret.WriteString("\n") continue } @@ -213,7 +212,6 @@ func exportAuth(ctx context.Context, client auth.ClientI, req ExportAuthoritiesR // write the export friendly string ret.WriteString(castr) - ret.WriteString("\n") } } @@ -277,8 +275,8 @@ func userCAFormat(ca types.CertAuthority, keyBytes []byte) (string, error) { } // hostCAFormat returns the certificate authority public key exported as a single line -// that can be placed in ~/.ssh/authorized_hosts. The format adheres to the man sshd (8) -// authorized_hosts format, a space-separated list of: marker, hosts, key, and comment. +// that can be placed in ~/.ssh/known_hosts. The format adheres to the man sshd (8) +// known_hosts format, a space-separated list of: marker, hosts, key, and comment. // For example: // // @cert-authority *.cluster-a ssh-rsa AAA... type=host @@ -290,5 +288,11 @@ func hostCAFormat(ca types.CertAuthority, keyBytes []byte, client auth.ClientI) return "", trace.Wrap(err) } allowedLogins, _ := roles.GetLoginsForTTL(apidefaults.MinCertDuration + time.Second) - return sshutils.MarshalAuthorizedHostsFormat(ca.GetClusterName(), keyBytes, allowedLogins) + return sshutils.MarshalKnownHost(sshutils.KnownHost{ + Hostname: ca.GetClusterName(), + AuthorizedKey: keyBytes, + Comment: map[string][]string{ + "logins": allowedLogins, + }, + }) } diff --git a/lib/client/client_store.go b/lib/client/client_store.go new file mode 100644 index 000000000000..965db32e4596 --- /dev/null +++ b/lib/client/client_store.go @@ -0,0 +1,216 @@ +/* +Copyright 2022 Gravitational, Inc. +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. +*/ + +package client + +import ( + "net/url" + "time" + + "github.com/gravitational/trace" + "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" + + "github.com/gravitational/teleport" + "github.com/gravitational/teleport/api/profile" + "github.com/gravitational/teleport/lib/auth" + "github.com/gravitational/teleport/lib/utils" +) + +// Store is a storage interface for client data. Store is made up of three +// partial data stores; KeyStore, TrustedCertsStore, and ProfileStore. +// +// A Store can be made up of partial data stores with different backends. For example, +// when using `tsh --add-keys-to-agent=only`, Store will be made up of an in-memory +// key store and an FS (~/.tsh) profile and trusted certs store. +type Store struct { + log *logrus.Entry + + KeyStore + TrustedCertsStore + ProfileStore +} + +// NewMemClientStore initializes an FS backed client store with the given base dir. +func NewFSClientStore(dirPath string) *Store { + dirPath = profile.FullProfilePath(dirPath) + return &Store{ + log: logrus.WithField(trace.Component, teleport.ComponentKeyStore), + KeyStore: NewFSKeyStore(dirPath), + TrustedCertsStore: NewFSTrustedCertsStore(dirPath), + ProfileStore: NewFSProfileStore(dirPath), + } +} + +// NewMemClientStore initializes a new in-memory client store. +func NewMemClientStore() *Store { + return &Store{ + log: logrus.WithField(trace.Component, teleport.ComponentKeyStore), + KeyStore: NewMemKeyStore(), + TrustedCertsStore: NewMemTrustedCertsStore(), + ProfileStore: NewMemProfileStore(), + } +} + +// AddKey adds the given key to the key store. The key's trusted certificates are +// added to the trusted certs store. +func (s *Store) AddKey(key *Key) error { + if err := s.KeyStore.AddKey(key); err != nil { + return trace.Wrap(err) + } + if err := s.TrustedCertsStore.SaveTrustedCerts(key.ProxyHost, key.TrustedCerts); err != nil { + return trace.Wrap(err) + } + return nil +} + +// GetKey gets the requested key with trusted the requested certificates. The key's +// trusted certs will be retrieved from the trusted certs store. +func (s *Store) GetKey(idx KeyIndex, opts ...CertOption) (*Key, error) { + key, err := s.KeyStore.GetKey(idx, opts...) + if err != nil { + return nil, trace.Wrap(err) + } + + tlsCertExpiration, err := key.TeleportTLSCertValidBefore() + if err != nil { + return nil, trace.Wrap(err) + } + s.log.Debugf("Teleport TLS certificate valid until %q.", tlsCertExpiration) + + // Validate the SSH certificate. + if key.Cert != nil { + if err := key.CheckCert(); err != nil { + if !utils.IsCertExpiredError(err) { + return nil, trace.Wrap(err) + } + } + } + + trustedCerts, err := s.TrustedCertsStore.GetTrustedCerts(idx.ProxyHost) + if err != nil { + return nil, trace.Wrap(err) + } + key.TrustedCerts = trustedCerts + return key, nil +} + +// AddTrustedHostKeys is a helper function to add ssh host keys directly, rather than through SaveTrustedCerts. +func (s *Store) AddTrustedHostKeys(proxyHost string, clusterName string, hostKeys ...ssh.PublicKey) error { + var authorizedKeys [][]byte + for _, hostKey := range hostKeys { + authorizedKeys = append(authorizedKeys, ssh.MarshalAuthorizedKey(hostKey)) + } + err := s.SaveTrustedCerts(proxyHost, []auth.TrustedCerts{ + { + ClusterName: clusterName, + AuthorizedKeys: authorizedKeys, + }, + }) + return trace.Wrap(err) +} + +// ReadProfileStatus returns the profile status for the given profile name. +// If no profile name is provided, return the current profile. +func (s *Store) ReadProfileStatus(profileName string) (*ProfileStatus, error) { + var err error + if profileName == "" { + profileName, err = s.CurrentProfile() + if err != nil { + return nil, trace.BadParameter("no profile provided and no current profile") + } + } else { + // remove ports from proxy host, because profile name is stored by host name + profileName, err = utils.Host(profileName) + if err != nil { + return nil, trace.Wrap(err) + } + } + + profile, err := s.GetProfile(profileName) + if err != nil { + return nil, trace.Wrap(err) + } + idx := KeyIndex{ + ProxyHost: profileName, + ClusterName: profile.SiteName, + Username: profile.Username, + } + key, err := s.GetKey(idx, WithAllCerts...) + if err != nil { + if trace.IsNotFound(err) { + // If we can't find a key to match the profile, return a partial status. This + // is used for some superficial functions `tsh logout` and `tsh status`. + return &ProfileStatus{ + Name: profileName, + Dir: profile.Dir, + ProxyURL: url.URL{ + Scheme: "https", + Host: profile.WebProxyAddr, + }, + Username: profile.Username, + Cluster: profile.SiteName, + KubeEnabled: profile.KubeProxyAddr != "", + // Set ValidUntil to now to show that the keys are not available. + ValidUntil: time.Now(), + }, nil + } + return nil, trace.Wrap(err) + } + + _, onDisk := s.KeyStore.(*FSKeyStore) + + return profileStatusFromKey(key, profileOptions{ + ProfileName: profileName, + ProfileDir: profile.Dir, + WebProxyAddr: profile.WebProxyAddr, + Username: profile.Username, + SiteName: profile.SiteName, + KubeProxyAddr: profile.KubeProxyAddr, + IsVirtual: !onDisk, + }) +} + +// FullProfileStatus returns the name of the current profile with a +// a list of all profile statuses. +func (s *Store) FullProfileStatus() (*ProfileStatus, []*ProfileStatus, error) { + currentProfileName, err := s.CurrentProfile() + if err != nil { + return nil, nil, trace.Wrap(err) + } + + currentProfile, err := s.ReadProfileStatus(currentProfileName) + if err != nil { + return nil, nil, trace.Wrap(err) + } + + profileNames, err := s.ListProfiles() + if err != nil { + return nil, nil, trace.Wrap(err) + } + + var profiles []*ProfileStatus + for _, profileName := range profileNames { + if profileName == currentProfileName { + // already loaded this one + continue + } + status, err := s.ReadProfileStatus(profileName) + if err != nil { + return nil, nil, trace.Wrap(err) + } + profiles = append(profiles, status) + } + + return currentProfile, profiles, nil +} diff --git a/lib/client/client_store_test.go b/lib/client/client_store_test.go new file mode 100644 index 000000000000..74fb509190a8 --- /dev/null +++ b/lib/client/client_store_test.go @@ -0,0 +1,387 @@ +/* +Copyright 2016-2022 Gravitational, Inc. + +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. +*/ + +package client + +import ( + "context" + "crypto/x509/pkix" + "sync/atomic" + "testing" + "time" + + "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" + + "github.com/gravitational/teleport/api/profile" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/utils/keys" + apisshutils "github.com/gravitational/teleport/api/utils/sshutils" + "github.com/gravitational/teleport/lib/auth" + "github.com/gravitational/teleport/lib/auth/testauthority" + "github.com/gravitational/teleport/lib/defaults" + "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/sshutils" + "github.com/gravitational/teleport/lib/tlsca" + "github.com/gravitational/teleport/lib/utils" +) + +type testAuthority struct { + keygen *testauthority.Keygen + tlsCA *tlsca.CertAuthority + trustedCerts auth.TrustedCerts +} + +func newTestAuthority(t *testing.T) testAuthority { + tlsCA, trustedCerts, err := newSelfSignedCA(CAPriv, "localhost") + require.NoError(t, err) + + return testAuthority{ + keygen: testauthority.New(), + tlsCA: tlsCA, + trustedCerts: trustedCerts, + } +} + +// makeSignedKey helper returns a new user key signed by CAPriv key. +func (s *testAuthority) makeSignedKey(t *testing.T, idx KeyIndex, makeExpired bool) *Key { + priv, err := s.keygen.GeneratePrivateKey() + require.NoError(t, err) + + allowedLogins := []string{idx.Username, "root"} + ttl := 20 * time.Minute + if makeExpired { + ttl = -ttl + } + + // reuse the same RSA keys for SSH and TLS keys + clock := clockwork.NewRealClock() + identity := tlsca.Identity{ + Username: idx.Username, + Groups: []string{"groups"}, + } + subject, err := identity.Subject() + require.NoError(t, err) + tlsCert, err := s.tlsCA.GenerateCertificate(tlsca.CertificateRequest{ + Clock: clock, + PublicKey: priv.Public(), + Subject: subject, + NotAfter: clock.Now().UTC().Add(ttl), + }) + require.NoError(t, err) + + caSigner, err := ssh.ParsePrivateKey(CAPriv) + require.NoError(t, err) + + cert, err := s.keygen.GenerateUserCert(services.UserCertParams{ + CASigner: caSigner, + PublicUserKey: ssh.MarshalAuthorizedKey(priv.SSHPublicKey()), + Username: idx.Username, + AllowedLogins: allowedLogins, + TTL: ttl, + PermitAgentForwarding: false, + PermitPortForwarding: true, + }) + require.NoError(t, err) + + key := NewKey(priv) + key.KeyIndex = idx + key.PrivateKey = priv + key.Cert = cert + key.TLSCert = tlsCert + key.TrustedCerts = []auth.TrustedCerts{s.trustedCerts} + key.DBTLSCerts["example-db"] = tlsCert + return key +} + +func newSelfSignedCA(privateKey []byte, cluster string) (*tlsca.CertAuthority, auth.TrustedCerts, error) { + priv, err := keys.ParsePrivateKey(privateKey) + if err != nil { + return nil, auth.TrustedCerts{}, trace.Wrap(err) + } + + cert, err := tlsca.GenerateSelfSignedCAWithSigner(priv, pkix.Name{ + CommonName: cluster, + Organization: []string{cluster}, + }, nil, defaults.CATTL) + if err != nil { + return nil, auth.TrustedCerts{}, trace.Wrap(err) + } + ca, err := tlsca.FromCertAndSigner(cert, priv) + if err != nil { + return nil, auth.TrustedCerts{}, trace.Wrap(err) + } + sshPub, err := ssh.NewPublicKey(priv.Public()) + if err != nil { + return nil, auth.TrustedCerts{}, trace.Wrap(err) + } + return ca, auth.TrustedCerts{ + ClusterName: cluster, + TLSCertificates: [][]byte{cert}, + AuthorizedKeys: [][]byte{ssh.MarshalAuthorizedKey(sshPub)}, + }, nil +} + +func newTestFSClientStore(t *testing.T) *Store { + fsClientStore := NewFSClientStore(t.TempDir()) + return fsClientStore +} + +func testEachClientStore(t *testing.T, testFunc func(t *testing.T, clientStore *Store)) { + t.Run("FS", func(t *testing.T) { + testFunc(t, newTestFSClientStore(t)) + }) + + t.Run("Mem", func(t *testing.T) { + testFunc(t, NewMemClientStore()) + }) +} + +func TestClientStore(t *testing.T) { + t.Parallel() + a := newTestAuthority(t) + + testEachClientStore(t, func(t *testing.T, clientStore *Store) { + t.Parallel() + + idx := KeyIndex{ + ProxyHost: "proxy.example.com", + ClusterName: "root", + Username: "test-user", + } + key := a.makeSignedKey(t, idx, false) + + // Add key should add the key and trusted certs to their respective stores. + err := clientStore.AddKey(key) + require.NoError(t, err) + + // the key's trusted certs should be added to the trusted certs store. + retrievedTrustedCerts, err := clientStore.GetTrustedCerts(idx.ProxyHost) + require.NoError(t, err) + require.Equal(t, key.TrustedCerts, retrievedTrustedCerts) + + // Getting the key from the key store should have no trusted certs. + retrievedKey, err := clientStore.KeyStore.GetKey(idx, WithAllCerts...) + require.NoError(t, err) + expectKey := key.Copy() + expectKey.TrustedCerts = nil + require.Equal(t, expectKey, retrievedKey) + + // Getting the key from the client store should fill in the trusted certs. + retrievedKey, err = clientStore.GetKey(idx, WithAllCerts...) + require.NoError(t, err) + require.Equal(t, key, retrievedKey) + + var profileDir string + if fs, ok := clientStore.KeyStore.(*FSKeyStore); ok { + profileDir = fs.KeyDir + } + + // Create and save a corresponding profile for the key. + profile := &profile.Profile{ + WebProxyAddr: idx.ProxyHost + ":3080", + SiteName: idx.ClusterName, + Username: idx.Username, + } + err = clientStore.SaveProfile(profile, true) + require.NoError(t, err) + expectStatus, err := profileStatusFromKey(key, profileOptions{ + ProfileName: profile.Name(), + WebProxyAddr: profile.WebProxyAddr, + ProfileDir: profileDir, + Username: profile.Username, + SiteName: profile.SiteName, + KubeProxyAddr: profile.KubeProxyAddr, + IsVirtual: profileDir == "", + }) + require.NoError(t, err) + + // ReadProfileStatus should prepare a *ProfileStatus using the saved + // profile and key together. + profileStatus, err := clientStore.ReadProfileStatus(profile.Name()) + require.NoError(t, err) + require.Equal(t, expectStatus, profileStatus) + + // FullProfileStatus should return the current profile status, and any + // other available profiles' statuses. + otherKey := key.Copy() + otherKey.ProxyHost = "other.example.com" + err = clientStore.AddKey(otherKey) + require.NoError(t, err) + + otherProfile := profile.Copy() + otherProfile.WebProxyAddr = "other.example.com:3080" + err = clientStore.SaveProfile(otherProfile, false) + require.NoError(t, err) + + expectOtherStatus, err := profileStatusFromKey(key, profileOptions{ + ProfileName: otherProfile.Name(), + WebProxyAddr: otherProfile.WebProxyAddr, + ProfileDir: profileDir, + Username: otherProfile.Username, + SiteName: otherProfile.SiteName, + KubeProxyAddr: otherProfile.KubeProxyAddr, + IsVirtual: profileDir == "", + }) + require.NoError(t, err) + + currentStatus, otherStatuses, err := clientStore.FullProfileStatus() + require.NoError(t, err) + require.Equal(t, expectStatus, currentStatus) + require.Len(t, otherStatuses, 1) + require.Equal(t, expectOtherStatus, otherStatuses[0]) + }) +} + +// TestProxySSHConfig tests proxy client SSH config function +// that generates SSH client configuration for proxy tunnel connections +func TestProxySSHConfig(t *testing.T) { + t.Parallel() + auth := newTestAuthority(t) + + testEachClientStore(t, func(t *testing.T, clientStore *Store) { + t.Parallel() + + idx := KeyIndex{"host.a", "bob", "root"} + key := auth.makeSignedKey(t, idx, false) + + caPub, _, _, _, err := ssh.ParseAuthorizedKey(CAPub) + require.NoError(t, err) + + err = clientStore.AddKey(key) + require.NoError(t, err) + + firsthost := "127.0.0.1" + err = clientStore.AddTrustedHostKeys(idx.ProxyHost, firsthost, caPub) + require.NoError(t, err) + + retrievedKey, err := clientStore.GetKey(idx, WithSSHCerts{}) + require.NoError(t, err) + + clientConfig, err := retrievedKey.ProxyClientSSHConfig(firsthost) + require.NoError(t, err) + + var called atomic.Int32 + handler := sshutils.NewChanHandlerFunc(func(_ context.Context, _ *sshutils.ConnectionContext, nch ssh.NewChannel) { + called.Add(1) + nch.Reject(ssh.Prohibited, "nothing to see here") + }) + + hostPriv, hostPub, err := auth.keygen.GenerateKeyPair() + require.NoError(t, err) + + caSigner, err := ssh.ParsePrivateKey(CAPriv) + require.NoError(t, err) + + hostCert, err := auth.keygen.GenerateHostCert(services.HostCertParams{ + CASigner: caSigner, + PublicHostKey: hostPub, + HostID: "127.0.0.1", + NodeName: "127.0.0.1", + ClusterName: "host-cluster-name", + Role: types.RoleNode, + }) + require.NoError(t, err) + + hostSigner, err := sshutils.NewSigner(hostPriv, hostCert) + require.NoError(t, err) + + srv, err := sshutils.NewServer( + "test", + utils.NetAddr{AddrNetwork: "tcp", Addr: "127.0.0.1:0"}, + handler, + []ssh.Signer{hostSigner}, + sshutils.AuthMethods{ + PublicKey: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { + certChecker := apisshutils.CertChecker{ + CertChecker: ssh.CertChecker{ + IsUserAuthority: func(cert ssh.PublicKey) bool { + // Makes sure that user presented key signed by or with trusted authority. + return apisshutils.KeysEqual(caPub, cert) + }, + }, + } + return certChecker.Authenticate(conn, key) + }, + }, + ) + require.NoError(t, err) + require.NoError(t, srv.Start()) + defer srv.Close() + + clt, err := ssh.Dial("tcp", srv.Addr(), clientConfig) + require.NoError(t, err) + defer clt.Close() + + // Call new session to initiate opening new channel. This should get + // rejected and fail. + _, err = clt.NewSession() + require.Error(t, err) + require.Equal(t, int(called.Load()), 1) + + _, spub, err := testauthority.New().GenerateKeyPair() + require.NoError(t, err) + caPub22, _, _, _, err := ssh.ParseAuthorizedKey(spub) + require.NoError(t, err) + err = clientStore.AddTrustedHostKeys(idx.ProxyHost, "second-host", caPub22) + require.NoError(t, err) + + // The ProxyClientSSHConfig should create configuration that validates server authority only based on + // second-host instead of all known hosts. + retrievedKey, err = clientStore.GetKey(idx, WithSSHCerts{}) + require.NoError(t, err) + clientConfig, err = retrievedKey.ProxyClientSSHConfig("second-host") + require.NoError(t, err) + + // ssh server cert doesn't match second-host user known host thus connection should fail. + _, err = ssh.Dial("tcp", srv.Addr(), clientConfig) + require.Error(t, err) + }) +} + +var ( + CAPriv = []byte(`-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEAwBgwn+vkjCcKEr2fbX1mLN555B9amVYfD/fUZBNbXKpHaqYn +lM2WlyRR+xCrU9H/X6xT+wKJs1tsxFbxdBc1RWJtaqz/VpQCjomOulBzwumzB5hT +pJfGblGjkPvpt1zwfmKdpBg0jxXUHHR4u4N6OX0dxd0ImRQ4W9QUtEqzgqToS5u4 +iwpeg6i1SoAdHBaSeqYhK9+nGrrJBAl/HVSgvL9tGn/+cQqlOiQz0t61V20+oMBA +P+rOTIiwRXn98iMKFjzVW1HTL5Lwit3oJQX0Lrd/I6tN2De6TJxbbOOkF45V/P/k +nBzbxV0fpnhcvZMnQqg1qdUmNVi6VC1O5qIPiwIDAQABAoIBAEg0T4KtLnkn63dj +41tKeW+AKJ0A1BMy9fYQl7sOM5c/QhzqW5JpPKOPOWl/uIaHNtCFfAOrzoqmYNnk +PFoApztvZeVlJY0rkVJ2jjmmJ/0pzuuZ7Ea/7gxlj2/d4NnVi2hWNR8LIiZudA5G +EWOaZgTZ7KkFDkhL+2s46pdiRNtj7l5FXn2tCh7jmFgKS4m1/QqV9KdE5EjwB2mj +BoP/j4V8O0RM05QpiYX/D5/Rr06tBavwTGW3vz/7OPIbf1el1mjfbLlt3z2tH0A5 +BSGB4JEwIZ3+2xlZokHy95OSDzE46TsSzgNx3SDzGRc8UnSZN9yunxnL4ej11WYt +59YmD+ECgYEA3zxrDAtscpoxJSwcSkwqcMdElMK4D/BZw/tE9HhpHx3Pdd5XtMio +CHUkkqxwGJeVIixDjwnl4VfA1s0wy3CtHq6mmwfUviYrH2eqxe5RxNyZOZguk6is +GurZzD+ZfacsEIHyz2fZdnEAIFubu/S6x4TQPGg23oxnQpXXq1vzZFkCgYEA3Emz +W4MXvYWvRdbn+W3onHz/vty9owj/BKSP6giPGrpQFdLs8yoBUw1yTOGqAIfuWMLS +xvjULSlhei5PYD1xM2+B4luxM8K25DlqUpgRVtdmjQ/wxnzlmhDAPIMh7LUtw/6o +JJ+diAKTI86T8tokIL7WFaSvzdrz7/WrZQWkpoMCgYAPVAK1rQMhS10chE7c+yXe +4I/g9w3Ualh/kH1HnAz7yfw4x6+WBkEjc4ezWovH5ICk/A0XgUJ7mp7vIN+82FvK +w4tFEeCVveEwItojBR4wOkV7Iuvvz6EhqAaUc7mCWzw3VfTqMONJsrCjiCbFXSSG +FqSFwVIjLdjZRZitd37a4QKBgQDWfjjTIVlLY9EfWrszZu54+Ul4Sa2pAwh1N9sd +kUnuR33VUjUALGVvvgcOjyieLb1J1iGwNfc7JjDQ7CjD1+/Smn/IrWlksfKtVK6P +T5yKh2BGeEAEtPZHxom4IiM1PdEbJ2oHhxe3qHInCm2KqRdGfysrldjMw6aEfxxt +WEpTCwKBgHLZYgNf/dGgWgw7bVu/k61jxw3yZuU/0marFOPINME/AnTcSAGnkC0S +oDZhaPxjz3+2AHWAjUgW1ltTY8FsJYTOYsvzkYPfya4CgHCLg3D9ss1m4Rc7w5qo +Fa6bvW5jo543NztjlKts7XYVqroMCu0sIMS7R4JGsmw3VJcnnMP2 +-----END RSA PRIVATE KEY-----`) + + CAPub = []byte(`ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDAGDCf6+SMJwoSvZ9tfWYs3nnkH1qZVh8P99RkE1tcqkdqpieUzZaXJFH7EKtT0f9frFP7AomzW2zEVvF0FzVFYm1qrP9WlAKOiY66UHPC6bMHmFOkl8ZuUaOQ++m3XPB+Yp2kGDSPFdQcdHi7g3o5fR3F3QiZFDhb1BS0SrOCpOhLm7iLCl6DqLVKgB0cFpJ6piEr36causkECX8dVKC8v20af/5xCqU6JDPS3rVXbT6gwEA/6s5MiLBFef3yIwoWPNVbUdMvkvCK3eglBfQut38jq03YN7pMnFts46QXjlX8/+ScHNvFXR+meFy9kydCqDWp1SY1WLpULU7mog+L ekontsevoy@turing`) +) diff --git a/lib/client/conntest/ssh.go b/lib/client/conntest/ssh.go index 1c4d5e49bc08..7d3b5867bdfe 100644 --- a/lib/client/conntest/ssh.go +++ b/lib/client/conntest/ssh.go @@ -135,7 +135,7 @@ func (s *SSHConnectionTester) TestConnection(ctx context.Context, req TestConnec return nil, trace.Wrap(err) } - key.TrustedCA = auth.AuthoritiesToTrustedCerts(certAuths) + key.TrustedCerts = auth.AuthoritiesToTrustedCerts(certAuths) keyAuthMethod, err := key.AsAuthMethod() if err != nil { diff --git a/lib/client/db/database_certificates.go b/lib/client/db/database_certificates.go index c98a61219b97..c0a5cf4b0897 100644 --- a/lib/client/db/database_certificates.go +++ b/lib/client/db/database_certificates.go @@ -113,7 +113,10 @@ func GenerateDatabaseCertificates(ctx context.Context, req GenerateDatabaseCerti } req.Key.TLSCert = resp.Cert - req.Key.TrustedCA = []auth.TrustedCerts{{TLSCertificates: resp.CACerts}} + req.Key.TrustedCerts = []auth.TrustedCerts{{ + ClusterName: req.Key.ClusterName, + TLSCertificates: resp.CACerts, + }} filesWritten, err := identityfile.Write(identityfile.WriteConfig{ OutputPath: req.OutputLocation, Key: req.Key, diff --git a/lib/client/identityfile/identity.go b/lib/client/identityfile/identity.go index 37330183c027..1e101861eac5 100644 --- a/lib/client/identityfile/identity.go +++ b/lib/client/identityfile/identity.go @@ -33,10 +33,13 @@ import ( "github.com/pavlo-v-chernykh/keystore-go/v4" "github.com/gravitational/teleport/api/identityfile" + "github.com/gravitational/teleport/api/profile" "github.com/gravitational/teleport/api/utils/keypaths" + "github.com/gravitational/teleport/api/utils/keys" "github.com/gravitational/teleport/lib/client" "github.com/gravitational/teleport/lib/kube/kubeconfig" "github.com/gravitational/teleport/lib/sshutils" + "github.com/gravitational/teleport/lib/tlsca" "github.com/gravitational/teleport/lib/utils" "github.com/gravitational/teleport/lib/utils/prompt" ) @@ -207,14 +210,18 @@ func Write(cfg WriteConfig) (filesWritten []string, err error) { }, } // append trusted host certificate authorities - for _, ca := range cfg.Key.TrustedCA { + for _, ca := range cfg.Key.TrustedCerts { // append ssh ca certificates - for _, publicKey := range ca.HostCertificates { - data, err := sshutils.MarshalAuthorizedHostsFormat(ca.ClusterName, publicKey, nil) + for _, publicKey := range ca.AuthorizedKeys { + knownHost, err := sshutils.MarshalKnownHost(sshutils.KnownHost{ + Hostname: ca.ClusterName, + ProxyHost: cfg.Key.ProxyHost, + AuthorizedKey: publicKey, + }) if err != nil { return nil, trace.Wrap(err) } - idFile.CACerts.SSH = append(idFile.CACerts.SSH, []byte(data)) + idFile.CACerts.SSH = append(idFile.CACerts.SSH, []byte(knownHost)) } // append tls ca certificates idFile.CACerts.TLS = append(idFile.CACerts.TLS, ca.TLSCertificates...) @@ -275,7 +282,7 @@ func Write(cfg WriteConfig) (filesWritten []string, err error) { return nil, trace.Wrap(err) } var caCerts []byte - for _, ca := range cfg.Key.TrustedCA { + for _, ca := range cfg.Key.TrustedCerts { for _, cert := range ca.TLSCertificates { caCerts = append(caCerts, cert...) } @@ -299,7 +306,7 @@ func Write(cfg WriteConfig) (filesWritten []string, err error) { return nil, trace.Wrap(err) } var caCerts []byte - for _, ca := range cfg.Key.TrustedCA { + for _, ca := range cfg.Key.TrustedCerts { for _, cert := range ca.TLSCertificates { caCerts = append(caCerts, cert...) } @@ -317,7 +324,7 @@ func Write(cfg WriteConfig) (filesWritten []string, err error) { } var caCerts []byte - for _, ca := range cfg.Key.TrustedCA { + for _, ca := range cfg.Key.TrustedCerts { for _, cert := range ca.TLSCertificates { block, _ := pem.Decode(cert) cert, err := x509.ParseCertificate(block.Bytes) @@ -422,7 +429,7 @@ func writeCassandraFormat(cfg WriteConfig, writer ConfigWriter) ([]string, error func prepareCassandraTruststore(cfg WriteConfig) (*bytes.Buffer, error) { var caCerts []byte - for _, ca := range cfg.Key.TrustedCA { + for _, ca := range cfg.Key.TrustedCerts { for _, cert := range ca.TLSCertificates { block, _ := pem.Decode(cert) caCerts = append(caCerts, block.Bytes...) @@ -512,3 +519,106 @@ func checkOverwrite(writer ConfigWriter, force bool, paths ...string) error { } return nil } + +// KeyFromIdentityFile loads client key from identity file. +func KeyFromIdentityFile(identityPath, proxyHost, clusterName string) (*client.Key, error) { + if proxyHost == "" { + return nil, trace.BadParameter("proxyHost must be provided to parse identity file") + } + ident, err := identityfile.ReadFile(identityPath) + if err != nil { + return nil, trace.Wrap(err, "failed to parse identity file") + } + + priv, err := keys.ParsePrivateKey(ident.PrivateKey) + if err != nil { + return nil, trace.Wrap(err) + } + + key := client.NewKey(priv) + key.Cert = ident.Certs.SSH + key.TLSCert = ident.Certs.TLS + key.KeyIndex = client.KeyIndex{ + ProxyHost: proxyHost, + ClusterName: clusterName, + } + + // validate TLS Cert (if present): + if len(ident.Certs.TLS) > 0 { + certDERBlock, _ := pem.Decode(ident.Certs.TLS) + cert, err := x509.ParseCertificate(certDERBlock.Bytes) + if err != nil { + return nil, trace.Wrap(err) + } + + if key.ClusterName == "" { + key.ClusterName = cert.Issuer.CommonName + } + + parsedIdent, err := tlsca.FromSubject(cert.Subject, cert.NotAfter) + if err != nil { + return nil, trace.Wrap(err) + } + key.Username = parsedIdent.Username + + // If this identity file has any database certs, copy it into the DBTLSCerts map. + if parsedIdent.RouteToDatabase.ServiceName != "" { + key.DBTLSCerts[parsedIdent.RouteToDatabase.ServiceName] = ident.Certs.TLS + } + + // Similarly, if this identity has any app certs, copy them in. + if parsedIdent.RouteToApp.Name != "" { + key.AppTLSCerts[parsedIdent.RouteToApp.Name] = ident.Certs.TLS + } + } else { + key.Username, err = key.CertUsername() + if err != nil { + return nil, trace.Wrap(err) + } + } + + key.TrustedCerts, err = client.TrustedCertsFromCACerts(proxyHost, ident.CACerts.TLS, ident.CACerts.SSH) + if err != nil { + return nil, trace.Wrap(err) + } + + return key, nil +} + +// NewClientStoreFromIdentityFile initializes a new in-memory client store +// and loads data from the given identity file into it. A temporary profile +// is also added to its profile store with the limited profile data available +// in the identity file. +// +// Since identity files do not save a proxy address, proxyAddr must be provided +// to fill in this data gap. clusterName can also be provided to aim the key at +// a leaf cluster rather than the default root cluster. +func NewClientStoreFromIdentityFile(identityFile, proxyAddr, clusterName string) (*client.Store, error) { + proxyHost, err := utils.Host(proxyAddr) + if err != nil { + return nil, trace.Wrap(err) + } + + key, err := KeyFromIdentityFile(identityFile, proxyHost, clusterName) + if err != nil { + return nil, trace.Wrap(err) + } + + // Preload the client key from the agent. + clientStore := client.NewMemClientStore() + if err := clientStore.AddKey(key); err != nil { + return nil, trace.Wrap(err) + } + + // Save temporary profile into the key store. + profile := &profile.Profile{ + WebProxyAddr: proxyAddr, + SiteName: key.ClusterName, + Username: key.Username, + } + if err := clientStore.SaveProfile(profile, true); err != nil { + return nil, trace.Wrap(err) + } + + return clientStore, nil +} diff --git a/lib/client/identityfile/identity_test.go b/lib/client/identityfile/identity_test.go index bc6c4fc96448..92a1bd166033 100644 --- a/lib/client/identityfile/identity_test.go +++ b/lib/client/identityfile/identity_test.go @@ -18,6 +18,8 @@ import ( "bytes" "crypto" "crypto/x509/pkix" + "fmt" + "net" "os" "path" "path/filepath" @@ -29,6 +31,7 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/crypto/ssh" + "github.com/gravitational/teleport/api/profile" "github.com/gravitational/teleport/api/utils/keypaths" "github.com/gravitational/teleport/lib/auth" "github.com/gravitational/teleport/lib/auth/testauthority" @@ -36,12 +39,13 @@ import ( "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/kube/kubeconfig" "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/sshutils" "github.com/gravitational/teleport/lib/tlsca" ) func newSelfSignedCA(priv crypto.Signer) (*tlsca.CertAuthority, auth.TrustedCerts, error) { cert, err := tlsca.GenerateSelfSignedCAWithSigner(priv, pkix.Name{ - CommonName: "localhost", + CommonName: "root", Organization: []string{"localhost"}, }, nil, defaults.CATTL) if err != nil { @@ -51,7 +55,15 @@ func newSelfSignedCA(priv crypto.Signer) (*tlsca.CertAuthority, auth.TrustedCert if err != nil { return nil, auth.TrustedCerts{}, trace.Wrap(err) } - return ca, auth.TrustedCerts{TLSCertificates: [][]byte{cert}}, nil + sshPub, err := ssh.NewPublicKey(priv.Public()) + if err != nil { + return nil, auth.TrustedCerts{}, trace.Wrap(err) + } + return ca, auth.TrustedCerts{ + ClusterName: "root", + TLSCertificates: [][]byte{cert}, + AuthorizedKeys: [][]byte{ssh.MarshalAuthorizedKey(sshPub)}, + }, nil } func newClientKey(t *testing.T) *client.Key { @@ -65,6 +77,7 @@ func newClientKey(t *testing.T) *client.Key { clock := clockwork.NewRealClock() identity := tlsca.Identity{ Username: "testuser", + Groups: []string{"groups"}, } subject, err := identity.Subject() @@ -88,22 +101,21 @@ func newClientKey(t *testing.T) *client.Key { CASigner: caSigner, PublicUserKey: ssh.MarshalAuthorizedKey(privateKey.SSHPublicKey()), Username: "testuser", + AllowedLogins: []string{"testuser"}, }) require.NoError(t, err) - return &client.Key{ - PrivateKey: privateKey, - Cert: certificate, - TLSCert: tlsCert, - TrustedCA: []auth.TrustedCerts{ - tc, - }, - KeyIndex: client.KeyIndex{ - ProxyHost: "localhost", - Username: "testuser", - ClusterName: "root", - }, + key := client.NewKey(privateKey) + key.KeyIndex = client.KeyIndex{ + ProxyHost: "localhost", + Username: "testuser", + ClusterName: "root", } + key.Cert = certificate + key.TLSCert = tlsCert + key.TrustedCerts = []auth.TrustedCerts{tc} + + return key } func TestWrite(t *testing.T) { @@ -138,11 +150,19 @@ func TestWrite(t *testing.T) { out, err = os.ReadFile(cfg.OutputPath) require.NoError(t, err) + knownHosts, err := sshutils.MarshalKnownHost(sshutils.KnownHost{ + Hostname: key.ClusterName, + ProxyHost: key.ProxyHost, + AuthorizedKey: key.TrustedCerts[0].AuthorizedKeys[0], + }) + require.NoError(t, err) + wantArr := [][]byte{ key.PrivateKeyPEM(), key.Cert, key.TLSCert, - bytes.Join(key.TLSCAs(), []byte{}), + []byte(knownHosts), + bytes.Join(key.TrustedCerts[0].TLSCertificates, []byte{}), } want := string(bytes.Join(wantArr, nil)) require.Equal(t, want, string(out)) @@ -230,3 +250,131 @@ func assertKubeconfigContents(t *testing.T, path, clusterName, serverAddr, kubeT require.Equal(t, kc.Clusters[clusterName].Server, serverAddr) require.Equal(t, kc.Clusters[clusterName].TLSServerName, kubeTLSName) } + +func TestIdentityRead(t *testing.T) { + t.Parallel() + + // 3 different types of identities + ids := []string{ + "cert-key.pem", // cert + key concatenated together, cert first + "key-cert.pem", // cert + key concatenated together, key first + "key", // two separate files: key and key-cert.pub + } + for _, id := range ids { + // test reading: + k, err := KeyFromIdentityFile(fixturePath(fmt.Sprintf("certs/identities/%s", id)), "proxy.example.com", "") + require.NoError(t, err) + require.NotNil(t, k) + + // test creating an auth method from the key: + am, err := k.AsAuthMethod() + require.NoError(t, err) + require.NotNil(t, am) + } + k, err := KeyFromIdentityFile(fixturePath("certs/identities/lonekey"), "proxy.example.com", "") + require.Nil(t, k) + require.Error(t, err) + + // lets read an identity which includes a CA cert + k, err = KeyFromIdentityFile(fixturePath("certs/identities/key-cert-ca.pem"), "proxy.example.com", "") + require.NoError(t, err) + require.NotNil(t, k) + + // prepare the cluster CA separately + certBytes, err := os.ReadFile(fixturePath("certs/identities/ca.pem")) + require.NoError(t, err) + + _, hosts, cert, _, _, err := ssh.ParseKnownHosts(certBytes) + require.NoError(t, err) + + var a net.Addr + // host auth callback must succeed + cb := k.HostKeyCallback("proxy.example.com") + require.NoError(t, cb(hosts[0], a, cert)) + + // load an identity which include TLS certificates + k, err = KeyFromIdentityFile(fixturePath("certs/identities/tls.pem"), "proxy.example.com", "") + require.NoError(t, err) + require.NotNil(t, k) + require.NotNil(t, k.TLSCert) + + // generate a TLS client config + conf, err := k.TeleportClientTLSConfig(nil, []string{"one"}) + require.NoError(t, err) + require.NotNil(t, conf) +} + +func fixturePath(path string) string { + return "../../../fixtures/" + path +} + +func TestKeyFromIdentityFile(t *testing.T) { + t.Parallel() + key := newClientKey(t) + key.ProxyHost = "proxy.example.com" + key.ClusterName = "cluster" + + identityFilePath := filepath.Join(t.TempDir(), "out") + + // First write an ssh key to the file. + _, err := Write(WriteConfig{ + OutputPath: identityFilePath, + Format: FormatFile, + Key: key, + OverwriteDestination: true, + }) + require.NoError(t, err) + + // parsed key is unchanged from original with proxy and cluster provided. + parsedKey, err := KeyFromIdentityFile(identityFilePath, key.ProxyHost, key.ClusterName) + require.NoError(t, err) + require.Equal(t, key, parsedKey) + + // Identity file's cluster name defaults to root cluster name. + parsedKey, err = KeyFromIdentityFile(identityFilePath, key.ProxyHost, "") + key.ClusterName = "root" + require.NoError(t, err) + require.Equal(t, key, parsedKey) + + // Returns error if proxy host is not provided. + _, err = KeyFromIdentityFile(identityFilePath, "", "") + require.Error(t, err) + require.True(t, trace.IsBadParameter(err)) +} + +func TestNewClientStoreFromIdentityFile(t *testing.T) { + t.Parallel() + key := newClientKey(t) + key.ProxyHost = "proxy.example.com" + key.ClusterName = "cluster" + + identityFilePath := filepath.Join(t.TempDir(), "out") + + // First write an ssh key to the file. + _, err := Write(WriteConfig{ + OutputPath: identityFilePath, + Format: FormatFile, + Key: key, + OverwriteDestination: true, + }) + require.NoError(t, err) + + clientStore, err := NewClientStoreFromIdentityFile(identityFilePath, key.ProxyHost+":3080", key.ClusterName) + require.NoError(t, err) + + currentProfile, err := clientStore.CurrentProfile() + require.NoError(t, err) + require.Equal(t, key.ProxyHost, currentProfile) + + retrievedProfile, err := clientStore.GetProfile(currentProfile) + require.NoError(t, err) + require.Equal(t, &profile.Profile{ + WebProxyAddr: key.ProxyHost + ":3080", + SiteName: key.ClusterName, + Username: key.Username, + }, retrievedProfile) + + retrievedKey, err := clientStore.GetKey(key.KeyIndex, client.WithAllCerts...) + require.NoError(t, err) + require.Equal(t, key, retrievedKey) +} diff --git a/lib/client/interfaces.go b/lib/client/interfaces.go index 433abe0eff16..d795f09c4ff1 100644 --- a/lib/client/interfaces.go +++ b/lib/client/interfaces.go @@ -25,6 +25,7 @@ import ( "crypto/tls" "crypto/x509" "fmt" + "net" "strings" "time" @@ -34,10 +35,9 @@ import ( "github.com/gravitational/teleport" "github.com/gravitational/teleport/api/constants" - "github.com/gravitational/teleport/api/identityfile" apiutils "github.com/gravitational/teleport/api/utils" "github.com/gravitational/teleport/api/utils/keys" - "github.com/gravitational/teleport/api/utils/sshutils" + apisshutils "github.com/gravitational/teleport/api/utils/sshutils" "github.com/gravitational/teleport/lib/auth" "github.com/gravitational/teleport/lib/auth/native" "github.com/gravitational/teleport/lib/services" @@ -103,8 +103,17 @@ type Key struct { // WindowsDesktopCerts are TLS certificates for Windows Desktop access. // Map key is the desktop server name. WindowsDesktopCerts map[string][]byte `json:"WindowsDesktopCerts,omitempty"` - // TrustedCA is a list of trusted certificate authorities - TrustedCA []auth.TrustedCerts + // TrustedCerts is a list of trusted certificate authorities + TrustedCerts []auth.TrustedCerts +} + +// Copy returns a shallow copy of k, or nil if k is nil. +func (k *Key) Copy() *Key { + if k == nil { + return nil + } + copy := *k + return © } // GenerateRSAKey generates a new unsigned key. @@ -127,92 +136,6 @@ func NewKey(priv *keys.PrivateKey) *Key { } } -// extractIdentityFromCert parses a tlsca.Identity from raw PEM cert bytes. -func extractIdentityFromCert(certBytes []byte) (*tlsca.Identity, error) { - cert, err := tlsca.ParseCertificatePEM(certBytes) - if err != nil { - return nil, trace.Wrap(err, "failed to parse TLS certificate") - } - - parsed, err := tlsca.FromSubject(cert.Subject, cert.NotAfter) - if err != nil { - return nil, trace.Wrap(err) - } - - return parsed, nil -} - -// KeyFromIdentityFile loads the private key + certificate -// from an identity file into a Key. -func KeyFromIdentityFile(path string) (*Key, error) { - ident, err := identityfile.ReadFile(path) - if err != nil { - return nil, trace.Wrap(err, "failed to parse identity file") - } - - priv, err := keys.ParsePrivateKey(ident.PrivateKey) - if err != nil { - return nil, trace.Wrap(err) - } - - dbTLSCerts := make(map[string][]byte) - appCerts := make(map[string][]byte) - - // validate TLS Cert (if present): - if len(ident.Certs.TLS) > 0 { - if _, err := priv.TLSCertificate(ident.Certs.TLS); err != nil { - return nil, trace.Wrap(err) - } - - parsedIdent, err := extractIdentityFromCert(ident.Certs.TLS) - if err != nil { - return nil, trace.Wrap(err) - } - - // If this identity file has any database certs, copy it into the DBTLSCerts map. - if parsedIdent.RouteToDatabase.ServiceName != "" { - dbTLSCerts[parsedIdent.RouteToDatabase.ServiceName] = ident.Certs.TLS - } - - // Similarly, if this identity has any app certs, copy them in. - if parsedIdent.RouteToApp.Name != "" { - appCerts[parsedIdent.RouteToApp.Name] = ident.Certs.TLS - } - - } - - // Validate TLS CA certs (if present). - var trustedCA []auth.TrustedCerts - if len(ident.CACerts.TLS) > 0 || len(ident.CACerts.SSH) > 0 { - trustedCA = []auth.TrustedCerts{{ - TLSCertificates: ident.CACerts.TLS, - HostCertificates: ident.CACerts.SSH, - }} - - pool := x509.NewCertPool() - for i, certPEM := range ident.CACerts.TLS { - if !pool.AppendCertsFromPEM(certPEM) { - return nil, trace.BadParameter("identity file contains invalid TLS CA cert (#%v)", i+1) - } - } - - for _, caCert := range ident.CACerts.SSH { - if _, _, _, _, _, err := ssh.ParseKnownHosts(caCert); err != nil { - return nil, trace.BadParameter("CA cert parsing error: %v; make sure this identity file was generated by 'tsh login -o' or 'tctl auth sign --format=file' or try generating it again", err.Error()) - } - } - } - - return &Key{ - PrivateKey: priv, - Cert: ident.Certs.SSH, - TLSCert: ident.Certs.TLS, - TrustedCA: trustedCA, - DBTLSCerts: dbTLSCerts, - AppTLSCerts: appCerts, - }, nil -} - // RootClusterCAs returns root cluster CAs. func (k *Key) RootClusterCAs() ([][]byte, error) { rootClusterName, err := k.RootClusterName() @@ -220,7 +143,7 @@ func (k *Key) RootClusterCAs() ([][]byte, error) { return nil, trace.Wrap(err) } var out [][]byte - for _, cas := range k.TrustedCA { + for _, cas := range k.TrustedCerts { for _, v := range cas.TLSCertificates { cert, err := tlsca.ParseCertificatePEM(v) if err != nil { @@ -239,7 +162,7 @@ func (k *Key) RootClusterCAs() ([][]byte, error) { // TLSCAs returns all TLS CA certificates from this key func (k *Key) TLSCAs() (result [][]byte) { - for _, ca := range k.TrustedCA { + for _, ca := range k.TrustedCerts { result = append(result, ca.TLSCertificates...) } return result @@ -263,48 +186,62 @@ func (k *Key) KubeClientTLSConfig(cipherSuites []uint16, kubeClusterName string) return tlsConfig, nil } -// SSHCAs returns all SSH CA certificates from this key -func (k *Key) SSHCAs() (result [][]byte) { - for _, ca := range k.TrustedCA { - result = append(result, ca.HostCertificates...) - } - return result -} - -// SSHCAsForClusters returns SSH CA for particular clusters. -func (k *Key) SSHCAsForClusters(clusters []string) (result [][]byte, err error) { - for _, ca := range k.TrustedCA { - for _, hc := range ca.HostCertificates { - _, hosts, _, _, _, err := ssh.ParseKnownHosts(hc) - if err != nil { - return nil, trace.Wrap(err) - } - - for _, h := range hosts { - for _, c := range clusters { - if h == c { - result = append(result, hc) +// HostKeyCallback returns a host key callback that checks if the given host key was signed +// by a Teleport certificate authority (CA) or a host certificate the user has seen before. +func (k *Key) HostKeyCallback(hostname string) ssh.HostKeyCallback { + return func(addr string, remote net.Addr, key ssh.PublicKey) error { + certChecker := apisshutils.CertChecker{ + CertChecker: ssh.CertChecker{ + IsHostAuthority: func(key ssh.PublicKey, addr string) bool { + for _, ak := range k.AuthorizedHostKeys(hostname) { + authorizedKey, _, _, _, err := ssh.ParseAuthorizedKey(ak) + if err != nil { + log.Errorf("Failed to parse authorized key: %v; raw key: %s", err, string(ak)) + return false + } + if apisshutils.KeysEqual(authorizedKey, key) { + return true + } } - } - } + return false + }, + HostKeyFallback: func(hostname string, remote net.Addr, key ssh.PublicKey) error { + for _, ak := range k.AuthorizedHostKeys(hostname) { + authorizedKey, _, _, _, err := ssh.ParseAuthorizedKey(ak) + if err != nil { + return trace.Wrap(err) + } + if apisshutils.KeysEqual(authorizedKey, key) { + return nil + } + } + return trace.BadParameter("host %s presented a public key not signed by Teleport", hostname) + }, + }, + FIPS: isFIPS(), } + err := certChecker.CheckHostKey(addr, remote, key) + if err != nil { + log.Debugf("Host validation failed: %v.", err) + return trace.Wrap(err) + } + log.Debugf("Validated host %v.", addr) + return nil } - return result, nil } -// GetClusterNames gets the names of clusters this key has CAs for. -func (k *Key) GetClusterNames() ([]string, error) { - var clusters []string - for _, ca := range k.TrustedCA { - for _, hc := range ca.HostCertificates { - _, hosts, _, _, _, err := ssh.ParseKnownHosts(hc) - if err != nil { - return nil, trace.Wrap(err) - } - clusters = append(clusters, hosts...) +// AuthorizedHostKeys returns all authorized host keys from this key. If any host +// names are provided, only matching host keys will be returned. +func (k *Key) AuthorizedHostKeys(hostnames ...string) (result [][]byte) { + for _, ca := range k.TrustedCerts { + // Mirror the hosts we would find in a known_hosts entry. + hosts := []string{k.ProxyHost, ca.ClusterName, "*." + ca.ClusterName} + + if len(hostnames) == 0 || apisshutils.HostNameMatch(hostnames, hosts) { + result = append(result, ca.AuthorizedKeys...) } } - return apiutils.Deduplicate(clusters), nil + return result } // TeleportClientTLSConfig returns client TLS configuration used @@ -361,20 +298,18 @@ func (k *Key) clientCertPool(clusters ...string) (*x509.CertPool, error) { // // The config is set up to authenticate to proxy with the first available principal // and ( if keyStore != nil ) trust local SSH CAs without asking for public keys. -func (k *Key) ProxyClientSSHConfig(keyStore sshKnowHostGetter, host string) (*ssh.ClientConfig, error) { +func (k *Key) ProxyClientSSHConfig(hostname string) (*ssh.ClientConfig, error) { sshCert, err := k.SSHCert() if err != nil { return nil, trace.Wrap(err, "failed to extract username from SSH certificate") } - sshConfig, err := sshutils.ProxyClientSSHConfig(sshCert, k) + sshConfig, err := apisshutils.ProxyClientSSHConfig(sshCert, k) if err != nil { return nil, trace.Wrap(err) } - if keyStore != nil { - sshConfig.HostKeyCallback = NewKeyStoreCertChecker(keyStore, host) - } + sshConfig.HostKeyCallback = k.HostKeyCallback(hostname) return sshConfig, nil } @@ -546,7 +481,7 @@ func (k *Key) AsAuthMethod() (ssh.AuthMethod, error) { if err != nil { return nil, trace.Wrap(err) } - return sshutils.AsAuthMethod(cert, k) + return apisshutils.AsAuthMethod(cert, k) } // SSHSigner returns an ssh.Signer using the SSH certificate in this key. @@ -555,7 +490,7 @@ func (k *Key) SSHSigner() (ssh.Signer, error) { if err != nil { return nil, trace.Wrap(err) } - return sshutils.SSHSigner(cert, k) + return apisshutils.SSHSigner(cert, k) } // SSHCert returns parsed SSH certificate @@ -563,7 +498,7 @@ func (k *Key) SSHCert() (*ssh.Certificate, error) { if k.Cert == nil { return nil, trace.NotFound("SSH cert not available") } - return sshutils.ParseCertificate(k.Cert) + return apisshutils.ParseCertificate(k.Cert) } // ActiveRequests gets the active requests associated with this key. @@ -600,45 +535,24 @@ func (k *Key) CheckCert() error { func (k *Key) checkCert(sshCert *ssh.Certificate) error { // Check that the certificate was for the current public key. If not, the // public/private key pair may have been rotated. - if !sshutils.KeysEqual(sshCert.Key, k.SSHPublicKey()) { + if !apisshutils.KeysEqual(sshCert.Key, k.SSHPublicKey()) { return trace.CompareFailed("public key in profile does not match the public key in SSH certificate") } // A valid principal is always passed in because the principals are not being // checked here, but rather the validity period, signature, and algorithms. - certChecker := sshutils.CertChecker{ + certChecker := apisshutils.CertChecker{ FIPS: isFIPS(), } + if len(sshCert.ValidPrincipals) == 0 { + return trace.BadParameter("cert is not valid for any principles") + } if err := certChecker.CheckCert(sshCert.ValidPrincipals[0], sshCert); err != nil { return trace.Wrap(err) } return nil } -// HostKeyCallback returns an ssh.HostKeyCallback that validates host -// keys/certs against SSH CAs in the Key. -// -// If not CAs are present in the Key, the returned ssh.HostKeyCallback is nil. -// This causes golang.org/x/crypto/ssh to prompt the user to verify host key -// fingerprint (same as OpenSSH does for an unknown host). -func (k *Key) HostKeyCallback(withHostKeyFallback bool) (ssh.HostKeyCallback, error) { - return sshutils.HostKeyCallback(k.SSHCAs(), withHostKeyFallback) -} - -// HostKeyCallbackForClusters returns an ssh.HostKeyCallback that validates host -// keys/certs against SSH clusters CAs. -// -// If not CAs are present in the Key, the returned ssh.HostKeyCallback is nil. -// This causes golang.org/x/crypto/ssh to prompt the user to verify host key -// fingerprint (same as OpenSSH does for an unknown host). -func (k *Key) HostKeyCallbackForClusters(withHostKeyFallback bool, clusters []string) (ssh.HostKeyCallback, error) { - sshCA, err := k.SSHCAsForClusters(clusters) - if err != nil { - return nil, trace.Wrap(err) - } - return sshutils.HostKeyCallback(sshCA, withHostKeyFallback) -} - // RootClusterName extracts the root cluster name from the issuer // of the Teleport TLS certificate. func (k *Key) RootClusterName() (string, error) { diff --git a/lib/client/keyagent.go b/lib/client/keyagent.go index 08674281333c..61bf20bee6fa 100644 --- a/lib/client/keyagent.go +++ b/lib/client/keyagent.go @@ -47,8 +47,8 @@ type LocalKeyAgent struct { // ExtendedAgent is the teleport agent agent.ExtendedAgent - // keyStore is the storage backend for certificates and keys - keyStore LocalKeyStore + // clientStore is the local storage backend for the client. + clientStore *Store // sshAgent is the system ssh agent sshAgent agent.ExtendedAgent @@ -78,46 +78,6 @@ type LocalKeyAgent struct { loadAllCAs bool } -// sshKnowHostGetter allows to fetch key for particular host - trusted cluster. -type sshKnowHostGetter interface { - // GetKnownHostKeys returns all public keys for a hostname. - GetKnownHostKeys(hostname string) ([]ssh.PublicKey, error) -} - -// NewKeyStoreCertChecker returns a new certificate checker -// using trusted certs from key store -func NewKeyStoreCertChecker(keyStore sshKnowHostGetter, host string) ssh.HostKeyCallback { - // CheckHostSignature checks if the given host key was signed by a Teleport - // certificate authority (CA) or a host certificate the user has seen before. - return func(addr string, remote net.Addr, key ssh.PublicKey) error { - certChecker := sshutils.CertChecker{ - CertChecker: ssh.CertChecker{ - IsHostAuthority: func(key ssh.PublicKey, addr string) bool { - keys, err := keyStore.GetKnownHostKeys(host) - if err != nil { - log.Errorf("Unable to fetch certificate authorities: %v.", err) - return false - } - for i := range keys { - if sshutils.KeysEqual(key, keys[i]) { - return true - } - } - return false - }, - }, - FIPS: isFIPS(), - } - err := certChecker.CheckHostKey(addr, remote, key) - if err != nil { - log.Debugf("Host validation failed: %v.", err) - return trace.Wrap(err) - } - log.Debugf("Validated host %v.", addr) - return nil - } -} - func agentIsPresent() bool { return os.Getenv(teleport.SSHAuthSock) != "" } @@ -136,14 +96,14 @@ func shouldAddKeysToAgent(addKeysToAgent string) bool { // LocalAgentConfig contains parameters for creating the local keys agent. type LocalAgentConfig struct { - Keystore LocalKeyStore - Agent agent.ExtendedAgent - ProxyHost string - Username string - KeysOption string - Insecure bool - Site string - LoadAllCAs bool + ClientStore *Store + Agent agent.ExtendedAgent + ProxyHost string + Username string + KeysOption string + Insecure bool + Site string + LoadAllCAs bool } // NewLocalAgent reads all available credentials from the provided LocalKeyStore @@ -161,7 +121,7 @@ func NewLocalAgent(conf LocalAgentConfig) (a *LocalKeyAgent, err error) { trace.Component: teleport.ComponentKeyAgent, }), ExtendedAgent: conf.Agent, - keyStore: conf.Keystore, + clientStore: conf.ClientStore, noHosts: make(map[string]bool), username: conf.Username, proxyHost: conf.ProxyHost, @@ -331,7 +291,16 @@ func (a *LocalKeyAgent) UnloadKeys() error { // the backing keystore. func (a *LocalKeyAgent) GetKey(clusterName string, opts ...CertOption) (*Key, error) { idx := KeyIndex{a.proxyHost, a.username, clusterName} - return a.keyStore.GetKey(idx, opts...) + key, err := a.clientStore.GetKey(idx, opts...) + if err != nil { + return nil, trace.Wrap(err) + } + trustedCerts, err := a.clientStore.GetTrustedCerts(idx.ProxyHost) + if err != nil { + return nil, trace.Wrap(err) + } + key.TrustedCerts = trustedCerts + return key, nil } // GetCoreKey returns the key without any cluster-dependent certificates, @@ -340,39 +309,18 @@ func (a *LocalKeyAgent) GetCoreKey() (*Key, error) { return a.GetKey("") } -// AddHostSignersToCache takes a list of CAs whom we trust. This list is added to a database -// of "seen" CAs. -// -// Every time we connect to a new host, we'll request its certificate to be signed by one -// of these trusted CAs. -// -// Why do we trust these CAs? Because we received them from a trusted Teleport Proxy. -// Why do we trust the proxy? Because we've connected to it via HTTPS + username + Password + OTP. -func (a *LocalKeyAgent) AddHostSignersToCache(certAuthorities []auth.TrustedCerts) error { - for _, ca := range certAuthorities { - publicKeys, err := ca.SSHCertPublicKeys() - if err != nil { - a.log.Error(err) - return trace.Wrap(err) - } - a.log.Debugf("Adding CA key for %s", ca.ClusterName) - err = a.keyStore.AddKnownHostKeys(ca.ClusterName, a.proxyHost, publicKeys) - if err != nil { - return trace.Wrap(err) - } - } - return nil -} - -// SaveTrustedCerts saves trusted TLS certificates of certificate authorities. +// SaveTrustedCerts saves trusted TLS certificates and host keys of certificate authorities. +// SaveTrustedCerts adds the given trusted CA TLS certificates and SSH host keys to the store. +// Existing TLS certificates for the given trusted certs will be overwritten, while host keys +// will be appended to existing entries. func (a *LocalKeyAgent) SaveTrustedCerts(certAuthorities []auth.TrustedCerts) error { - return a.keyStore.SaveTrustedCerts(a.proxyHost, certAuthorities) + return a.clientStore.SaveTrustedCerts(a.proxyHost, certAuthorities) } // GetTrustedCertsPEM returns trusted TLS certificates of certificate authorities PEM // blocks. func (a *LocalKeyAgent) GetTrustedCertsPEM() ([][]byte, error) { - return a.keyStore.GetTrustedCertsPEM(a.proxyHost) + return a.clientStore.GetTrustedCertsPEM(a.proxyHost) } // UserRefusedHosts returns 'true' if a user refuses connecting to remote hosts @@ -381,9 +329,9 @@ func (a *LocalKeyAgent) UserRefusedHosts() bool { return len(a.noHosts) > 0 } -// CheckHostSignature checks if the given host key was signed by a Teleport +// CheckHostKey checks if the given host key was signed by a Teleport // certificate authority (CA) or a host certificate the user has seen before. -func (a *LocalKeyAgent) CheckHostSignature(addr string, remote net.Addr, hostKey ssh.PublicKey) error { +func (a *LocalKeyAgent) CheckHostKey(addr string, remote net.Addr, hostKey ssh.PublicKey) error { key, err := a.GetCoreKey() if err != nil { return trace.Wrap(err) @@ -394,15 +342,15 @@ func (a *LocalKeyAgent) CheckHostSignature(addr string, remote net.Addr, hostKey } clusters := []string{rootCluster} - if rootCluster != a.siteName { - // In case of establishing connection to leaf cluster the client validate ssh cert against root - // cluster proxy cert and leaf cluster cert. - clusters = append(clusters, a.siteName) - } else if a.loadAllCAs { + if a.loadAllCAs { clusters, err = a.GetClusterNames() if err != nil { return trace.Wrap(err) } + } else if rootCluster != a.siteName { + // In case of establishing connection to leaf cluster the client validate ssh cert against root + // cluster proxy cert and leaf cluster cert. + clusters = append(clusters, a.siteName) } certChecker := sshutils.CertChecker{ @@ -430,16 +378,12 @@ func (a *LocalKeyAgent) checkHostCertificateForClusters(clusters ...string) func // Check the local cache (where all Teleport CAs are placed upon login) to // see if any of them match. - var keys []ssh.PublicKey - for _, cluster := range clusters { - key, err := a.keyStore.GetKnownHostKeys(cluster) - if err != nil { - a.log.Errorf("Unable to fetch certificate authorities: %v.", err) - return false - } - keys = append(keys, key...) - + keys, err := a.clientStore.GetTrustedHostKeys(clusters...) + if err != nil { + a.log.Errorf("Unable to fetch certificate authorities: %v.", err) + return false } + for i := range keys { if sshutils.KeysEqual(key, keys[i]) { return true @@ -448,7 +392,7 @@ func (a *LocalKeyAgent) checkHostCertificateForClusters(clusters ...string) func // If this certificate was not seen before, prompt the user essentially // treating it like a key. - err := a.checkHostKey(addr, nil, key) + err = a.checkHostKey(addr, nil, key) return err == nil } } @@ -469,10 +413,16 @@ func (a *LocalKeyAgent) checkHostKey(addr string, remote net.Addr, key ssh.Publi a.log.Warnf("Host %s presented a public key not signed by Teleport. Proceeding due to insecure mode being ON.", addr) // Check if this exact host is in the local cache. - keys, _ := a.keyStore.GetKnownHostKeys(addr) - if len(keys) > 0 && sshutils.KeysEqual(key, keys[0]) { - a.log.Debugf("Verified host %s.", addr) - return nil + keys, err := a.clientStore.GetTrustedHostKeys(addr) + if err != nil { + a.log.WithError(err).Debugf("Failed to retrieve client's trusted host keys.") + } else { + for _, trustedHostKey := range keys { + if sshutils.KeysEqual(key, trustedHostKey) { + a.log.Debugf("Verified host %s.", addr) + return nil + } + } } // If this key was not seen before, prompt the user with a fingerprint. @@ -486,9 +436,8 @@ func (a *LocalKeyAgent) checkHostKey(addr string, remote net.Addr, key ssh.Publi return trace.Wrap(err) } - // If the user trusts the key, store the key in the local known hosts - // cache ~/.tsh/known_hosts. - err = a.keyStore.AddKnownHostKeys(addr, a.proxyHost, []ssh.PublicKey{key}) + // If the user trusts the key, store the key in the client trusted certs store. + err = a.clientStore.AddTrustedHostKeys(a.proxyHost, addr, key) if err != nil { a.log.Warnf("Failed to save the host key: %v.", err) return trace.Wrap(err) @@ -561,7 +510,7 @@ func (a *LocalKeyAgent) addKey(key *Key) error { // In order to prevent unrelated key data to be left over after the new // key is added, delete any already stored key with the same index if their // RSA private keys do not match. - storedKey, err := a.keyStore.GetKey(key.KeyIndex) + storedKey, err := a.clientStore.GetKey(key.KeyIndex) if err != nil { if !trace.IsNotFound(err) { return trace.Wrap(err) @@ -569,16 +518,22 @@ func (a *LocalKeyAgent) addKey(key *Key) error { } else { if !key.EqualPrivateKey(storedKey) { a.log.Debugf("Deleting obsolete stored key with index %+v.", storedKey.KeyIndex) - if err := a.keyStore.DeleteKey(storedKey.KeyIndex); err != nil { + if err := a.clientStore.DeleteKey(storedKey.KeyIndex); err != nil { return trace.Wrap(err) } } } // Save the new key to the keystore (usually into ~/.tsh). - if err := a.keyStore.AddKey(key); err != nil { + if err := a.clientStore.AddKey(key); err != nil { return trace.Wrap(err) } + + // Save the new key to the keystore (usually into ~/.tsh). + if err := a.clientStore.SaveTrustedCerts(key.ProxyHost, key.TrustedCerts); err != nil { + return trace.Wrap(err) + } + return nil } @@ -586,7 +541,7 @@ func (a *LocalKeyAgent) addKey(key *Key) error { // and unloads the key from the agent. func (a *LocalKeyAgent) DeleteKey() error { // remove key from key store - err := a.keyStore.DeleteKey(KeyIndex{ProxyHost: a.proxyHost, Username: a.username}) + err := a.clientStore.DeleteKey(KeyIndex{ProxyHost: a.proxyHost, Username: a.username}) if err != nil { return trace.Wrap(err) } @@ -604,7 +559,7 @@ func (a *LocalKeyAgent) DeleteKey() error { // DeleteUserCerts deletes only the specified certs of the user's key, // keeping the private key intact. func (a *LocalKeyAgent) DeleteUserCerts(clusterName string, opts ...CertOption) error { - err := a.keyStore.DeleteUserCerts(KeyIndex{a.proxyHost, a.username, clusterName}, opts...) + err := a.clientStore.DeleteUserCerts(KeyIndex{a.proxyHost, a.username, clusterName}, opts...) return trace.Wrap(err) } @@ -612,7 +567,7 @@ func (a *LocalKeyAgent) DeleteUserCerts(clusterName string, opts ...CertOption) // from the agent. func (a *LocalKeyAgent) DeleteKeys() error { // Remove keys from the filesystem. - err := a.keyStore.DeleteKeys() + err := a.clientStore.DeleteKeys() if err != nil { return trace.Wrap(err) } @@ -633,7 +588,7 @@ func (a *LocalKeyAgent) Signers() ([]ssh.Signer, error) { // If we find a valid key store, load all valid ssh certificates as signers. if k, err := a.GetCoreKey(); err == nil { - certs, err := a.keyStore.GetSSHCertificates(a.proxyHost, a.username) + certs, err := a.clientStore.GetSSHCertificates(a.proxyHost, a.username) if err != nil { return nil, trace.Wrap(err) } diff --git a/lib/client/keyagent_test.go b/lib/client/keyagent_test.go index 0f709d9d733e..a2f6156af2e6 100644 --- a/lib/client/keyagent_test.go +++ b/lib/client/keyagent_test.go @@ -249,13 +249,12 @@ func TestHostCertVerification(t *testing.T) { s := makeSuite(t) // Make a new local agent. - keystore, err := NewFSLocalKeyStore(s.keyDir) - require.NoError(t, err) + clientStore := NewFSClientStore(s.keyDir) lka, err := NewLocalAgent(LocalAgentConfig{ - Keystore: keystore, - ProxyHost: s.hostname, - Username: s.username, - KeysOption: AddKeysToAgentAuto, + ClientStore: clientStore, + ProxyHost: s.hostname, + Username: s.username, + KeysOption: AddKeysToAgentAuto, }) require.NoError(t, err) @@ -305,9 +304,11 @@ func TestHostCertVerification(t *testing.T) { caSigner, err := ssh.ParsePrivateKey(caPriv) require.NoError(t, err) - caPublicKey, _, _, _, err := ssh.ParseAuthorizedKey(caPub) + + hostKey, _, _, _, err := ssh.ParseAuthorizedKey(caPub) require.NoError(t, err) - err = lka.keyStore.AddKnownHostKeys(hostname, s.hostname, []ssh.PublicKey{caPublicKey}) + + err = lka.clientStore.AddTrustedHostKeys(s.hostname, hostname, hostKey) require.NoError(t, err) _, trustedCerts, err := newSelfSignedCA(caPriv, hostname) @@ -323,7 +324,7 @@ func TestHostCertVerification(t *testing.T) { // Call SaveTrustedCerts to create cas profile dir - this step is needed to support migration from profile combined // CA file certs.pem to per cluster CA files in cas profile directory. - err = lka.keyStore.SaveTrustedCerts(s.hostname, []auth.TrustedCerts{root.trustedCerts, leaf.trustedCerts}) + err = lka.clientStore.SaveTrustedCerts(s.hostname, []auth.TrustedCerts{root.trustedCerts, leaf.trustedCerts}) require.NoError(t, err) // Generate a host certificate for node with role "node". @@ -421,7 +422,7 @@ func TestHostCertVerification(t *testing.T) { lka.siteName = "example.com" lka.loadAllCAs = false } - err = lka.CheckHostSignature(tt.inAddr, nil, tt.hostPublicKey) + err = lka.CheckHostKey(tt.inAddr, nil, tt.hostPublicKey) tt.assert(t, err) }) } @@ -431,14 +432,13 @@ func TestHostKeyVerification(t *testing.T) { s := makeSuite(t) // make a new local agent - keystore, err := NewFSLocalKeyStore(s.keyDir) - require.NoError(t, err) + keystore := NewFSClientStore(s.keyDir) lka, err := NewLocalAgent(LocalAgentConfig{ - Keystore: keystore, - ProxyHost: s.hostname, - Username: s.username, - KeysOption: AddKeysToAgentAuto, - Insecure: true, + ClientStore: keystore, + ProxyHost: s.hostname, + Username: s.username, + KeysOption: AddKeysToAgentAuto, + Insecure: true, }) require.NoError(t, err) @@ -452,7 +452,7 @@ func TestHostKeyVerification(t *testing.T) { // Call SaveTrustedCerts to create cas profile dir - this step is needed to support migration from profile combined // CA file certs.pem to per cluster CA files in cas profile directory. - err = lka.keyStore.SaveTrustedCerts(s.hostname, nil) + err = lka.clientStore.SaveTrustedCerts(s.hostname, nil) require.NoError(t, err) // by default user has not refused any hosts: @@ -473,7 +473,7 @@ func TestHostKeyVerification(t *testing.T) { return fakeErr } var a net.TCPAddr - err = lka.CheckHostSignature("luna", &a, pk) + err = lka.CheckHostKey("luna", &a, pk) require.Error(t, err) require.Equal(t, "luna cannot be trusted", err.Error()) require.True(t, lka.UserRefusedHosts()) @@ -490,7 +490,7 @@ func TestHostKeyVerification(t *testing.T) { return nil } require.False(t, lka.UserRefusedHosts()) - err = lka.CheckHostSignature("luna", &a, pk) + err = lka.CheckHostKey("luna", &a, pk) require.NoError(t, err) require.True(t, userWasAsked) @@ -498,7 +498,7 @@ func TestHostKeyVerification(t *testing.T) { // just said "yes") userWasAsked = false require.False(t, lka.UserRefusedHosts()) - err = lka.CheckHostSignature("luna", &a, pk) + err = lka.CheckHostKey("luna", &a, pk) require.NoError(t, err) require.False(t, userWasAsked) } @@ -508,13 +508,12 @@ func TestDefaultHostPromptFunc(t *testing.T) { keygen := testauthority.New() - keystore, err := NewFSLocalKeyStore(s.keyDir) - require.NoError(t, err) + clientStore := NewFSClientStore(s.keyDir) a, err := NewLocalAgent(LocalAgentConfig{ - Keystore: keystore, - ProxyHost: s.hostname, - Username: s.username, - KeysOption: AddKeysToAgentAuto, + ClientStore: clientStore, + ProxyHost: s.hostname, + Username: s.username, + KeysOption: AddKeysToAgentAuto, }) require.NoError(t, err) @@ -557,14 +556,13 @@ func TestLocalKeyAgent_AddDatabaseKey(t *testing.T) { s := makeSuite(t) // make a new local agent - keystore, err := NewFSLocalKeyStore(s.keyDir) - require.NoError(t, err) + clientStore := NewFSClientStore(s.keyDir) lka, err := NewLocalAgent( LocalAgentConfig{ - Keystore: keystore, - ProxyHost: s.hostname, - Username: s.username, - KeysOption: AddKeysToAgentAuto, + ClientStore: clientStore, + ProxyHost: s.hostname, + Username: s.username, + KeysOption: AddKeysToAgentAuto, }) require.NoError(t, err) @@ -706,14 +704,13 @@ func startDebugAgent(t *testing.T) error { func (s *KeyAgentTestSuite) newKeyAgent(t *testing.T) *LocalKeyAgent { // make a new local agent - keystore, err := NewFSLocalKeyStore(s.keyDir) - require.NoError(t, err) + clientStore := NewFSClientStore(s.keyDir) keyAgent, err := NewLocalAgent(LocalAgentConfig{ - Keystore: keystore, - ProxyHost: s.hostname, - Site: s.clusterName, - Username: s.username, - KeysOption: AddKeysToAgentAuto, + ClientStore: clientStore, + ProxyHost: s.hostname, + Site: s.clusterName, + Username: s.username, + KeysOption: AddKeysToAgentAuto, }) require.NoError(t, err) return keyAgent diff --git a/lib/client/keystore.go b/lib/client/keystore.go index 2b0958338419..8ee58fe4543b 100644 --- a/lib/client/keystore.go +++ b/lib/client/keystore.go @@ -17,17 +17,9 @@ limitations under the License. package client import ( - "bufio" - "context" - "encoding/pem" - "fmt" - "io" - osfs "io/fs" "os" "path/filepath" "runtime" - "strings" - "time" "github.com/gravitational/trace" "github.com/sirupsen/logrus" @@ -39,13 +31,9 @@ import ( "github.com/gravitational/teleport/api/utils/keypaths" "github.com/gravitational/teleport/api/utils/keys" apisshutils "github.com/gravitational/teleport/api/utils/sshutils" - "github.com/gravitational/teleport/lib/auth" - "github.com/gravitational/teleport/lib/sshutils" - "github.com/gravitational/teleport/lib/utils" ) const ( - // profileDirPerms is the default permissions applied to the profile // directory (usually ~/.tsh) profileDirPerms os.FileMode = 0700 @@ -63,16 +51,13 @@ const ( tshAzureDirName = "azure" ) -// LocalKeyStore interface allows for different storage backends for tsh to -// load/save its keys. -// -// The _only_ filesystem-based implementation of LocalKeyStore is declared -// below (FSLocalKeyStore) -type LocalKeyStore interface { +// KeyStore is a storage interface for client session keys and certificates. +type KeyStore interface { // AddKey adds the given key to the store. AddKey(key *Key) error - // GetKey returns the user's key including the specified certs. + // GetKey returns the user's key including the specified certs. The key's + // TrustedCerts will be nil and should be filled in using a TrustedCertsStore. GetKey(idx KeyIndex, opts ...CertOption) (*Key, error) // DeleteKey deletes the user's key with all its certs. @@ -85,60 +70,80 @@ type LocalKeyStore interface { // DeleteKeys removes all session keys. DeleteKeys() error - // AddKnownHostKeys adds the public key to the list of known hosts for - // a hostname. - AddKnownHostKeys(hostname, proxyHost string, keys []ssh.PublicKey) error - - // GetKnownHostKeys returns all public keys for a hostname. - GetKnownHostKeys(hostname string) ([]ssh.PublicKey, error) - - // SaveTrustedCerts saves trusted TLS certificates of certificate authorities. - SaveTrustedCerts(proxyHost string, cas []auth.TrustedCerts) error - - // GetTrustedCertsPEM gets trusted TLS certificates of certificate authorities. - // Each returned byte slice contains an individual PEM block. - GetTrustedCertsPEM(proxyHost string) ([][]byte, error) - // GetSSHCertificates gets all certificates signed for the given user and proxy, // including certificates for trusted clusters. GetSSHCertificates(proxyHost, username string) ([]*ssh.Certificate, error) } -// FSLocalKeyStore implements LocalKeyStore interface using the filesystem. +// FSKeyStore is an on-disk implementation of the KeyStore interface. // // The FS store uses the file layout outlined in `api/utils/keypaths.go`. -type FSLocalKeyStore struct { - fsLocalNonSessionKeyStore +type FSKeyStore struct { + // log holds the structured logger. + log logrus.FieldLogger + + // KeyDir is the directory where all keys are stored. + KeyDir string } -// NewFSLocalKeyStore creates a new filesystem-based local keystore object -// and initializes it. +// NewFSKeyStore initializes a new FSClientStore. // // If dirPath is empty, sets it to ~/.tsh. -func NewFSLocalKeyStore(dirPath string) (s *FSLocalKeyStore, err error) { - dirPath, err = initKeysDir(dirPath) - if err != nil { - return nil, trace.Wrap(err) +func NewFSKeyStore(dirPath string) *FSKeyStore { + dirPath = profile.FullProfilePath(dirPath) + return &FSKeyStore{ + log: logrus.WithField(trace.Component, teleport.ComponentKeyStore), + KeyDir: dirPath, } - return &FSLocalKeyStore{ - fsLocalNonSessionKeyStore: fsLocalNonSessionKeyStore{ - log: logrus.WithField(trace.Component, teleport.ComponentKeyStore), - KeyDir: dirPath, - }, - }, nil } -// initKeysDir initializes the keystore root directory, usually `~/.tsh`. -func initKeysDir(dirPath string) (string, error) { - dirPath = profile.FullProfilePath(dirPath) - if err := os.MkdirAll(dirPath, os.ModeDir|profileDirPerms); err != nil { - return "", trace.ConvertSystemError(err) - } - return dirPath, nil +// userKeyPath returns the private key path for the given KeyIndex. +func (fs *FSKeyStore) userKeyPath(idx KeyIndex) string { + return keypaths.UserKeyPath(fs.KeyDir, idx.ProxyHost, idx.Username) +} + +// tlsCertPath returns the TLS certificate path given KeyIndex. +func (fs *FSKeyStore) tlsCertPath(idx KeyIndex) string { + return keypaths.TLSCertPath(fs.KeyDir, idx.ProxyHost, idx.Username) +} + +// sshDir returns the SSH certificate path for the given KeyIndex. +func (fs *FSKeyStore) sshDir(proxy, user string) string { + return keypaths.SSHDir(fs.KeyDir, proxy, user) +} + +// sshCertPath returns the SSH certificate path for the given KeyIndex. +func (fs *FSKeyStore) sshCertPath(idx KeyIndex) string { + return keypaths.SSHCertPath(fs.KeyDir, idx.ProxyHost, idx.Username, idx.ClusterName) +} + +// ppkFilePath returns the PPK (PuTTY-formatted) keypair path for the given KeyIndex. +func (fs *FSKeyStore) ppkFilePath(idx KeyIndex) string { + return keypaths.PPKFilePath(fs.KeyDir, idx.ProxyHost, idx.Username) +} + +// publicKeyPath returns the public key path for the given KeyIndex. +func (fs *FSKeyStore) publicKeyPath(idx KeyIndex) string { + return keypaths.PublicKeyPath(fs.KeyDir, idx.ProxyHost, idx.Username) +} + +// appCertPath returns the TLS certificate path for the given KeyIndex and app name. +func (fs *FSKeyStore) appCertPath(idx KeyIndex, appname string) string { + return keypaths.AppCertPath(fs.KeyDir, idx.ProxyHost, idx.Username, idx.ClusterName, appname) +} + +// databaseCertPath returns the TLS certificate path for the given KeyIndex and database name. +func (fs *FSKeyStore) databaseCertPath(idx KeyIndex, dbname string) string { + return keypaths.DatabaseCertPath(fs.KeyDir, idx.ProxyHost, idx.Username, idx.ClusterName, dbname) +} + +// kubeCertPath returns the TLS certificate path for the given KeyIndex and kube cluster name. +func (fs *FSKeyStore) kubeCertPath(idx KeyIndex, kubename string) string { + return keypaths.KubeCertPath(fs.KeyDir, idx.ProxyHost, idx.Username, idx.ClusterName, kubename) } // AddKey adds the given key to the store. -func (fs *FSLocalKeyStore) AddKey(key *Key) error { +func (fs *FSKeyStore) AddKey(key *Key) error { if err := key.KeyIndex.Check(); err != nil { return trace.Wrap(err) } @@ -165,7 +170,7 @@ func (fs *FSLocalKeyStore) AddKey(key *Key) error { return trace.Wrap(err) } // PPKFile can only be generated from an RSA private key. - fs.log.WithError(err).Debugf("Failed to convert private key to PPK-formatted keypair.") + fs.log.WithError(err).Debugf("Cannot convert private key to PPK-formatted keypair.") } // Store per-cluster key data. @@ -205,20 +210,16 @@ func (fs *FSLocalKeyStore) AddKey(key *Key) error { return nil } -func (fs *FSLocalKeyStore) writeBytes(bytes []byte, fp string) error { +func (fs *FSKeyStore) writeBytes(bytes []byte, fp string) error { if err := os.MkdirAll(filepath.Dir(fp), os.ModeDir|profileDirPerms); err != nil { - fs.log.Error(err) return trace.ConvertSystemError(err) } err := os.WriteFile(fp, bytes, keyFilePerms) - if err != nil { - fs.log.Error(err) - } return trace.ConvertSystemError(err) } // DeleteKey deletes the user's key with all its certs. -func (fs *FSLocalKeyStore) DeleteKey(idx KeyIndex) error { +func (fs *FSKeyStore) DeleteKey(idx KeyIndex) error { files := []string{ fs.userKeyPath(idx), fs.publicKeyPath(idx), @@ -247,7 +248,7 @@ func (fs *FSLocalKeyStore) DeleteKey(idx KeyIndex) error { // // Useful when needing to log out of a specific service, like a particular // database proxy. -func (fs *FSLocalKeyStore) DeleteUserCerts(idx KeyIndex, opts ...CertOption) error { +func (fs *FSKeyStore) DeleteUserCerts(idx KeyIndex, opts ...CertOption) error { for _, o := range opts { certPath := o.certPath(fs.KeyDir, idx) if err := os.RemoveAll(certPath); err != nil { @@ -258,7 +259,7 @@ func (fs *FSLocalKeyStore) DeleteUserCerts(idx KeyIndex, opts ...CertOption) err } // DeleteKeys removes all session keys. -func (fs *FSLocalKeyStore) DeleteKeys() error { +func (fs *FSKeyStore) DeleteKeys() error { files, err := os.ReadDir(fs.KeyDir) if err != nil { return trace.ConvertSystemError(err) @@ -289,7 +290,7 @@ func (fs *FSLocalKeyStore) DeleteKeys() error { // GetKey returns the user's key including the specified certs. // If the key is not found, returns trace.NotFound error. -func (fs *FSLocalKeyStore) GetKey(idx KeyIndex, opts ...CertOption) (*Key, error) { +func (fs *FSKeyStore) GetKey(idx KeyIndex, opts ...CertOption) (*Key, error) { if len(opts) > 0 { if err := idx.Check(); err != nil { return nil, trace.Wrap(err, "GetKey with CertOptions requires a fully specified KeyIndex") @@ -303,37 +304,20 @@ func (fs *FSLocalKeyStore) GetKey(idx KeyIndex, opts ...CertOption) (*Key, error tlsCertFile := fs.tlsCertPath(idx) tlsCert, err := os.ReadFile(tlsCertFile) if err != nil { - fs.log.Error(err) - return nil, trace.ConvertSystemError(err) - } - tlsCA, err := fs.GetTrustedCertsPEM(idx.ProxyHost) - if err != nil { - fs.log.Error(err) return nil, trace.ConvertSystemError(err) } priv, err := keys.LoadKeyPair(fs.userKeyPath(idx), fs.publicKeyPath(idx)) if err != nil { - fs.log.Error(err) return nil, trace.ConvertSystemError(err) } key := NewKey(priv) key.KeyIndex = idx key.TLSCert = tlsCert - key.TrustedCA = []auth.TrustedCerts{{ - TLSCertificates: tlsCA, - }} - - tlsCertExpiration, err := key.TeleportTLSCertValidBefore() - if err != nil { - return nil, trace.Wrap(err) - } - fs.log.Debugf("Returning Teleport TLS certificate %q valid until %q.", tlsCertFile, tlsCertExpiration) for _, o := range opts { if err := fs.updateKeyWithCerts(o, key); err != nil && !trace.IsNotFound(err) { - fs.log.Error(err) return nil, trace.Wrap(err) } } @@ -345,7 +329,7 @@ func (fs *FSLocalKeyStore) GetKey(idx KeyIndex, opts ...CertOption) (*Key, error return key, nil } -func (fs *FSLocalKeyStore) updateKeyWithCerts(o CertOption, key *Key) error { +func (fs *FSKeyStore) updateKeyWithCerts(o CertOption, key *Key) error { certPath := o.certPath(fs.KeyDir, key.KeyIndex) info, err := os.Stat(certPath) if err != nil { @@ -380,6 +364,30 @@ func (fs *FSLocalKeyStore) updateKeyWithCerts(o CertOption, key *Key) error { return o.updateKeyWithBytes(key, certBytes) } +// GetSSHCertificates gets all certificates signed for the given user and proxy. +func (fs *FSKeyStore) GetSSHCertificates(proxyHost, username string) ([]*ssh.Certificate, error) { + certDir := fs.sshDir(proxyHost, username) + certFiles, err := os.ReadDir(certDir) + if err != nil { + return nil, trace.Wrap(err) + } + + sshCerts := make([]*ssh.Certificate, len(certFiles)) + for i, certFile := range certFiles { + data, err := os.ReadFile(filepath.Join(certDir, certFile.Name())) + if err != nil { + return nil, trace.ConvertSystemError(err) + } + + sshCerts[i], err = apisshutils.ParseCertificate(data) + if err != nil { + return nil, trace.Wrap(err) + } + } + + return sshCerts, nil +} + // CertOption is an additional step to run when loading/deleting user certificates. type CertOption interface { // certPath returns a path to the cert (or to a dir holding the certs) @@ -408,14 +416,6 @@ func (o WithSSHCerts) certPath(keyDir string, idx KeyIndex) string { func (o WithSSHCerts) updateKeyWithBytes(key *Key, certBytes []byte) error { key.Cert = certBytes - - // Validate the SSH certificate. - if err := key.CheckCert(); err != nil { - if !utils.IsCertExpiredError(err) { - return trace.Wrap(err) - } - } - return nil } @@ -447,7 +447,7 @@ func (o WithKubeCerts) updateKeyWithMap(key *Key, certMap map[string][]byte) err } func (o WithKubeCerts) deleteFromKey(key *Key) { - key.KubeTLSCerts = nil + key.KubeTLSCerts = make(map[string][]byte) } // WithDBCerts is a CertOption for handling database access certificates. @@ -475,7 +475,7 @@ func (o WithDBCerts) updateKeyWithMap(key *Key, certMap map[string][]byte) error } func (o WithDBCerts) deleteFromKey(key *Key) { - key.DBTLSCerts = nil + key.DBTLSCerts = make(map[string][]byte) } // WithAppCerts is a CertOption for handling application access certificates. @@ -503,514 +503,105 @@ func (o WithAppCerts) updateKeyWithMap(key *Key, certMap map[string][]byte) erro } func (o WithAppCerts) deleteFromKey(key *Key) { - key.AppTLSCerts = nil -} - -// fsLocalNonSessionKeyStore is a FS-based store implementing methods -// for CA certificates and known host fingerprints. It is embedded -// in both FSLocalKeyStore and MemLocalKeyStore. -type fsLocalNonSessionKeyStore struct { - // log holds the structured logger. - log logrus.FieldLogger - - // KeyDir is the directory where all keys are stored. - KeyDir string -} - -// proxyKeyDir returns the keystore's keys directory for the given proxy. -func (fs *fsLocalNonSessionKeyStore) proxyKeyDir(proxy string) string { - return keypaths.ProxyKeyDir(fs.KeyDir, proxy) -} - -// casDir returns path to trusted clusters certificates directory. -func (fs *fsLocalNonSessionKeyStore) casDir(proxy string) string { - return keypaths.CAsDir(fs.KeyDir, proxy) -} - -// clusterCAPath returns path to cluster certificate. -func (fs *fsLocalNonSessionKeyStore) clusterCAPath(proxy, clusterName string) string { - return keypaths.TLSCAsPathCluster(fs.KeyDir, proxy, clusterName) -} - -// knownHostsPath returns the keystore's known hosts file path. -func (fs *fsLocalNonSessionKeyStore) knownHostsPath() string { - return keypaths.KnownHostsPath(fs.KeyDir) -} - -// userKeyPath returns the private key path for the given KeyIndex. -func (fs *fsLocalNonSessionKeyStore) userKeyPath(idx KeyIndex) string { - return keypaths.UserKeyPath(fs.KeyDir, idx.ProxyHost, idx.Username) -} - -// tlsCertPath returns the TLS certificate path given KeyIndex. -func (fs *fsLocalNonSessionKeyStore) tlsCertPath(idx KeyIndex) string { - return keypaths.TLSCertPath(fs.KeyDir, idx.ProxyHost, idx.Username) -} - -// tlsCAsPath returns the TLS CA certificates path for the given KeyIndex. -func (fs *fsLocalNonSessionKeyStore) tlsCAsPath(proxy string) string { - return keypaths.TLSCAsPath(fs.KeyDir, proxy) -} - -// sshDir returns the SSH certificate path for the given KeyIndex. -func (fs *fsLocalNonSessionKeyStore) sshDir(proxy, user string) string { - return keypaths.SSHDir(fs.KeyDir, proxy, user) -} - -// sshCertPath returns the SSH certificate path for the given KeyIndex. -func (fs *fsLocalNonSessionKeyStore) sshCertPath(idx KeyIndex) string { - return keypaths.SSHCertPath(fs.KeyDir, idx.ProxyHost, idx.Username, idx.ClusterName) -} - -// ppkFilePath returns the PPK (PuTTY-formatted) keypair path for the given KeyIndex. -func (fs *fsLocalNonSessionKeyStore) ppkFilePath(idx KeyIndex) string { - return keypaths.PPKFilePath(fs.KeyDir, idx.ProxyHost, idx.Username) -} - -// publicKeyPath returns the public key path for the given KeyIndex. -func (fs *fsLocalNonSessionKeyStore) publicKeyPath(idx KeyIndex) string { - return keypaths.PublicKeyPath(fs.KeyDir, idx.ProxyHost, idx.Username) -} - -// appCertPath returns the TLS certificate path for the given KeyIndex and app name. -func (fs *fsLocalNonSessionKeyStore) appCertPath(idx KeyIndex, appname string) string { - return keypaths.AppCertPath(fs.KeyDir, idx.ProxyHost, idx.Username, idx.ClusterName, appname) -} - -// databaseCertPath returns the TLS certificate path for the given KeyIndex and database name. -func (fs *fsLocalNonSessionKeyStore) databaseCertPath(idx KeyIndex, dbname string) string { - return keypaths.DatabaseCertPath(fs.KeyDir, idx.ProxyHost, idx.Username, idx.ClusterName, dbname) -} - -// kubeCertPath returns the TLS certificate path for the given KeyIndex and kube cluster name. -func (fs *fsLocalNonSessionKeyStore) kubeCertPath(idx KeyIndex, kubename string) string { - return keypaths.KubeCertPath(fs.KeyDir, idx.ProxyHost, idx.Username, idx.ClusterName, kubename) -} - -// AddKnownHostKeys adds a new entry to `known_hosts` file. -func (fs *fsLocalNonSessionKeyStore) AddKnownHostKeys(hostname, proxyHost string, hostKeys []ssh.PublicKey) (retErr error) { - // We're trying to serialize our writes to the 'known_hosts' file to avoid corruption, since there - // are cases when multiple tsh instances will try to write to it. - unlock, err := utils.FSTryWriteLockTimeout(context.Background(), fs.knownHostsPath(), 5*time.Second) - if err != nil { - return trace.WrapWithMessage(err, "could not acquire lock for the `known_hosts` file") - } - defer utils.StoreErrorOf(unlock, &retErr) - - fp, err := os.OpenFile(fs.knownHostsPath(), os.O_CREATE|os.O_RDWR, 0640) - if err != nil { - return trace.ConvertSystemError(err) - } - defer utils.StoreErrorOf(fp.Close, &retErr) - // read all existing entries into a map (this removes any pre-existing dupes) - entries := make(map[string]int) - output := make([]string, 0) - scanner := bufio.NewScanner(fp) - for scanner.Scan() { - line := scanner.Text() - if _, exists := entries[line]; !exists { - output = append(output, line) - entries[line] = 1 - } - } - // check if the scanner ran into an error - if err := scanner.Err(); err != nil { - return trace.Wrap(err) - } - // add every host key to the list of entries - for i := range hostKeys { - fs.log.Debugf("Adding known host %s with proxy %s and key: %v", hostname, proxyHost, sshutils.Fingerprint(hostKeys[i])) - bytes := ssh.MarshalAuthorizedKey(hostKeys[i]) - - // Write keys in an OpenSSH-compatible format. A previous format was not - // quite OpenSSH-compatible, so we may write a duplicate entry here. Any - // duplicates will be pruned below. - // We include both the proxy server and original hostname as well as the - // root domain wildcard. OpenSSH clients match against both the proxy - // host and nodes (via the wildcard). Teleport itself occasionally uses - // the root cluster name. - line := fmt.Sprintf( - "@cert-authority %s,%s,*.%s %s type=host", - proxyHost, hostname, hostname, strings.TrimSpace(string(bytes)), - ) - if _, exists := entries[line]; !exists { - output = append(output, line) - } - } - // Prune any duplicate host entries for migrated hosts. Note that only - // duplicates matching the current hostname/proxyHost will be pruned; others - // will be cleaned up at subsequent logins. - output = pruneOldHostKeys(output) - // re-create the file: - _, err = fp.Seek(0, 0) - if err != nil { - return trace.Wrap(err) - } - if err = fp.Truncate(0); err != nil { - return trace.Wrap(err) - } - for _, line := range output { - fmt.Fprintf(fp, "%s\n", line) - } - return fp.Sync() + key.AppTLSCerts = make(map[string][]byte) } -// matchesWildcard ensures the given `hostname` matches the given `pattern`. -// The `pattern` may be prefixed with `*.` which will match exactly one domain -// segment, meaning `*.example.com` will match `foo.example.com` but not -// `foo.bar.example.com`. -func matchesWildcard(hostname, pattern string) bool { - // Trim any trailing "." in case of an absolute domain. - hostname = strings.TrimSuffix(hostname, ".") - - // Don't allow non-wildcard patterns. - if !strings.HasPrefix(pattern, "*.") { - return false - } - - // Never match a top-level hostname. - if !strings.Contains(hostname, ".") { - return false - } - - // Don't allow empty matches. - pattern = pattern[2:] - if strings.TrimSpace(pattern) == "" { - return false - } - - hostnameParts := strings.Split(hostname, ".") - hostnameRoot := strings.Join(hostnameParts[1:], ".") - - return hostnameRoot == pattern +type MemKeyStore struct { + // keys is a three-dimensional map indexed by [proxyHost][username][clusterName] + keys keyMap } -// GetKnownHostKeys returns all known public keys from `known_hosts`. -func (fs *fsLocalNonSessionKeyStore) GetKnownHostKeys(hostname string) (keys []ssh.PublicKey, retErr error) { - unlock, err := utils.FSTryReadLockTimeout(context.Background(), fs.knownHostsPath(), 5*time.Second) - if err != nil { - return nil, trace.WrapWithMessage(err, "could not acquire lock for the `known_hosts` file") - } - defer utils.StoreErrorOf(unlock, &retErr) +// keyMap is a three-dimensional map indexed by [proxyHost][username][clusterName] +type keyMap map[string]map[string]map[string]*Key - bytes, err := os.ReadFile(fs.knownHostsPath()) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, trace.Wrap(err) +func NewMemKeyStore() *MemKeyStore { + return &MemKeyStore{ + keys: make(keyMap), } - var ( - pubKey ssh.PublicKey - retval []ssh.PublicKey = make([]ssh.PublicKey, 0) - hosts []string - hostMatch bool - ) - for err == nil { - _, hosts, pubKey, _, bytes, err = ssh.ParseKnownHosts(bytes) - if err == nil { - hostMatch = (hostname == "") - if !hostMatch { - for i := range hosts { - if hosts[i] == hostname || matchesWildcard(hostname, hosts[i]) { - hostMatch = true - break - } - } - } - if hostMatch { - retval = append(retval, pubKey) - } - } - } - if err != io.EOF { - return nil, trace.Wrap(err) - } - return retval, nil } -// SaveTrustedCerts saves trusted TLS certificates of certificate authorities. -func (fs *fsLocalNonSessionKeyStore) SaveTrustedCerts(proxyHost string, cas []auth.TrustedCerts) (retErr error) { - if err := os.MkdirAll(fs.proxyKeyDir(proxyHost), os.ModeDir|profileDirPerms); err != nil { - fs.log.Error(err) - return trace.ConvertSystemError(err) - } - - // Save trusted clusters certs in CAS directory. - if err := fs.saveTrustedCertsInCASDir(proxyHost, cas); err != nil { - return trace.Wrap(err) - } - - // For backward compatibility save trusted in legacy certs.pem file. - if err := fs.saveTrustedCertsInLegacyCAFile(proxyHost, cas); err != nil { +// AddKey writes a key to the underlying key store. +func (ms *MemKeyStore) AddKey(key *Key) error { + if err := key.KeyIndex.Check(); err != nil { return trace.Wrap(err) } - - return nil -} - -func (fs *fsLocalNonSessionKeyStore) saveTrustedCertsInCASDir(proxyHost string, cas []auth.TrustedCerts) error { - casDirPath := filepath.Join(fs.casDir(proxyHost)) - if err := os.MkdirAll(casDirPath, os.ModeDir|profileDirPerms); err != nil { - fs.log.Error(err) - return trace.ConvertSystemError(err) + _, ok := ms.keys[key.ProxyHost] + if !ok { + ms.keys[key.ProxyHost] = map[string]map[string]*Key{} } - - for _, ca := range cas { - if !isSafeClusterName(ca.ClusterName) { - fs.log.Warnf("Skipped unsafe cluster name: %q", ca.ClusterName) - continue - } - // Create CA files in cas dir for each cluster. - caFile, err := os.OpenFile(fs.clusterCAPath(proxyHost, ca.ClusterName), os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0640) - if err != nil { - return trace.ConvertSystemError(err) - } - - if err := writeClusterCertificates(caFile, ca.TLSCertificates); err != nil { - return trace.Wrap(err) - } + _, ok = ms.keys[key.ProxyHost][key.Username] + if !ok { + ms.keys[key.ProxyHost][key.Username] = map[string]*Key{} } - return nil -} + keyCopy := key.Copy() -func (fs *fsLocalNonSessionKeyStore) saveTrustedCertsInLegacyCAFile(proxyHost string, cas []auth.TrustedCerts) (retErr error) { - certsFile := fs.tlsCAsPath(proxyHost) - fp, err := os.OpenFile(certsFile, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0640) - if err != nil { - return trace.ConvertSystemError(err) - } - defer utils.StoreErrorOf(fp.Close, &retErr) - for _, ca := range cas { - for _, cert := range ca.TLSCertificates { - if _, err := fp.Write(cert); err != nil { - return trace.ConvertSystemError(err) - } - if _, err := fmt.Fprintln(fp); err != nil { - return trace.ConvertSystemError(err) - } - } - } - return fp.Sync() -} + // TrustedCA is stored separately in the Memory store so we wipe out + // the keys' trusted CA to prevent inconsistencies. + keyCopy.TrustedCerts = nil -// isSafeClusterName check if cluster name is safe and doesn't contain miscellaneous characters. -func isSafeClusterName(name string) bool { - return !strings.Contains(name, "..") -} + ms.keys[key.ProxyHost][key.Username][key.ClusterName] = keyCopy -func writeClusterCertificates(f *os.File, tlsCertificates [][]byte) error { - defer f.Close() - for _, cert := range tlsCertificates { - if _, err := f.Write(cert); err != nil { - return trace.ConvertSystemError(err) - } - } - if err := f.Sync(); err != nil { - return trace.ConvertSystemError(err) - } return nil } -// GetTrustedCertsPEM returns trusted TLS certificates of certificate authorities PEM -// blocks. -func (fs *fsLocalNonSessionKeyStore) GetTrustedCertsPEM(proxyHost string) ([][]byte, error) { - dir := fs.casDir(proxyHost) - - if _, err := os.Stat(dir); err != nil { - if os.IsNotExist(err) { - return nil, trace.NotFound("please relogin, tsh user profile doesn't contain CAS directory: %s", dir) - } - return nil, trace.ConvertSystemError(err) - } - - var blocks [][]byte - err := filepath.Walk(dir, func(path string, info osfs.FileInfo, err error) error { - if err != nil { - return nil - } - if info.IsDir() { - return nil - } - - data, err := os.ReadFile(path) - for len(data) > 0 { - if err != nil { - return trace.Wrap(err) - } - block, rest := pem.Decode(data) - if block == nil { - break - } - if block.Type != "CERTIFICATE" || len(block.Headers) != 0 { - fs.log.Debugf("Skipping PEM block type=%v headers=%v.", block.Type, block.Headers) - data = rest - continue - } - // rest contains the remainder of data after reading a block. - // Therefore, the block length is len(data) - len(rest). - // Use that length to slice the block from the start of data. - blocks = append(blocks, data[:len(data)-len(rest)]) - data = rest - } - return nil - }) - if err != nil { - return nil, trace.Wrap(err) - } - - return blocks, nil -} - -// GetSSHCertificates gets all certificates signed for the given user and proxy. -func (fs *fsLocalNonSessionKeyStore) GetSSHCertificates(proxyHost, username string) ([]*ssh.Certificate, error) { - certDir := fs.sshDir(proxyHost, username) - certFiles, err := os.ReadDir(certDir) - if err != nil { - return nil, trace.Wrap(err) - } - - sshCerts := make([]*ssh.Certificate, len(certFiles)) - for i, certFile := range certFiles { - data, err := os.ReadFile(filepath.Join(certDir, certFile.Name())) - if err != nil { - return nil, trace.ConvertSystemError(err) - } - - sshCerts[i], err = apisshutils.ParseCertificate(data) - if err != nil { - return nil, trace.Wrap(err) +// GetKey returns the user's key including the specified certs. +func (ms *MemKeyStore) GetKey(idx KeyIndex, opts ...CertOption) (*Key, error) { + if len(opts) > 0 { + if err := idx.Check(); err != nil { + return nil, trace.Wrap(err, "GetKey with CertOptions requires a fully specified KeyIndex") } } - return sshCerts, nil -} - -// noLocalKeyStore is a LocalKeyStore representing the absence of a keystore. -// All methods return errors. This exists to avoid nil checking everywhere in -// LocalKeyAgent and prevent nil pointer panics. -type noLocalKeyStore struct{} - -var errNoLocalKeyStore = trace.NotFound("there is no local keystore") - -func (noLocalKeyStore) AddKey(key *Key) error { - return errNoLocalKeyStore -} -func (noLocalKeyStore) GetKey(idx KeyIndex, opts ...CertOption) (*Key, error) { - return nil, errNoLocalKeyStore -} -func (noLocalKeyStore) DeleteKey(idx KeyIndex) error { - return errNoLocalKeyStore -} -func (noLocalKeyStore) DeleteUserCerts(idx KeyIndex, opts ...CertOption) error { - return errNoLocalKeyStore -} -func (noLocalKeyStore) DeleteKeys() error { return errNoLocalKeyStore } -func (noLocalKeyStore) AddKnownHostKeys(hostname, proxyHost string, keys []ssh.PublicKey) error { - return errNoLocalKeyStore -} -func (noLocalKeyStore) GetKnownHostKeys(hostname string) ([]ssh.PublicKey, error) { - return nil, errNoLocalKeyStore -} -func (noLocalKeyStore) SaveTrustedCerts(proxyHost string, cas []auth.TrustedCerts) error { - return errNoLocalKeyStore -} -func (noLocalKeyStore) GetTrustedCertsPEM(proxyHost string) ([][]byte, error) { - return nil, errNoLocalKeyStore -} -func (noLocalKeyStore) GetSSHCertificates(proxyHost, username string) ([]*ssh.Certificate, error) { - return nil, errNoLocalKeyStore -} - -// MemLocalKeyStore is an in-memory session keystore implementation. -type MemLocalKeyStore struct { - fsLocalNonSessionKeyStore - inMem memLocalKeyStoreMap -} - -// memLocalKeyStoreMap is a three-dimensional map indexed by [proxyHost][username][clusterName] -type memLocalKeyStoreMap = map[string]map[string]map[string]*Key - -// NewMemLocalKeyStore initializes a MemLocalKeyStore. -// The key directory here is only used for storing CA certificates and known -// host fingerprints. -func NewMemLocalKeyStore(dirPath string) (*MemLocalKeyStore, error) { - dirPath, err := initKeysDir(dirPath) - if err != nil { - return nil, trace.Wrap(err) - } - return &MemLocalKeyStore{ - fsLocalNonSessionKeyStore{ - log: logrus.WithField(trace.Component, teleport.ComponentKeyStore), - KeyDir: dirPath, - }, - memLocalKeyStoreMap{}, - }, nil -} - -// AddKey writes a key to the underlying key store. -func (s *MemLocalKeyStore) AddKey(key *Key) error { - if err := key.KeyIndex.Check(); err != nil { - return trace.Wrap(err) - } - _, ok := s.inMem[key.ProxyHost] - if !ok { - s.inMem[key.ProxyHost] = map[string]map[string]*Key{} - } - _, ok = s.inMem[key.ProxyHost][key.Username] - if !ok { - s.inMem[key.ProxyHost][key.Username] = map[string]*Key{} - } - s.inMem[key.ProxyHost][key.Username][key.ClusterName] = key - return nil -} - -// GetKey returns the user's key including the specified certs. -func (s *MemLocalKeyStore) GetKey(idx KeyIndex, opts ...CertOption) (*Key, error) { + // If clusterName is not specified then the cluster-dependent fields + // are not considered relevant and we may simply return any key + // associated with any cluster name whatsoever. var key *Key if idx.ClusterName == "" { - // If clusterName is not specified then the cluster-dependent fields - // are not considered relevant and we may simply return any key - // associated with any cluster name whatsoever. - for _, found := range s.inMem[idx.ProxyHost][idx.Username] { - key = found + for _, k := range ms.keys[idx.ProxyHost][idx.Username] { + key = k break } } else { - key = s.inMem[idx.ProxyHost][idx.Username][idx.ClusterName] + if k, ok := ms.keys[idx.ProxyHost][idx.Username][idx.ClusterName]; ok { + key = k + } } + if key == nil { return nil, trace.NotFound("key for %+v not found", idx) } - // It is not necessary to handle opts because all the optional certs are - // already part of the Key struct as stored in memory. - - tlsCertExpiration, err := key.TeleportTLSCertValidBefore() - if err != nil { - return nil, trace.Wrap(err) - } - s.log.Debugf("Returning Teleport TLS certificate from memory, valid until %q.", tlsCertExpiration) - - // Validate the SSH certificate. - if err := key.CheckCert(); err != nil { - if !utils.IsCertExpiredError(err) { - return nil, trace.Wrap(err) + retKey := NewKey(key.PrivateKey) + retKey.KeyIndex = idx + retKey.TLSCert = key.TLSCert + for _, o := range opts { + switch o.(type) { + case WithSSHCerts: + retKey.Cert = key.Cert + case WithKubeCerts: + retKey.KubeTLSCerts = key.KubeTLSCerts + case WithDBCerts: + retKey.DBTLSCerts = key.DBTLSCerts + case WithAppCerts: + retKey.AppTLSCerts = key.AppTLSCerts } } - return key, nil + return retKey, nil } // DeleteKey deletes the user's key with all its certs. -func (s *MemLocalKeyStore) DeleteKey(idx KeyIndex) error { - delete(s.inMem[idx.ProxyHost], idx.Username) +func (ms *MemKeyStore) DeleteKey(idx KeyIndex) error { + if _, ok := ms.keys[idx.ProxyHost][idx.Username][idx.ClusterName]; !ok { + return trace.NotFound("key for %+v not found", idx) + } + delete(ms.keys[idx.ProxyHost], idx.Username) return nil } // DeleteKeys removes all session keys. -func (s *MemLocalKeyStore) DeleteKeys() error { - s.inMem = memLocalKeyStoreMap{} +func (ms *MemKeyStore) DeleteKeys() error { + ms.keys = make(keyMap) return nil } @@ -1020,17 +611,17 @@ func (s *MemLocalKeyStore) DeleteKeys() error { // // Useful when needing to log out of a specific service, like a particular // database proxy. -func (s *MemLocalKeyStore) DeleteUserCerts(idx KeyIndex, opts ...CertOption) error { +func (ms *MemKeyStore) DeleteUserCerts(idx KeyIndex, opts ...CertOption) error { var keys []*Key if idx.ClusterName != "" { - key, ok := s.inMem[idx.ProxyHost][idx.Username][idx.ClusterName] + key, ok := ms.keys[idx.ProxyHost][idx.Username][idx.ClusterName] if !ok { return nil } keys = []*Key{key} } else { - keys = make([]*Key, 0, len(s.inMem[idx.ProxyHost][idx.Username])) - for _, key := range s.inMem[idx.ProxyHost][idx.Username] { + keys = make([]*Key, 0, len(ms.keys[idx.ProxyHost][idx.Username])) + for _, key := range ms.keys[idx.ProxyHost][idx.Username] { keys = append(keys, key) } } @@ -1044,9 +635,9 @@ func (s *MemLocalKeyStore) DeleteUserCerts(idx KeyIndex, opts ...CertOption) err } // GetSSHCertificates gets all certificates signed for the given user and proxy. -func (s *MemLocalKeyStore) GetSSHCertificates(proxyHost, username string) ([]*ssh.Certificate, error) { +func (ms *MemKeyStore) GetSSHCertificates(proxyHost, username string) ([]*ssh.Certificate, error) { var sshCerts []*ssh.Certificate - for _, key := range s.inMem[proxyHost][username] { + for _, key := range ms.keys[proxyHost][username] { sshCert, err := key.SSHCert() if err != nil { return nil, trace.Wrap(err) diff --git a/lib/client/keystore_test.go b/lib/client/keystore_test.go index a3b04b0c64ab..b7258ceb573b 100644 --- a/lib/client/keystore_test.go +++ b/lib/client/keystore_test.go @@ -17,677 +17,274 @@ limitations under the License. package client import ( - "context" - "crypto/rsa" - "crypto/x509/pkix" "fmt" "os" "path/filepath" - "sync" - "sync/atomic" "testing" - "time" "github.com/gravitational/trace" - "github.com/jonboulle/clockwork" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/crypto/ssh" - "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/api/utils/keypaths" - apisshutils "github.com/gravitational/teleport/api/utils/sshutils" - "github.com/gravitational/teleport/lib/auth" - "github.com/gravitational/teleport/lib/auth/testauthority" - "github.com/gravitational/teleport/lib/defaults" - "github.com/gravitational/teleport/lib/fixtures" - "github.com/gravitational/teleport/lib/services" - "github.com/gravitational/teleport/lib/sshutils" - "github.com/gravitational/teleport/lib/tlsca" - "github.com/gravitational/teleport/lib/utils" "github.com/gravitational/teleport/lib/utils/cert" ) -func TestListKeys(t *testing.T) { - s, cleanup := newTest(t) - defer cleanup() - - const keyNum = 5 +func newTestFSKeyStore(t *testing.T) *FSKeyStore { + fsKeyStore := NewFSKeyStore(t.TempDir()) + return fsKeyStore +} - // add 5 keys for "bob" - keys := make([]Key, keyNum) - for i := 0; i < keyNum; i++ { - idx := KeyIndex{fmt.Sprintf("host-%v", i), "bob", "root"} - key := s.makeSignedKey(t, idx, false) - require.NoError(t, s.addKey(key)) - keys[i] = *key - } - // add 1 key for "sam" - samIdx := KeyIndex{"sam.host", "sam", "root"} - samKey := s.makeSignedKey(t, samIdx, false) - require.NoError(t, s.addKey(samKey)) - - // read all bob keys: - for i := 0; i < keyNum; i++ { - key, err := s.store.GetKey(keys[i].KeyIndex, WithSSHCerts{}, WithDBCerts{}) - require.NoError(t, err) - require.Equal(t, &keys[i], key) - } +func testEachKeyStore(t *testing.T, testFunc func(t *testing.T, keyStore KeyStore)) { + t.Run("FS", func(t *testing.T) { + testFunc(t, newTestFSKeyStore(t)) + }) - // read sam's key and make sure it's the same: - skey, err := s.store.GetKey(samIdx, WithSSHCerts{}) - require.NoError(t, err) - require.Equal(t, samKey.Cert, skey.Cert) - require.Equal(t, samKey.MarshalSSHPublicKey(), skey.MarshalSSHPublicKey()) + t.Run("Mem", func(t *testing.T) { + testFunc(t, NewMemKeyStore()) + }) } -func TestGetCertificates(t *testing.T) { - s, cleanup := newTest(t) - defer cleanup() +func TestKeyStore(t *testing.T) { + t.Parallel() + s := newTestAuthority(t) - const keyNum = 3 + testEachKeyStore(t, func(t *testing.T, keyStore KeyStore) { + t.Parallel() - // add keys for 3 different clusters with the same user and proxy. - keys := make([]Key, keyNum) - var proxy = "proxy.example.com" - var user = "bob" - for i := 0; i < keyNum; i++ { - idx := KeyIndex{proxy, user, fmt.Sprintf("cluster-%v", i)} + // create a test key + idx := KeyIndex{"test.proxy.com", "test-user", "root"} key := s.makeSignedKey(t, idx, false) - require.NoError(t, s.addKey(key)) - keys[i] = *key - } - - certificates, err := s.store.GetSSHCertificates(proxy, user) - require.NoError(t, err) - for i := 0; i < keyNum; i++ { - expectCert, err := keys[i].SSHCert() + // add the test key to the memory store + err := keyStore.AddKey(key) require.NoError(t, err) - require.Equal(t, expectCert, certificates[i]) - } -} -func TestKeyCRUD(t *testing.T) { - s, cleanup := newTest(t) - defer cleanup() + // check that the key exists in the store and is the same, + // except the key's trusted certs should be empty, to be + // filled in by a trusted certs store. + retrievedKey, err := keyStore.GetKey(idx, WithAllCerts...) + require.NoError(t, err) + key.TrustedCerts = nil + require.Equal(t, key, retrievedKey) - idx := KeyIndex{"host.a", "bob", "root"} - key := s.makeSignedKey(t, idx, false) + // Delete just the db cert, reload & verify it's gone + err = keyStore.DeleteUserCerts(idx, WithDBCerts{}) + require.NoError(t, err) + retrievedKey, err = keyStore.GetKey(idx, WithSSHCerts{}, WithDBCerts{}) + require.NoError(t, err) + expectKey := key.Copy() + expectKey.DBTLSCerts = make(map[string][]byte) + require.Equal(t, expectKey, retrievedKey) - // add key: - err := s.addKey(key) - require.NoError(t, err) + // check for the key, now without cluster name + retrievedKey, err = keyStore.GetKey(KeyIndex{idx.ProxyHost, idx.Username, ""}) + require.NoError(t, err) + expectKey.ClusterName = "" + expectKey.Cert = nil + require.Equal(t, expectKey, retrievedKey) - // load back and compare: - keyCopy, err := s.store.GetKey(idx, WithSSHCerts{}, WithDBCerts{}) - require.NoError(t, err) - key.ProxyHost = keyCopy.ProxyHost - require.Equal(t, keyCopy, key) - require.Len(t, key.DBTLSCerts, 1) + // delete the key + err = keyStore.DeleteKey(idx) + require.NoError(t, err) - // Delete just the db cert, reload & verify it's gone - err = s.store.DeleteUserCerts(idx, WithDBCerts{}) - require.NoError(t, err) - keyCopy, err = s.store.GetKey(idx, WithSSHCerts{}, WithDBCerts{}) - require.NoError(t, err) - key.DBTLSCerts = make(map[string][]byte) - require.Equal(t, keyCopy, key) + // check that the key doesn't exist in the store + retrievedKey, err = keyStore.GetKey(idx) + require.Error(t, err) + require.True(t, trace.IsNotFound(err)) + require.Nil(t, retrievedKey) - // Delete & verify that it's gone - err = s.store.DeleteKey(idx) - require.NoError(t, err) - _, err = s.store.GetKey(idx) - require.Error(t, err) - require.True(t, trace.IsNotFound(err)) - - // Delete non-existing - err = s.store.DeleteKey(KeyIndex{ProxyHost: "non-existing-host", Username: "non-existing-user"}) - require.Error(t, err) - require.True(t, trace.IsNotFound(err)) + // Delete non-existing + err = keyStore.DeleteKey(idx) + require.Error(t, err) + require.True(t, trace.IsNotFound(err)) + }) } -func TestDeleteAll(t *testing.T) { - s, cleanup := newTest(t) - defer cleanup() - - // generate keys - idxFoo := KeyIndex{"proxy.example.com", "foo", "root"} - keyFoo := s.makeSignedKey(t, idxFoo, false) - idxBar := KeyIndex{"proxy.example.com", "bar", "root"} - keyBar := s.makeSignedKey(t, idxBar, false) - - // add keys - err := s.addKey(keyFoo) - require.NoError(t, err) - err = s.addKey(keyBar) - require.NoError(t, err) - - // check keys exist - _, err = s.store.GetKey(idxFoo) - require.NoError(t, err) - _, err = s.store.GetKey(idxBar) - require.NoError(t, err) - - // delete all keys - err = s.store.DeleteKeys() - require.NoError(t, err) +func TestListKeys(t *testing.T) { + t.Parallel() + auth := newTestAuthority(t) + + testEachKeyStore(t, func(t *testing.T, keyStore KeyStore) { + t.Parallel() + const keyNum = 5 + + // add 5 keys for "bob" + keys := make([]Key, keyNum) + for i := 0; i < keyNum; i++ { + idx := KeyIndex{fmt.Sprintf("host-%v", i), "bob", "root"} + key := auth.makeSignedKey(t, idx, false) + require.NoError(t, keyStore.AddKey(key)) + keys[i] = *key + } + // add 1 key for "sam" + samIdx := KeyIndex{"sam.host", "sam", "root"} + samKey := auth.makeSignedKey(t, samIdx, false) + require.NoError(t, keyStore.AddKey(samKey)) + + // read all bob keys: + for i := 0; i < keyNum; i++ { + key, err := keyStore.GetKey(keys[i].KeyIndex, WithSSHCerts{}, WithDBCerts{}) + require.NoError(t, err) + key.TrustedCerts = keys[i].TrustedCerts + require.Equal(t, &keys[i], key) + } - // verify keys are gone - _, err = s.store.GetKey(idxFoo) - require.True(t, trace.IsNotFound(err)) - _, err = s.store.GetKey(idxBar) - require.Error(t, err) + // read sam's key and make sure it's the same: + skey, err := keyStore.GetKey(samIdx, WithSSHCerts{}) + require.NoError(t, err) + require.Equal(t, samKey.Cert, skey.Cert) + require.Equal(t, samKey.MarshalSSHPublicKey(), skey.MarshalSSHPublicKey()) + }) } -func TestKnownHosts(t *testing.T) { +func TestGetCertificates(t *testing.T) { t.Parallel() - t.Run("can successfully write/read keys", func(t *testing.T) { - s, cleanup := newTest(t) - t.Cleanup(cleanup) + auth := newTestAuthority(t) + + testEachKeyStore(t, func(t *testing.T, keyStore KeyStore) { + const keyNum = 3 + + // add keys for 3 different clusters with the same user and proxy. + keys := make([]Key, keyNum) + certs := make([]*ssh.Certificate, keyNum) + var proxy = "proxy.example.com" + var user = "bob" + for i := 0; i < keyNum; i++ { + idx := KeyIndex{proxy, user, fmt.Sprintf("cluster-%v", i)} + key := auth.makeSignedKey(t, idx, false) + err := keyStore.AddKey(key) + require.NoError(t, err) + keys[i] = *key + certs[i], err = key.SSHCert() + require.NoError(t, err) + } - err := os.MkdirAll(s.store.KeyDir, 0777) - require.NoError(t, err) - pub, _, _, _, err := ssh.ParseAuthorizedKey(CAPub) + retrievedCerts, err := keyStore.GetSSHCertificates(proxy, user) require.NoError(t, err) + require.ElementsMatch(t, certs, retrievedCerts) + }) +} - _, p2, _ := s.keygen.GenerateKeyPair() - pub2, _, _, _, _ := ssh.ParseAuthorizedKey(p2) - - err = s.store.AddKnownHostKeys("example.com", "proxy.example.com", []ssh.PublicKey{pub}) - require.NoError(t, err) - err = s.store.AddKnownHostKeys("example.com", "proxy.example.com", []ssh.PublicKey{pub2}) - require.NoError(t, err) - err = s.store.AddKnownHostKeys("example.org", "proxy.example.org", []ssh.PublicKey{pub2}) - require.NoError(t, err) +func TestDeleteAll(t *testing.T) { + t.Parallel() + auth := newTestAuthority(t) - keys, err := s.store.GetKnownHostKeys("") - require.NoError(t, err) - require.Len(t, keys, 3) - require.Equal(t, keys, []ssh.PublicKey{pub, pub2, pub2}) + testEachKeyStore(t, func(t *testing.T, keyStore KeyStore) { + // generate keys + idxFoo := KeyIndex{"proxy.example.com", "foo", "root"} + keyFoo := auth.makeSignedKey(t, idxFoo, false) + idxBar := KeyIndex{"proxy.example.com", "bar", "root"} + keyBar := auth.makeSignedKey(t, idxBar, false) - // check against dupes: - before, _ := s.store.GetKnownHostKeys("") - err = s.store.AddKnownHostKeys("example.org", "proxy.example.org", []ssh.PublicKey{pub2}) + // add keys + err := keyStore.AddKey(keyFoo) require.NoError(t, err) - err = s.store.AddKnownHostKeys("example.org", "proxy.example.org", []ssh.PublicKey{pub2}) + err = keyStore.AddKey(keyBar) require.NoError(t, err) - after, _ := s.store.GetKnownHostKeys("") - require.Equal(t, len(before), len(after)) - - // check by hostname: - keys, _ = s.store.GetKnownHostKeys("badhost") - require.Equal(t, len(keys), 0) - keys, _ = s.store.GetKnownHostKeys("example.org") - require.Equal(t, len(keys), 1) - require.True(t, apisshutils.KeysEqual(keys[0], pub2)) - - // check for proxy and wildcard as well: - keys, _ = s.store.GetKnownHostKeys("proxy.example.org") - require.Equal(t, 1, len(keys)) - require.True(t, apisshutils.KeysEqual(keys[0], pub2)) - keys, _ = s.store.GetKnownHostKeys("*.example.org") - require.Equal(t, 1, len(keys)) - require.True(t, apisshutils.KeysEqual(keys[0], pub2)) - }) - t.Run("can write keys in parallel without corrupting content of the file", func(t *testing.T) { - s, cleanup := newTest(t) - t.Cleanup(cleanup) - err := os.MkdirAll(s.store.KeyDir, 0777) + // check keys exist + _, err = keyStore.GetKey(idxFoo) require.NoError(t, err) - pub, _, _, _, err := ssh.ParseAuthorizedKey(CAPub) + _, err = keyStore.GetKey(idxBar) require.NoError(t, err) - err = s.store.AddKnownHostKeys("example1.com", "proxy.example1.com", []ssh.PublicKey{pub}) + // delete all keys + err = keyStore.DeleteKeys() require.NoError(t, err) - var wg sync.WaitGroup - for i := 0; i < 100; i++ { - wg.Add(1) - go func() { - _, p2, _ := s.keygen.GenerateKeyPair() - tmpPub, _, _, _, _ := ssh.ParseAuthorizedKey(p2) - - err := s.store.AddKnownHostKeys("example2.com", "proxy.example2.com", []ssh.PublicKey{tmpPub}) - assert.NoError(t, err) - - _, err = s.store.GetKnownHostKeys("") - assert.NoError(t, err) - - wg.Done() - }() - } - - wg.Wait() - - keys, err := s.store.GetKnownHostKeys("") - require.NoError(t, err) - require.NotNil(t, keys) - require.True(t, len(keys) > 1) + // verify keys are gone + _, err = keyStore.GetKey(idxFoo) + require.True(t, trace.IsNotFound(err)) + _, err = keyStore.GetKey(idxBar) + require.Error(t, err) }) } // TestCheckKey makes sure Teleport clients can load non-RSA algorithms in // normal operating mode. func TestCheckKey(t *testing.T) { - s, cleanup := newTest(t) - defer cleanup() - - idx := KeyIndex{"host.a", "bob", "root"} - key := s.makeSignedKey(t, idx, false) - - // Swap out the key with a ECDSA SSH key. - ellipticCertificate, _, err := cert.CreateEllipticCertificate("foo", ssh.UserCert) - require.NoError(t, err) - key.Cert = ssh.MarshalAuthorizedKey(ellipticCertificate) - - err = s.addKey(key) - require.NoError(t, err) - - _, err = s.store.GetKey(idx) - require.NoError(t, err) -} - -// TestProxySSHConfig tests proxy client SSH config function -// that generates SSH client configuration for proxy tunnel connections -func TestProxySSHConfig(t *testing.T) { - s, cleanup := newTest(t) - defer cleanup() - - idx := KeyIndex{"host.a", "bob", "root"} - key := s.makeSignedKey(t, idx, false) - - caPub, _, _, _, err := ssh.ParseAuthorizedKey(CAPub) - require.NoError(t, err) - - firsthost := "127.0.0.1" - err = s.store.AddKnownHostKeys(firsthost, idx.ProxyHost, []ssh.PublicKey{caPub}) - require.NoError(t, err) - - clientConfig, err := key.ProxyClientSSHConfig(s.store, firsthost) - require.NoError(t, err) + t.Parallel() + auth := newTestAuthority(t) - var called atomic.Int32 - handler := sshutils.NewChanHandlerFunc(func(_ context.Context, _ *sshutils.ConnectionContext, nch ssh.NewChannel) { - called.Add(1) - nch.Reject(ssh.Prohibited, "nothing to see here") - }) + testEachKeyStore(t, func(t *testing.T, keyStore KeyStore) { + idx := KeyIndex{"host.a", "bob", "root"} + key := auth.makeSignedKey(t, idx, false) - hostPriv, hostPub, err := s.keygen.GenerateKeyPair() - require.NoError(t, err) + // Swap out the key with a ECDSA SSH key. + ellipticCertificate, _, err := cert.CreateEllipticCertificate("foo", ssh.UserCert) + require.NoError(t, err) + key.Cert = ssh.MarshalAuthorizedKey(ellipticCertificate) - caSigner, err := ssh.ParsePrivateKey(CAPriv) - require.NoError(t, err) + err = keyStore.AddKey(key) + require.NoError(t, err) - hostCert, err := s.keygen.GenerateHostCert(services.HostCertParams{ - CASigner: caSigner, - PublicHostKey: hostPub, - HostID: "127.0.0.1", - NodeName: "127.0.0.1", - ClusterName: "host-cluster-name", - Role: types.RoleNode, + _, err = keyStore.GetKey(idx) + require.NoError(t, err) }) - require.NoError(t, err) - - hostSigner, err := sshutils.NewSigner(hostPriv, hostCert) - require.NoError(t, err) - - srv, err := sshutils.NewServer( - "test", - utils.NetAddr{AddrNetwork: "tcp", Addr: "127.0.0.1:0"}, - handler, - []ssh.Signer{hostSigner}, - sshutils.AuthMethods{ - PublicKey: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) { - certChecker := apisshutils.CertChecker{ - CertChecker: ssh.CertChecker{ - IsUserAuthority: func(cert ssh.PublicKey) bool { - // Makes sure that user presented key signed by or with trusted authority. - return apisshutils.KeysEqual(caPub, cert) - }, - }, - } - return certChecker.Authenticate(conn, key) - }, - }, - ) - require.NoError(t, err) - require.NoError(t, srv.Start()) - defer srv.Close() - - clt, err := ssh.Dial("tcp", srv.Addr(), clientConfig) - require.NoError(t, err) - defer clt.Close() - - // Call new session to initiate opening new channel. This should get - // rejected and fail. - _, err = clt.NewSession() - require.Error(t, err) - require.Equal(t, int(called.Load()), 1) - - _, spub, err := testauthority.New().GenerateKeyPair() - require.NoError(t, err) - caPub22, _, _, _, err := ssh.ParseAuthorizedKey(spub) - require.NoError(t, err) - err = s.store.AddKnownHostKeys("second-host", idx.ProxyHost, []ssh.PublicKey{caPub22}) - require.NoError(t, err) - - // The ProxyClientSSHConfig should create configuration that validates server authority only based on - // second-host instead of all known hosts. - clientConfig, err = key.ProxyClientSSHConfig(s.store, "second-host") - require.NoError(t, err) - _, err = ssh.Dial("tcp", srv.Addr(), clientConfig) - // ssh server cert doesn't match second-host user known host thus connection should fail. - require.Error(t, err) } // TestCheckKeyFIPS makes sure Teleport clients don't load invalid // certificates while in FIPS mode. func TestCheckKeyFIPS(t *testing.T) { - s, cleanup := newTest(t) - defer cleanup() + t.Parallel() + auth := newTestAuthority(t) // This test only runs in FIPS mode. if !isFIPS() { t.Skip("This test only runs in FIPS mode.") } - idx := KeyIndex{"host.a", "bob", "root"} - key := s.makeSignedKey(t, idx, false) - - // Swap out the key with a ECDSA SSH key. - ellipticCertificate, _, err := cert.CreateEllipticCertificate("foo", ssh.UserCert) - require.NoError(t, err) - key.Cert = ssh.MarshalAuthorizedKey(ellipticCertificate) - - err = s.addKey(key) - require.NoError(t, err) - - // Should return trace.BadParameter error because only RSA keys are supported. - _, err = s.store.GetKey(idx) - require.True(t, trace.IsBadParameter(err)) -} - -func TestSaveGetTrustedCerts(t *testing.T) { - s, cleanup := newTest(t) - defer cleanup() - - proxy := "proxy.example.com" - certsFile := keypaths.CAsDir(s.storeDir, proxy) - err := os.MkdirAll(filepath.Dir(certsFile), 0700) - require.NoError(t, err) - - pemBytes, ok := fixtures.PEMBytes["rsa"] - require.True(t, ok) - _, firstLeafCluster, err := newSelfSignedCA(pemBytes, "localhost") - require.NoError(t, err) - _, firstLeafClusterSecondCert, err := newSelfSignedCA(pemBytes, "localhost") - require.NoError(t, err) + testEachKeyStore(t, func(t *testing.T, keyStore KeyStore) { + idx := KeyIndex{"host.a", "bob", "root"} + key := auth.makeSignedKey(t, idx, false) - _, secondLeafCluster, err := newSelfSignedCA(pemBytes, "localhost") - require.NoError(t, err) + // Swap out the key with a ECDSA SSH key. + ellipticCertificate, _, err := cert.CreateEllipticCertificate("foo", ssh.UserCert) + require.NoError(t, err) + key.Cert = ssh.MarshalAuthorizedKey(ellipticCertificate) - cas := []auth.TrustedCerts{ - { - ClusterName: "firstLeafCluster", - TLSCertificates: append(firstLeafCluster.TLSCertificates, firstLeafClusterSecondCert.TLSCertificates...), - }, - { - ClusterName: "secondLeafCluster", - TLSCertificates: secondLeafCluster.TLSCertificates, - }, - } - err = s.store.SaveTrustedCerts(proxy, cas) - require.NoError(t, err) + err = keyStore.AddKey(key) + require.NoError(t, err) - blocks, err := s.store.GetTrustedCertsPEM(proxy) - require.NoError(t, err) - require.Equal(t, 3, len(blocks)) + // Should return trace.BadParameter error because only RSA keys are supported. + _, err = keyStore.GetKey(idx) + require.True(t, trace.IsBadParameter(err)) + }) } func TestAddKey_withoutSSHCert(t *testing.T) { - s, cleanup := newTest(t) - defer cleanup() + t.Parallel() + auth := newTestAuthority(t) + keyStore := newTestFSKeyStore(t) // without ssh cert, db certs only idx := KeyIndex{"host.a", "bob", "root"} - key := s.makeSignedKey(t, idx, false) + key := auth.makeSignedKey(t, idx, false) key.Cert = nil - require.NoError(t, s.addKey(key)) + require.NoError(t, keyStore.AddKey(key)) // ssh cert path should NOT exist - sshCertPath := s.store.sshCertPath(key.KeyIndex) + sshCertPath := keyStore.sshCertPath(key.KeyIndex) _, err := os.Stat(sshCertPath) require.ErrorIs(t, err, os.ErrNotExist) // check db certs - keyCopy, err := s.store.GetKey(idx, WithDBCerts{}) + keyCopy, err := keyStore.GetKey(idx, WithDBCerts{}) require.NoError(t, err) require.Len(t, keyCopy.DBTLSCerts, 1) } func TestConfigDirNotDeleted(t *testing.T) { - s, cleanup := newTest(t) - t.Cleanup(cleanup) + t.Parallel() + auth := newTestAuthority(t) + keyStore := newTestFSKeyStore(t) + idx := KeyIndex{"host.a", "bob", "root"} - s.store.AddKey(s.makeSignedKey(t, idx, false)) - configPath := filepath.Join(s.storeDir, "config") + keyStore.AddKey(auth.makeSignedKey(t, idx, false)) + configPath := filepath.Join(keyStore.KeyDir, "config") require.NoError(t, os.Mkdir(configPath, 0700)) - require.NoError(t, s.store.DeleteKeys()) + require.NoError(t, keyStore.DeleteKeys()) require.DirExists(t, configPath) - require.NoDirExists(t, filepath.Join(s.storeDir, "keys")) -} - -type keyStoreTest struct { - storeDir string - store *FSLocalKeyStore - keygen *testauthority.Keygen - tlsCA *tlsca.CertAuthority - tlsCACert auth.TrustedCerts -} - -func (s *keyStoreTest) addKey(key *Key) error { - if err := s.store.AddKey(key); err != nil { - return err - } - // Also write the trusted CA certs for the host. - return s.store.SaveTrustedCerts(key.ProxyHost, []auth.TrustedCerts{s.tlsCACert}) -} - -// makeSignedKey helper returns all 3 components of a user key (signed by CAPriv key) -func (s *keyStoreTest) makeSignedKey(t *testing.T, idx KeyIndex, makeExpired bool) *Key { - priv, err := s.keygen.GeneratePrivateKey() - require.NoError(t, err) - - allowedLogins := []string{idx.Username, "root"} - ttl := 20 * time.Minute - if makeExpired { - ttl = -ttl - } - - // reuse the same RSA keys for SSH and TLS keys - clock := clockwork.NewRealClock() - identity := tlsca.Identity{ - Username: idx.Username, - } - subject, err := identity.Subject() - require.NoError(t, err) - tlsCert, err := s.tlsCA.GenerateCertificate(tlsca.CertificateRequest{ - Clock: clock, - PublicKey: priv.Public(), - Subject: subject, - NotAfter: clock.Now().UTC().Add(ttl), - }) - require.NoError(t, err) - - caSigner, err := ssh.ParsePrivateKey(CAPriv) - require.NoError(t, err) - - cert, err := s.keygen.GenerateUserCert(services.UserCertParams{ - CASigner: caSigner, - PublicUserKey: ssh.MarshalAuthorizedKey(priv.SSHPublicKey()), - Username: idx.Username, - AllowedLogins: allowedLogins, - TTL: ttl, - PermitAgentForwarding: false, - PermitPortForwarding: true, - }) - require.NoError(t, err) - - key := NewKey(priv) - key.KeyIndex = idx - key.PrivateKey = priv - key.Cert = cert - key.TLSCert = tlsCert - key.TrustedCA = []auth.TrustedCerts{s.tlsCACert} - key.DBTLSCerts["example-db"] = tlsCert - return key -} - -func newSelfSignedCA(privateKey []byte, cluster string) (*tlsca.CertAuthority, auth.TrustedCerts, error) { - rsaKey, err := ssh.ParseRawPrivateKey(privateKey) - if err != nil { - return nil, auth.TrustedCerts{}, trace.Wrap(err) - } - cert, err := tlsca.GenerateSelfSignedCAWithSigner(rsaKey.(*rsa.PrivateKey), pkix.Name{ - CommonName: cluster, - Organization: []string{cluster}, - }, nil, defaults.CATTL) - if err != nil { - return nil, auth.TrustedCerts{}, trace.Wrap(err) - } - ca, err := tlsca.FromCertAndSigner(cert, rsaKey.(*rsa.PrivateKey)) - if err != nil { - return nil, auth.TrustedCerts{}, trace.Wrap(err) - } - return ca, auth.TrustedCerts{TLSCertificates: [][]byte{cert}}, nil -} - -func newTest(t *testing.T) (keyStoreTest, func()) { - dir, err := os.MkdirTemp("", "teleport-keystore") - require.NoError(t, err) - - store, err := NewFSLocalKeyStore(dir) - require.NoError(t, err) - - s := keyStoreTest{ - keygen: testauthority.New(), - storeDir: dir, - store: store, - } - require.True(t, utils.IsDir(s.store.KeyDir)) - - s.tlsCA, s.tlsCACert, err = newSelfSignedCA(CAPriv, "localhost") - require.NoError(t, err) - - return s, func() { - os.RemoveAll(dir) - } -} - -var ( - CAPriv = []byte(`-----BEGIN RSA PRIVATE KEY----- -MIIEowIBAAKCAQEAwBgwn+vkjCcKEr2fbX1mLN555B9amVYfD/fUZBNbXKpHaqYn -lM2WlyRR+xCrU9H/X6xT+wKJs1tsxFbxdBc1RWJtaqz/VpQCjomOulBzwumzB5hT -pJfGblGjkPvpt1zwfmKdpBg0jxXUHHR4u4N6OX0dxd0ImRQ4W9QUtEqzgqToS5u4 -iwpeg6i1SoAdHBaSeqYhK9+nGrrJBAl/HVSgvL9tGn/+cQqlOiQz0t61V20+oMBA -P+rOTIiwRXn98iMKFjzVW1HTL5Lwit3oJQX0Lrd/I6tN2De6TJxbbOOkF45V/P/k -nBzbxV0fpnhcvZMnQqg1qdUmNVi6VC1O5qIPiwIDAQABAoIBAEg0T4KtLnkn63dj -41tKeW+AKJ0A1BMy9fYQl7sOM5c/QhzqW5JpPKOPOWl/uIaHNtCFfAOrzoqmYNnk -PFoApztvZeVlJY0rkVJ2jjmmJ/0pzuuZ7Ea/7gxlj2/d4NnVi2hWNR8LIiZudA5G -EWOaZgTZ7KkFDkhL+2s46pdiRNtj7l5FXn2tCh7jmFgKS4m1/QqV9KdE5EjwB2mj -BoP/j4V8O0RM05QpiYX/D5/Rr06tBavwTGW3vz/7OPIbf1el1mjfbLlt3z2tH0A5 -BSGB4JEwIZ3+2xlZokHy95OSDzE46TsSzgNx3SDzGRc8UnSZN9yunxnL4ej11WYt -59YmD+ECgYEA3zxrDAtscpoxJSwcSkwqcMdElMK4D/BZw/tE9HhpHx3Pdd5XtMio -CHUkkqxwGJeVIixDjwnl4VfA1s0wy3CtHq6mmwfUviYrH2eqxe5RxNyZOZguk6is -GurZzD+ZfacsEIHyz2fZdnEAIFubu/S6x4TQPGg23oxnQpXXq1vzZFkCgYEA3Emz -W4MXvYWvRdbn+W3onHz/vty9owj/BKSP6giPGrpQFdLs8yoBUw1yTOGqAIfuWMLS -xvjULSlhei5PYD1xM2+B4luxM8K25DlqUpgRVtdmjQ/wxnzlmhDAPIMh7LUtw/6o -JJ+diAKTI86T8tokIL7WFaSvzdrz7/WrZQWkpoMCgYAPVAK1rQMhS10chE7c+yXe -4I/g9w3Ualh/kH1HnAz7yfw4x6+WBkEjc4ezWovH5ICk/A0XgUJ7mp7vIN+82FvK -w4tFEeCVveEwItojBR4wOkV7Iuvvz6EhqAaUc7mCWzw3VfTqMONJsrCjiCbFXSSG -FqSFwVIjLdjZRZitd37a4QKBgQDWfjjTIVlLY9EfWrszZu54+Ul4Sa2pAwh1N9sd -kUnuR33VUjUALGVvvgcOjyieLb1J1iGwNfc7JjDQ7CjD1+/Smn/IrWlksfKtVK6P -T5yKh2BGeEAEtPZHxom4IiM1PdEbJ2oHhxe3qHInCm2KqRdGfysrldjMw6aEfxxt -WEpTCwKBgHLZYgNf/dGgWgw7bVu/k61jxw3yZuU/0marFOPINME/AnTcSAGnkC0S -oDZhaPxjz3+2AHWAjUgW1ltTY8FsJYTOYsvzkYPfya4CgHCLg3D9ss1m4Rc7w5qo -Fa6bvW5jo543NztjlKts7XYVqroMCu0sIMS7R4JGsmw3VJcnnMP2 ------END RSA PRIVATE KEY-----`) - - CAPub = []byte(`ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDAGDCf6+SMJwoSvZ9tfWYs3nnkH1qZVh8P99RkE1tcqkdqpieUzZaXJFH7EKtT0f9frFP7AomzW2zEVvF0FzVFYm1qrP9WlAKOiY66UHPC6bMHmFOkl8ZuUaOQ++m3XPB+Yp2kGDSPFdQcdHi7g3o5fR3F3QiZFDhb1BS0SrOCpOhLm7iLCl6DqLVKgB0cFpJ6piEr36causkECX8dVKC8v20af/5xCqU6JDPS3rVXbT6gwEA/6s5MiLBFef3yIwoWPNVbUdMvkvCK3eglBfQut38jq03YN7pMnFts46QXjlX8/+ScHNvFXR+meFy9kydCqDWp1SY1WLpULU7mog+L ekontsevoy@turing`) -) - -func TestMemLocalKeyStore(t *testing.T) { - s, cleanup := newTest(t) - defer cleanup() - - // create keystore - dir := t.TempDir() - keystore, err := NewMemLocalKeyStore(dir) - require.NoError(t, err) - - // create a test key - idx := KeyIndex{"test.com", "test", "root"} - key := s.makeSignedKey(t, idx, false) - - // add the test key to the memory store - err = keystore.AddKey(key) - require.NoError(t, err) - - // check that the key exists in the store - retrievedKey, err := keystore.GetKey(idx) - require.NoError(t, err) - require.Equal(t, key, retrievedKey) - - // delete the key - err = keystore.DeleteKey(idx) - require.NoError(t, err) - - // check that the key doesn't exist in the store - retrievedKey, err = keystore.GetKey(idx) - require.Error(t, err) - require.Nil(t, retrievedKey) - - // add it again - err = keystore.AddKey(key) - require.NoError(t, err) - - // check for the key, now without cluster name - retrievedKey, err = keystore.GetKey(KeyIndex{idx.ProxyHost, idx.Username, ""}) - require.NoError(t, err) - require.Equal(t, key, retrievedKey) - - // delete all keys - err = keystore.DeleteKeys() - require.NoError(t, err) - - // verify it's deleted - retrievedKey, err = keystore.GetKey(idx) - require.Error(t, err) - require.Nil(t, retrievedKey) -} - -func TestMatchesWildcard(t *testing.T) { - // Not a wildcard pattern. - require.False(t, matchesWildcard("foo.example.com", "example.com")) - - // Not a match. - require.False(t, matchesWildcard("foo.example.org", "*.example.com")) - - // Too many levels deep. - require.False(t, matchesWildcard("a.b.example.com", "*.example.com")) - - // Single-part hostnames never match. - require.False(t, matchesWildcard("example", "*.example.com")) - require.False(t, matchesWildcard("example", "*.example")) - require.False(t, matchesWildcard("example", "example")) - require.False(t, matchesWildcard("example", "*.")) - - // Valid wildcard matches. - require.True(t, matchesWildcard("foo.example.com", "*.example.com")) - require.True(t, matchesWildcard("bar.example.com", "*.example.com")) - require.True(t, matchesWildcard("bar.example.com.", "*.example.com")) - require.True(t, matchesWildcard("bar.foo", "*.foo")) + require.NoDirExists(t, filepath.Join(keyStore.KeyDir, "keys")) } diff --git a/lib/client/local_proxy_middleware.go b/lib/client/local_proxy_middleware.go index c02ccddaa066..af49d7e05736 100644 --- a/lib/client/local_proxy_middleware.go +++ b/lib/client/local_proxy_middleware.go @@ -82,7 +82,7 @@ func (c *DBCertChecker) ensureValidCerts(ctx context.Context, lp *alpnproxy.Loca // renewCerts attempts to renew the database certs for the local proxy. func (c *DBCertChecker) renewCerts(ctx context.Context, lp *alpnproxy.LocalProxy) error { var accessRequests []string - if profile, err := StatusCurrent(c.tc.HomePath, c.tc.WebProxyAddr, ""); err != nil { + if profile, err := c.tc.ProfileStatus(); err != nil { log.WithError(err).Warn("unable to load profile, requesting database certs without access requests") } else { accessRequests = profile.ActiveRequests.AccessRequests diff --git a/lib/client/profile.go b/lib/client/profile.go new file mode 100644 index 000000000000..099c8a06cc5f --- /dev/null +++ b/lib/client/profile.go @@ -0,0 +1,511 @@ +/* +Copyright 2016 Gravitational, Inc. +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. +*/ + +package client + +import ( + "net/url" + "os" + "path/filepath" + "sort" + "sync" + "time" + + "github.com/gravitational/trace" + "github.com/jonboulle/clockwork" + "github.com/sirupsen/logrus" + + "github.com/gravitational/teleport" + "github.com/gravitational/teleport/api/profile" + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/api/types/wrappers" + "github.com/gravitational/teleport/api/utils/keypaths" + "github.com/gravitational/teleport/lib/services" + "github.com/gravitational/teleport/lib/tlsca" +) + +// ProfileStore is a storage interface for client profile data. +type ProfileStore interface { + // CurrentProfile returns the current profile. + CurrentProfile() (string, error) + + // ListProfiles returns a list of all profiles. + ListProfiles() ([]string, error) + + // GetProfile returns the requested profile. + GetProfile(profileName string) (*profile.Profile, error) + + // SaveProfile saves the given profile. If makeCurrent + // is true, it makes this profile current. + SaveProfile(profile *profile.Profile, setCurrent bool) error +} + +// MemProfileStore is an in-memory implementation of ProfileStore. +type MemProfileStore struct { + // currentProfile is the currently selected profile. + currentProfile string + // profiles is a map of proxyHosts to profiles. + profiles map[string]*profile.Profile +} + +// NewMemProfileStore creates a new instance of MemProfileStore. +func NewMemProfileStore() *MemProfileStore { + return &MemProfileStore{ + profiles: make(map[string]*profile.Profile), + } +} + +// CurrentProfile returns the current profile. +func (ms *MemProfileStore) CurrentProfile() (string, error) { + return ms.currentProfile, nil +} + +// ListProfiles returns a list of all profiles. +func (ms *MemProfileStore) ListProfiles() ([]string, error) { + var profileNames []string + for profileName := range ms.profiles { + profileNames = append(profileNames, profileName) + } + return profileNames, nil +} + +// GetProfile returns the requested profile. +func (ms *MemProfileStore) GetProfile(profileName string) (*profile.Profile, error) { + if profile, ok := ms.profiles[profileName]; ok { + return profile.Copy(), nil + } + return nil, trace.NotFound("profile for proxy host %q not found", profileName) +} + +// SaveProfile saves the given profile. If makeCurrent +// is true, it makes this profile current. +func (ms *MemProfileStore) SaveProfile(profile *profile.Profile, makecurrent bool) error { + ms.profiles[profile.Name()] = profile.Copy() + if makecurrent { + ms.currentProfile = profile.Name() + } + return nil +} + +// FSProfileStore is an on-disk implementation of the ProfileStore interface. +// +// The FS store uses the file layout outlined in `api/utils/keypaths.go`. +type FSProfileStore struct { + // log holds the structured logger. + log logrus.FieldLogger + + // Dir is the directory where all keys are stored. + Dir string +} + +// NewFSProfileStore creates a new instance of FSProfileStore. +func NewFSProfileStore(dirPath string) *FSProfileStore { + dirPath = profile.FullProfilePath(dirPath) + return &FSProfileStore{ + log: logrus.WithField(trace.Component, teleport.ComponentKeyStore), + Dir: dirPath, + } +} + +// CurrentProfile returns the current profile. +func (fs *FSProfileStore) CurrentProfile() (string, error) { + profileName, err := profile.GetCurrentProfileName(fs.Dir) + if err != nil { + return "", trace.Wrap(err) + } + return profileName, nil +} + +// ListProfiles returns a list of all profiles. +func (fs *FSProfileStore) ListProfiles() ([]string, error) { + profileNames, err := profile.ListProfileNames(fs.Dir) + if err != nil { + return nil, trace.Wrap(err) + } + return profileNames, nil +} + +// GetProfile returns the requested profile. +func (fs *FSProfileStore) GetProfile(profileName string) (*profile.Profile, error) { + profile, err := profile.FromDir(fs.Dir, profileName) + if err != nil { + return nil, trace.Wrap(err) + } + return profile, nil +} + +// SaveProfile saves the given profile. If makeCurrent +// is true, it makes this profile current. +func (fs *FSProfileStore) SaveProfile(profile *profile.Profile, makeCurrent bool) error { + if err := os.MkdirAll(fs.Dir, os.ModeDir|profileDirPerms); err != nil { + return trace.ConvertSystemError(err) + } + + err := profile.SaveToDir(fs.Dir, makeCurrent) + return trace.Wrap(err) +} + +// ProfileStatus combines metadata from the logged in profile and associated +// SSH certificate. +type ProfileStatus struct { + // Name is the profile name. + Name string + + // Dir is the directory where profile is located. + Dir string + + // ProxyURL is the URL the web client is accessible at. + ProxyURL url.URL + + // Username is the Teleport username. + Username string + + // Roles is a list of Teleport Roles this user has been assigned. + Roles []string + + // Logins are the Linux accounts, also known as principals in OpenSSH terminology. + Logins []string + + // KubeEnabled is true when this profile is configured to connect to a + // kubernetes cluster. + KubeEnabled bool + + // KubeUsers are the kubernetes users used by this profile. + KubeUsers []string + + // KubeGroups are the kubernetes groups used by this profile. + KubeGroups []string + + // Databases is a list of database services this profile is logged into. + Databases []tlsca.RouteToDatabase + + // Apps is a list of apps this profile is logged into. + Apps []tlsca.RouteToApp + + // ValidUntil is the time at which this SSH certificate will expire. + ValidUntil time.Time + + // Extensions is a list of enabled SSH features for the certificate. + Extensions []string + + // CriticalOptions is a map of SSH critical options for the certificate. + CriticalOptions map[string]string + + // Cluster is a selected cluster + Cluster string + + // Traits hold claim data used to populate a role at runtime. + Traits wrappers.Traits + + // ActiveRequests tracks the privilege escalation requests applied + // during certificate construction. + ActiveRequests services.RequestIDs + + // AWSRoleARNs is a list of allowed AWS role ARNs user can assume. + AWSRolesARNs []string + + // AzureIdentities is a list of allowed Azure identities user can assume. + AzureIdentities []string + + // AllowedResourceIDs is a list of resources the user can access. An empty + // list means there are no resource-specific restrictions. + AllowedResourceIDs []types.ResourceID + + // IsVirtual is set when this profile does not actually exist on disk, + // probably because it was constructed from an identity file. When set, + // certain profile functions - particularly those that return paths to + // files on disk - must be accompanied by fallback logic when those paths + // do not exist. + IsVirtual bool +} + +// profileOptions contains fields needed to initialize a profile beyond those +// derived directly from a Key. +type profileOptions struct { + ProfileName string + ProfileDir string + WebProxyAddr string + Username string + SiteName string + KubeProxyAddr string + IsVirtual bool +} + +// profileFromkey returns a ProfileStatus for the given key and options. +func profileStatusFromKey(key *Key, opts profileOptions) (*ProfileStatus, error) { + sshCert, err := key.SSHCert() + if err != nil { + return nil, trace.Wrap(err) + } + + // Extract from the certificate how much longer it will be valid for. + validUntil := time.Unix(int64(sshCert.ValidBefore), 0) + + // Extract roles from certificate. Note, if the certificate is in old format, + // this will be empty. + var roles []string + rawRoles, ok := sshCert.Extensions[teleport.CertExtensionTeleportRoles] + if ok { + roles, err = services.UnmarshalCertRoles(rawRoles) + if err != nil { + return nil, trace.Wrap(err) + } + } + sort.Strings(roles) + + // Extract traits from the certificate. Note if the certificate is in the + // old format, this will be empty. + var traits wrappers.Traits + rawTraits, ok := sshCert.Extensions[teleport.CertExtensionTeleportTraits] + if ok { + err = wrappers.UnmarshalTraits([]byte(rawTraits), &traits) + if err != nil { + return nil, trace.Wrap(err) + } + } + + var activeRequests services.RequestIDs + rawRequests, ok := sshCert.Extensions[teleport.CertExtensionTeleportActiveRequests] + if ok { + if err := activeRequests.Unmarshal([]byte(rawRequests)); err != nil { + return nil, trace.Wrap(err) + } + } + + allowedResourcesStr := sshCert.Extensions[teleport.CertExtensionAllowedResources] + allowedResourceIDs, err := types.ResourceIDsFromString(allowedResourcesStr) + if err != nil { + return nil, trace.Wrap(err) + } + + // Extract extensions from certificate. This lists the abilities of the + // certificate (like can the user request a PTY, port forwarding, etc.) + var extensions []string + for ext := range sshCert.Extensions { + if ext == teleport.CertExtensionTeleportRoles || + ext == teleport.CertExtensionTeleportTraits || + ext == teleport.CertExtensionTeleportRouteToCluster || + ext == teleport.CertExtensionTeleportActiveRequests || + ext == teleport.CertExtensionAllowedResources { + continue + } + extensions = append(extensions, ext) + } + sort.Strings(extensions) + + tlsCert, err := key.TeleportTLSCertificate() + if err != nil { + return nil, trace.Wrap(err) + } + tlsID, err := tlsca.FromSubject(tlsCert.Subject, time.Time{}) + if err != nil { + return nil, trace.Wrap(err) + } + + databases, err := findActiveDatabases(key) + if err != nil { + return nil, trace.Wrap(err) + } + + appCerts, err := key.AppTLSCertificates() + if err != nil { + return nil, trace.Wrap(err) + } + var apps []tlsca.RouteToApp + for _, cert := range appCerts { + tlsID, err := tlsca.FromSubject(cert.Subject, time.Time{}) + if err != nil { + return nil, trace.Wrap(err) + } + if tlsID.RouteToApp.PublicAddr != "" { + apps = append(apps, tlsID.RouteToApp) + } + } + + return &ProfileStatus{ + Name: opts.ProfileName, + Dir: opts.ProfileDir, + ProxyURL: url.URL{ + Scheme: "https", + Host: opts.WebProxyAddr, + }, + Username: opts.Username, + Logins: sshCert.ValidPrincipals, + ValidUntil: validUntil, + Extensions: extensions, + CriticalOptions: sshCert.CriticalOptions, + Roles: roles, + Cluster: opts.SiteName, + Traits: traits, + ActiveRequests: activeRequests, + KubeEnabled: opts.KubeProxyAddr != "", + KubeUsers: tlsID.KubernetesUsers, + KubeGroups: tlsID.KubernetesGroups, + Databases: databases, + Apps: apps, + AWSRolesARNs: tlsID.AWSRoleARNs, + AzureIdentities: tlsID.AzureIdentities, + IsVirtual: opts.IsVirtual, + AllowedResourceIDs: allowedResourceIDs, + }, nil +} + +// IsExpired returns true if profile is not expired yet +func (p *ProfileStatus) IsExpired(clock clockwork.Clock) bool { + return p.ValidUntil.Sub(clock.Now()) <= 0 +} + +// virtualPathWarnOnce is used to ensure warnings about missing virtual path +// environment variables are consolidated into a single message and not spammed +// to the console. +var virtualPathWarnOnce sync.Once + +// virtualPathFromEnv attempts to retrieve the path as defined by the given +// formatter from the environment. +func (p *ProfileStatus) virtualPathFromEnv(kind VirtualPathKind, params VirtualPathParams) (string, bool) { + if !p.IsVirtual { + return "", false + } + + for _, envName := range VirtualPathEnvNames(kind, params) { + if val, ok := os.LookupEnv(envName); ok { + return val, true + } + } + + // If we can't resolve any env vars, this will return garbage which we + // should at least warn about. As ugly as this is, arguably making every + // profile path lookup fallible is even uglier. + log.Debugf("Could not resolve path to virtual profile entry of type %s "+ + "with parameters %+v.", kind, params) + + virtualPathWarnOnce.Do(func() { + log.Errorf("A virtual profile is in use due to an identity file " + + "(`-i ...`) but this functionality requires additional files on " + + "disk and may fail. Consider using a compatible wrapper " + + "application (e.g. Machine ID) for this command.") + }) + + return "", false +} + +// CACertPathForCluster returns path to the cluster CA certificate for this profile. +// +// It's stored in /keys//cas/.pem by default. +func (p *ProfileStatus) CACertPathForCluster(cluster string) string { + // Return an env var override if both valid and present for this identity. + if path, ok := p.virtualPathFromEnv(VirtualPathCA, VirtualPathCAParams(types.HostCA)); ok { + return path + } + + return filepath.Join(keypaths.ProxyKeyDir(p.Dir, p.Name), "cas", cluster+".pem") +} + +// KeyPath returns path to the private key for this profile. +// +// It's kept in /keys//. +func (p *ProfileStatus) KeyPath() string { + // Return an env var override if both valid and present for this identity. + if path, ok := p.virtualPathFromEnv(VirtualPathKey, nil); ok { + return path + } + + return keypaths.UserKeyPath(p.Dir, p.Name, p.Username) +} + +// DatabaseCertPathForCluster returns path to the specified database access +// certificate for this profile, for the specified cluster. +// +// It's kept in /keys//-db//-x509.pem +// +// If the input cluster name is an empty string, the selected cluster in the +// profile will be used. +func (p *ProfileStatus) DatabaseCertPathForCluster(clusterName string, databaseName string) string { + if clusterName == "" { + clusterName = p.Cluster + } + + if path, ok := p.virtualPathFromEnv(VirtualPathDatabase, VirtualPathDatabaseParams(databaseName)); ok { + return path + } + + return keypaths.DatabaseCertPath(p.Dir, p.Name, p.Username, clusterName, databaseName) +} + +// AppCertPath returns path to the specified app access certificate +// for this profile. +// +// It's kept in /keys//-app//-x509.pem +func (p *ProfileStatus) AppCertPath(name string) string { + if path, ok := p.virtualPathFromEnv(VirtualPathApp, VirtualPathAppParams(name)); ok { + return path + } + + return keypaths.AppCertPath(p.Dir, p.Name, p.Username, p.Cluster, name) +} + +// AppLocalCAPath returns the specified app's self-signed localhost CA path for +// this profile. +// +// It's kept in /keys//-app//-localca.pem +func (p *ProfileStatus) AppLocalCAPath(name string) string { + return keypaths.AppLocalCAPath(p.Dir, p.Name, p.Username, p.Cluster, name) +} + +// KubeConfigPath returns path to the specified kubeconfig for this profile. +// +// It's kept in /keys//-kube//-kubeconfig +func (p *ProfileStatus) KubeConfigPath(name string) string { + if path, ok := p.virtualPathFromEnv(VirtualPathKubernetes, VirtualPathKubernetesParams(name)); ok { + return path + } + + return keypaths.KubeConfigPath(p.Dir, p.Name, p.Username, p.Cluster, name) +} + +// DatabaseServices returns a list of database service names for this profile. +func (p *ProfileStatus) DatabaseServices() (result []string) { + for _, db := range p.Databases { + result = append(result, db.ServiceName) + } + return result +} + +// DatabasesForCluster returns a list of databases for this profile, for the +// specified cluster name. +func (p *ProfileStatus) DatabasesForCluster(clusterName string) ([]tlsca.RouteToDatabase, error) { + if clusterName == "" || clusterName == p.Cluster { + return p.Databases, nil + } + + idx := KeyIndex{ + ProxyHost: p.Name, + Username: p.Username, + ClusterName: clusterName, + } + + store := NewFSKeyStore(p.Dir) + key, err := store.GetKey(idx, WithDBCerts{}) + if err != nil { + return nil, trace.Wrap(err) + } + return findActiveDatabases(key) +} + +// AppNames returns a list of app names this profile is logged into. +func (p *ProfileStatus) AppNames() (result []string) { + for _, app := range p.Apps { + result = append(result, app.Name) + } + return result +} diff --git a/lib/client/profile_test.go b/lib/client/profile_test.go new file mode 100644 index 000000000000..ba64ba242523 --- /dev/null +++ b/lib/client/profile_test.go @@ -0,0 +1,86 @@ +/* +Copyright 2016-2022 Gravitational, Inc. + +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. +*/ + +package client + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/profile" +) + +func newTestFSProfileStore(t *testing.T) *FSProfileStore { + fsProfileStore := NewFSProfileStore(t.TempDir()) + return fsProfileStore +} + +func testEachProfileStore(t *testing.T, testFunc func(t *testing.T, profileStore ProfileStore)) { + t.Run("FS", func(t *testing.T) { + testFunc(t, newTestFSProfileStore(t)) + }) + + t.Run("Mem", func(t *testing.T) { + testFunc(t, NewMemProfileStore()) + }) +} + +func TestProfileStore(t *testing.T) { + t.Parallel() + + testEachProfileStore(t, func(t *testing.T, profileStore ProfileStore) { + var dir string + if fsProfileStore, ok := profileStore.(*FSProfileStore); ok { + dir = fsProfileStore.Dir + } + profiles := []*profile.Profile{ + { + WebProxyAddr: "proxy1.example.com", + Username: "test-user", + SiteName: "root", + Dir: dir, + }, { + WebProxyAddr: "proxy2.example.com", + Username: "test-user", + SiteName: "root", + Dir: dir, + }, + } + + err := profileStore.SaveProfile(profiles[0], true) + require.NoError(t, err) + err = profileStore.SaveProfile(profiles[1], false) + require.NoError(t, err) + + current, err := profileStore.CurrentProfile() + require.NoError(t, err) + require.Equal(t, "proxy1.example.com", current) + + listProfiles, err := profileStore.ListProfiles() + require.NoError(t, err) + require.Len(t, listProfiles, 2) + require.ElementsMatch(t, []string{"proxy1.example.com", "proxy2.example.com"}, listProfiles) + + retProfiles := make([]*profile.Profile, 2) + for i, profileName := range listProfiles { + profile, err := profileStore.GetProfile(profileName) + require.NoError(t, err) + retProfiles[i] = profile + } + require.ElementsMatch(t, profiles, retProfiles) + }) +} diff --git a/lib/client/trusted_certs_store.go b/lib/client/trusted_certs_store.go new file mode 100644 index 000000000000..57c24b2f5811 --- /dev/null +++ b/lib/client/trusted_certs_store.go @@ -0,0 +1,524 @@ +/* +Copyright 2016 Gravitational, Inc. + +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. +*/ + +package client + +import ( + "bufio" + "bytes" + "context" + "encoding/pem" + "fmt" + iofs "io/fs" + "os" + "path/filepath" + "strings" + "time" + + "github.com/gravitational/trace" + "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" + + "github.com/gravitational/teleport" + "github.com/gravitational/teleport/api/profile" + apiutils "github.com/gravitational/teleport/api/utils" + "github.com/gravitational/teleport/api/utils/keypaths" + apisshutils "github.com/gravitational/teleport/api/utils/sshutils" + "github.com/gravitational/teleport/lib/auth" + "github.com/gravitational/teleport/lib/sshutils" + "github.com/gravitational/teleport/lib/tlsca" + "github.com/gravitational/teleport/lib/utils" +) + +// TrustedCertsStore is a storage interface for trusted CA certificates and public keys. +type TrustedCertsStore interface { + // SaveTrustedCerts adds the given trusted CA TLS certificates and SSH host keys to the store. + // Existing TLS certificates for the given trusted certs will be overwritten, while host keys + // will be appended to existing entries. + SaveTrustedCerts(proxyHost string, cas []auth.TrustedCerts) error + + // GetTrustedCerts gets the trusted CA TLS certificates and SSH host keys for the given proxyHost. + GetTrustedCerts(proxyHost string) ([]auth.TrustedCerts, error) + + // GetTrustedCertsPEM gets trusted TLS certificates of certificate authorities. + // Each returned byte slice contains an individual PEM block. + GetTrustedCertsPEM(proxyHost string) ([][]byte, error) + + // GetTrustedHostKeys returns all trusted public host keys. If hostnames are provided, only + // matching host keys will be returned. Host names should be a proxy host or cluster name. + GetTrustedHostKeys(hostnames ...string) ([]ssh.PublicKey, error) +} + +// MemTrustedCertsStore is an in-memory implementation of TrustedCertsStore. +type MemTrustedCertsStore struct { + // memLocalCAStoreMap is a two-dimensinoal map indexed by [proxyHost][clusterName] + trustedCerts trustedCertsMap +} + +// trustedCertsMap is a two-dimensinoal map indexed by [proxyHost][clusterName] +type trustedCertsMap map[string]map[string]auth.TrustedCerts + +// NewMemTrustedCertsStore creates a new instance of MemTrustedCertsStore. +func NewMemTrustedCertsStore() *MemTrustedCertsStore { + return &MemTrustedCertsStore{ + trustedCerts: make(trustedCertsMap), + } +} + +// SaveTrustedCerts saves trusted TLS certificates of certificate authorities. +func (ms *MemTrustedCertsStore) SaveTrustedCerts(proxyHost string, cas []auth.TrustedCerts) error { + if proxyHost == "" { + return trace.BadParameter("proxyHost must be provided to add trusted certs") + } + _, ok := ms.trustedCerts[proxyHost] + if !ok { + ms.trustedCerts[proxyHost] = map[string]auth.TrustedCerts{} + } + for _, ca := range cas { + if ca.ClusterName == "" { + return trace.BadParameter("trusted certs entry cannot have an empty cluster name") + } + + entry, ok := ms.trustedCerts[proxyHost][ca.ClusterName] + if !ok { + entry = auth.TrustedCerts{ClusterName: ca.ClusterName} + } + + // If TLS certificates were provided, replace the existing entry's certs. + if len(ca.TLSCertificates) != 0 { + entry.TLSCertificates = ca.TLSCertificates + } + + // Unlike with trusted TLS certificates, we don't replace the trusted host keys. + // Instead, append to the existing entry, without duplicates. This matches the + // behavior of the known hosts file. + entry.AuthorizedKeys = apiutils.DeduplicateAny(append(entry.AuthorizedKeys, ca.AuthorizedKeys...), bytes.Equal) + + ms.trustedCerts[proxyHost][ca.ClusterName] = entry + } + + return nil +} + +// GetTrustedCerts gets the trusted CA TLS certificates and SSH host keys for the given proxyHost. +func (ms *MemTrustedCertsStore) GetTrustedCerts(proxyHost string) ([]auth.TrustedCerts, error) { + var trustedCerts []auth.TrustedCerts + for _, entry := range ms.trustedCerts[proxyHost] { + trustedCerts = append(trustedCerts, entry) + } + return trustedCerts, nil +} + +// GetTrustedCertsPEM gets trusted TLS certificates of certificate authorities. +// Each returned byte slice contains an individual PEM block. +func (ms *MemTrustedCertsStore) GetTrustedCertsPEM(proxyHost string) ([][]byte, error) { + var tlsHostCerts [][]byte + for _, ca := range ms.trustedCerts[proxyHost] { + tlsHostCerts = append(tlsHostCerts, ca.TLSCertificates...) + } + return tlsHostCerts, nil +} + +// GetTrustedHostKeys returns all trusted public host keys. If hostnames are provided, only +// matching host keys will be returned. Host names should be a proxy host or cluster name. +func (ms *MemTrustedCertsStore) GetTrustedHostKeys(hostnames ...string) ([]ssh.PublicKey, error) { + // authorized hosts are not retrieved by proxyHost, only clusterName, so we search all proxy entries. + var hostKeys []ssh.PublicKey + for proxyHost, proxyEntries := range ms.trustedCerts { + for _, entry := range proxyEntries { + // Mirror the hosts we would find in a known_hosts entry. + hosts := []string{proxyHost, entry.ClusterName, "*." + entry.ClusterName} + + if len(hostnames) == 0 || apisshutils.HostNameMatch(hostnames, hosts) { + clusterHostKeys, err := apisshutils.ParseAuthorizedKeys(entry.AuthorizedKeys) + if err != nil { + return nil, trace.Wrap(err) + } + hostKeys = append(hostKeys, clusterHostKeys...) + } + } + } + + return hostKeys, nil +} + +// FSTrustedCertsStore is an on-disk implementation of the TrustedCAStore interface. +// +// The FS store uses the file layout outlined in `api/utils/keypaths.go`. +type FSTrustedCertsStore struct { + // log holds the structured logger. + log logrus.FieldLogger + + // Dir is the directory where all keys are stored. + Dir string +} + +// NewFSTrustedCertsStore creates a new instance of FSTrustedCertsStore. +func NewFSTrustedCertsStore(dirPath string) *FSTrustedCertsStore { + dirPath = profile.FullProfilePath(dirPath) + return &FSTrustedCertsStore{ + log: logrus.WithField(trace.Component, teleport.ComponentKeyStore), + Dir: dirPath, + } +} + +// knownHostsPath returns the known_hosts file path. +func (fs *FSTrustedCertsStore) knownHostsPath() string { + return keypaths.KnownHostsPath(fs.Dir) +} + +// proxyKeyDir returns the keys directory for the given proxy. +func (fs *FSTrustedCertsStore) proxyKeyDir(proxy string) string { + return keypaths.ProxyKeyDir(fs.Dir, proxy) +} + +// casDir returns path to trusted clusters certificates directory. +func (fs *FSTrustedCertsStore) casDir(proxy string) string { + return keypaths.CAsDir(fs.Dir, proxy) +} + +// clusterCAPath returns path to trusted cluster certificate. +func (fs *FSTrustedCertsStore) clusterCAPath(proxy, clusterName string) string { + return keypaths.TLSCAsPathCluster(fs.Dir, proxy, clusterName) +} + +// tlsCAsPath returns the TLS CA certificates legacy path for the given KeyIndex. +func (fs *FSTrustedCertsStore) tlsCAsPath(proxy string) string { + return keypaths.TLSCAsPath(fs.Dir, proxy) +} + +// GetTrustedCerts gets the trusted CA TLS certificates and SSH host keys for the given proxyHost. +func (fs *FSTrustedCertsStore) GetTrustedCerts(proxyHost string) ([]auth.TrustedCerts, error) { + tlsCA, err := fs.GetTrustedCertsPEM(proxyHost) + if err != nil { + return nil, trace.ConvertSystemError(err) + } + knownHosts, err := fs.getKnownHostsFile() + if err != nil { + return nil, trace.ConvertSystemError(err) + } + + return TrustedCertsFromCACerts(proxyHost, tlsCA, [][]byte{knownHosts}) +} + +// GetTrustedHostKeys returns all trusted public host keys. If hostnames are provided, only +// matching host keys will be returned. Host names should be a proxy host or cluster name. +func (fs *FSTrustedCertsStore) GetTrustedHostKeys(hostnames ...string) (keys []ssh.PublicKey, retErr error) { + knownHosts, err := fs.getKnownHostsFile() + if err != nil { + return nil, trace.Wrap(err) + } + + // Return all known host keys with one of the given cluster names or proxyHost as a hostname. + return apisshutils.ParseKnownHosts([][]byte{knownHosts}, hostnames...) +} + +func (fs *FSTrustedCertsStore) getKnownHostsFile() (knownHosts []byte, retErr error) { + unlock, err := utils.FSTryReadLockTimeout(context.Background(), fs.knownHostsPath(), 5*time.Second) + if os.IsNotExist(err) { + return nil, trace.NotFound("please relogin, tsh user profile doesn't contain known_hosts: %s", fs.Dir) + } else if err != nil { + return nil, trace.WrapWithMessage(err, "could not acquire lock for the `known_hosts` file") + } + defer utils.StoreErrorOf(unlock, &retErr) + + knownHosts, err = os.ReadFile(fs.knownHostsPath()) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, trace.Wrap(err) + } + return knownHosts, nil +} + +// SaveTrustedCerts saves trusted TLS certificates of certificate authorities. +func (fs *FSTrustedCertsStore) SaveTrustedCerts(proxyHost string, cas []auth.TrustedCerts) (retErr error) { + if proxyHost == "" { + return trace.BadParameter("proxyHost must be provided to add trusted certs") + } + + for _, ca := range cas { + if ca.ClusterName == "" { + return trace.BadParameter("ca entry cannot have an empty cluster name") + } + } + + // Save trusted clusters certs in CAS directory. + if err := fs.saveTrustedCertsInCASDir(proxyHost, cas); err != nil { + return trace.Wrap(err) + } + + // For backward compatibility save trusted in legacy certs.pem file. + if err := fs.saveTrustedCertsInLegacyCAFile(proxyHost, cas); err != nil { + return trace.Wrap(err) + } + + if err := fs.addKnownHosts(proxyHost, cas); err != nil { + return trace.Wrap(err) + } + + return nil +} + +func (fs *FSTrustedCertsStore) saveTrustedCertsInCASDir(proxyHost string, cas []auth.TrustedCerts) error { + casDirPath := filepath.Join(fs.casDir(proxyHost)) + if err := os.MkdirAll(casDirPath, os.ModeDir|profileDirPerms); err != nil { + return trace.ConvertSystemError(err) + } + + for _, ca := range cas { + if len(ca.TLSCertificates) == 0 { + continue + } + // check if cluster name is safe and doesn't contain miscellaneous characters. + if strings.Contains(ca.ClusterName, "..") { + fs.log.Warnf("Skipped unsafe cluster name: %q", ca.ClusterName) + continue + } + // Create CA files in cas dir for each cluster. + if err := fs.writeClusterCertificates(proxyHost, ca.ClusterName, ca.TLSCertificates); err != nil { + return trace.Wrap(err) + } + } + return nil +} + +func (fs *FSTrustedCertsStore) writeClusterCertificates(proxyHost, clusterName string, tlsCertificates [][]byte) (retErr error) { + caFile, err := os.OpenFile(fs.clusterCAPath(proxyHost, clusterName), os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0640) + if err != nil { + return trace.ConvertSystemError(err) + } + defer caFile.Close() + + for _, cert := range tlsCertificates { + if _, err := caFile.Write(cert); err != nil { + return trace.ConvertSystemError(err) + } + } + if err := caFile.Sync(); err != nil { + return trace.ConvertSystemError(err) + } + return nil +} + +func (fs *FSTrustedCertsStore) saveTrustedCertsInLegacyCAFile(proxyHost string, cas []auth.TrustedCerts) (retErr error) { + if err := os.MkdirAll(fs.proxyKeyDir(proxyHost), os.ModeDir|profileDirPerms); err != nil { + return trace.ConvertSystemError(err) + } + + certsFile := fs.tlsCAsPath(proxyHost) + fp, err := os.OpenFile(certsFile, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0640) + if err != nil { + return trace.ConvertSystemError(err) + } + defer utils.StoreErrorOf(fp.Close, &retErr) + + for _, ca := range cas { + for _, cert := range ca.TLSCertificates { + if _, err := fp.Write(cert); err != nil { + return trace.ConvertSystemError(err) + } + if _, err := fmt.Fprintln(fp); err != nil { + return trace.ConvertSystemError(err) + } + } + } + if err := fp.Sync(); err != nil { + return trace.ConvertSystemError(err) + } + return nil +} + +// addKnownHosts adds new entries to `known_hosts` file for the provided CAs. +func (fs *FSTrustedCertsStore) addKnownHosts(proxyHost string, cas []auth.TrustedCerts) (retErr error) { + if err := os.MkdirAll(fs.proxyKeyDir(proxyHost), os.ModeDir|profileDirPerms); err != nil { + return trace.ConvertSystemError(err) + } + + // We're trying to serialize our writes to the 'known_hosts' file to avoid corruption, since there + // are cases when multiple tsh instances will try to write to it. + unlock, err := utils.FSTryWriteLockTimeout(context.Background(), fs.knownHostsPath(), 5*time.Second) + if err != nil { + return trace.WrapWithMessage(err, "could not acquire lock for the `known_hosts` file") + } + defer utils.StoreErrorOf(unlock, &retErr) + + fp, err := os.OpenFile(fs.knownHostsPath(), os.O_CREATE|os.O_RDWR, 0640) + if err != nil { + return trace.ConvertSystemError(err) + } + defer utils.StoreErrorOf(fp.Close, &retErr) + + // read all existing entries into a map (this removes any pre-existing dupes) + entries := make(map[string]int) + output := make([]string, 0) + scanner := bufio.NewScanner(fp) + for scanner.Scan() { + line := scanner.Text() + "\n" + if _, exists := entries[line]; !exists { + output = append(output, line) + entries[line] = 1 + } + } + // check if the scanner ran into an error + if err := scanner.Err(); err != nil { + return trace.Wrap(err) + } + + // add every host key to the list of entries + for _, ca := range cas { + for _, hostKey := range ca.AuthorizedKeys { + fs.log.Debugf("Adding known host %s with proxy %s", ca.ClusterName, proxyHost) + + // Write keys in an OpenSSH-compatible format. A previous format was not + // quite OpenSSH-compatible, so we may write a duplicate entry here. Any + // duplicates will be pruned below. + // We include both the proxy server and original hostname as well as the + // root domain wildcard. OpenSSH clients match against both the proxy + // host and nodes (via the wildcard). Teleport itself occasionally uses + // the root cluster name. + line, err := sshutils.MarshalKnownHost(sshutils.KnownHost{ + Hostname: ca.ClusterName, + ProxyHost: proxyHost, + AuthorizedKey: hostKey, + }) + if err != nil { + return trace.Wrap(err) + } + + if _, exists := entries[line]; !exists { + output = append(output, line) + } + } + } + + // Prune any duplicate host entries for migrated hosts. Note that only + // duplicates matching the current hostname/proxyHost will be pruned; others + // will be cleaned up at subsequent logins. + output = pruneOldHostKeys(output) + // re-create the file: + _, err = fp.Seek(0, 0) + if err != nil { + return trace.Wrap(err) + } + if err = fp.Truncate(0); err != nil { + return trace.Wrap(err) + } + for _, line := range output { + if _, err := fp.Write([]byte(line)); err != nil { + return trace.Wrap(err) + } + } + return fp.Sync() +} + +// GetTrustedCertsPEM returns trusted TLS certificates of certificate authorities PEM +// blocks. +func (fs *FSTrustedCertsStore) GetTrustedCertsPEM(proxyHost string) ([][]byte, error) { + dir := fs.casDir(proxyHost) + + if _, err := os.Stat(dir); err != nil { + if os.IsNotExist(err) { + return nil, trace.NotFound("please relogin, tsh user profile doesn't contain CAS directory: %s", dir) + } + return nil, trace.ConvertSystemError(err) + } + + var blocks [][]byte + err := filepath.Walk(dir, func(path string, info iofs.FileInfo, err error) error { + if err != nil { + return nil + } + if info.IsDir() { + return nil + } + + data, err := os.ReadFile(path) + for len(data) > 0 { + if err != nil { + return trace.Wrap(err) + } + block, rest := pem.Decode(data) + if block == nil { + break + } + if block.Type != "CERTIFICATE" || len(block.Headers) != 0 { + fs.log.Debugf("Skipping PEM block type=%v headers=%v.", block.Type, block.Headers) + data = rest + continue + } + // rest contains the remainder of data after reading a block. + // Therefore, the block length is len(data) - len(rest). + // Use that length to slice the block from the start of data. + blocks = append(blocks, data[:len(data)-len(rest)]) + data = rest + } + return nil + }) + if err != nil { + return nil, trace.Wrap(err) + } + + return blocks, nil +} + +func TrustedCertsFromCACerts(proxyHost string, tlsCACerts, knownHosts [][]byte) ([]auth.TrustedCerts, error) { + clusterCAs := make(map[string]*auth.TrustedCerts) + + // Loop through TLS CA certificates to create trusted certs entries + // for known cluster names. + for _, certPEM := range tlsCACerts { + cert, err := tlsca.ParseCertificatePEM(certPEM) + if err != nil { + return nil, trace.Wrap(err) + } + + clusterName := cert.Issuer.CommonName + if entry, ok := clusterCAs[clusterName]; !ok { + clusterCAs[clusterName] = &auth.TrustedCerts{ + ClusterName: clusterName, + TLSCertificates: [][]byte{certPEM}, + } + } else { + entry.TLSCertificates = append(entry.TLSCertificates, certPEM) + } + } + + // Parse authorized hosts. If the authorized host is for the given proxy host, + // add the authorized host to the trusted certs entries. + parsedKnownHosts, err := sshutils.UnmarshalKnownHosts(knownHosts) + if err != nil { + return nil, trace.Wrap(err) + } + for _, kh := range parsedKnownHosts { + if kh.ProxyHost == proxyHost { + if _, ok := clusterCAs[kh.Hostname]; !ok { + clusterCAs[kh.Hostname] = &auth.TrustedCerts{ + ClusterName: kh.Hostname, + } + } + clusterCAs[kh.Hostname].AuthorizedKeys = append(clusterCAs[kh.Hostname].AuthorizedKeys, kh.AuthorizedKey) + } + } + + var trustedCerts []auth.TrustedCerts + for _, trustedCA := range clusterCAs { + trustedCerts = append(trustedCerts, *trustedCA) + } + + return trustedCerts, nil +} diff --git a/lib/client/trusted_certs_store_test.go b/lib/client/trusted_certs_store_test.go new file mode 100644 index 000000000000..44b304981a94 --- /dev/null +++ b/lib/client/trusted_certs_store_test.go @@ -0,0 +1,233 @@ +/* +Copyright 2016-2022 Gravitational, Inc. + +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. +*/ + +package client + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/crypto/ssh" + + apisshutils "github.com/gravitational/teleport/api/utils/sshutils" + "github.com/gravitational/teleport/lib/auth" + "github.com/gravitational/teleport/lib/fixtures" +) + +func newTestFSTrustedCertsStore(t *testing.T) TrustedCertsStore { + fsTrustedCertsStore := NewFSTrustedCertsStore(t.TempDir()) + return fsTrustedCertsStore +} + +func testEachTrustedCertsStore(t *testing.T, testFunc func(t *testing.T, TrustedCertsStore TrustedCertsStore)) { + t.Run("FS", func(t *testing.T) { + testFunc(t, newTestFSTrustedCertsStore(t)) + }) + + t.Run("Mem", func(t *testing.T) { + testFunc(t, NewMemTrustedCertsStore()) + }) +} + +func TestTrustedCertsStore(t *testing.T) { + t.Parallel() + a := newTestAuthority(t) + + testEachTrustedCertsStore(t, func(t *testing.T, trustedCertsStore TrustedCertsStore) { + t.Parallel() + + pemBytes, ok := fixtures.PEMBytes["rsa"] + require.True(t, ok) + + ca, rootCluster, err := newSelfSignedCA(pemBytes, "root") + require.NoError(t, err) + _, rootClusterSecondCert, err := newSelfSignedCA(pemBytes, "root") + require.NoError(t, err) + _, leafCluster, err := newSelfSignedCA(pemBytes, "leaf") + require.NoError(t, err) + + caHostKey, err := ssh.NewPublicKey(ca.Signer.Public()) + require.NoError(t, err) + + // Add trusted certs to the store. + proxy := "proxy.example.com" + trustedCerts := []auth.TrustedCerts{ + { + ClusterName: rootCluster.ClusterName, + TLSCertificates: append(rootCluster.TLSCertificates, rootClusterSecondCert.TLSCertificates...), + AuthorizedKeys: rootCluster.AuthorizedKeys, + }, { + ClusterName: leafCluster.ClusterName, + TLSCertificates: leafCluster.TLSCertificates, + AuthorizedKeys: leafCluster.AuthorizedKeys, + }, + } + err = trustedCertsStore.SaveTrustedCerts(proxy, trustedCerts) + require.NoError(t, err) + + // GetTrustedCerts should return the trusted certs. + retrievedTrustedCerts, err := trustedCertsStore.GetTrustedCerts(proxy) + require.NoError(t, err) + require.ElementsMatch(t, trustedCerts, retrievedTrustedCerts) + + // Check against duplicates (no change). + err = trustedCertsStore.SaveTrustedCerts(proxy, trustedCerts) + require.NoError(t, err) + retrievedTrustedCerts, err = trustedCertsStore.GetTrustedCerts(proxy) + require.NoError(t, err) + require.ElementsMatch(t, trustedCerts, retrievedTrustedCerts) + + // GetTrustedCertsPEM should returns the trusted TLS certificates. + retrievedCerts, err := trustedCertsStore.GetTrustedCertsPEM(proxy) + require.NoError(t, err) + expectCerts := append( + append( + rootCluster.TLSCertificates, + rootClusterSecondCert.TLSCertificates...), + leafCluster.TLSCertificates..., + ) + require.ElementsMatch(t, expectCerts, retrievedCerts) + + // GetTrustedHostKeys should return each CA's public host key. We should + // find a host key for each cluster, which in this case is the same host key. + hostKeys, err := trustedCertsStore.GetTrustedHostKeys(rootCluster.ClusterName, leafCluster.ClusterName) + require.NoError(t, err) + require.ElementsMatch(t, []ssh.PublicKey{caHostKey, caHostKey}, hostKeys) + + // Saving a new trusted certs entry should overwrite existing TLS certificates. + // Host keys shouldn't be overwritten. + err = trustedCertsStore.SaveTrustedCerts(proxy, []auth.TrustedCerts{ + { + ClusterName: rootCluster.ClusterName, + TLSCertificates: rootCluster.TLSCertificates, + }, + }) + require.NoError(t, err) + trustedCerts[0].TLSCertificates = rootCluster.TLSCertificates + retrievedTrustedCerts, err = trustedCertsStore.GetTrustedCerts(proxy) + require.NoError(t, err) + require.ElementsMatch(t, trustedCerts, retrievedTrustedCerts) + + // Adding a new trusted certs with host keys should append to existing entry. + // TLS certs shouldn't be overwritten if not provided. + _, publicKey, err := a.keygen.GenerateKeyPair() + require.NoError(t, err) + sshPub, _, _, _, err := ssh.ParseAuthorizedKey(publicKey) + require.NoError(t, err) + trustedCertsStore.SaveTrustedCerts(proxy, []auth.TrustedCerts{{ + ClusterName: rootCluster.ClusterName, + AuthorizedKeys: [][]byte{ssh.MarshalAuthorizedKey(sshPub)}, + }}) + require.NoError(t, err) + trustedCerts[0].AuthorizedKeys = append(trustedCerts[0].AuthorizedKeys, ssh.MarshalAuthorizedKey(sshPub)) + retrievedTrustedCerts, err = trustedCertsStore.GetTrustedCerts(proxy) + require.NoError(t, err) + require.ElementsMatch(t, trustedCerts, retrievedTrustedCerts) + }) +} + +func TestAddTrustedHostKeys(t *testing.T) { + t.Parallel() + auth := newTestAuthority(t) + + testEachClientStore(t, func(t *testing.T, clientStore *Store) { + t.Parallel() + + pub, _, _, _, err := ssh.ParseAuthorizedKey(CAPub) + require.NoError(t, err) + + _, p2, _ := auth.keygen.GenerateKeyPair() + pub2, _, _, _, _ := ssh.ParseAuthorizedKey(p2) + + err = clientStore.AddTrustedHostKeys("proxy.example.com", "root", pub) + require.NoError(t, err) + err = clientStore.AddTrustedHostKeys("proxy.example.com", "root", pub2) + require.NoError(t, err) + err = clientStore.AddTrustedHostKeys("leaf.example.com", "leaf", pub2) + require.NoError(t, err) + + keys, err := clientStore.GetTrustedHostKeys() + require.NoError(t, err) + require.Len(t, keys, 3) + require.ElementsMatch(t, keys, []ssh.PublicKey{pub, pub2, pub2}) + + // check against dupes: + before, _ := clientStore.GetTrustedHostKeys() + err = clientStore.AddTrustedHostKeys("leaf.example.com", "leaf", pub2) + require.NoError(t, err) + err = clientStore.AddTrustedHostKeys("leaf.example.com", "leaf", pub2) + require.NoError(t, err) + after, _ := clientStore.GetTrustedHostKeys() + require.Equal(t, len(before), len(after)) + + // check by hostname: + keys, _ = clientStore.GetTrustedHostKeys("nocluster") + require.Equal(t, len(keys), 0) + keys, _ = clientStore.GetTrustedHostKeys("leaf") + require.Equal(t, len(keys), 1) + require.True(t, apisshutils.KeysEqual(keys[0], pub2)) + + // check for proxy and wildcard as well: + keys, _ = clientStore.GetTrustedHostKeys("leaf.example.com") + require.Equal(t, 1, len(keys)) + require.True(t, apisshutils.KeysEqual(keys[0], pub2)) + keys, _ = clientStore.GetTrustedHostKeys("*.leaf") + require.Equal(t, 1, len(keys)) + require.True(t, apisshutils.KeysEqual(keys[0], pub2)) + keys, _ = clientStore.GetTrustedHostKeys("prefix.leaf") + require.Equal(t, 1, len(keys)) + require.True(t, apisshutils.KeysEqual(keys[0], pub2)) + }) +} + +// Test that we can write keys to known_hosts in parallel without corrupting +// content of the file when using file based client store. +func TestParallelKnownHostsFileWrite(t *testing.T) { + t.Parallel() + auth := newTestAuthority(t) + clientStore := newTestFSClientStore(t) + + pub, _, _, _, err := ssh.ParseAuthorizedKey(CAPub) + require.NoError(t, err) + + err = clientStore.AddTrustedHostKeys("proxy.example1.com", "example1.com", pub) + require.NoError(t, err) + + _, p2, _ := auth.keygen.GenerateKeyPair() + tmpPub, _, _, _, _ := ssh.ParseAuthorizedKey(p2) + + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { + err := clientStore.AddTrustedHostKeys("proxy.example2.com", "example2.com", tmpPub) + assert.NoError(t, err) + + _, err = clientStore.GetTrustedHostKeys("") + assert.NoError(t, err) + + wg.Done() + }() + } + + wg.Wait() + + keys, err := clientStore.GetTrustedHostKeys() + require.NoError(t, err) + require.Len(t, keys, 2) +} diff --git a/lib/kube/kubeconfig/kubeconfig_test.go b/lib/kube/kubeconfig/kubeconfig_test.go index eb87476a27de..95e29a190460 100644 --- a/lib/kube/kubeconfig/kubeconfig_test.go +++ b/lib/kube/kubeconfig/kubeconfig_test.go @@ -390,8 +390,8 @@ func TestUpdateLoadAllCAs(t *testing.T) { require.NoError(t, err) _, leafCACertPEM, err := genUserKey("example.com") require.NoError(t, err) - creds.TrustedCA[0].ClusterName = clusterName - creds.TrustedCA = append(creds.TrustedCA, auth.TrustedCerts{ + creds.TrustedCerts[0].ClusterName = clusterName + creds.TrustedCerts = append(creds.TrustedCerts, auth.TrustedCerts{ ClusterName: leafClusterName, TLSCertificates: [][]byte{leafCACertPEM}, }) @@ -515,7 +515,7 @@ func genUserKey(hostname string) (*client.Key, []byte, error) { return &client.Key{ PrivateKey: priv, TLSCert: tlsCert, - TrustedCA: []auth.TrustedCerts{{ + TrustedCerts: []auth.TrustedCerts{{ TLSCertificates: [][]byte{caCert}, }}, }, caCert, nil diff --git a/lib/sshutils/marshal.go b/lib/sshutils/marshal.go index ef967ac25f2d..91a90a8ff5cd 100644 --- a/lib/sshutils/marshal.go +++ b/lib/sshutils/marshal.go @@ -18,8 +18,12 @@ package sshutils import ( "fmt" + "io" "net/url" "strings" + + "github.com/gravitational/trace" + "golang.org/x/crypto/ssh" ) // MarshalAuthorizedKeysFormat returns the certificate authority public key exported as a single @@ -37,23 +41,96 @@ func MarshalAuthorizedKeysFormat(clusterName string, keyBytes []byte) (string, e "clustername": []string{clusterName}, } - return fmt.Sprintf("cert-authority %s %s", strings.TrimSpace(string(keyBytes)), comment.Encode()), nil + return fmt.Sprintf("cert-authority %s %s\n", strings.TrimSpace(string(keyBytes)), comment.Encode()), nil +} + +// KnownHost is a structural representation of a known hosts entry for a Teleport host. +type KnownHost struct { + AuthorizedKey []byte + ProxyHost string + Hostname string + Comment map[string][]string } -// MarshalAuthorizedHostsFormat returns the certificate authority public key exported as a single line -// that can be placed in ~/.ssh/authorized_hosts. The format adheres to the man sshd (8) -// authorized_hosts format, a space-separated list of: marker, hosts, key, and comment. +// MarshalKnownHost returns the certificate authority public key exported as a single line +// that can be placed in ~/.ssh/known_hosts. The format adheres to the man sshd (8) +// known_hosts format, a space-separated list of: marker, hosts, key, and comment. // For example: // -// @cert-authority *.cluster-a,cluster-a ssh-rsa AAA... type=host +// @cert-authority proxy.example.com,cluster-a,*.cluster-a ssh-rsa AAA... type=host // // URL encoding is used to pass the CA type and allowed logins into the comment field. -func MarshalAuthorizedHostsFormat(clusterName string, keyBytes []byte, logins []string) (string, error) { - comment := url.Values{ - "type": []string{"host"}, - "logins": logins, +func MarshalKnownHost(kh KnownHost) (string, error) { + if kh.Hostname == "" { + return "", trace.BadParameter("missing required argument clusterName") + } + + if len(kh.AuthorizedKey) == 0 { + return "", trace.BadParameter("missing required argument keyBytes") + } + + comment := url.Values(kh.Comment) + if comment == nil { + comment = url.Values{} + } + + if _, ok := comment["type"]; !ok { + comment["type"] = []string{"host"} + } + + hosts := []string{kh.Hostname, "*." + kh.Hostname} + if kh.ProxyHost != "" { + hosts = append([]string{kh.ProxyHost}, hosts...) + } + + return fmt.Sprintf("@cert-authority %s %s %s\n", strings.Join(hosts, ","), strings.TrimSpace(string(kh.AuthorizedKey)), comment.Encode()), nil +} + +// UnmarshalKnownHosts returns a list of authorized hosts from the given known_hosts +// file. Entries in the given file should adhere to the man sshd (8) known_hosts format, +// a space-separated list of: marker, hosts, key, and comment. +// For example: +// +// @cert-authority proxy.example.com,cluster-a,*.cluster-a ssh-rsa AAA... type=host +// +// UnmarshalKnownHosts will try to guess the proxy host and cluster name for entries that +// look like Teleport authorized host entries, generated with MarshalKnownHost. +func UnmarshalKnownHosts(knownHostsFile [][]byte) ([]KnownHost, error) { + var knownHosts []KnownHost + for _, line := range knownHostsFile { + for { + _, hosts, publicKey, commentString, rest, err := ssh.ParseKnownHosts(line) + if err == io.EOF { + break + } else if err != nil { + return nil, trace.Wrap(err, "failed parsing known hosts: %v; raw line: %q", err, line) + } + + ah := KnownHost{ + AuthorizedKey: ssh.MarshalAuthorizedKey(publicKey), + } + + comment, err := url.ParseQuery(commentString) + if err != nil { + return nil, trace.Wrap(err) + } + ah.Comment = map[string][]string(comment) + + // Assuming the known host was generated from MarshalKnownHost, + // we can get the proxyHost and clusterName for the host. + switch len(hosts) { + case 1, 2: + ah.Hostname = hosts[0] + case 3: + ah.ProxyHost = hosts[0] + ah.Hostname = hosts[1] + } + + knownHosts = append(knownHosts, ah) + + line = rest + } } - return fmt.Sprintf("@cert-authority %s,*.%s %s %s", - clusterName, clusterName, strings.TrimSpace(string(keyBytes)), comment.Encode()), nil + return knownHosts, nil } diff --git a/lib/sshutils/marshal_test.go b/lib/sshutils/marshal_test.go new file mode 100644 index 000000000000..41e76a7e179c --- /dev/null +++ b/lib/sshutils/marshal_test.go @@ -0,0 +1,69 @@ +/* +Copyright 2022 Gravitational, Inc. + +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. +*/ +package sshutils + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMarshalKnownHost(t *testing.T) { + var file [][]byte + for _, kh := range knownHosts { + line, err := MarshalKnownHost(kh) + require.NoError(t, err) + file = append(file, []byte(line)) + } + require.Equal(t, knownHostsFile, file) +} + +func TestUnmarshalKnownHosts(t *testing.T) { + parsedKnownHosts, err := UnmarshalKnownHosts(knownHostsFile) + require.NoError(t, err) + for i, parsed := range parsedKnownHosts { + require.Equal(t, knownHosts[i].AuthorizedKey, parsed.AuthorizedKey) + require.Equal(t, knownHosts[i].ProxyHost, parsed.ProxyHost) + require.Equal(t, knownHosts[i].Hostname, parsed.Hostname) + for key, val := range knownHosts[i].Comment { + require.Equal(t, knownHosts[i].Comment[key], val) + } + } +} + +var knownHosts = []KnownHost{ + { + AuthorizedKey: []byte("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDEk4cVIiydp9xSPIb8UqXpShY8zPlk/lpR69UL+0+RnNXtQl7GcQUZsrXDB2gOCfj+doKZj8Pt8oQVSDJF/vKhr+KS2Z+LC2Gyt8D5IY/acyyhSN5VoIo0JzIOr5CPGJNpLChREFuveV30hLihSfY52cqSvu7N5u34BlZ29WTLeBD9WssAG5HZUES8Xo3neHBl4SOck+mdiUvOIPhcnPiYRmYltOI3GJRu5y1xGemoPU3MnMziQMqnKCc2+To6IC8CkeQqa8D//BxLjenjSgn1K/SLUHraMb5qCmf77fyshj6A9jamgo0UOaOqem+jyg8idnz6JbVfXwW0nEaSyPzX\n"), + ProxyHost: "proxy.example.com", + Hostname: "cluster1", + Comment: map[string][]string{ + "logins": {"root"}, + }, + }, { + AuthorizedKey: []byte("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDEk4cVIiydp9xSPIb8UqXpShY8zPlk/lpR69UL+0+RnNXtQl7GcQUZsrXDB2gOCfj+doKZj8Pt8oQVSDJF/vKhr+KS2Z+LC2Gyt8D5IY/acyyhSN5VoIo0JzIOr5CPGJNpLChREFuveV30hLihSfY52cqSvu7N5u34BlZ29WTLeBD9WssAG5HZUES8Xo3neHBl4SOck+mdiUvOIPhcnPiYRmYltOI3GJRu5y1xGemoPU3MnMziQMqnKCc2+To6IC8CkeQqa8D//BxLjenjSgn1K/SLUHraMb5qCmf77fyshj6A9jamgo0UOaOqem+jyg8idnz6JbVfXwW0nEaSyPzX\n"), + ProxyHost: "proxy.example.com", + Hostname: "cluster2", + }, { + AuthorizedKey: []byte("ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDEk4cVIiydp9xSPIb8UqXpShY8zPlk/lpR69UL+0+RnNXtQl7GcQUZsrXDB2gOCfj+doKZj8Pt8oQVSDJF/vKhr+KS2Z+LC2Gyt8D5IY/acyyhSN5VoIo0JzIOr5CPGJNpLChREFuveV30hLihSfY52cqSvu7N5u34BlZ29WTLeBD9WssAG5HZUES8Xo3neHBl4SOck+mdiUvOIPhcnPiYRmYltOI3GJRu5y1xGemoPU3MnMziQMqnKCc2+To6IC8CkeQqa8D//BxLjenjSgn1K/SLUHraMb5qCmf77fyshj6A9jamgo0UOaOqem+jyg8idnz6JbVfXwW0nEaSyPzX\n"), + Hostname: "cluster3", + }, +} + +var knownHostsFile = [][]byte{ + []byte("@cert-authority proxy.example.com,cluster1,*.cluster1 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDEk4cVIiydp9xSPIb8UqXpShY8zPlk/lpR69UL+0+RnNXtQl7GcQUZsrXDB2gOCfj+doKZj8Pt8oQVSDJF/vKhr+KS2Z+LC2Gyt8D5IY/acyyhSN5VoIo0JzIOr5CPGJNpLChREFuveV30hLihSfY52cqSvu7N5u34BlZ29WTLeBD9WssAG5HZUES8Xo3neHBl4SOck+mdiUvOIPhcnPiYRmYltOI3GJRu5y1xGemoPU3MnMziQMqnKCc2+To6IC8CkeQqa8D//BxLjenjSgn1K/SLUHraMb5qCmf77fyshj6A9jamgo0UOaOqem+jyg8idnz6JbVfXwW0nEaSyPzX logins=root&type=host\n"), + []byte("@cert-authority proxy.example.com,cluster2,*.cluster2 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDEk4cVIiydp9xSPIb8UqXpShY8zPlk/lpR69UL+0+RnNXtQl7GcQUZsrXDB2gOCfj+doKZj8Pt8oQVSDJF/vKhr+KS2Z+LC2Gyt8D5IY/acyyhSN5VoIo0JzIOr5CPGJNpLChREFuveV30hLihSfY52cqSvu7N5u34BlZ29WTLeBD9WssAG5HZUES8Xo3neHBl4SOck+mdiUvOIPhcnPiYRmYltOI3GJRu5y1xGemoPU3MnMziQMqnKCc2+To6IC8CkeQqa8D//BxLjenjSgn1K/SLUHraMb5qCmf77fyshj6A9jamgo0UOaOqem+jyg8idnz6JbVfXwW0nEaSyPzX type=host\n"), + []byte("@cert-authority cluster3,*.cluster3 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDEk4cVIiydp9xSPIb8UqXpShY8zPlk/lpR69UL+0+RnNXtQl7GcQUZsrXDB2gOCfj+doKZj8Pt8oQVSDJF/vKhr+KS2Z+LC2Gyt8D5IY/acyyhSN5VoIo0JzIOr5CPGJNpLChREFuveV30hLihSfY52cqSvu7N5u34BlZ29WTLeBD9WssAG5HZUES8Xo3neHBl4SOck+mdiUvOIPhcnPiYRmYltOI3GJRu5y1xGemoPU3MnMziQMqnKCc2+To6IC8CkeQqa8D//BxLjenjSgn1K/SLUHraMb5qCmf77fyshj6A9jamgo0UOaOqem+jyg8idnz6JbVfXwW0nEaSyPzX type=host\n"), +} diff --git a/lib/tbot/config/configtemplate.go b/lib/tbot/config/configtemplate.go index 354777d3bc6f..1a9b5852f446 100644 --- a/lib/tbot/config/configtemplate.go +++ b/lib/tbot/config/configtemplate.go @@ -268,10 +268,10 @@ func newClientKey(ident *identity.Identity, hostCAs []types.CertAuthority) (*cli KeyIndex: client.KeyIndex{ ClusterName: ident.ClusterName, }, - PrivateKey: pk, - Cert: ident.CertBytes, - TLSCert: ident.TLSCertBytes, - TrustedCA: auth.AuthoritiesToTrustedCerts(hostCAs), + PrivateKey: pk, + Cert: ident.CertBytes, + TLSCert: ident.TLSCertBytes, + TrustedCerts: auth.AuthoritiesToTrustedCerts(hostCAs), // Note: these fields are never used or persisted with identity files, // so we won't bother to set them. (They may need to be reconstituted diff --git a/lib/tbot/config/configtemplate_ssh_host_cert.go b/lib/tbot/config/configtemplate_ssh_host_cert.go index 248d6fa3711d..8d1b3a59a021 100644 --- a/lib/tbot/config/configtemplate_ssh_host_cert.go +++ b/lib/tbot/config/configtemplate_ssh_host_cert.go @@ -94,7 +94,7 @@ func (c *TemplateSSHHostCert) Describe(destination bot.Destination) []FileDescri // exportSSHUserCAs generates SSH CAs. func exportSSHUserCAs(cas []types.CertAuthority, localAuthName string) (string, error) { - var exported []string + var exported string for _, ca := range cas { // Don't export trusted CAs. @@ -111,11 +111,11 @@ func exportSSHUserCAs(cas []types.CertAuthority, localAuthName string) (string, // remove "cert-authority " s = strings.TrimPrefix(s, sshHostTrimPrefix) - exported = append(exported, s) + exported += s } } - return strings.Join(exported, "\n") + "\n", nil + return exported, nil } // Render generates SSH host cert files. diff --git a/lib/teleterm/clusters/cluster_access_requests.go b/lib/teleterm/clusters/cluster_access_requests.go index 1ea8b49092b1..49da550d679a 100644 --- a/lib/teleterm/clusters/cluster_access_requests.go +++ b/lib/teleterm/clusters/cluster_access_requests.go @@ -258,7 +258,7 @@ func (c *Cluster) AssumeRole(ctx context.Context, req *api.AssumeRoleRequest) er return trace.Wrap(err) } - err = c.clusterClient.SaveProfile(c.dir, true) + err = c.clusterClient.SaveProfile(true) if err != nil { return trace.Wrap(err) } diff --git a/lib/teleterm/clusters/cluster_auth.go b/lib/teleterm/clusters/cluster_auth.go index 4cd887ed37a8..bb4a8fded683 100644 --- a/lib/teleterm/clusters/cluster_auth.go +++ b/lib/teleterm/clusters/cluster_auth.go @@ -42,7 +42,7 @@ func (c *Cluster) SyncAuthPreference(ctx context.Context) (*webclient.WebConfigA return nil, trace.Wrap(err) } - if err := c.clusterClient.SaveProfile(c.dir, false); err != nil { + if err := c.clusterClient.SaveProfile(false); err != nil { return nil, trace.Wrap(err) } @@ -191,11 +191,11 @@ func (c *Cluster) login(ctx context.Context, sshLoginFunc client.SSHLoginFunc) e return trace.Wrap(err) } - if err := c.clusterClient.SaveProfile(c.dir, true); err != nil { + if err := c.clusterClient.SaveProfile(true); err != nil { return trace.Wrap(err) } - status, err := client.ReadProfileStatus(c.dir, key.ProxyHost) + status, err := c.clusterClient.ProfileStatus() if err != nil { return trace.Wrap(err) } diff --git a/lib/teleterm/clusters/storage.go b/lib/teleterm/clusters/storage.go index 9d6f5ef29c1d..fbd9a52b5190 100644 --- a/lib/teleterm/clusters/storage.go +++ b/lib/teleterm/clusters/storage.go @@ -161,7 +161,7 @@ func (s *Storage) addCluster(ctx context.Context, dir, webProxyAddress string) ( return nil, trace.Wrap(err) } - if err := cfg.SaveProfile(s.Dir, false); err != nil { + if err := cfg.SaveProfile(false); err != nil { return nil, trace.Wrap(err) } @@ -185,8 +185,10 @@ func (s *Storage) fromProfile(profileName, leafClusterName string) (*Cluster, er clusterNameForKey := profileName clusterURI := uri.NewClusterURI(profileName) + profileStore := client.NewFSProfileStore(s.Dir) + cfg := client.MakeDefaultConfig() - if err := cfg.LoadProfile(s.Dir, profileName); err != nil { + if err := cfg.LoadProfile(profileStore, profileName); err != nil { return nil, trace.Wrap(err) } cfg.KeysDir = s.Dir @@ -213,7 +215,7 @@ func (s *Storage) fromProfile(profileName, leafClusterName string) (*Cluster, er } if err == nil && cfg.Username != "" { - status, err = client.ReadProfileStatus(s.Dir, profileName) + status, err = clusterClient.ProfileStatus() if err != nil { return nil, trace.Wrap(err) } diff --git a/tool/tctl/common/auth_command.go b/tool/tctl/common/auth_command.go index 95a2050193f7..854554f74b19 100644 --- a/tool/tctl/common/auth_command.go +++ b/tool/tctl/common/auth_command.go @@ -257,7 +257,7 @@ func (a *AuthCommand) generateSnowflakeKey(ctx context.Context, clusterAPI auth. return trace.Wrap(err) } - key.TrustedCA = []auth.TrustedCerts{{TLSCertificates: services.GetTLSCerts(databaseCA)}} + key.TrustedCerts = []auth.TrustedCerts{{TLSCertificates: services.GetTLSCerts(databaseCA)}} filesWritten, err := identityfile.Write(identityfile.WriteConfig{ OutputPath: a.output, @@ -350,7 +350,7 @@ func (a *AuthCommand) generateHostKeys(ctx context.Context, clusterAPI auth.Clie if err != nil { return trace.Wrap(err) } - key.TrustedCA = auth.AuthoritiesToTrustedCerts(hostCAs) + key.TrustedCerts = auth.AuthoritiesToTrustedCerts(hostCAs) // if no name was given, take the first name on the list of principals filePath := a.output @@ -653,7 +653,7 @@ func (a *AuthCommand) generateUserKeys(ctx context.Context, clusterAPI auth.Clie if err != nil { return trace.Wrap(err) } - key.TrustedCA = auth.AuthoritiesToTrustedCerts(hostCAs) + key.TrustedCerts = auth.AuthoritiesToTrustedCerts(hostCAs) // Is TLS routing enabled? proxyListenerMode := types.ProxyListenerMode_Separate diff --git a/tool/tctl/common/tctl.go b/tool/tctl/common/tctl.go index 1c049bda95d4..aad844d51ea1 100644 --- a/tool/tctl/common/tctl.go +++ b/tool/tctl/common/tctl.go @@ -30,15 +30,14 @@ import ( "github.com/gravitational/trace" "github.com/jonboulle/clockwork" log "github.com/sirupsen/logrus" - "golang.org/x/crypto/ssh" "github.com/gravitational/teleport" "github.com/gravitational/teleport/api/breaker" "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/api/utils/sshutils" "github.com/gravitational/teleport/lib/auth" "github.com/gravitational/teleport/lib/auth/authclient" "github.com/gravitational/teleport/lib/client" + "github.com/gravitational/teleport/lib/client/identityfile" "github.com/gravitational/teleport/lib/config" "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/service" @@ -277,9 +276,8 @@ func ApplyConfig(ccf *GlobalCLIFlags, cfg *service.Config) (*authclient.Config, } // Config file should take precedence, if available. - if fileConf == nil && ccf.IdentityFilePath == "" { - // No config file or identity file. - // Try the extension loader. + if fileConf == nil { + // No config file. Try profile or identity file. log.Debug("No config file or identity file, loading auth config via extension.") authConfig, err := LoadConfigFromProfile(ccf, cfg) if err == nil { @@ -304,118 +302,74 @@ func ApplyConfig(ccf *GlobalCLIFlags, cfg *service.Config) (*authclient.Config, } authConfig := new(authclient.Config) - // --identity flag - if ccf.IdentityFilePath != "" { - key, err := client.KeyFromIdentityFile(ccf.IdentityFilePath) - if err != nil { - return nil, trace.Wrap(err) - } - clusterName, err := key.RootClusterName() - if err != nil { - return nil, trace.Wrap(err) - } - - authConfig.TLS, err = key.TeleportClientTLSConfig(cfg.CipherSuites, []string{clusterName}) - if err != nil { - return nil, trace.Wrap(err) - } - - authConfig.SSH, err = key.ProxyClientSSHConfig(&sshTrustedHostKeyWrapper{key}, clusterName) - if err != nil { - return nil, trace.Wrap(err) - } - } else { - // read the host UUID only in case the identity was not provided, - // because it will be used for reading local auth server identity - cfg.HostUUID, err = utils.ReadHostUUID(cfg.DataDir) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - return nil, trace.Wrap(err, "Could not load Teleport host UUID file at %s. "+ - "Please make sure that Teleport is up and running prior to using tctl.", - filepath.Join(cfg.DataDir, utils.HostUUIDFile)) - } else if errors.Is(err, fs.ErrPermission) { - return nil, trace.Wrap(err, "Teleport does not have permission to read Teleport host UUID file at %s. "+ - "Ensure that you are running as a user with appropriate permissions.", - filepath.Join(cfg.DataDir, utils.HostUUIDFile)) - } - return nil, trace.Wrap(err) - } - identity, err := auth.ReadLocalIdentity(filepath.Join(cfg.DataDir, teleport.ComponentProcess), auth.IdentityID{Role: types.RoleAdmin, HostUUID: cfg.HostUUID}) - if err != nil { - // The "admin" identity is not present? This means the tctl is running - // NOT on the auth server - if trace.IsNotFound(err) { - return nil, trace.AccessDenied("tctl must be either used on the auth server or provided with the identity file via --identity flag") - } - return nil, trace.Wrap(err) - } - authConfig.TLS, err = identity.TLSConfig(cfg.CipherSuites) - if err != nil { - return nil, trace.Wrap(err) + // read the host UUID only in case the identity was not provided, + // because it will be used for reading local auth server identity + cfg.HostUUID, err = utils.ReadHostUUID(cfg.DataDir) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, trace.Wrap(err, "Could not load Teleport host UUID file at %s. "+ + "Please make sure that Teleport is up and running prior to using tctl.", + filepath.Join(cfg.DataDir, utils.HostUUIDFile)) + } else if errors.Is(err, fs.ErrPermission) { + return nil, trace.Wrap(err, "Teleport does not have permission to read Teleport host UUID file at %s. "+ + "Ensure that you are running as a user with appropriate permissions.", + filepath.Join(cfg.DataDir, utils.HostUUIDFile)) } + return nil, trace.Wrap(err) } - authConfig.TLS.InsecureSkipVerify = ccf.Insecure - authConfig.AuthServers = cfg.AuthServerAddresses() - authConfig.Log = cfg.Log - - return authConfig, nil -} - -// sshTrustedHostKeyWrapper wraps a client Key allowing to call GetKnownHostKeys function for particular hostname. -type sshTrustedHostKeyWrapper struct { - *client.Key -} - -// GetKnownHostKeys returns know trusted key for a particular hostname. -func (m *sshTrustedHostKeyWrapper) GetKnownHostKeys(hostname string) ([]ssh.PublicKey, error) { - ca, err := m.Key.SSHCAsForClusters([]string{hostname}) + identity, err := auth.ReadLocalIdentity(filepath.Join(cfg.DataDir, teleport.ComponentProcess), auth.IdentityID{Role: types.RoleAdmin, HostUUID: cfg.HostUUID}) if err != nil { + // The "admin" identity is not present? This means the tctl is running + // NOT on the auth server + if trace.IsNotFound(err) { + return nil, trace.AccessDenied("tctl must be either used on the auth server or provided with the identity file via --identity flag") + } return nil, trace.Wrap(err) } - trustedKeys, err := sshutils.ParseKnownHosts(ca) + authConfig.TLS, err = identity.TLSConfig(cfg.CipherSuites) if err != nil { return nil, trace.Wrap(err) } - return trustedKeys, nil + authConfig.TLS.InsecureSkipVerify = ccf.Insecure + authConfig.AuthServers = cfg.AuthServerAddresses() + authConfig.Log = cfg.Log + + return authConfig, nil } // LoadConfigFromProfile applies config from ~/.tsh/ profile if it's present func LoadConfigFromProfile(ccf *GlobalCLIFlags, cfg *service.Config) (*authclient.Config, error) { - if ccf.IdentityFilePath != "" { - return nil, trace.NotFound("identity has been supplied, skip loading the config") - } - proxyAddr := "" if len(ccf.AuthServerAddr) != 0 { proxyAddr = ccf.AuthServerAddr[0] } - profile, _, err := client.Status(cfg.TeleportHome, proxyAddr) - if err != nil { - if !trace.IsNotFound(err) { + + clientStore := client.NewFSClientStore(cfg.TeleportHome) + if ccf.IdentityFilePath != "" { + var err error + clientStore, err = identityfile.NewClientStoreFromIdentityFile(ccf.IdentityFilePath, proxyAddr, "") + if err != nil { return nil, trace.Wrap(err) } } - // client is already logged in using tsh login and profile is not expired - if profile == nil { - return nil, trace.NotFound("profile is not found") + + profile, err := clientStore.ReadProfileStatus(proxyAddr) + if err != nil { + return nil, trace.Wrap(err) } if profile.IsExpired(clockwork.NewRealClock()) { return nil, trace.BadParameter("your credentials have expired, please login using `tsh login`") } - log.WithFields(log.Fields{"proxy": profile.ProxyURL.String(), "user": profile.Username}).Debugf("Found active profile.") - c := client.MakeDefaultConfig() - if err := c.LoadProfile(cfg.TeleportHome, proxyAddr); err != nil { - return nil, trace.Wrap(err) - } - keyStore, err := client.NewFSLocalKeyStore(c.KeysDir) - if err != nil { + log.WithFields(log.Fields{"proxy": profile.ProxyURL.String(), "user": profile.Username}).Debugf("Found profile.") + if err := c.LoadProfile(clientStore, proxyAddr); err != nil { return nil, trace.Wrap(err) } + webProxyHost, _ := c.WebProxyHostPort() idx := client.KeyIndex{ProxyHost: webProxyHost, Username: c.Username, ClusterName: profile.Cluster} - key, err := keyStore.GetKey(idx, client.WithSSHCerts{}) + key, err := clientStore.GetKey(idx, client.WithSSHCerts{}) if err != nil { return nil, trace.Wrap(err) } @@ -435,7 +389,7 @@ func LoadConfigFromProfile(ccf *GlobalCLIFlags, cfg *service.Config) (*authclien return nil, trace.Wrap(err) } authConfig.TLS.InsecureSkipVerify = ccf.Insecure - authConfig.SSH, err = key.ProxyClientSSHConfig(keyStore, rootCluster) + authConfig.SSH, err = key.ProxyClientSSHConfig(rootCluster) if err != nil { return nil, trace.Wrap(err) } diff --git a/tool/tsh/app.go b/tool/tsh/app.go index e05a18b33f49..81f94fba9e24 100644 --- a/tool/tsh/app.go +++ b/tool/tsh/app.go @@ -50,7 +50,7 @@ func onAppLogin(cf *CLIConf) error { if err != nil { return trace.Wrap(err) } - profile, err := client.StatusCurrent(cf.HomePath, cf.Proxy, cf.IdentityFileIn) + profile, err := tc.ProfileStatus() if err != nil { return trace.Wrap(err) } @@ -110,7 +110,7 @@ func onAppLogin(cf *CLIConf) error { return trace.Wrap(err) } - if err := tc.SaveProfile(cf.HomePath, true); err != nil { + if err := tc.SaveProfile(true); err != nil { return trace.Wrap(err) } if app.IsAWSConsole() { @@ -225,7 +225,7 @@ func onAppLogout(cf *CLIConf) error { if err != nil { return trace.Wrap(err) } - profile, err := client.StatusCurrent(cf.HomePath, cf.Proxy, cf.IdentityFileIn) + profile, err := tc.ProfileStatus() if err != nil { return trace.Wrap(err) } @@ -270,7 +270,7 @@ func onAppConfig(cf *CLIConf) error { if err != nil { return trace.Wrap(err) } - profile, err := client.StatusCurrent(cf.HomePath, cf.Proxy, cf.IdentityFileIn) + profile, err := tc.ProfileStatus() if err != nil { return trace.Wrap(err) } @@ -393,7 +393,7 @@ func serializeAppConfig(configInfo *appConfigInfo, format string) (string, error // If logged into multiple apps, returns an error unless one was specified // explicitly on CLI. func pickActiveApp(cf *CLIConf) (*tlsca.RouteToApp, error) { - profile, err := client.StatusCurrent(cf.HomePath, cf.Proxy, cf.IdentityFileIn) + profile, err := cf.ProfileStatus() if err != nil { return nil, trace.Wrap(err) } diff --git a/tool/tsh/aws.go b/tool/tsh/aws.go index e286687a0ec4..901f0704ed83 100644 --- a/tool/tsh/aws.go +++ b/tool/tsh/aws.go @@ -420,7 +420,7 @@ func getARNFromFlags(cf *CLIConf, profile *client.ProfileStatus, app types.Appli } func pickActiveAWSApp(cf *CLIConf) (*awsApp, error) { - profile, err := client.StatusCurrent(cf.HomePath, cf.Proxy, cf.IdentityFileIn) + profile, err := cf.ProfileStatus() if err != nil { return nil, trace.Wrap(err) } diff --git a/tool/tsh/azure.go b/tool/tsh/azure.go index 34cf95741ee8..ae64a4b5d510 100644 --- a/tool/tsh/azure.go +++ b/tool/tsh/azure.go @@ -395,7 +395,7 @@ func getAzureIdentityFromFlags(cf *CLIConf, profile *client.ProfileStatus) (stri } func pickActiveAzureApp(cf *CLIConf) (*azureApp, error) { - profile, err := client.StatusCurrent(cf.HomePath, cf.Proxy, cf.IdentityFileIn) + profile, err := cf.ProfileStatus() if err != nil { return nil, trace.Wrap(err) } diff --git a/tool/tsh/db.go b/tool/tsh/db.go index 389c039f9d23..85a0ca64f212 100644 --- a/tool/tsh/db.go +++ b/tool/tsh/db.go @@ -54,13 +54,12 @@ func onListDatabases(cf *CLIConf) error { return trace.Wrap(listDatabasesAllClusters(cf)) } - // Retrieve profile to be able to show which databases user is logged into. - profile, err := client.StatusCurrent(cf.HomePath, cf.Proxy, cf.IdentityFileIn) + tc, err := makeClient(cf, false) if err != nil { return trace.Wrap(err) } - tc, err := makeClient(cf, false) + profile, err := tc.ProfileStatus() if err != nil { return trace.Wrap(err) } @@ -197,7 +196,7 @@ func listDatabasesAllClusters(cf *CLIConf) error { sort.Sort(dbListings) - profile, err := client.StatusCurrent(cf.HomePath, cf.Proxy, cf.IdentityFileIn) + profile, err := cf.ProfileStatus() if err != nil { return trace.Wrap(err) } @@ -293,7 +292,7 @@ func databaseLogin(cf *CLIConf, tc *client.TeleportClient, db tlsca.RouteToDatab return trace.Wrap(err) } - profile, err := client.StatusCurrent(cf.HomePath, cf.Proxy, cf.IdentityFileIn) + profile, err := tc.ProfileStatus() if err != nil { return trace.Wrap(err) } @@ -325,7 +324,7 @@ func databaseLogin(cf *CLIConf, tc *client.TeleportClient, db tlsca.RouteToDatab } // Refresh the profile. - profile, err = client.StatusCurrent(cf.HomePath, cf.Proxy, cf.IdentityFileIn) + profile, err = tc.ProfileStatus() if err != nil { return trace.Wrap(err) } @@ -340,7 +339,7 @@ func onDatabaseLogout(cf *CLIConf) error { if err != nil { return trace.Wrap(err) } - profile, err := client.StatusCurrent(cf.HomePath, cf.Proxy, cf.IdentityFileIn) + profile, err := tc.ProfileStatus() if err != nil { return trace.Wrap(err) } @@ -460,7 +459,7 @@ func onDatabaseConfig(cf *CLIConf) error { if err != nil { return trace.Wrap(err) } - profile, err := client.StatusCurrent(cf.HomePath, cf.Proxy, cf.IdentityFileIn) + profile, err := tc.ProfileStatus() if err != nil { return trace.Wrap(err) } @@ -718,7 +717,7 @@ func onDatabaseConnect(cf *CLIConf) error { if err != nil { return trace.Wrap(err) } - profile, err := client.StatusCurrent(cf.HomePath, cf.Proxy, cf.IdentityFileIn) + profile, err := tc.ProfileStatus() if err != nil { return trace.Wrap(err) } @@ -956,7 +955,7 @@ func isMFADatabaseAccessRequired(cf *CLIConf, tc *client.TeleportClient, databas // If logged into multiple databases, returns an error unless one specified // explicitly via --db flag. func pickActiveDatabase(cf *CLIConf) (*tlsca.RouteToDatabase, error) { - profile, err := client.StatusCurrent(cf.HomePath, cf.Proxy, cf.IdentityFileIn) + profile, err := cf.ProfileStatus() if err != nil { return nil, trace.Wrap(err) } diff --git a/tool/tsh/db_test.go b/tool/tsh/db_test.go index e2bcb00eb1b5..2b1a8e073cf5 100644 --- a/tool/tsh/db_test.go +++ b/tool/tsh/db_test.go @@ -139,8 +139,10 @@ func TestDatabaseLogin(t *testing.T) { require.NoError(t, err) // Fetch the active profile. - profile, err := client.StatusFor(tmpHomePath, proxyAddr.Host(), alice.GetName()) + clientStore := client.NewFSClientStore(tmpHomePath) + profile, err := clientStore.ReadProfileStatus(proxyAddr.Host()) require.NoError(t, err) + require.Equal(t, alice.GetName(), profile.Username) // Verify certificates. certs, keys, err := decodePEM(profile.DatabaseCertPathForCluster("", test.databaseName)) diff --git a/tool/tsh/proxy.go b/tool/tsh/proxy.go index 516c3200737e..1d5d95562e6a 100644 --- a/tool/tsh/proxy.go +++ b/tool/tsh/proxy.go @@ -371,7 +371,7 @@ func onProxyCommandDB(cf *CLIConf) error { if err != nil { return trace.Wrap(err) } - profile, err := libclient.StatusCurrent(cf.HomePath, cf.Proxy, cf.IdentityFileIn) + profile, err := client.ProfileStatus() if err != nil { return trace.Wrap(err) } diff --git a/tool/tsh/tsh.go b/tool/tsh/tsh.go index e5f6fb593d27..5f1efdb121c2 100644 --- a/tool/tsh/tsh.go +++ b/tool/tsh/tsh.go @@ -55,8 +55,6 @@ import ( "github.com/gravitational/teleport/api/types" apievents "github.com/gravitational/teleport/api/types/events" "github.com/gravitational/teleport/api/types/wrappers" - apiutils "github.com/gravitational/teleport/api/utils" - apisshutils "github.com/gravitational/teleport/api/utils/sshutils" "github.com/gravitational/teleport/lib/asciitable" "github.com/gravitational/teleport/lib/auth" wancli "github.com/gravitational/teleport/lib/auth/webauthncli" @@ -1257,7 +1255,7 @@ func onVersion(cf *CLIConf) error { // fetchProxyVersion returns the current version of the Teleport Proxy. func fetchProxyVersion(cf *CLIConf) (string, error) { - profile, _, err := client.Status(cf.HomePath, cf.Proxy) + profile, err := cf.ProfileStatus() if err != nil { if trace.IsNotFound(err) { return "", nil @@ -1436,7 +1434,7 @@ func onLogin(cf *CLIConf) error { // Get the status of the active profile as well as the status // of any other proxies the user is logged into. - profile, profiles, err := client.Status(cf.HomePath, cf.Proxy) + profile, profiles, err := cf.FullProfileStatus() if err != nil { if !trace.IsNotFound(err) { return trace.Wrap(err) @@ -1481,7 +1479,7 @@ func onLogin(cf *CLIConf) error { return trace.Wrap(err) } - if err := tc.SaveProfile(cf.HomePath, true); err != nil { + if err := tc.SaveProfile(true); err != nil { return trace.Wrap(err) } @@ -1507,7 +1505,7 @@ func onLogin(cf *CLIConf) error { if err != nil { return trace.Wrap(err) } - if err := tc.SaveProfile(cf.HomePath, true); err != nil { + if err := tc.SaveProfile(true); err != nil { return trace.Wrap(err) } if err := updateKubeConfig(cf, tc, ""); err != nil { @@ -1541,9 +1539,6 @@ func onLogin(cf *CLIConf) error { cf.Username = tc.Username } - // -i flag specified? save the retrieved cert into an identity file - makeIdentityFile := (cf.IdentityFileOut != "") - // stdin hijack is OK for login, since it tsh doesn't read input after the // login ceremony is complete. // Only allow the option during the login ceremony. @@ -1563,21 +1558,22 @@ func onLogin(cf *CLIConf) error { // "authoritative" source. cf.Username = tc.Username + if err := tc.ActivateKey(cf.Context, key); err != nil { + return trace.Wrap(err) + } + // TODO(fspmarshall): Refactor access request & cert reissue logic to allow // access requests to be applied to identity files. - if makeIdentityFile { - if err := setupNoninteractiveClient(tc, key); err != nil { - return trace.Wrap(err) - } + if cf.IdentityFileOut != "" { // key.TrustedCA at this point only has the CA of the root cluster we // logged into. We need to fetch all the CAs for leaf clusters too, to // make them available in the identity file. - rootClusterName := key.TrustedCA[0].ClusterName + rootClusterName := key.TrustedCerts[0].ClusterName authorities, err := tc.GetTrustedCA(cf.Context, rootClusterName) if err != nil { return trace.Wrap(err) } - key.TrustedCA = auth.AuthoritiesToTrustedCerts(authorities) + key.TrustedCerts = auth.AuthoritiesToTrustedCerts(authorities) // If we're in multiplexed mode get SNI name for kube from single multiplexed proxy addr kubeTLSServerName := "" if tc.TLSRoutingEnabled { @@ -1602,10 +1598,6 @@ func onLogin(cf *CLIConf) error { return nil } - if err := tc.ActivateKey(cf.Context, key); err != nil { - return trace.Wrap(err) - } - // Attempt device login. This activates a fresh key if successful. // We do not save the resulting in the identity file above on purpose, as this // certificate is bound to the present device. @@ -1626,7 +1618,7 @@ func onLogin(cf *CLIConf) error { } // Regular login without -i flag. - if err := tc.SaveProfile(cf.HomePath, true); err != nil { + if err := tc.SaveProfile(true); err != nil { return trace.Wrap(err) } @@ -1712,93 +1704,10 @@ func onLogin(cf *CLIConf) error { return nil } -// setupNoninteractiveClient sets up existing client to use -// non-interactive authentication methods -func setupNoninteractiveClient(tc *client.TeleportClient, key *client.Key) error { - certUsername, err := key.CertUsername() - if err != nil { - return trace.Wrap(err) - } - tc.Username = certUsername - - // Extract and set the HostLogin to be the first principal. It doesn't - // matter what the value is, but some valid principal has to be set - // otherwise the certificate won't be validated. - certPrincipals, err := key.CertPrincipals() - if err != nil { - return trace.Wrap(err) - } - if len(certPrincipals) == 0 { - return trace.BadParameter("no principals found") - } - tc.HostLogin = certPrincipals[0] - - authMethod, err := key.AsAuthMethod() - if err != nil { - return trace.Wrap(err) - } - - rootCluster, err := key.RootClusterName() - if err != nil { - return trace.Wrap(err) - } - tc.TLS, err = key.TeleportClientTLSConfig(nil, []string{rootCluster}) - if err != nil { - return trace.Wrap(err) - } - tc.AuthMethods = []ssh.AuthMethod{authMethod} - tc.Interactive = false - tc.SkipLocalAuth = true - - // When user logs in for the first time without a CA in ~/.tsh/known_hosts, - // and specifies the -out flag, we need to avoid writing anything to - // ~/.tsh/ but still validate the proxy cert. Because the existing - // client.Client methods have a side-effect of persisting the CA on disk, - // we do all of this by hand. - // - // Wrap tc.HostKeyCallback with a another checker. This outer checker uses - // key.TrustedCA to validate the remote host cert first, before falling - // back to the original HostKeyCallback. - oldHostKeyCallback := tc.HostKeyCallback - tc.HostKeyCallback = func(hostname string, remote net.Addr, hostKey ssh.PublicKey) error { - checker := ssh.CertChecker{ - // ssh.CertChecker will parse hostKey, extract public key of the - // signer (CA) and call IsHostAuthority. IsHostAuthority in turn - // has to match hostCAKey to any known trusted CA. - IsHostAuthority: func(hostCAKey ssh.PublicKey, address string) bool { - for _, ca := range key.TrustedCA { - caKeys, err := ca.SSHCertPublicKeys() - if err != nil { - return false - } - for _, caKey := range caKeys { - if apisshutils.KeysEqual(caKey, hostCAKey) { - return true - } - } - } - return false - }, - } - err := checker.CheckHostKey(hostname, remote, hostKey) - if err != nil { - if oldHostKeyCallback == nil { - return trace.Wrap(err) - } - errOld := oldHostKeyCallback(hostname, remote, hostKey) - if errOld != nil { - return trace.NewAggregate(err, errOld) - } - } - return nil - } - return nil -} - // onLogout deletes a "session certificate" from ~/.tsh for a given proxy func onLogout(cf *CLIConf) error { // Extract all clusters the user is currently logged into. - active, available, err := client.Status(cf.HomePath, "") + active, available, err := cf.FullProfileStatus() if err != nil { if trace.IsNotFound(err) { fmt.Printf("All users logged out.\n") @@ -1829,7 +1738,7 @@ func onLogout(cf *CLIConf) error { } // Load profile for the requested proxy/user. - profile, err := client.StatusFor(cf.HomePath, proxyHost, cf.Username) + profile, err := tc.ProfileStatus() if err != nil && !trace.IsNotFound(err) { return trace.Wrap(err) } @@ -1872,10 +1781,6 @@ func onLogout(cf *CLIConf) error { fmt.Printf("Logged out %v from %v.\n", cf.Username, proxyHost) // Remove all keys. case proxyHost == "" && cf.Username == "": - // The makeClient function requires a proxy. However this value is not used - // because the user will be logged out from all proxies. Pass a dummy value - // to allow creation of the TeleportClient. - cf.Proxy = "dummy:1234" tc, err := makeClient(cf, true) if err != nil { return trace.Wrap(err) @@ -2676,7 +2581,7 @@ func onListClusters(cf *CLIConf) error { return trace.Wrap(err) } - profile, _, err := client.Status(cf.HomePath, cf.Proxy) + profile, err := cf.ProfileStatus() if err != nil { return trace.Wrap(err) } @@ -3134,89 +3039,17 @@ func makeClientForProxy(cf *CLIConf, proxy string, useProfileLogin bool) (*clien c.JumpHosts = hosts } - var key *client.Key - - // Look if a user identity was given via -i flag - if cf.IdentityFileIn != "" { - // Ignore local authentication methods when identity file is provided - c.SkipLocalAuth = true - // Force the use of the certificate principals so Unix - // username does not get used when logging in - c.UseKeyPrincipals = hostLogin == "" - - var ( - expiryDate time.Time - hostAuthFunc ssh.HostKeyCallback - ) - // read the ID file and create an "auth method" from it: - key, err = client.KeyFromIdentityFile(cf.IdentityFileIn) - if err != nil { - return nil, trace.Wrap(err) - } - - rootCluster, err := key.RootClusterName() - if err != nil { - return nil, trace.Wrap(err) - } - clusters := []string{rootCluster} - if cf.SiteName != "" { - clusters = append(clusters, cf.SiteName) - } else { - cf.SiteName = rootCluster - } - hostAuthFunc, err = key.HostKeyCallbackForClusters(cf.InsecureSkipVerify, apiutils.Deduplicate(clusters)) - if err != nil { - return nil, trace.Wrap(err) - } - - if hostAuthFunc != nil { - c.HostKeyCallback = hostAuthFunc - } else { - return nil, trace.BadParameter("missing trusted certificate authorities in the identity, upgrade to newer version of tctl, export identity and try again") - } - certUsername, err := key.CertUsername() - if err != nil { - return nil, trace.Wrap(err) - } - log.Debugf("Extracted username %q from the identity file %v.", certUsername, cf.IdentityFileIn) - c.Username = certUsername - - // Also configure missing KeyIndex fields. - key.ProxyHost, err = utils.Host(proxy) - if err != nil { - return nil, trace.Wrap(err) - } - key.ClusterName = rootCluster - key.Username = certUsername - - // With the key index fields properly set, preload this key into a local store. - c.PreloadKey = key - - authMethod, err := key.AsAuthMethod() - if err != nil { - return nil, trace.Wrap(err) - } - c.AuthMethods = []ssh.AuthMethod{authMethod} + c.ClientStore, err = initClientStore(cf, proxy) + if err != nil { + return nil, trace.Wrap(err) + } - if len(key.TLSCert) > 0 { - c.TLS, err = key.TeleportClientTLSConfig(nil, clusters) - if err != nil { - return nil, trace.Wrap(err) - } - } - // check the expiration date - expiryDate, _ = key.CertValidBefore() - if expiryDate.Before(time.Now()) { - fmt.Fprintf(os.Stderr, "WARNING: the certificate has expired on %v\n", expiryDate) - } - } else { - // load profile. if no --proxy is given the currently active profile is used, otherwise - // fetch profile for exact proxy we are trying to connect to. - err = c.LoadProfile(cf.HomePath, proxy) - if err != nil { - fmt.Printf("WARNING: Failed to load tsh profile for %q: %v\n", proxy, err) - } + // load profile. if no --proxy is given the currently active profile is used, otherwise + // fetch profile for exact proxy we are trying to connect to. + if err = c.LoadProfile(c.ClientStore, proxy); err != nil && !trace.IsNotFound(err) { + fmt.Printf("WARNING: Failed to load tsh profile for %q: %v\n", proxy, err) } + // 3: override with the CLI flags if cf.Namespace != "" { c.Namespace = cf.Namespace @@ -3340,6 +3173,11 @@ func makeClientForProxy(cf *CLIConf, proxy string, useProfileLogin bool) (*clien c.AddKeysToAgent = client.AddKeysToAgentNo } + // avoid adding keys to agent when using an identity file. + if (cf.IdentityFileOut != "" || cf.IdentityFileIn != "") && c.AddKeysToAgent == client.AddKeysToAgentAuto { + c.AddKeysToAgent = client.AddKeysToAgentNo + } + c.EnableEscapeSequences = cf.EnableEscapeSequences // pass along mock sso login if provided (only used in tests) @@ -3369,35 +3207,18 @@ func makeClientForProxy(cf *CLIConf, proxy string, useProfileLogin bool) (*clien } } - // If identity file was provided, we skip loading the local profile info - // (above). This profile info provides the proxy-advertised listening - // addresses. - // To compensate, when using an identity file, explicitly fetch these - // addresses from the proxy (this is what Ping does). + // If we are using an idenitity file which uses a placeholder profile, ping the webproxy + // for profile info and load it into the client. if cf.IdentityFileIn != "" { log.Debug("Pinging the proxy to fetch listening addresses for non-web ports.") + _, err := tc.Ping(cf.Context) if err != nil { return nil, trace.Wrap(err) } - // If, after pinging the proxy, we've determined that the client should load - // all CAs, update the host key callback and TLS config. - if tc.LoadAllCAs { - sites, err := key.GetClusterNames() - if err != nil { - return nil, trace.Wrap(err) - } - tc.Config.HostKeyCallback, err = key.HostKeyCallbackForClusters(cf.InsecureSkipVerify, sites) - if err != nil { - return nil, trace.Wrap(err) - } - if len(key.TLSCert) > 0 { - tc.Config.TLS, err = key.TeleportClientTLSConfig(nil, sites) - if err != nil { - return nil, trace.Wrap(err) - } - } + if err := tc.SaveProfile(true); err != nil { + return nil, trace.Wrap(err) } } @@ -3410,6 +3231,55 @@ func makeClientForProxy(cf *CLIConf, proxy string, useProfileLogin bool) (*clien return tc, nil } +func initClientStore(cf *CLIConf, proxy string) (*client.Store, error) { + if cf.IdentityFileIn != "" { + keyStore, err := identityfile.NewClientStoreFromIdentityFile(cf.IdentityFileIn, proxy, cf.SiteName) + if err != nil { + return nil, trace.Wrap(err) + } + return keyStore, nil + } + + // When logging in with an identity file output, we want to avoid writing + // any keys to disk, so we use a full memory client store. + if cf.IdentityFileOut != "" { + return client.NewMemClientStore(), nil + } + + clientStore := client.NewFSClientStore(cf.HomePath) + + // Store client keys in memory, but still save trusted certs and profile to disk. + if cf.AddKeysToAgent == client.AddKeysToAgentOnly { + clientStore.KeyStore = client.NewMemKeyStore() + } + + return clientStore, nil +} + +func (c *CLIConf) ProfileStatus() (*client.ProfileStatus, error) { + clientStore, err := initClientStore(c, c.Proxy) + if err != nil { + return nil, trace.Wrap(err) + } + profile, err := clientStore.ReadProfileStatus(c.Proxy) + if err != nil { + return nil, trace.Wrap(err) + } + return profile, nil +} + +func (c *CLIConf) FullProfileStatus() (*client.ProfileStatus, []*client.ProfileStatus, error) { + clientStore, err := initClientStore(c, c.Proxy) + if err != nil { + return nil, nil, trace.Wrap(err) + } + currentProfile, profiles, err := clientStore.FullProfileStatus() + if err != nil { + return nil, nil, trace.Wrap(err) + } + return currentProfile, profiles, nil +} + type mfaModeOpts struct { AuthenticatorAttachment wancli.AuthenticatorAttachment PreferOTP bool @@ -3528,7 +3398,7 @@ func refuseArgs(command string, args []string) error { // onShow reads an identity file (a public SSH key or a cert) and dumps it to stdout func onShow(cf *CLIConf) error { - key, err := client.KeyFromIdentityFile(cf.IdentityFileIn) + key, err := identityfile.KeyFromIdentityFile(cf.IdentityFileIn, cf.Proxy, cf.SiteName) if err != nil { return trace.Wrap(err) } @@ -3634,7 +3504,7 @@ func onStatus(cf *CLIConf) error { // of any other proxies the user is logged into. // // Return error if not logged in, no active profile, or expired. - profile, profiles, err := client.Status(cf.HomePath, cf.Proxy) + profile, profiles, err := cf.FullProfileStatus() if err != nil { if trace.IsNotFound(err) { return trace.NotFound("Not logged in.") @@ -3998,7 +3868,7 @@ func onRequestResolution(cf *CLIConf, tc *client.TeleportClient, req types.Acces // reissueWithRequests handles a certificate reissue, applying new requests by ID, // and saving the updated profile. func reissueWithRequests(cf *CLIConf, tc *client.TeleportClient, newRequests []string, dropRequests []string) error { - profile, err := client.StatusCurrent(cf.HomePath, cf.Proxy, cf.IdentityFileIn) + profile, err := tc.ClientStore.ReadProfileStatus(cf.Proxy) if err != nil { return trace.Wrap(err) } @@ -4022,7 +3892,7 @@ func reissueWithRequests(cf *CLIConf, tc *client.TeleportClient, newRequests []s if err := tc.ReissueUserCerts(cf.Context, client.CertCacheDrop, params); err != nil { return trace.Wrap(err) } - if err := tc.SaveProfile(cf.HomePath, true); err != nil { + if err := tc.SaveProfile(true); err != nil { return trace.Wrap(err) } if err := updateKubeConfig(cf, tc, ""); err != nil { @@ -4051,7 +3921,7 @@ func onApps(cf *CLIConf) error { } // Retrieve profile to be able to show which apps user is logged into. - profile, err := client.StatusCurrent(cf.HomePath, cf.Proxy, cf.IdentityFileIn) + profile, err := tc.ProfileStatus() if err != nil { return trace.Wrap(err) } @@ -4114,7 +3984,7 @@ func listAppsAllClusters(cf *CLIConf) error { sort.Sort(listings) - profile, err := client.StatusCurrent(cf.HomePath, cf.Proxy, cf.IdentityFileIn) + profile, err := cf.ProfileStatus() if err != nil { return trace.Wrap(err) } @@ -4200,7 +4070,7 @@ func onRecordings(cf *CLIConf) error { // onEnvironment handles "tsh env" command. func onEnvironment(cf *CLIConf) error { - profile, err := client.StatusCurrent(cf.HomePath, cf.Proxy, cf.IdentityFileIn) + profile, err := cf.ProfileStatus() if err != nil { return trace.Wrap(err) } @@ -4337,7 +4207,7 @@ func validateParticipantMode(mode types.SessionParticipantMode) error { // forEachProfile performs an action for each profile a user is currently logged in to. func forEachProfile(cf *CLIConf, fn func(tc *client.TeleportClient, profile *client.ProfileStatus) error) error { - profile, profiles, err := client.Status(cf.HomePath, "") + profile, profiles, err := cf.FullProfileStatus() if err != nil { return trace.Wrap(err) } diff --git a/tool/tsh/tsh_test.go b/tool/tsh/tsh_test.go index be43ec2bfb03..6129cb6baeda 100644 --- a/tool/tsh/tsh_test.go +++ b/tool/tsh/tsh_test.go @@ -40,7 +40,6 @@ import ( "github.com/gravitational/trace" "github.com/stretchr/testify/require" otlp "go.opentelemetry.io/proto/otlp/trace/v1" - "golang.org/x/crypto/ssh" "golang.org/x/exp/slices" yamlv2 "gopkg.in/yaml.v2" @@ -61,6 +60,7 @@ import ( wancli "github.com/gravitational/teleport/lib/auth/webauthncli" "github.com/gravitational/teleport/lib/backend" "github.com/gravitational/teleport/lib/client" + "github.com/gravitational/teleport/lib/client/identityfile" "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/kube/kubeconfig" "github.com/gravitational/teleport/lib/modules" @@ -461,9 +461,9 @@ func TestLoginIdentityOut(t *testing.T) { requiresTLSRouting bool }{ { - name: "write indentity out", + name: "write identity out", validationFunc: func(t *testing.T, identityPath string) { - _, err := client.KeyFromIdentityFile(identityPath) + _, err := identityfile.KeyFromIdentityFile(identityPath, "proxy.example.com", "") require.NoError(t, err) }, }, @@ -1659,66 +1659,6 @@ func tryCreateTrustedCluster(t *testing.T, authServer *auth.Server, trustedClust require.FailNow(t, "Timeout creating trusted cluster") } -func TestIdentityRead(t *testing.T) { - t.Parallel() - - // 3 different types of identities - ids := []string{ - "cert-key.pem", // cert + key concatenated togther, cert first - "key-cert.pem", // cert + key concatenated togther, key first - "key", // two separate files: key and key-cert.pub - } - for _, id := range ids { - // test reading: - k, err := client.KeyFromIdentityFile(fmt.Sprintf("../../fixtures/certs/identities/%s", id)) - require.NoError(t, err) - require.NotNil(t, k) - - cb, err := k.HostKeyCallback(false) - require.NoError(t, err) - require.Nil(t, cb) - - // test creating an auth method from the key: - am, err := k.AsAuthMethod() - require.NoError(t, err) - require.NotNil(t, am) - } - k, err := client.KeyFromIdentityFile("../../fixtures/certs/identities/lonekey") - require.Nil(t, k) - require.Error(t, err) - - // lets read an indentity which includes a CA cert - k, err = client.KeyFromIdentityFile("../../fixtures/certs/identities/key-cert-ca.pem") - require.NoError(t, err) - require.NotNil(t, k) - - cb, err := k.HostKeyCallback(true) - require.NoError(t, err) - require.NotNil(t, cb) - - // prepare the cluster CA separately - certBytes, err := os.ReadFile("../../fixtures/certs/identities/ca.pem") - require.NoError(t, err) - - _, hosts, cert, _, _, err := ssh.ParseKnownHosts(certBytes) - require.NoError(t, err) - - var a net.Addr - // host auth callback must succeed - require.NoError(t, cb(hosts[0], a, cert)) - - // load an identity which include TLS certificates - k, err = client.KeyFromIdentityFile("../../fixtures/certs/identities/tls.pem") - require.NoError(t, err) - require.NotNil(t, k) - require.NotNil(t, k.TLSCert) - - // generate a TLS client config - conf, err := k.TeleportClientTLSConfig(nil, []string{"one"}) - require.NoError(t, err) - require.NotNil(t, conf) -} - func TestFormatConnectCommand(t *testing.T) { t.Parallel()