Skip to content

Commit 4a2bedb

Browse files
committed
switch to ssh_keys comparison
1 parent aa15ac6 commit 4a2bedb

File tree

3 files changed

+122
-100
lines changed

3 files changed

+122
-100
lines changed

lib/srv/git/forward.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -520,7 +520,7 @@ func verifyRemoteHost(targetServer types.Server) ssh.HostKeyCallback {
520520
return func(hostname string, remote net.Addr, key ssh.PublicKey) error {
521521
switch targetServer.GetSubKind() {
522522
case types.SubKindGitHub:
523-
return githubFingerprints.checkServerKey(key)
523+
return githubServerKeys.check(key)
524524
default:
525525
return trace.BadParameter("unsupported subkind %q", targetServer.GetSubKind())
526526
}

lib/srv/git/github.go

Lines changed: 60 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ import (
2222
"context"
2323
"encoding/json"
2424
"log/slog"
25-
"maps"
2625
"net/http"
2726
"slices"
27+
"strings"
2828
"sync"
2929
"time"
3030

@@ -42,133 +42,94 @@ import (
4242
"github.com/gravitational/teleport/lib/sshutils"
4343
)
4444

45-
type githubMetadataClient interface {
46-
fetchETag() (string, error)
47-
fetchFingerprints() ([]string, string, error)
45+
// githubServerKeyManager downloads SSH keys from the GitHub meta API and does a
46+
// lazy refresh every hour. The keys are used to verify GitHub server when
47+
// forwarding Git commands to it.
48+
type githubServerKeyManager struct {
49+
mu sync.Mutex
50+
keys []string
51+
lastCheck time.Time
52+
etag string
53+
54+
clock clockwork.Clock
55+
apiEndpoint string
56+
client *http.Client
4857
}
4958

50-
// githubFingerprintManager downloads SSH fingerprints from the GitHub meta API
51-
// and does a lazy refresh every hour. The fingerprints are used to verify
52-
// GitHub server when forwarding Git commands to it.
53-
type githubFingerprintManager struct {
54-
mu sync.RWMutex
55-
fingerprints []string
56-
lastCheck time.Time
57-
etag string
58-
59-
clock clockwork.Clock
60-
client githubMetadataClient
61-
}
62-
63-
func newGithubFingerprintManager() *githubFingerprintManager {
64-
return &githubFingerprintManager{
65-
clock: clockwork.NewRealClock(),
66-
client: newGithubMetadataTTPClient(),
59+
func newGitHubServeKeyManager() *githubServerKeyManager {
60+
return &githubServerKeyManager{
61+
clock: clockwork.NewRealClock(),
62+
apiEndpoint: "https://api.github.com/meta",
63+
client: &http.Client{
64+
Timeout: defaults.HTTPRequestTimeout,
65+
},
6766
}
6867
}
6968

70-
func (g *githubFingerprintManager) checkServerKey(key ssh.PublicKey) error {
71-
actualFingerprint := ssh.FingerprintSHA256(key)
72-
for _, fingerprint := range g.getKnownFingerprints() {
73-
if sshutils.EqualFingerprints(actualFingerprint, fingerprint) {
74-
return nil
75-
}
76-
}
77-
return trace.BadParameter("cannot verify github.com: unknown fingerprint %v algo %v", actualFingerprint, key.Type())
78-
}
69+
func (m *githubServerKeyManager) check(targetKey ssh.PublicKey) error {
70+
m.mu.Lock()
71+
defer m.mu.Unlock()
7972

80-
func (g *githubFingerprintManager) getKnownFingerprints() []string {
81-
const refreshDuration = time.Hour
82-
g.mu.RLock()
83-
if g.clock.Now().Sub(g.lastCheck) < refreshDuration {
84-
defer g.mu.RUnlock()
85-
return g.fingerprints
73+
// Refresh every 24 hours.
74+
if m.clock.Now().Sub(m.lastCheck) > time.Hour*24 {
75+
m.refreshLocked()
8676
}
87-
g.mu.RUnlock()
8877

89-
g.mu.Lock()
90-
defer g.mu.Unlock()
91-
if g.clock.Now().Sub(g.lastCheck) < refreshDuration {
92-
return g.fingerprints
78+
// Remove newline from ssh.MarshalAuthorizedKey.
79+
key := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(targetKey)))
80+
if slices.Contains(m.keys, key) {
81+
return nil
9382
}
83+
return trace.BadParameter("cannot verify github.com: unknown server key %q", key)
84+
}
9485

86+
func (m *githubServerKeyManager) refreshLocked() {
87+
ctx := context.Background()
9588
logger := slog.With(teleport.ComponentKey, "git:github")
9689

97-
// Check if eTag is the same to avoid downloading the whole thing which
98-
// contains a lot of irrelevant info.
99-
if g.etag != "" {
100-
etag, err := g.client.fetchETag()
101-
switch {
102-
case err != nil:
103-
logger.WarnContext(context.Background(), "Failed to fetch eTag from GitHub meta API", "error", err)
104-
// Don't give up yet if HEAD fails.
105-
106-
case etag == g.etag:
107-
g.lastCheck = g.clock.Now()
108-
logger.DebugContext(context.Background(), "ETag did not change for GitHub meta API")
109-
return g.fingerprints
110-
111-
default:
112-
logger.DebugContext(context.Background(), "ETag changed for GitHub meta API", "new", etag)
113-
}
114-
}
115-
116-
fingerprints, etag, err := g.client.fetchFingerprints()
90+
// Meta API reference:
91+
// https://docs.github.com/en/rest/meta/meta#get-github-meta-information
92+
req, err := http.NewRequest("GET", m.apiEndpoint, nil)
11793
if err != nil {
118-
logger.WarnContext(context.Background(), "Failed to fetch fingerprints from GitHub meta API", "error", err)
119-
return g.fingerprints
94+
logger.WarnContext(ctx, "Failed to make request for GitHub meta API", "error", err)
95+
return
12096
}
121-
logger.DebugContext(context.Background(), "Found SSH fingerprints from Github meta API", "fingerprints", fingerprints, "etag", etag)
122-
g.etag = etag
123-
g.fingerprints = fingerprints
124-
g.lastCheck = g.clock.Now()
125-
return g.fingerprints
126-
}
127-
128-
var githubFingerprints = newGithubFingerprintManager()
129-
130-
type githubMetadataHTTPClient struct {
131-
api string
132-
client *http.Client
133-
}
13497

135-
func newGithubMetadataTTPClient() *githubMetadataHTTPClient {
136-
return &githubMetadataHTTPClient{
137-
api: "https://api.github.com/meta",
138-
client: &http.Client{
139-
Timeout: defaults.HTTPRequestTimeout,
140-
},
98+
// ETag check.
99+
if m.etag != "" {
100+
req.Header.Set("If-None-Match", m.etag)
141101
}
142-
}
143102

144-
func (c *githubMetadataHTTPClient) fetchETag() (string, error) {
145-
resp, err := http.Head(c.api)
103+
resp, err := m.client.Do(req)
146104
if err != nil {
147-
return "", trace.Wrap(err)
105+
logger.WarnContext(ctx, "Failed to fetch GitHub meta API", "error", err)
106+
return
148107
}
149-
return resp.Header.Get("ETag"), nil
150-
}
108+
defer resp.Body.Close()
151109

152-
func (c *githubMetadataHTTPClient) fetchFingerprints() ([]string, string, error) {
153-
resp, err := http.Get(c.api)
154-
if err != nil {
155-
return nil, "", trace.Wrap(err)
110+
// Nothing changed. Just update the last check time.
111+
if resp.StatusCode == http.StatusNotModified {
112+
logger.DebugContext(ctx, "GitHub metadata is up-to-date")
113+
m.lastCheck = m.clock.Now()
114+
return
156115
}
157-
defer resp.Body.Close()
158116

159-
// Meta API reference:
160-
// https://docs.github.com/en/rest/meta/meta?apiVersion=2022-11-28#get-github-meta-information
161117
meta := struct {
162-
// Fingerprints lists the fingerprints by algo type.
163-
Fingerprints map[string]string `json:"ssh_key_fingerprints"`
118+
SSHKeys []string `json:"ssh_keys"`
164119
}{}
165120
if err := json.NewDecoder(resp.Body).Decode(&meta); err != nil {
166-
return nil, "", trace.Wrap(err)
121+
logger.WarnContext(ctx, "Failed to decode response from GitHub meta API", "error", err)
122+
return
167123
}
168124

169-
return slices.Collect(maps.Values(meta.Fingerprints)), resp.Header.Get("ETag"), nil
125+
m.etag = resp.Header.Get("ETag")
126+
m.keys = meta.SSHKeys
127+
m.lastCheck = m.clock.Now()
128+
logger.DebugContext(ctx, "Fetched GitHub metadata", "ssh_keys", m.keys, "etag", m.etag)
170129
}
171130

131+
var githubServerKeys = newGitHubServeKeyManager()
132+
172133
// AuthPreferenceGetter is an interface for retrieving the current configured
173134
// cluster auth preference.
174135
type AuthPreferenceGetter interface {

lib/srv/git/github_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,24 @@ package git
2121
import (
2222
"context"
2323
"crypto/rand"
24+
"encoding/json"
25+
"net/http"
26+
"net/http/httptest"
2427
"testing"
2528
"time"
2629

30+
"github.com/google/uuid"
2731
"github.com/gravitational/trace"
2832
"github.com/jonboulle/clockwork"
33+
"github.com/stretchr/testify/assert"
2934
"github.com/stretchr/testify/require"
3035
"golang.org/x/crypto/ssh"
3136
"google.golang.org/grpc"
3237

3338
integrationv1 "github.com/gravitational/teleport/api/gen/proto/go/teleport/integration/v1"
3439
"github.com/gravitational/teleport/api/types"
3540
apisshutils "github.com/gravitational/teleport/api/utils/sshutils"
41+
"github.com/gravitational/teleport/lib/fixtures"
3642
)
3743

3844
type fakeAuthPreferenceGetter struct {
@@ -143,3 +149,58 @@ func TestMakeGitHubSigner(t *testing.T) {
143149
})
144150
}
145151
}
152+
153+
func Test_githubServerKeyManager(t *testing.T) {
154+
clock := clockwork.NewFakeClock()
155+
etag := uuid.NewString()
156+
serverCalled := 0
157+
serverCalledWithETagMatch := 0
158+
body, err := json.Marshal(map[string][]string{
159+
"ssh_keys": []string{fixtures.SSHCAPublicKey},
160+
})
161+
require.NoError(t, err)
162+
163+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
164+
serverCalled++
165+
if req.Header.Get("If-None-Match") == etag {
166+
serverCalledWithETagMatch++
167+
w.WriteHeader(http.StatusNotModified)
168+
return
169+
}
170+
w.Header().Add("Etag", etag)
171+
w.WriteHeader(http.StatusOK)
172+
w.Write(body)
173+
}))
174+
t.Cleanup(server.Close)
175+
176+
validKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(fixtures.SSHCAPublicKey))
177+
require.NoError(t, err)
178+
invalidKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(`ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGv+gN2C23P08ieJRA9gU/Ik4bsOh3Kw193UYscJDw41mATj+Kqyf45Rmj8F8rs3i7mYKRXXu1IjNRBzNgpXxqc=`))
179+
require.NoError(t, err)
180+
181+
m := newGitHubServeKeyManager()
182+
m.apiEndpoint = server.URL
183+
m.clock = clock
184+
185+
// First check should download from the server.
186+
require.NoError(t, m.check(validKey))
187+
require.Error(t, m.check(invalidKey))
188+
assert.Equal(t, 1, serverCalled)
189+
assert.Equal(t, 0, serverCalledWithETagMatch)
190+
191+
// Advance time for a refresh.
192+
clock.Advance(time.Hour * 30)
193+
require.NoError(t, m.check(validKey))
194+
require.Error(t, m.check(invalidKey))
195+
assert.Equal(t, 2, serverCalled)
196+
assert.Equal(t, 1, serverCalledWithETagMatch)
197+
198+
// Simulate an etag change and advance time for a refresh.
199+
m.keys = nil
200+
m.etag = "does-not-match"
201+
clock.Advance(time.Hour * 30)
202+
require.NoError(t, m.check(validKey))
203+
require.Error(t, m.check(invalidKey))
204+
assert.Equal(t, 3, serverCalled)
205+
assert.Equal(t, 1, serverCalledWithETagMatch)
206+
}

0 commit comments

Comments
 (0)