From dfa641c249f7e4ab26c52c867084f171cdf28bed Mon Sep 17 00:00:00 2001 From: James Telfer <792299+jamestelfer@users.noreply.github.com> Date: Sun, 9 Jun 2024 23:02:37 +1000 Subject: [PATCH] feat: allow use of AWS KMS for JWT signing --- .envrc | 4 +- Makefile | 2 +- docs/kms.md | 66 ++++++++++++++ go.mod | 16 +++- go.sum | 34 ++++++++ integration/docker-compose.yaml | 7 ++ internal/config/config.go | 11 ++- internal/github/kmssigner.go | 139 ++++++++++++++++++++++++++++++ internal/github/kmssigner_test.go | 95 ++++++++++++++++++++ internal/github/token.go | 38 ++++++-- internal/github/token_test.go | 70 +++++++++++---- main.go | 6 +- 12 files changed, 451 insertions(+), 37 deletions(-) create mode 100644 docs/kms.md create mode 100644 internal/github/kmssigner.go create mode 100644 internal/github/kmssigner_test.go diff --git a/.envrc b/.envrc index f7298d6..bc09fdc 100644 --- a/.envrc +++ b/.envrc @@ -110,8 +110,10 @@ source_env_if_exists .envrc.private # GitHub API connectivity # -# required +# required (one of) # export GITHUB_APP_PRIVATE_KEY="" +# export GITHUB_APP_PRIVATE_KEY_ARN="" + # required # export GITHUB_APP_ID="" # required diff --git a/Makefile b/Makefile index 3b8506d..ab823cc 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,7 @@ run: build .PHONY: docker docker: build - docker compose -f integration/docker-compose.yaml up + docker compose -f integration/docker-compose.yaml up --abort-on-container-exit .PHONY: docker-down docker-down: diff --git a/docs/kms.md b/docs/kms.md new file mode 100644 index 0000000..b3cea4f --- /dev/null +++ b/docs/kms.md @@ -0,0 +1,66 @@ +# Using KMS to sign GitHub JWTs + +It is more secure (though more complicated) to provide Chinmina with an AWS KMS key to sign JWTs for GitHub requests. + +## Uploading the KMS key + +1. [Generate the private key][github-key-generate] for the GitHub application. + +2. Check the private key and convert it ready for upload + - the key spec for your GitHub key _should_ be RSA 2048. To verify that this is + the case, run `openssl rsa -text -noout -in yourkey.pem` and examine the + output. + - convert the GitHub key from PEM to DER format for AWS: + + ```shell + openssl rsa -inform PEM -outform DER -in ./private-key.pem -out private-key.cer + ``` + +3. Follow the [AWS instructions][aws-import-key-material] for importing the + application private key into GitHub. This includes creating an RSA 2048 key + of type "EXTERNAL", encrypting the key material according to the instructions + and uploading it. + +4. Create an alias for the KMS key to allow for easy [manual key + rotation][aws-manual-key-rotation]. + + > [!IMPORTANT] + > A key alias is essential to allow for key rotation. Unless you're stopped + > by environmental policy, use the alias. The key will be able to be rotated + > without any service downtime. + +5. Ensure that the key policy has a statement allowing Chinmina to access the key. The specified role should be the role that the Chinmina process has access to at runtime. + + ```json + { + "Sid": "Allow Chinmina to sign using the key", + "Effect": "Allow", + "Principal": { + "AWS": [ + "arn:aws:iam::226140413739:role/full-task-role-name" + ] + }, + "Action": [ + "kms:Sign" + ], + "Resource": "*" + } + ``` + + > [!IMPORTANT] + > Chinmina does not assume a role to access the key. It assumes valid + > credentials are present for the AWS SDK to use. + +## Configuring the Chinmina service + +1. Set the environment variable `GITHUB_APP_PRIVATE_KEY_ARN` to the ARN of the **alias** that has just been created. + +2. Update IAM for your key. + 1. Key resource policy + 2. Alias policy? + 3. IAM policy for Chinmina process (i.e. the AWS role available to Chinmina + when it runs) + +[github-key-generate]: https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/managing-private-keys-for-github-apps#generating-private-keys +[aws-import-key-material]: https://docs.aws.amazon.com/kms/latest/developerguide/importing-keys.html +[aws-manual-key-rotation]: https://docs.aws.amazon.com/kms/latest/developerguide/rotate-keys.html#rotate-keys-manually diff --git a/go.mod b/go.mod index a4694f7..8a3a91e 100644 --- a/go.mod +++ b/go.mod @@ -4,11 +4,13 @@ go 1.22.3 require ( github.com/auth0/go-jwt-middleware/v2 v2.2.1 + github.com/aws/aws-sdk-go-v2 v1.27.2 github.com/bradleyfalzon/ghinstallation/v2 v2.10.0 github.com/buildkite/go-buildkite/v3 v3.11.0 github.com/go-jose/go-jose/v4 v4.0.1 github.com/go-logr/logr v1.4.2 github.com/go-logr/zerologr v1.2.3 + github.com/golang-jwt/jwt/v4 v4.5.0 github.com/google/go-github/v61 v61.0.0 github.com/justinas/alice v1.2.0 github.com/maypok86/otter v1.2.0 @@ -21,6 +23,17 @@ require ( ) require ( + github.com/aws/aws-sdk-go-v2/credentials v1.17.18 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.5 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.9 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.9 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.11 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.20.11 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.5 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.28.12 // indirect + github.com/aws/smithy-go v1.20.2 // indirect github.com/cenkalti/backoff v1.1.1-0.20171020064038-309aa717adbf // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect @@ -28,7 +41,6 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gammazero/deque v0.2.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/google/go-github/v60 v60.0.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 // indirect @@ -41,6 +53,8 @@ require ( ) require ( + github.com/aws/aws-sdk-go-v2/config v1.27.18 + github.com/aws/aws-sdk-go-v2/service/kms v1.31.1 github.com/pmezard/go-difflib v1.0.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.51.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 diff --git a/go.sum b/go.sum index 46fc6d5..4b9e662 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,39 @@ github.com/auth0/go-jwt-middleware/v2 v2.2.1 h1:pqxEIwlCztD0T9ZygGfOrw4NK/F9iotnCnPJVADKbkE= github.com/auth0/go-jwt-middleware/v2 v2.2.1/go.mod h1:CSi0tuu0QrALbWdiQZwqFL8SbBhj4e2MJzkvNfjY0Us= +github.com/aws/aws-sdk-go-v2 v1.26.1 h1:5554eUqIYVWpU0YmeeYZ0wU64H2VLBs8TlhRB2L+EkA= +github.com/aws/aws-sdk-go-v2 v1.26.1/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM= +github.com/aws/aws-sdk-go-v2 v1.27.2 h1:pLsTXqX93rimAOZG2FIYraDQstZaaGVVN4tNw65v0h8= +github.com/aws/aws-sdk-go-v2 v1.27.2/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM= +github.com/aws/aws-sdk-go-v2/config v1.27.18 h1:wFvAnwOKKe7QAyIxziwSKjmer9JBMH1vzIL6W+fYuKk= +github.com/aws/aws-sdk-go-v2/config v1.27.18/go.mod h1:0xz6cgdX55+kmppvPm2IaKzIXOheGJhAufacPJaXZ7c= +github.com/aws/aws-sdk-go-v2/credentials v1.17.18 h1:D/ALDWqK4JdY3OFgA2thcPO1c9aYTT5STS/CvnkqY1c= +github.com/aws/aws-sdk-go-v2/credentials v1.17.18/go.mod h1:JuitCWq+F5QGUrmMPsk945rop6bB57jdscu+Glozdnc= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.5 h1:dDgptDO9dxeFkXy+tEgVkzSClHZje/6JkPW5aZyEvrQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.5/go.mod h1:gjvE2KBUgUQhcv89jqxrIxH9GaKs1JbZzWejj/DaHGA= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 h1:aw39xVGeRWlWx9EzGVnhOR4yOjQDHPQ6o6NmBlscyQg= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5/go.mod h1:FSaRudD0dXiMPK2UjknVwwTYyZMRsHv3TtkabsZih5I= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.9 h1:cy8ahBJuhtM8GTTSyOkfy6WVPV1IE+SS5/wfXUYuulw= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.9/go.mod h1:CZBXGLaJnEZI6EVNcPd7a6B5IC5cA/GkRWtu9fp3S6Y= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 h1:PG1F3OD1szkuQPzDw3CIQsRIrtTlUC3lP84taWzHlq0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5/go.mod h1:jU1li6RFryMz+so64PpKtudI+QzbKoIEivqdf6LNpOc= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.9 h1:A4SYk07ef04+vxZToz9LWvAXl9LW0NClpPpMsi31cz0= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.9/go.mod h1:5jJcHuwDagxN+ErjQ3PU3ocf6Ylc/p9x+BLO/+X4iXw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 h1:Ji0DY1xUsUr3I8cHps0G+XM3WWU16lP6yG8qu1GAZAs= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2/go.mod h1:5CsjAbs3NlGQyZNFACh+zztPDI7fU6eW9QsxjfnuBKg= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.11 h1:o4T+fKxA3gTMcluBNZZXE9DNaMkJuUL1O3mffCUjoJo= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.11/go.mod h1:84oZdJ+VjuJKs9v1UTC9NaodRZRseOXCTgku+vQJWR8= +github.com/aws/aws-sdk-go-v2/service/kms v1.31.1 h1:5wtyAwuUiJiM3DHYeGZmP5iMonM7DFBWAEaaVPHYZA0= +github.com/aws/aws-sdk-go-v2/service/kms v1.31.1/go.mod h1:2snWQJQUKsbN66vAawJuOGX7dr37pfOq9hb0tZDGIqQ= +github.com/aws/aws-sdk-go-v2/service/sso v1.20.11 h1:gEYM2GSpr4YNWc6hCd5nod4+d4kd9vWIAWrmGuLdlMw= +github.com/aws/aws-sdk-go-v2/service/sso v1.20.11/go.mod h1:gVvwPdPNYehHSP9Rs7q27U1EU+3Or2ZpXvzAYJNh63w= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.5 h1:iXjh3uaH3vsVcnyZX7MqCoCfcyxIrVE9iOQruRaWPrQ= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.5/go.mod h1:5ZXesEuy/QcO0WUnt+4sDkxhdXRHTu2yG0uCSH8B6os= +github.com/aws/aws-sdk-go-v2/service/sts v1.28.12 h1:M/1u4HBpwLuMtjlxuI2y6HoVLzF5e2mfxHCg7ZVMYmk= +github.com/aws/aws-sdk-go-v2/service/sts v1.28.12/go.mod h1:kcfd+eTdEi/40FIbLq4Hif3XMXnl5b/+t/KTfLt9xIk= +github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q= +github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E= github.com/bradleyfalzon/ghinstallation/v2 v2.10.0 h1:XWuWBRFEpqVrHepQob9yPS3Xg4K3Wr9QCx4fu8HbUNg= github.com/bradleyfalzon/ghinstallation/v2 v2.10.0/go.mod h1:qoGA4DxWPaYTgVCrmEspVSjlTu4WYAiSxMIhorMRXXc= github.com/buildkite/go-buildkite/v3 v3.11.0 h1:A43KDOuNczqrY8wqlsHNtPoYbgWXYC/slkB/2JYXr5E= diff --git a/integration/docker-compose.yaml b/integration/docker-compose.yaml index b8ead9e..b687c2f 100644 --- a/integration/docker-compose.yaml +++ b/integration/docker-compose.yaml @@ -34,6 +34,7 @@ services: - BUILDKITE_API_TOKEN - JWT_BUILDKITE_ORGANIZATION_SLUG - JWT_AUDIENCE=github-app-auth:jamestelfer + - GITHUB_APP_PRIVATE_KEY_ARN - GITHUB_APP_PRIVATE_KEY - GITHUB_APP_ID - GITHUB_APP_INSTALLATION_ID @@ -43,6 +44,12 @@ services: - OBSERVE_METRICS_ENABLED=false - OBSERVE_TYPE=grpc - OBSERVE_OTEL_LOG_LEVEL=debug + # pass through AWS credentials (allows for KMS signing to be used) + - AWS_ACCESS_KEY_ID + - AWS_SECRET_ACCESS_KEY + - AWS_SESSION_TOKEN + - AWS_REGION + - AWS_DEFAULT_REGION volumes: - "..:/src" diff --git a/internal/config/config.go b/internal/config/config.go index f83e68a..99e36e5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -35,10 +35,13 @@ type BuildkiteConfig struct { } type GithubConfig struct { - ApiURL string // internal only - PrivateKey string `env:"GITHUB_APP_PRIVATE_KEY, required"` - ApplicationID int64 `env:"GITHUB_APP_ID, required"` - InstallationID int64 `env:"GITHUB_APP_INSTALLATION_ID, required"` + ApiURL string // internal only + + PrivateKey string `env:"GITHUB_APP_PRIVATE_KEY"` + PrivateKeyARN string `env:"GITHUB_APP_PRIVATE_KEY_ARN"` + + ApplicationID int64 `env:"GITHUB_APP_ID, required"` + InstallationID int64 `env:"GITHUB_APP_INSTALLATION_ID, required"` } type ObserveConfig struct { diff --git a/internal/github/kmssigner.go b/internal/github/kmssigner.go new file mode 100644 index 0000000..f89da50 --- /dev/null +++ b/internal/github/kmssigner.go @@ -0,0 +1,139 @@ +package github + +import ( + "context" + "crypto" + "encoding/base64" + "errors" + "fmt" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/kms" + "github.com/aws/aws-sdk-go-v2/service/kms/types" + "github.com/bradleyfalzon/ghinstallation/v2" + "github.com/golang-jwt/jwt/v4" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + + // Explicitly import this to ensure the hash is available. This allows us to + // assume that crypto.SHA256.Available() will return true. + _ "crypto/sha256" +) + +var _ ghinstallation.Signer = KMSSigner{} +var _ jwt.SigningMethod = KMSSigningMethod{} + +// KMSClient defines the AWS API surface required by the KMSSigner. +type KMSClient interface { + Sign(ctx context.Context, in *kms.SignInput, optFns ...func(*kms.Options)) (*kms.SignOutput, error) +} + +func NewAWSKMSSigner(ctx context.Context, arn string) (KMSSigner, error) { + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + return KMSSigner{}, err + } + client := kms.NewFromConfig(cfg) + + return NewKMSSigner(client, arn), nil +} + +// KMSSigner defines a Signer compatible with the ghinstallation plugin that +// uses KMS to sign the JWT. KMS signing ensures that the private key is never +// exposed to the application. +type KMSSigner struct { + ARN string + Method jwt.SigningMethod +} + +func NewKMSSigner(client KMSClient, arn string) KMSSigner { + method := NewSigningMethod(client) + + return KMSSigner{ + ARN: arn, + Method: method, + } +} + +func (s KMSSigner) Sign(claims jwt.Claims) (string, error) { + defer functionDuration(func(l zerolog.Logger) { l.Info().Msg("KMSSigner.Sign()") })() + + tok, err := jwt.NewWithClaims(s.Method, claims).SignedString(s.ARN) + + return tok, err +} + +// Defines a golang-jwt compatible signing method that uses AWS KMS. +type KMSSigningMethod struct { + client KMSClient + hash crypto.Hash +} + +func NewSigningMethod(client KMSClient) KMSSigningMethod { + alg := crypto.SHA256 + + return KMSSigningMethod{ + client: client, + hash: alg, + } +} + +// Alg returns the signing algorithm allowed for this method, which is "RS256". +func (k KMSSigningMethod) Alg() string { + return "RS256" +} + +// Sign uses AWS KMS to sign the given string with the provided key (the string +// ARN of the KMS key to use). This will fail if the current AWS user does not +// have permission to sign the key, or if KMS cannot be reached, or if the key +// doesn't exist. +func (k KMSSigningMethod) Sign(signingString string, key any) (string, error) { + keyArn, ok := key.(string) + if !ok { + return "", errors.New("unexpected key type supplied (string expected)") + } + + // create a digest of the source material, ensuring that the data sent to AWS + // is both anonymous and a constant size. + hasher := k.hash.New() + hasher.Write([]byte(signingString)) + digest := hasher.Sum(nil) + + // Use KMS to sign the digest with the given ARN. + // + // Note: there is an outstanding PR on ghinstallation to allow this method to + // pass a context: https://github.com/bradleyfalzon/ghinstallation/pull/119 + result, err := k.client.Sign(context.Background(), &kms.SignInput{ + KeyId: aws.String(keyArn), + SigningAlgorithm: types.SigningAlgorithmSpecRsassaPkcs1V15Sha256, + MessageType: types.MessageTypeDigest, + Message: digest, + }) + if err != nil { + return "", fmt.Errorf("KMS signing failed: %w", err) + } + + // Return the base64 encoded signature. The JWT spec defines that no base64 + // padding should be included, so RawURLEncoding is used. + sig := result.Signature + encodedSig := base64.RawURLEncoding.EncodeToString(sig) + + return encodedSig, nil +} + +func (k KMSSigningMethod) Verify(signingString string, signature string, key interface{}) error { + // Not implemented as we are only signing JWTs for GitHub access, not + // verifying them + return errors.New("not implemented") +} + +func functionDuration(l func(zerolog.Logger)) func() { + start := time.Now() + + return func() { + d := time.Since(start) + l(log.With().Dur("duration", d).Logger()) + } +} diff --git a/internal/github/kmssigner_test.go b/internal/github/kmssigner_test.go new file mode 100644 index 0000000..b147393 --- /dev/null +++ b/internal/github/kmssigner_test.go @@ -0,0 +1,95 @@ +package github_test + +import ( + "context" + "encoding/base64" + "errors" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/kms" + "github.com/golang-jwt/jwt/v4" + "github.com/jamestelfer/chinmina-bridge/internal/github" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type KMSClientFunc func(context.Context, *kms.SignInput, ...func(*kms.Options)) (*kms.SignOutput, error) + +func (f KMSClientFunc) Sign(ctx context.Context, in *kms.SignInput, optFns ...func(*kms.Options)) (*kms.SignOutput, error) { + return f(ctx, in, optFns...) +} + +func TestSigner_Sign(t *testing.T) { + signature := []byte("test_signature") + expectedSignature := base64.RawURLEncoding.EncodeToString(signature) + + client := KMSClientFunc(func(ctx context.Context, si *kms.SignInput, _ ...func(*kms.Options)) (*kms.SignOutput, error) { + return &kms.SignOutput{ + Signature: signature, + }, nil + }) + s := github.NewKMSSigner(client, "arn:fictional") + + tok, err := s.Sign(jwt.RegisteredClaims{Issuer: "test"}) + require.NoError(t, err) + + segments := strings.SplitN(tok, ".", 3) + + assert.Equal(t, expectedSignature, segments[2]) +} + +func TestSigningMethod_AlgCorrect(t *testing.T) { + m := github.NewSigningMethod(nil) + assert.Equal(t, "RS256", m.Alg()) +} + +func TestSigningMethod_SignReturnsEncoded(t *testing.T) { + signature := []byte("test_signature") + expectedSignature := base64.RawURLEncoding.EncodeToString(signature) + + client := KMSClientFunc(func(ctx context.Context, si *kms.SignInput, _ ...func(*kms.Options)) (*kms.SignOutput, error) { + return &kms.SignOutput{ + Signature: signature, + }, nil + }) + + m := github.NewSigningMethod(client) + + sig, err := m.Sign("input-string", "keyArn") + require.NoError(t, err) + assert.Equal(t, expectedSignature, sig) +} + +func TestSigningMethod_SignFailsWhenKMSFails(t *testing.T) { + client := KMSClientFunc(func(ctx context.Context, si *kms.SignInput, _ ...func(*kms.Options)) (*kms.SignOutput, error) { + return nil, errors.New("simulated KMS failure") + }) + + m := github.NewSigningMethod(client) + + _, err := m.Sign("input-string", "keyArn") + assert.ErrorContains(t, err, "simulated KMS failure") +} + +func TestSigningMethod_SignFailsWithInvalidKey(t *testing.T) { + client := KMSClientFunc(func(ctx context.Context, si *kms.SignInput, _ ...func(*kms.Options)) (*kms.SignOutput, error) { + return nil, errors.New("simulated KMS failure") + }) + + m := github.NewSigningMethod(client) + + _, err := m.Sign("input-string", 222) + assert.ErrorContains(t, err, "unexpected key type supplied") +} + +func TestSigningMethod_VerifyFails(t *testing.T) { + client := KMSClientFunc(func(ctx context.Context, si *kms.SignInput, _ ...func(*kms.Options)) (*kms.SignOutput, error) { + return nil, errors.New("simulated KMS failure") + }) + + m := github.NewSigningMethod(client) + + err := m.Verify("source", "sig", "key") + assert.ErrorContains(t, err, "not implemented") +} diff --git a/internal/github/token.go b/internal/github/token.go index 4dddc0e..1e92cae 100644 --- a/internal/github/token.go +++ b/internal/github/token.go @@ -2,6 +2,7 @@ package github import ( "context" + "errors" "fmt" "net/http" "net/url" @@ -9,6 +10,7 @@ import ( "time" "github.com/bradleyfalzon/ghinstallation/v2" + "github.com/golang-jwt/jwt/v4" "github.com/google/go-github/v61/github" "github.com/jamestelfer/chinmina-bridge/internal/config" "github.com/rs/zerolog/log" @@ -19,17 +21,21 @@ type Client struct { installationID int64 } -func New(cfg config.GithubConfig) (Client, error) { +func New(ctx context.Context, cfg config.GithubConfig) (Client, error) { + signer, err := createSigner(ctx, cfg) + if err != nil { + return Client{}, fmt.Errorf("could not create signer for GitHub transport: %w", err) + } - // Create a transport using the JWT authentication method. The endpoints - // we're calling require this method. - appInstallationTransport, err := ghinstallation.NewAppsTransport( + // We're calling "installation_token", which is JWT authenticated, so we use + // the AppsTransport. + appInstallationTransport, err := ghinstallation.NewAppsTransportWithOptions( http.DefaultTransport, cfg.ApplicationID, - []byte(cfg.PrivateKey), + ghinstallation.WithSigner(signer), ) if err != nil { - return Client{}, fmt.Errorf("could not create github transport: %w", err) + return Client{}, fmt.Errorf("could not create GitHub transport: %w", err) } // Create a client for use with the application credentials. This client @@ -64,9 +70,6 @@ func (c Client) CreateAccessToken(ctx context.Context, repositoryURL string) (st return "", time.Time{}, err } - // qualifiedIdentifier, _ := strings.CutSuffix(u.Path, ".git") - // _, repoName, _ := strings.Cut(qualifiedIdentifier[1:], "/") - _, repoName := RepoForURL(*u) tok, r, err := c.client.Apps.CreateInstallationToken(ctx, c.installationID, @@ -86,6 +89,23 @@ func (c Client) CreateAccessToken(ctx context.Context, repositoryURL string) (st return tok.GetToken(), tok.GetExpiresAt().Time, nil } +func createSigner(ctx context.Context, cfg config.GithubConfig) (ghinstallation.Signer, error) { + if cfg.PrivateKeyARN != "" { + return NewAWSKMSSigner(ctx, cfg.PrivateKeyARN) + } + + if cfg.PrivateKey != "" { + key, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(cfg.PrivateKey)) + if err != nil { + return nil, fmt.Errorf("could not parse private key: %s", err) + } + + return ghinstallation.NewRSASigner(jwt.SigningMethodRS256, key), nil + } + + return nil, errors.New("no private key configuration specified") +} + func RepoForURL(u url.URL) (string, string) { if u.Hostname() != "github.com" || u.Path == "" { return "", "" diff --git a/internal/github/token_test.go b/internal/github/token_test.go index ef2e4a9..27714e4 100644 --- a/internal/github/token_test.go +++ b/internal/github/token_test.go @@ -19,6 +19,31 @@ import ( "github.com/stretchr/testify/require" ) +func TestNew_FailsWithInvalidConfig(t *testing.T) { + _, err := github.New( + context.Background(), + config.GithubConfig{ + // at least one of these is required + PrivateKey: "", + PrivateKeyARN: "", + }, + ) + assert.ErrorContains(t, err, "no private key configuration specified") +} + +func TestNew_SucceedsWithKMSConfig(t *testing.T) { + // set a purposefully invalid endpoint to prevent any errant remote calls + t.Setenv("AWS_ENDPOINT_URL", "http://localhost:20987/not-bound") + + _, err := github.New( + context.Background(), + config.GithubConfig{ + PrivateKeyARN: "arn://foo", + }, + ) + assert.NoError(t, err) +} + func TestCreateAccessToken_Succeeds(t *testing.T) { router := http.NewServeMux() @@ -40,12 +65,15 @@ func TestCreateAccessToken_Succeeds(t *testing.T) { // generate valid key for testing key := generateKey(t) - gh, err := github.New(config.GithubConfig{ - ApiURL: svr.URL, - PrivateKey: key, - ApplicationID: 10, - InstallationID: 20, - }) + gh, err := github.New( + context.Background(), + config.GithubConfig{ + ApiURL: svr.URL, + PrivateKey: key, + ApplicationID: 10, + InstallationID: 20, + }, + ) require.NoError(t, err) token, expiry, err := gh.CreateAccessToken(context.Background(), "https://github.com/organization/repository") @@ -69,12 +97,15 @@ func TestCreateAccessToken_Fails_On_Invalid_URL(t *testing.T) { // generate valid key for testing key := generateKey(t) - gh, err := github.New(config.GithubConfig{ - ApiURL: svr.URL, - PrivateKey: key, - ApplicationID: 10, - InstallationID: 20, - }) + gh, err := github.New( + context.Background(), + config.GithubConfig{ + ApiURL: svr.URL, + PrivateKey: key, + ApplicationID: 10, + InstallationID: 20, + }, + ) require.NoError(t, err) _, _, err = gh.CreateAccessToken(context.Background(), "sch_eme://invalid_url/") @@ -96,12 +127,15 @@ func TestCreateAccessToken_Fails_On_Failed_Request(t *testing.T) { // generate valid key for testing key := generateKey(t) - gh, err := github.New(config.GithubConfig{ - ApiURL: svr.URL, - PrivateKey: key, - ApplicationID: 10, - InstallationID: 20, - }) + gh, err := github.New( + context.Background(), + config.GithubConfig{ + ApiURL: svr.URL, + PrivateKey: key, + ApplicationID: 10, + InstallationID: 20, + }, + ) require.NoError(t, err) _, _, err = gh.CreateAccessToken(context.Background(), "https://dodgey") diff --git a/main.go b/main.go index d5bec1b..3b9e0ec 100644 --- a/main.go +++ b/main.go @@ -22,7 +22,7 @@ import ( "github.com/justinas/alice" ) -func configureServerRoutes(cfg config.Config) (http.Handler, error) { +func configureServerRoutes(ctx context.Context, cfg config.Config) (http.Handler, error) { // wrap a mux such that HTTP telemetry is configured by default muxWithoutTelemetry := http.NewServeMux() mux := observe.NewMux(muxWithoutTelemetry) @@ -45,7 +45,7 @@ func configureServerRoutes(cfg config.Config) (http.Handler, error) { return nil, fmt.Errorf("buildkite configuration failed: %w", err) } - gh, err := github.New(cfg.Github) + gh, err := github.New(ctx, cfg.Github) if err != nil { return nil, fmt.Errorf("github configuration failed: %w", err) } @@ -100,7 +100,7 @@ func launchServer() error { } // setup routing and dependencies - handler, err := configureServerRoutes(cfg) + handler, err := configureServerRoutes(ctx, cfg) if err != nil { return fmt.Errorf("server routing configuration failed: %w", err) }