Skip to content

Commit

Permalink
Merge pull request #53 from cultureamp/kms-signing
Browse files Browse the repository at this point in the history
feat: allow use of AWS KMS for JWT signing
  • Loading branch information
jamestelfer authored Jun 10, 2024
2 parents 0ab4679 + dfa641c commit f695d28
Show file tree
Hide file tree
Showing 12 changed files with 451 additions and 37 deletions.
4 changes: 3 additions & 1 deletion .envrc
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,10 @@ source_env_if_exists .envrc.private
# GitHub API connectivity
#

# required
# required (one of)
# export GITHUB_APP_PRIVATE_KEY="<app private key pem>"
# export GITHUB_APP_PRIVATE_KEY_ARN="<AWS KMS alias arn>"

# required
# export GITHUB_APP_ID="<id of app>"
# required
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
66 changes: 66 additions & 0 deletions docs/kms.md
Original file line number Diff line number Diff line change
@@ -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
16 changes: 15 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -21,14 +23,24 @@ 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
github.com/dolthub/maphash v0.1.0 // indirect
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
Expand All @@ -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
Expand Down
34 changes: 34 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
7 changes: 7 additions & 0 deletions integration/docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"

Expand Down
11 changes: 7 additions & 4 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
139 changes: 139 additions & 0 deletions internal/github/kmssigner.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
Loading

0 comments on commit f695d28

Please sign in to comment.