-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #53 from cultureamp/kms-signing
feat: allow use of AWS KMS for JWT signing
- Loading branch information
Showing
12 changed files
with
451 additions
and
37 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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()) | ||
} | ||
} |
Oops, something went wrong.