diff --git a/internal/api/api.go b/internal/api/api.go index cf54a1d65..cdf9b3f60 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -172,10 +172,11 @@ const ( ) type CodespaceEnvironmentConnection struct { - SessionID string `json:"sessionId"` - SessionToken string `json:"sessionToken"` - RelayEndpoint string `json:"relayEndpoint"` - RelaySAS string `json:"relaySas"` + SessionID string `json:"sessionId"` + SessionToken string `json:"sessionToken"` + RelayEndpoint string `json:"relayEndpoint"` + RelaySAS string `json:"relaySas"` + HostPublicKeys []string `json:"hostPublicKeys"` } func (a *API) ListCodespaces(ctx context.Context, user string) ([]*Codespace, error) { diff --git a/internal/codespaces/codespaces.go b/internal/codespaces/codespaces.go index 7f6760560..f127cb362 100644 --- a/internal/codespaces/codespaces.go +++ b/internal/codespaces/codespaces.go @@ -68,9 +68,10 @@ func ConnectToLiveshare(ctx context.Context, log logger, apiClient apiClient, us log.Println("Connecting to your codespace...") return liveshare.Connect(ctx, liveshare.Options{ - SessionID: codespace.Environment.Connection.SessionID, - SessionToken: codespace.Environment.Connection.SessionToken, - RelaySAS: codespace.Environment.Connection.RelaySAS, - RelayEndpoint: codespace.Environment.Connection.RelayEndpoint, + SessionID: codespace.Environment.Connection.SessionID, + SessionToken: codespace.Environment.Connection.SessionToken, + RelaySAS: codespace.Environment.Connection.RelaySAS, + RelayEndpoint: codespace.Environment.Connection.RelayEndpoint, + HostPublicKeys: codespace.Environment.Connection.HostPublicKeys, }) } diff --git a/internal/liveshare/client.go b/internal/liveshare/client.go index 2b1f97831..913f19195 100644 --- a/internal/liveshare/client.go +++ b/internal/liveshare/client.go @@ -24,11 +24,12 @@ import ( // An Options specifies Live Share connection parameters. type Options struct { - SessionID string - SessionToken string // token for SSH session - RelaySAS string - RelayEndpoint string - TLSConfig *tls.Config // (optional) + SessionID string + SessionToken string // token for SSH session + RelaySAS string + RelayEndpoint string + HostPublicKeys []string + TLSConfig *tls.Config // (optional) } // uri returns a websocket URL for the specified options. @@ -71,7 +72,7 @@ func Connect(ctx context.Context, opts Options) (*Session, error) { if opts.SessionToken == "" { return nil, errors.New("SessionToken is required") } - ssh := newSSHSession(opts.SessionToken, sock) + ssh := newSSHSession(opts.SessionToken, opts.HostPublicKeys, sock) if err := ssh.connect(ctx); err != nil { return nil, fmt.Errorf("error connecting to ssh session: %w", err) } diff --git a/internal/liveshare/client_test.go b/internal/liveshare/client_test.go index 2bfcfa63f..9db20ed1c 100644 --- a/internal/liveshare/client_test.go +++ b/internal/liveshare/client_test.go @@ -15,9 +15,10 @@ import ( func TestConnect(t *testing.T) { opts := Options{ - SessionID: "session-id", - SessionToken: "session-token", - RelaySAS: "relay-sas", + SessionID: "session-id", + SessionToken: "session-token", + RelaySAS: "relay-sas", + HostPublicKeys: []string{livesharetest.SSHPublicKey}, } joinWorkspace := func(req *jsonrpc2.Request) (interface{}, error) { var joinWorkspaceReq joinWorkspaceArgs diff --git a/internal/liveshare/session_test.go b/internal/liveshare/session_test.go index 461b4eb66..1064e490b 100644 --- a/internal/liveshare/session_test.go +++ b/internal/liveshare/session_test.go @@ -29,11 +29,12 @@ func makeMockSession(opts ...livesharetest.ServerOption) (*livesharetest.Server, } session, err := Connect(context.Background(), Options{ - SessionID: "session-id", - SessionToken: sessionToken, - RelayEndpoint: "sb" + strings.TrimPrefix(testServer.URL(), "https"), - RelaySAS: "relay-sas", - TLSConfig: &tls.Config{InsecureSkipVerify: true}, + SessionID: "session-id", + SessionToken: sessionToken, + RelayEndpoint: "sb" + strings.TrimPrefix(testServer.URL(), "https"), + RelaySAS: "relay-sas", + HostPublicKeys: []string{livesharetest.SSHPublicKey}, + TLSConfig: &tls.Config{InsecureSkipVerify: true}, }) if err != nil { return nil, nil, fmt.Errorf("error connecting to Live Share: %w", err) @@ -194,3 +195,29 @@ func TestServerUpdateSharedVisibility(t *testing.T) { } } } + +func TestInvalidHostKey(t *testing.T) { + joinWorkspace := func(req *jsonrpc2.Request) (interface{}, error) { + return joinWorkspaceResult{1}, nil + } + const sessionToken = "session-token" + opts := []livesharetest.ServerOption{ + livesharetest.WithPassword(sessionToken), + livesharetest.WithService("workspace.joinWorkspace", joinWorkspace), + } + testServer, err := livesharetest.NewServer(opts...) + if err != nil { + t.Errorf("error creating server: %w", err) + } + _, err = Connect(context.Background(), Options{ + SessionID: "session-id", + SessionToken: sessionToken, + RelayEndpoint: "sb" + strings.TrimPrefix(testServer.URL(), "https"), + RelaySAS: "relay-sas", + HostPublicKeys: []string{}, + TLSConfig: &tls.Config{InsecureSkipVerify: true}, + }) + if err == nil { + t.Error("expected invalid host key error, got: nil") + } +} diff --git a/internal/liveshare/ssh.go b/internal/liveshare/ssh.go index 15f67d2a4..e7de9055a 100644 --- a/internal/liveshare/ssh.go +++ b/internal/liveshare/ssh.go @@ -2,6 +2,8 @@ package liveshare import ( "context" + "encoding/base64" + "errors" "fmt" "io" "net" @@ -12,15 +14,16 @@ import ( type sshSession struct { *ssh.Session - token string - socket net.Conn - conn ssh.Conn - reader io.Reader - writer io.Writer + token string + hostPublicKeys []string + socket net.Conn + conn ssh.Conn + reader io.Reader + writer io.Writer } -func newSSHSession(token string, socket net.Conn) *sshSession { - return &sshSession{token: token, socket: socket} +func newSSHSession(token string, hostPublicKeys []string, socket net.Conn) *sshSession { + return &sshSession{token: token, hostPublicKeys: hostPublicKeys, socket: socket} } func (s *sshSession) connect(ctx context.Context) error { @@ -30,8 +33,16 @@ func (s *sshSession) connect(ctx context.Context) error { ssh.Password(s.token), }, HostKeyAlgorithms: []string{"rsa-sha2-512", "rsa-sha2-256"}, - HostKeyCallback: ssh.InsecureIgnoreHostKey(), - Timeout: 10 * time.Second, + HostKeyCallback: func(hostname string, addr net.Addr, key ssh.PublicKey) error { + encodedKey := base64.StdEncoding.EncodeToString(key.Marshal()) + for _, hpk := range s.hostPublicKeys { + if encodedKey == hpk { + return nil // we found a match for expected public key, safely return + } + } + return errors.New("invalid host public key") + }, + Timeout: 10 * time.Second, } sshClientConn, chans, reqs, err := ssh.NewClientConn(s.socket, "", &clientConfig) diff --git a/internal/liveshare/test/server.go b/internal/liveshare/test/server.go index 058080b56..3f038a15b 100644 --- a/internal/liveshare/test/server.go +++ b/internal/liveshare/test/server.go @@ -16,33 +16,23 @@ import ( ) const sshPrivateKey = `-----BEGIN RSA PRIVATE KEY----- -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 -----END RSA PRIVATE KEY-----` +const SSHPublicKey = `AAAAB3NzaC1yc2EAAAADAQABAAAAgQC6VU6XsMaTot9ogsGcJ+juvJOmDvvCZmgJRTRwKkW0u2BLz4yVrCzQcxaY4kaIuR80Y+1f0BLnZgh4pTREDR0T+p8hUsDSHim1ttKI8rK0hRtJ2qhYlR4qt7P51rPA4KFA9z9gDjTwQLbDq21QMC4+n4d8CL3xRVGtlUAMM3Kl3w==` + // Server represents a LiveShare relay host server. type Server struct { password string