As we reevaluate how to best support and maintain Staging Ref in the future, we encourage development teams using this environment to highlight their use cases in the following issue: https://gitlab.com/gitlab-com/gl-infra/software-delivery/framework/software-delivery-framework-issue-tracker/-/issues/36.

Skip to content
Snippets Groups Projects
Unverified Commit 0a490b53 authored by Igor Drozdov's avatar Igor Drozdov
Browse files

Support authentication using SSH Certificates

GitLab Shell detects that the passed key is a certificate and
requests /authorized_certs instead of /authorized_keys endpoint
parent 7a394def
No related branches found
No related tags found
No related merge requests found
Loading
Loading
@@ -58,6 +58,20 @@ func NewWithKrb5Principal(gitlabKrb5Principal string, env sshenv.Env, config *co
return nil, disallowedcommand.Error
}
func NewWithUsername(gitlabUsername string, env sshenv.Env, config *config.Config, readWriter *readwriter.ReadWriter) (command.Command, error) {
args, err := Parse(nil, env)
if err != nil {
return nil, err
}
args.GitlabUsername = gitlabUsername
if cmd := Build(args, config, readWriter); cmd != nil {
return cmd, nil
}
return nil, disallowedcommand.Error
}
func Parse(arguments []string, env sshenv.Env) (*commandargs.Shell, error) {
args := &commandargs.Shell{Arguments: arguments, Env: env}
Loading
Loading
Loading
Loading
@@ -300,3 +300,16 @@ func TestParseFailure(t *testing.T) {
})
}
}
func TestNewWithUsername(t *testing.T) {
env := sshenv.Env{IsSSHConnection: true, OriginalCommand: "git-receive-pack 'group/repo'"}
c, err := cmd.NewWithUsername("username", env, nil, nil)
require.NoError(t, err)
require.IsType(t, &receivepack.Command{}, c)
require.Equal(t, c.(*receivepack.Command).Args.GitlabUsername, "username")
env = sshenv.Env{IsSSHConnection: true, OriginalCommand: "invalid"}
c, err = cmd.NewWithUsername("username", env, nil, nil)
require.Error(t, err)
require.Nil(t, c)
}
Loading
Loading
@@ -30,6 +30,9 @@ type Request struct {
Username string `json:"username,omitempty"`
Krb5Principal string `json:"krb5principal,omitempty"`
CheckIp string `json:"check_ip,omitempty"`
// NamespacePath is the full path of the namespace in which the authenticated
// user is allowed to perform operation.
NamespacePath string `json:"namespace_path,omitempty"`
}
type Gitaly struct {
Loading
Loading
@@ -80,7 +83,13 @@ func NewClient(config *config.Config) (*Client, error) {
}
func (c *Client) Verify(ctx context.Context, args *commandargs.Shell, action commandargs.CommandType, repo string) (*Response, error) {
request := &Request{Action: action, Repo: repo, Protocol: protocol, Changes: anyChanges}
request := &Request{
Action: action,
Repo: repo,
Protocol: protocol,
Changes: anyChanges,
NamespacePath: args.Env.NamespacePath,
}
if args.GitlabUsername != "" {
request.Username = args.GitlabUsername
Loading
Loading
Loading
Loading
@@ -19,9 +19,11 @@ import (
)
var (
namespace = "group"
repo = "group/private"
receivePackAction = commandargs.ReceivePack
uploadPackAction = commandargs.UploadPack
defaultEnv = sshenv.Env{NamespacePath: namespace}
)
func buildExpectedResponse(who string) *Response {
Loading
Loading
@@ -71,7 +73,7 @@ func TestSuccessfulResponses(t *testing.T) {
who: "key-1",
}, {
desc: "Provide username within the request",
args: &commandargs.Shell{GitlabUsername: "first"},
args: &commandargs.Shell{GitlabUsername: "first", Env: defaultEnv},
who: "user-1",
}, {
desc: "Provide krb5principal within the request",
Loading
Loading
@@ -99,7 +101,7 @@ func TestGeoPushGetCustomAction(t *testing.T) {
},
}, nil)
args := &commandargs.Shell{GitlabUsername: "custom"}
args := &commandargs.Shell{GitlabUsername: "custom", Env: defaultEnv}
result, err := client.Verify(context.Background(), args, receivePackAction, repo)
require.NoError(t, err)
Loading
Loading
@@ -128,7 +130,7 @@ func TestGeoPullGetCustomAction(t *testing.T) {
},
}, nil)
args := &commandargs.Shell{GitlabUsername: "custom"}
args := &commandargs.Shell{GitlabUsername: "custom", Env: defaultEnv}
result, err := client.Verify(context.Background(), args, uploadPackAction, repo)
require.NoError(t, err)
Loading
Loading
@@ -261,6 +263,7 @@ func setup(t *testing.T, userResponses, keyResponses map[string]testResponse) *C
w.WriteHeader(tr.status)
_, err := w.Write(tr.body)
require.NoError(t, err)
require.Equal(t, namespace, requestBody.NamespacePath)
} else if tr, ok := userResponses[requestBody.Krb5Principal]; ok {
w.WriteHeader(tr.status)
_, err := w.Write(tr.body)
Loading
Loading
package authorizedcerts
import (
"context"
"fmt"
"net/url"
"gitlab.com/gitlab-org/gitlab-shell/v14/client"
"gitlab.com/gitlab-org/gitlab-shell/v14/internal/config"
"gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet"
)
const (
AuthorizedCertsPath = "/authorized_certs"
)
type Client struct {
config *config.Config
client *client.GitlabNetClient
}
type Response struct {
Username string `json:"username"`
Namespace string `json:"namespace"`
}
func NewClient(config *config.Config) (*Client, error) {
client, err := gitlabnet.GetClient(config)
if err != nil {
return nil, fmt.Errorf("Error creating http client: %v", err)
}
return &Client{config: config, client: client}, nil
}
func (c *Client) GetByKey(ctx context.Context, userId, fingerprint string) (*Response, error) {
path, err := pathWithKey(userId, fingerprint)
if err != nil {
return nil, err
}
response, err := c.client.Get(ctx, path)
if err != nil {
return nil, err
}
defer response.Body.Close()
parsedResponse := &Response{}
if err := gitlabnet.ParseJSON(response, parsedResponse); err != nil {
return nil, err
}
return parsedResponse, nil
}
func pathWithKey(userId, fingerprint string) (string, error) {
u, err := url.Parse(AuthorizedCertsPath)
if err != nil {
return "", err
}
params := u.Query()
params.Set("key", fingerprint)
params.Set("user_identifier", userId)
u.RawQuery = params.Encode()
return u.String(), nil
}
package authorizedcerts
import (
"context"
"encoding/json"
"net/http"
"testing"
"github.com/stretchr/testify/require"
"gitlab.com/gitlab-org/gitlab-shell/v14/client"
"gitlab.com/gitlab-org/gitlab-shell/v14/client/testserver"
"gitlab.com/gitlab-org/gitlab-shell/v14/internal/config"
)
var (
requests []testserver.TestRequestHandler
)
func init() {
requests = []testserver.TestRequestHandler{
{
Path: "/api/v4/internal/authorized_certs",
Handler: func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("key") == "key" {
body := &Response{
Namespace: "group",
Username: r.URL.Query().Get("user_identifier"),
}
json.NewEncoder(w).Encode(body)
} else if r.URL.Query().Get("key") == "broken-message" {
w.WriteHeader(http.StatusForbidden)
body := &client.ErrorResponse{
Message: "Not allowed!",
}
json.NewEncoder(w).Encode(body)
} else if r.URL.Query().Get("key") == "broken-json" {
w.Write([]byte("{ \"message\": \"broken json!\""))
} else if r.URL.Query().Get("key") == "broken-empty" {
w.WriteHeader(http.StatusForbidden)
} else {
w.WriteHeader(http.StatusNotFound)
}
},
},
}
}
func TestGetByKey(t *testing.T) {
client := setup(t)
result, err := client.GetByKey(context.Background(), "user-id", "key")
require.NoError(t, err)
require.Equal(t, &Response{Namespace: "group", Username: "user-id"}, result)
}
func TestGetByKeyErrorResponses(t *testing.T) {
client := setup(t)
testCases := []struct {
desc string
key string
expectedError string
}{
{
desc: "A response with an error message",
key: "broken-message",
expectedError: "Not allowed!",
},
{
desc: "A response with bad JSON",
key: "broken-json",
expectedError: "Parsing failed",
},
{
desc: "A forbidden (403) response without message",
key: "broken-empty",
expectedError: "Internal API error (403)",
},
{
desc: "A not found (404) response without message",
key: "not-found",
expectedError: "Internal API error (404)",
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
resp, err := client.GetByKey(context.Background(), "user-id", tc.key)
require.EqualError(t, err, tc.expectedError)
require.Nil(t, resp)
})
}
}
func setup(t *testing.T) *Client {
url := testserver.StartSocketHttpServer(t, requests)
client, err := NewClient(&config.Config{GitlabUrl: url})
require.NoError(t, err)
return client
}
Loading
Loading
@@ -6,11 +6,13 @@ import (
"fmt"
"os"
"strconv"
"strings"
"time"
"golang.org/x/crypto/ssh"
"gitlab.com/gitlab-org/gitlab-shell/v14/internal/config"
"gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet/authorizedcerts"
"gitlab.com/gitlab-org/gitlab-shell/v14/internal/gitlabnet/authorizedkeys"
"gitlab.com/gitlab-org/labkit/log"
Loading
Loading
@@ -37,10 +39,11 @@ var (
)
type serverConfig struct {
cfg *config.Config
hostKeys []ssh.Signer
hostKeyToCertMap map[string]*ssh.Certificate
authorizedKeysClient *authorizedkeys.Client
cfg *config.Config
hostKeys []ssh.Signer
hostKeyToCertMap map[string]*ssh.Certificate
authorizedKeysClient *authorizedkeys.Client
authorizedCertsClient *authorizedcerts.Client
}
func parseHostKeys(keyFiles []string) []ssh.Signer {
Loading
Loading
@@ -113,7 +116,12 @@ func parseHostCerts(hostKeys []ssh.Signer, certFiles []string) map[string]*ssh.C
func newServerConfig(cfg *config.Config) (*serverConfig, error) {
authorizedKeysClient, err := authorizedkeys.NewClient(cfg)
if err != nil {
return nil, fmt.Errorf("failed to initialize GitLab client: %w", err)
return nil, fmt.Errorf("failed to initialize authorized keys client: %w", err)
}
authorizedCertsClient, err := authorizedcerts.NewClient(cfg)
if err != nil {
return nil, fmt.Errorf("failed to initialize authorized certs client: %w", err)
}
hostKeys := parseHostKeys(cfg.Server.HostKeyFiles)
Loading
Loading
@@ -123,10 +131,16 @@ func newServerConfig(cfg *config.Config) (*serverConfig, error) {
hostKeyToCertMap := parseHostCerts(hostKeys, cfg.Server.HostCertFiles)
return &serverConfig{cfg: cfg, authorizedKeysClient: authorizedKeysClient, hostKeys: hostKeys, hostKeyToCertMap: hostKeyToCertMap}, nil
return &serverConfig{
cfg: cfg,
authorizedKeysClient: authorizedKeysClient,
authorizedCertsClient: authorizedCertsClient,
hostKeys: hostKeys,
hostKeyToCertMap: hostKeyToCertMap,
}, nil
}
func (s *serverConfig) getAuthKey(ctx context.Context, user string, key ssh.PublicKey) (*authorizedkeys.Response, error) {
func (s *serverConfig) handleUserKey(ctx context.Context, user string, key ssh.PublicKey) (*ssh.Permissions, error) {
if user != s.cfg.User {
return nil, fmt.Errorf("unknown user")
}
Loading
Loading
@@ -134,15 +148,60 @@ func (s *serverConfig) getAuthKey(ctx context.Context, user string, key ssh.Publ
return nil, fmt.Errorf("DSA is prohibited")
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
res, err := s.authorizedKeysClient.GetByKey(ctx, base64.RawStdEncoding.EncodeToString(key.Marshal()))
if err != nil {
return nil, err
}
return res, nil
return &ssh.Permissions{
// Record the public key used for authentication.
Extensions: map[string]string{
"key-id": strconv.FormatInt(res.Id, 10),
},
}, nil
}
func (s *serverConfig) handleUserCertificate(ctx context.Context, user string, cert *ssh.Certificate) (*ssh.Permissions, error) {
fingerprint := ssh.FingerprintSHA256(cert.SignatureKey)
if cert.CertType != ssh.UserCert {
return nil, fmt.Errorf("handleUserCertificate: cert has type %d", cert.CertType)
}
certChecker := &ssh.CertChecker{}
if err := certChecker.CheckCert(user, cert); err != nil {
return nil, err
}
logger := log.WithContextFields(ctx,
log.Fields{
"ssh_user": user,
"public_key_fingerprint": ssh.FingerprintSHA256(cert),
"signing_ca_fingerprint": fingerprint,
"certificate_identity": cert.KeyId,
},
)
res, err := s.authorizedCertsClient.GetByKey(ctx, cert.KeyId, strings.TrimPrefix(fingerprint, "SHA256:"))
if err != nil {
logger.WithError(err).Warn("user certificate is not signed by a trusted key")
return nil, err
}
logger.WithFields(
log.Fields{
"certificate_username": res.Username,
"certificate_namespace": res.Namespace,
},
).Info("user certificate is signed by a trusted key")
return &ssh.Permissions{
Extensions: map[string]string{
"username": res.Username,
"namespace": res.Namespace,
},
}, nil
}
func (s *serverConfig) get(ctx context.Context) *ssh.ServerConfig {
Loading
Loading
@@ -166,19 +225,18 @@ func (s *serverConfig) get(ctx context.Context) *ssh.ServerConfig {
},
}
}
sshCfg := &ssh.ServerConfig{
PublicKeyCallback: func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
res, err := s.getAuthKey(ctx, conn.User(), key)
if err != nil {
return nil, err
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
cert, ok := key.(*ssh.Certificate)
if ok {
return s.handleUserCertificate(ctx, conn.User(), cert)
}
return &ssh.Permissions{
// Record the public key used for authentication.
Extensions: map[string]string{
"key-id": strconv.FormatInt(res.Id, 10),
},
}, nil
return s.handleUserKey(ctx, conn.User(), key)
},
GSSAPIWithMICConfig: gssapiWithMICConfig,
ServerVersion: "SSH-2.0-GitLab-SSHD",
Loading
Loading
Loading
Loading
@@ -5,13 +5,20 @@ import (
"crypto/dsa"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"errors"
"net/http"
"os"
"path"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
"gitlab.com/gitlab-org/gitlab-shell/v14/client"
"gitlab.com/gitlab-org/gitlab-shell/v14/client/testserver"
"gitlab.com/gitlab-org/gitlab-shell/v14/internal/config"
"gitlab.com/gitlab-org/gitlab-shell/v14/internal/testhelper"
)
Loading
Loading
@@ -66,12 +73,30 @@ func TestFailedAuthorizedKeysClient(t *testing.T) {
_, err := newServerConfig(&config.Config{GitlabUrl: "ftp://localhost"})
require.Error(t, err)
require.Equal(t, "failed to initialize GitLab client: Error creating http client: unknown GitLab URL prefix", err.Error())
require.Equal(t, "failed to initialize authorized keys client: Error creating http client: unknown GitLab URL prefix", err.Error())
}
func TestFailedGetAuthKey(t *testing.T) {
func TestUserKeyHandling(t *testing.T) {
testhelper.PrepareTestRootDir(t)
validRSAKey := rsaPublicKey(t)
requests := []testserver.TestRequestHandler{
{
Path: "/api/v4/internal/authorized_keys",
Handler: func(w http.ResponseWriter, r *http.Request) {
key := base64.RawStdEncoding.EncodeToString(validRSAKey.Marshal())
if key == r.URL.Query().Get("key") {
w.Write([]byte(`{ "id": 1, "key": "key" }`))
} else {
w.WriteHeader(http.StatusInternalServerError)
}
},
},
}
url := testserver.StartSocketHttpServer(t, requests)
srvCfg := config.ServerConfig{
Listen: "127.0.0.1",
ConcurrentSessionsLimit: 1,
Loading
Loading
@@ -83,39 +108,122 @@ func TestFailedGetAuthKey(t *testing.T) {
}
cfg, err := newServerConfig(
&config.Config{GitlabUrl: "http://localhost", User: "user", Server: srvCfg},
&config.Config{GitlabUrl: url, User: "user", Server: srvCfg},
)
require.NoError(t, err)
testCases := []struct {
desc string
user string
key ssh.PublicKey
expectedError string
desc string
user string
key ssh.PublicKey
expectedErr error
expectedPermissions *ssh.Permissions
}{
{
desc: "wrong user",
user: "wrong-user",
key: rsaPublicKey(t),
expectedError: "unknown user",
desc: "wrong user",
user: "wrong-user",
key: rsaPublicKey(t),
expectedErr: errors.New("unknown user"),
}, {
desc: "prohibited dsa key",
user: "user",
key: dsaPublicKey(t),
expectedError: "DSA is prohibited",
desc: "prohibited dsa key",
user: "user",
key: dsaPublicKey(t),
expectedErr: errors.New("DSA is prohibited"),
}, {
desc: "API error",
user: "user",
key: rsaPublicKey(t),
expectedError: "Internal API unreachable",
desc: "API error",
user: "user",
key: rsaPublicKey(t),
expectedErr: &client.ApiError{Msg: "Internal API unreachable"},
}, {
desc: "successful request",
user: "user",
key: validRSAKey,
expectedPermissions: &ssh.Permissions{
Extensions: map[string]string{"key-id": "1"},
},
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
_, err = cfg.getAuthKey(context.Background(), tc.user, tc.key)
require.Error(t, err)
require.Equal(t, tc.expectedError, err.Error())
permissions, err := cfg.handleUserKey(context.Background(), tc.user, tc.key)
require.Equal(t, tc.expectedErr, err)
require.Equal(t, tc.expectedPermissions, permissions)
})
}
}
func TestUserCertificateHandling(t *testing.T) {
testhelper.PrepareTestRootDir(t)
validUserCert := userCert(t, ssh.UserCert, time.Now().Add(time.Hour))
requests := []testserver.TestRequestHandler{
{
Path: "/api/v4/internal/authorized_certs",
Handler: func(w http.ResponseWriter, r *http.Request) {
key := strings.TrimPrefix(ssh.FingerprintSHA256(validUserCert.SignatureKey), "SHA256:")
if key == r.URL.Query().Get("key") && r.URL.Query().Get("user_identifier") == "root@example.com" {
w.Write([]byte(`{ "username": "root", "namespace": "namespace" }`))
} else {
w.WriteHeader(http.StatusInternalServerError)
}
},
},
}
url := testserver.StartSocketHttpServer(t, requests)
srvCfg := config.ServerConfig{
Listen: "127.0.0.1",
ConcurrentSessionsLimit: 1,
HostKeyFiles: []string{
path.Join(testhelper.TestRoot, "certs/valid/server.key"),
path.Join(testhelper.TestRoot, "certs/invalid-path.key"),
path.Join(testhelper.TestRoot, "certs/invalid/server.crt"),
},
}
cfg, err := newServerConfig(
&config.Config{GitlabUrl: url, User: "user", Server: srvCfg},
)
require.NoError(t, err)
testCases := []struct {
desc string
cert *ssh.Certificate
expectedErr error
expectedPermissions *ssh.Permissions
}{
{
desc: "wrong cert type",
cert: userCert(t, ssh.HostCert, time.Now().Add(time.Hour)),
expectedErr: errors.New("handleUserCertificate: cert has type 2"),
}, {
desc: "expired cert",
cert: userCert(t, ssh.UserCert, time.Now().Add(-time.Hour)),
expectedErr: errors.New("ssh: cert has expired"),
}, {
desc: "API error",
cert: userCert(t, ssh.UserCert, time.Now().Add(time.Hour)),
expectedErr: &client.ApiError{Msg: "Internal API unreachable"},
}, {
desc: "successful request",
cert: validUserCert,
expectedPermissions: &ssh.Permissions{
Extensions: map[string]string{
"username": "root",
"namespace": "namespace",
},
},
},
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
permissions, err := cfg.handleUserCertificate(context.Background(), "user", tc.cert)
require.Equal(t, tc.expectedErr, err)
require.Equal(t, tc.expectedPermissions, permissions)
})
}
}
Loading
Loading
@@ -240,3 +348,24 @@ func dsaPublicKey(t *testing.T) ssh.PublicKey {
return publicKey
}
func userCert(t *testing.T, certType uint32, validBefore time.Time) *ssh.Certificate {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
signer, err := ssh.NewSignerFromKey(privateKey)
require.NoError(t, err)
pubKey, err := ssh.NewPublicKey(&privateKey.PublicKey)
require.NoError(t, err)
cert := &ssh.Certificate{
CertType: certType,
Key: pubKey,
KeyId: "root@example.com",
ValidBefore: uint64(validBefore.Unix()),
}
require.NoError(t, cert.SignCert(rand.Reader, signer))
return cert
}
Loading
Loading
@@ -28,6 +28,8 @@ type session struct {
channel ssh.Channel
gitlabKeyId string
gitlabKrb5Principal string
gitlabUsername string
namespace string
remoteAddr string
// State managed by the session
Loading
Loading
@@ -162,6 +164,7 @@ func (s *session) handleShell(ctx context.Context, req *ssh.Request) (context.Co
OriginalCommand: s.execCmd,
GitProtocolVersion: s.gitProtocolVersion,
RemoteAddr: s.remoteAddr,
NamespacePath: s.namespace,
}
rw := &readwriter.ReadWriter{
Loading
Loading
@@ -175,6 +178,8 @@ func (s *session) handleShell(ctx context.Context, req *ssh.Request) (context.Co
if s.gitlabKrb5Principal != "" {
cmd, err = shellCmd.NewWithKrb5Principal(s.gitlabKrb5Principal, env, s.cfg, rw)
} else if s.gitlabUsername != "" {
cmd, err = shellCmd.NewWithUsername(s.gitlabUsername, env, s.cfg, rw)
} else {
cmd, err = shellCmd.NewWithKey(s.gitlabKeyId, env, s.cfg, rw)
}
Loading
Loading
Loading
Loading
@@ -132,9 +132,13 @@ func TestHandleExec(t *testing.T) {
t.Run(tc.desc, func(t *testing.T) {
sessions := []*session{
{
gitlabKeyId: "root",
gitlabKeyId: "id",
cfg: &config.Config{GitlabUrl: url},
},
{
gitlabUsername: "root",
cfg: &config.Config{GitlabUrl: url},
},
{
gitlabKrb5Principal: "test@TEST.TEST",
cfg: &config.Config{GitlabUrl: url},
Loading
Loading
Loading
Loading
@@ -202,6 +202,8 @@ func (s *Server) handleConn(ctx context.Context, nconn net.Conn) {
channel: channel,
gitlabKeyId: sconn.Permissions.Extensions["key-id"],
gitlabKrb5Principal: sconn.Permissions.Extensions["krb5principal"],
gitlabUsername: sconn.Permissions.Extensions["username"],
namespace: sconn.Permissions.Extensions["namespace"],
remoteAddr: remoteAddr,
started: time.Now(),
}
Loading
Loading
Loading
Loading
@@ -19,6 +19,7 @@ type Env struct {
IsSSHConnection bool
OriginalCommand string
RemoteAddr string
NamespacePath string
}
func NewFromEnv() Env {
Loading
Loading
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment