diff --git a/components/public-api-server/pkg/auth/personal_access_token.go b/components/public-api-server/pkg/auth/personal_access_token.go new file mode 100644 index 00000000000000..1d7ec02ccb080b --- /dev/null +++ b/components/public-api-server/pkg/auth/personal_access_token.go @@ -0,0 +1,139 @@ +// Copyright (c) 2022 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License-AGPL.txt in the project root for license information. + +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "math/big" + "strings" +) + +const PersonalAccessTokenPrefix = "gitpod_pat_" + +// PersonalAccessToken token is an Access Token for individuals. Any action taken with this token will act on behalf of the token creator. +// The PersonalAccessToken, in string form, takes the following shape: gitpod_pat_. +// E.g. gitpod_pat_ko8KC1tJ-GkqIwqNliwF4tBUk2Jd5nEe9qOWqYfobtY.6ZDQVanpaTKj9hQuji0thCe8KFCcmEDGpsaTkSSb +type PersonalAccessToken struct { + // prefix is the human readable prefix for the token used to identify which type of token it is, + // but also for code-scanning of leaked credentials. + // e.g. `gitpod_pat_` + prefix string + + // value is the secret value of the token + value string + + // signature is the generated signature of the value + // signature is used to validate the personal access token before using it + // signature is Base 64 URL Encoded, without padding + signature string +} + +func (t *PersonalAccessToken) String() string { + return fmt.Sprintf("%s%s.%s", t.prefix, t.signature, t.value) +} + +func (t *PersonalAccessToken) Value() string { + return t.value +} + +func GeneratePersonalAccessToken(signer Signer) (PersonalAccessToken, error) { + if signer == nil { + return PersonalAccessToken{}, errors.New("no personal access token signer available") + } + + value, err := generateTokenValue(40) + if err != nil { + return PersonalAccessToken{}, fmt.Errorf("failed to generate personal access token value: %w", err) + } + + signature, err := signer.Sign([]byte(value)) + if err != nil { + return PersonalAccessToken{}, fmt.Errorf("failed to sign personal access token value: %w", err) + } + + return PersonalAccessToken{ + prefix: PersonalAccessTokenPrefix, + value: value, + // We use base64.RawURLEncoding because we do not want padding in the token in the form of '=' signs + signature: base64.RawURLEncoding.EncodeToString(signature), + }, nil +} + +func ParsePersonalAccessToken(token string, signer Signer) (PersonalAccessToken, error) { + if token == "" { + return PersonalAccessToken{}, errors.New("empty personal access") + } + // Assume we start with the following token: gitpod_pat_ko8KC1tJ-GkqIwqNliwF4tBUk2Jd5nEe9qOWqYfobtY.6ZDQVanpaTKj9hQuji0thCe8KFCcmEDGpsaTkSSb + // First, we identify if the token contains the required prefix + if !strings.HasPrefix(token, PersonalAccessTokenPrefix) { + return PersonalAccessToken{}, fmt.Errorf("personal access token does not have %s prefix", PersonalAccessTokenPrefix) + } + + // Remove the gitpod_pat_ prefix + token = strings.TrimPrefix(token, PersonalAccessTokenPrefix) + + // We now have the token in the following form: + // ko8KC1tJ-GkqIwqNliwF4tBUk2Jd5nEe9qOWqYfobtY.6ZDQVanpaTKj9hQuji0thCe8KFCcmEDGpsaTkSSb + // Break it into . + parts := strings.SplitN(token, ".", 2) + if len(parts) != 2 { + return PersonalAccessToken{}, errors.New("failed to break personal access token into signature and value") + } + + // Sanity check the extracted values + signature, value := parts[0], parts[1] + if signature == "" { + return PersonalAccessToken{}, errors.New("personal access token has empty signature") + } + if value == "" { + return PersonalAccessToken{}, errors.New("personal access token has empty value") + } + + // We must validate the signature before we proceed further. + signatureForValue, err := signer.Sign([]byte(value)) + if err != nil { + return PersonalAccessToken{}, fmt.Errorf("failed to compute signature of personal access token value: %w", err) + } + + // The signature we receive is Base64 encoded, we also encode the signature for value we've just generated. + encodedSignatureForValue := base64.RawURLEncoding.EncodeToString(signatureForValue) + + // Perform a cryptographically safe comparison between the signature, and the value we've just signed + if subtle.ConstantTimeCompare([]byte(signature), []byte(encodedSignatureForValue)) != 1 { + return PersonalAccessToken{}, errors.New("personal access token signature does not match token value") + } + + return PersonalAccessToken{ + prefix: PersonalAccessTokenPrefix, + value: value, + signature: signature, + }, nil +} + +func generateTokenValue(size int) (string, error) { + if size <= 0 { + return "", errors.New("token size must be greater than 0") + } + + // letters represent the resulting character-set of the token + // we use only upper/lower alphanumberic to ensure the token is + // * easy to select by double-clicking it + // * URL safe + const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + ret := make([]byte, size) + for i := 0; i < size; i++ { + num, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) + if err != nil { + return "", err + } + ret[i] = letters[num.Int64()] + } + + return string(ret), nil +} diff --git a/components/public-api-server/pkg/auth/personal_access_token_test.go b/components/public-api-server/pkg/auth/personal_access_token_test.go new file mode 100644 index 00000000000000..177cd3f2938841 --- /dev/null +++ b/components/public-api-server/pkg/auth/personal_access_token_test.go @@ -0,0 +1,111 @@ +// Copyright (c) 2022 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License-AGPL.txt in the project root for license information. + +package auth + +import ( + "encoding/base64" + "fmt" + "regexp" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGeneratePersonalAccessToken(t *testing.T) { + signer := NewHS256Signer([]byte("my-secret")) + + pat, err := GeneratePersonalAccessToken(signer) + require.NoError(t, err) + + signature, err := signer.Sign([]byte(pat.value)) + require.NoError(t, err) + + require.NotEmpty(t, pat.value) + require.Len(t, pat.value, 40) + require.Equal(t, PersonalAccessToken{ + prefix: PersonalAccessTokenPrefix, + value: pat.value, + signature: base64.RawURLEncoding.EncodeToString(signature), + }, pat) + require.Equal(t, fmt.Sprintf("%s%s.%s", pat.prefix, pat.signature, pat.value), pat.String()) + + // must also be able to parse the token back + parsed, err := ParsePersonalAccessToken(pat.String(), signer) + require.NoError(t, err) + require.Equal(t, pat, parsed) +} + +func TestParsePersonalAccessToken_Errors(t *testing.T) { + signer := NewHS256Signer([]byte("my-secret")) + + scenarios := []struct { + Name string + Token string + }{ + { + Name: "empty token is rejected", + Token: "", + }, + { + Name: "invalid prefix", + Token: "gitpod_yolo_fooo", + }, + { + Name: "invalid token with correct prefix", + Token: "gitpod_pat_foo", + }, + { + Name: "invalid token with correct prefix and empty value and signature", + Token: "gitpod_pat_.", + }, + { + Name: "invalid token with correct prefix but missing signature", + Token: "gitpod_pat_.value", + }, + { + Name: "invalid token with correct prefix but missing value", + Token: "gitpod_pat_signature.", + }, + { + Name: "invalid signature", + Token: "gitpod_pat_signature.value", + }, + } + + for _, s := range scenarios { + t.Run(s.Name, func(t *testing.T) { + _, err := ParsePersonalAccessToken(s.Token, signer) + require.Error(t, err) + }) + + } +} + +func TestGenerateTokenValue_OnlyAlphaNumberic(t *testing.T) { + sizes := []int{10, 20, 30, 40, 50, 60, 70, 80} + + var tokens []string + for _, size := range sizes { + for i := 0; i < 10; i++ { + token, err := generateTokenValue(size) + require.NoError(t, err) + + tokens = append(tokens, token) + } + } + + for _, token := range tokens { + rxp := regexp.MustCompile(`([a-zA-Z]|\d)+`) + require.Regexp(t, rxp, token, "must match alphanumeric") + } +} + +func TestGenerateTokenValue_FailsWithSizeZeroOrSmaller(t *testing.T) { + _, err := generateTokenValue(0) + require.Error(t, err) + + _, err = generateTokenValue(-1) + require.Error(t, err) +} diff --git a/components/public-api-server/pkg/auth/signature.go b/components/public-api-server/pkg/auth/signature.go new file mode 100644 index 00000000000000..3eafc10de713fa --- /dev/null +++ b/components/public-api-server/pkg/auth/signature.go @@ -0,0 +1,35 @@ +// Copyright (c) 2022 Gitpod GmbH. All rights reserved. +// Licensed under the GNU Affero General Public License (AGPL). +// See License-AGPL.txt in the project root for license information. + +package auth + +import ( + "crypto/hmac" + "crypto/sha256" + "fmt" +) + +type Signer interface { + Sign(message []byte) ([]byte, error) +} + +func NewHS256Signer(key []byte) *HS256Signer { + return &HS256Signer{ + key: key, + } +} + +type HS256Signer struct { + key []byte +} + +// Signs message with HS256 (HMAC with SHA-256) +func (s *HS256Signer) Sign(message []byte) ([]byte, error) { + h := hmac.New(sha256.New, s.key) + _, err := h.Write(message) + if err != nil { + return nil, fmt.Errorf("failed to sign secret: %w", err) + } + return h.Sum(nil), nil +}