diff --git a/lib/srv/git/forward.go b/lib/srv/git/forward.go index bac6b2297e7f2..a706537a2da97 100644 --- a/lib/srv/git/forward.go +++ b/lib/srv/git/forward.go @@ -520,7 +520,7 @@ func verifyRemoteHost(targetServer types.Server) ssh.HostKeyCallback { return func(hostname string, remote net.Addr, key ssh.PublicKey) error { switch targetServer.GetSubKind() { case types.SubKindGitHub: - return VerifyGitHubHostKey(hostname, remote, key) + return githubFingerprints.checkServerKey(key) default: return trace.BadParameter("unsupported subkind %q", targetServer.GetSubKind()) } diff --git a/lib/srv/git/github.go b/lib/srv/git/github.go index 24f44e7f3e38b..ff298de86f5f0 100644 --- a/lib/srv/git/github.go +++ b/lib/srv/git/github.go @@ -20,8 +20,12 @@ package git import ( "context" - "net" + "encoding/json" + "log/slog" + "maps" + "net/http" "slices" + "sync" "time" "github.com/gravitational/trace" @@ -30,35 +34,141 @@ import ( "google.golang.org/grpc" "google.golang.org/protobuf/types/known/durationpb" + "github.com/gravitational/teleport" integrationv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/integration/v1" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/cryptosuites" + "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/sshutils" ) -// knownGithubDotComFingerprints contains a list of known GitHub fingerprints. -// -// https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints -// -// TODO(greedy52) these fingerprints can change (e.g. GitHub changed its RSA -// key in 2023 because of an incident). Instead of hard-coding the values, we -// should try to periodically (e.g. once per day) poll them from the API. -var knownGithubDotComFingerprints = []string{ - "SHA256:uNiVztksCsDhcc0u9e8BujQXVUpKZIDTMczCvj3tD2s", - "SHA256:p2QAMXNIC1TJYWeIOttrVc98/R1BUFWu3/LiyKgUfQM", - "SHA256:+DiY3wvvV6TuJJhbpZisF/zLDA0zPMSvHdkr4UvCOqU", +type githubMetadataClient interface { + fetchETag() (string, error) + fetchFingerprints() ([]string, string, error) } -// VerifyGitHubHostKey is an ssh.HostKeyCallback that verifies the host key -// belongs to "github.com". -func VerifyGitHubHostKey(_ string, _ net.Addr, key ssh.PublicKey) error { +// githubFingerprintManager downloads SSH fingerprints from the GitHub meta API +// and does a lazy refresh every hour. The fingerprints are used to verify +// GitHub server when forwarding Git commands to it. +type githubFingerprintManager struct { + mu sync.RWMutex + fingerprints []string + lastCheck time.Time + etag string + + clock clockwork.Clock + client githubMetadataClient +} + +func newGithubFingerprintManager() *githubFingerprintManager { + return &githubFingerprintManager{ + clock: clockwork.NewRealClock(), + client: newGithubMetadataTTPClient(), + } +} + +func (g *githubFingerprintManager) checkServerKey(key ssh.PublicKey) error { actualFingerprint := ssh.FingerprintSHA256(key) - if slices.Contains(knownGithubDotComFingerprints, actualFingerprint) { - return nil + for _, fingerprint := range g.getKnownFingerprints() { + if sshutils.EqualFingerprints(actualFingerprint, fingerprint) { + return nil + } } return trace.BadParameter("cannot verify github.com: unknown fingerprint %v algo %v", actualFingerprint, key.Type()) } +func (g *githubFingerprintManager) getKnownFingerprints() []string { + const refreshDuration = time.Hour + g.mu.RLock() + if g.clock.Now().Sub(g.lastCheck) < refreshDuration { + defer g.mu.RUnlock() + return g.fingerprints + } + g.mu.RUnlock() + + g.mu.Lock() + defer g.mu.Unlock() + if g.clock.Now().Sub(g.lastCheck) < refreshDuration { + return g.fingerprints + } + + logger := slog.With(teleport.ComponentKey, "git:github") + + // Check if eTag is the same to avoid downloading the whole thing which + // contains a lot of irrelevant info. + if g.etag != "" { + etag, err := g.client.fetchETag() + switch { + case err != nil: + logger.WarnContext(context.Background(), "Failed to fetch eTag from GitHub meta API", "error", err) + // Don't give up yet if HEAD fails. + + case etag == g.etag: + g.lastCheck = g.clock.Now() + logger.DebugContext(context.Background(), "ETag did not change for GitHub meta API") + return g.fingerprints + + default: + logger.DebugContext(context.Background(), "ETag changed for GitHub meta API", "new", etag) + } + } + + fingerprints, etag, err := g.client.fetchFingerprints() + if err != nil { + logger.WarnContext(context.Background(), "Failed to fetch fingerprints from GitHub meta API", "error", err) + return g.fingerprints + } + logger.DebugContext(context.Background(), "Found SSH fingerprints from Github meta API", "fingerprints", fingerprints, "etag", etag) + g.etag = etag + g.fingerprints = fingerprints + g.lastCheck = g.clock.Now() + return g.fingerprints +} + +var githubFingerprints = newGithubFingerprintManager() + +type githubMetadataHTTPClient struct { + api string + client *http.Client +} + +func newGithubMetadataTTPClient() *githubMetadataHTTPClient { + return &githubMetadataHTTPClient{ + api: "https://api.github.com/meta", + client: &http.Client{ + Timeout: defaults.HTTPRequestTimeout, + }, + } +} + +func (c *githubMetadataHTTPClient) fetchETag() (string, error) { + resp, err := http.Head(c.api) + if err != nil { + return "", trace.Wrap(err) + } + return resp.Header.Get("ETag"), nil +} + +func (c *githubMetadataHTTPClient) fetchFingerprints() ([]string, string, error) { + resp, err := http.Get(c.api) + if err != nil { + return nil, "", trace.Wrap(err) + } + defer resp.Body.Close() + + // Meta API reference: + // https://docs.github.com/en/rest/meta/meta?apiVersion=2022-11-28#get-github-meta-information + meta := struct { + // Fingerprints lists the fingerprints by algo type. + Fingerprints map[string]string `json:"ssh_key_fingerprints"` + }{} + if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil { + return nil, "", trace.Wrap(err) + } + + return slices.Collect(maps.Values(meta.Fingerprints)), resp.Header.Get("ETag"), nil +} + // AuthPreferenceGetter is an interface for retrieving the current configured // cluster auth preference. type AuthPreferenceGetter interface {