diff --git a/github/apps.go b/github/apps.go index b35350b748..c901c879f3 100644 --- a/github/apps.go +++ b/github/apps.go @@ -1,34 +1,32 @@ package github import ( - "crypto/x509" + "context" "encoding/json" - "encoding/pem" - "errors" "fmt" "io" "net/http" "net/url" "time" - "github.com/go-jose/go-jose/v3" "github.com/go-jose/go-jose/v3/jwt" ) +// Signer is an interface for signing JWTs. +// It allow for different implementations (e.g., using a local PEM file or delegating to AWS KMS). +type Signer interface { + SignJWT(ctx context.Context, claims jwt.Claims) (string, error) +} + // GenerateOAuthTokenFromApp generates a GitHub OAuth access token from a set of valid GitHub App credentials. // The returned token can be used to interact with both GitHub's REST and GraphQL APIs. -func GenerateOAuthTokenFromApp(apiURL *url.URL, appID, appInstallationID, pemData string) (string, error) { - appJWT, err := generateAppJWT(appID, time.Now(), []byte(pemData)) - if err != nil { - return "", err - } - - token, err := getInstallationAccessToken(apiURL, appJWT, appInstallationID) +func GenerateOAuthTokenFromApp(ctx context.Context, signer Signer, apiURL *url.URL, appID, appInstallationID string) (string, error) { + appJWT, err := generateAppJWT(ctx, appID, time.Now(), signer) if err != nil { return "", err } - return token, nil + return getInstallationAccessToken(apiURL, appJWT, appInstallationID) } func getInstallationAccessToken(apiURL *url.URL, jwt, installationID string) (string, error) { @@ -67,38 +65,15 @@ func getInstallationAccessToken(apiURL *url.URL, jwt, installationID string) (st return resData.Token, nil } -func generateAppJWT(appID string, now time.Time, pemData []byte) (string, error) { - block, _ := pem.Decode(pemData) - if block == nil { - return "", errors.New("no decodeable PEM data found") - } - - privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) - if err != nil { - return "", err - } - - signer, err := jose.NewSigner( - jose.SigningKey{Algorithm: jose.RS256, Key: privateKey}, - (&jose.SignerOptions{}).WithType("JWT"), - ) - if err != nil { - return "", err - } - - claims := &jwt.Claims{ +func generateAppJWT(ctx context.Context, appID string, now time.Time, signer Signer) (string, error) { + claims := jwt.Claims{ Issuer: appID, // Using now - 60s to accommodate any client/server clock drift. IssuedAt: jwt.NewNumericDate(now.Add(time.Duration(-60) * time.Second)), // The JWT's lifetime can be short as it is only used immediately - // after to retrieve the installation's access token. + // after to retrieve the installation's access token. Expiry: jwt.NewNumericDate(now.Add(time.Duration(5) * time.Minute)), } - token, err := jwt.Signed(signer).Claims(claims).CompactSerialize() - if err != nil { - return "", err - } - - return token, nil + return signer.SignJWT(ctx, claims) } diff --git a/github/apps_test.go b/github/apps_test.go index 769285a767..6315e02a32 100644 --- a/github/apps_test.go +++ b/github/apps_test.go @@ -1,6 +1,7 @@ package github import ( + "context" "crypto/rsa" "crypto/x509" "encoding/pem" @@ -29,7 +30,12 @@ var ( ) func TestGenerateAppJWT(t *testing.T) { - appJWT, err := generateAppJWT(testGitHubAppID, testEpochTime, testGitHubAppPrivateKeyPemData) + signer, err := NewPEMSigner(testGitHubAppPrivateKeyPemData) + if err != nil { + t.Fatalf("Failed to create PEM signer: %s", err) + } + + appJWT, err := generateAppJWT(context.Background(), testGitHubAppID, testEpochTime, signer) t.Log(appJWT) if err != nil { t.Logf("Failed to generate GitHub app JWT: %s", err) diff --git a/github/data_source_github_app_token.go b/github/data_source_github_app_token.go index 442c57dd15..ab5bbfcb39 100644 --- a/github/data_source_github_app_token.go +++ b/github/data_source_github_app_token.go @@ -24,9 +24,17 @@ func dataSourceGithubAppToken() *schema.Resource { Description: descriptions["app_auth.installation_id"], }, "pem_file": { - Type: schema.TypeString, - Required: true, - Description: descriptions["app_auth.pem_file"], + Type: schema.TypeString, + Optional: true, + Sensitive: true, + Description: descriptions["app_auth.pem_file"], + ExactlyOneOf: []string{"pem_file", "aws_kms_key_id"}, + }, + "aws_kms_key_id": { + Type: schema.TypeString, + Optional: true, + Description: descriptions["app_auth.aws_kms_key_id"], + ExactlyOneOf: []string{"pem_file", "aws_kms_key_id"}, }, "token": { Type: schema.TypeString, @@ -41,23 +49,32 @@ func dataSourceGithubAppToken() *schema.Resource { func dataSourceGithubAppTokenRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { appID := d.Get("app_id").(string) installationID := d.Get("installation_id").(string) - pemFile := d.Get("pem_file").(string) - // The Go encoding/pem package only decodes PEM formatted blocks - // that contain new lines. Some platforms, like Terraform Cloud, - // do not support new lines within Environment Variables. - // Any occurrence of \n in the `pem_file` argument's value - // (explicit value, or default value taken from - // GITHUB_APP_PEM_FILE Environment Variable) is replaced with an - // actual new line character before decoding. - pemFile = strings.ReplaceAll(pemFile, `\n`, "\n") + var signer Signer + var err error + if v, ok := d.GetOk("aws_kms_key_id"); ok { + signer, err = NewAWSKMSSigner(ctx, v.(string)) + if err != nil { + return diag.FromErr(err) + } + } else { + // The Go encoding/pem package only decodes PEM formatted blocks + // that contain new lines. Some platforms, like Terraform Cloud, + // do not support new lines within Environment Variables. + // Any occurrence of \n in the `pem_file` argument's value is replaced + // with an actual new line character before decoding. + pemFile := strings.ReplaceAll(d.Get("pem_file").(string), `\n`, "\n") + signer, err = NewPEMSigner([]byte(pemFile)) + if err != nil { + return diag.FromErr(err) + } + } - token, err := GenerateOAuthTokenFromApp(meta.(*Owner).v3client.BaseURL, appID, installationID, pemFile) + token, err := GenerateOAuthTokenFromApp(ctx, signer, meta.(*Owner).v3client.BaseURL, appID, installationID) if err != nil { return diag.FromErr(err) } - err = d.Set("token", token) - if err != nil { + if err := d.Set("token", token); err != nil { return diag.FromErr(err) } d.SetId("id") diff --git a/github/provider.go b/github/provider.go index 2d5d6dc33a..8bb655c68a 100644 --- a/github/provider.go +++ b/github/provider.go @@ -114,11 +114,19 @@ func Provider() *schema.Provider { Description: descriptions["app_auth.installation_id"], }, "pem_file": { - Type: schema.TypeString, - Required: true, - Sensitive: true, - DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_PEM_FILE", nil), - Description: descriptions["app_auth.pem_file"], + Type: schema.TypeString, + Optional: true, + Sensitive: true, + DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_PEM_FILE", nil), + Description: descriptions["app_auth.pem_file"], + ExactlyOneOf: []string{"app_auth.0.pem_file", "app_auth.0.aws_kms_key_id"}, + }, + "aws_kms_key_id": { + Type: schema.TypeString, + Optional: true, + DefaultFunc: schema.EnvDefaultFunc("GITHUB_APP_AWS_KMS_KEY_ID", nil), + Description: descriptions["app_auth.aws_kms_key_id"], + ExactlyOneOf: []string{"app_auth.0.pem_file", "app_auth.0.aws_kms_key_id"}, }, }, }, @@ -324,7 +332,8 @@ func init() { "`token`. Anonymous mode is enabled if both `token` and `app_auth` are not set.", "app_auth.id": "The GitHub App ID.", "app_auth.installation_id": "The GitHub App installation instance ID.", - "app_auth.pem_file": "The GitHub App PEM file contents.", + "app_auth.pem_file": "The GitHub App PEM file contents. Exactly one of `pem_file` or `aws_kms_key_id` must be set.", + "app_auth.aws_kms_key_id": "The AWS KMS key ID or ARN of an RSA key used to sign GitHub App JWTs. Exactly one of `pem_file` or `aws_kms_key_id` must be set.", "write_delay_ms": "Amount of time in milliseconds to sleep in between writes to GitHub API. " + "Defaults to 1000ms or 1s if not set.", "read_delay_ms": "Amount of time in milliseconds to sleep in between non-write requests to GitHub API. " + @@ -381,7 +390,7 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { if appAuth, ok := d.Get("app_auth").([]any); ok && len(appAuth) > 0 && appAuth[0] != nil { appAuthAttr := appAuth[0].(map[string]any) - var appID, appInstallationID, appPemFile string + var appID, appInstallationID string if v, ok := appAuthAttr["id"].(string); ok && v != "" { appID = v @@ -395,7 +404,13 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { return nil, wrapErrors([]error{fmt.Errorf("app_auth.installation_id must be set and contain a non-empty value")}) } - if v, ok := appAuthAttr["pem_file"].(string); ok && v != "" { + var signer Signer + if v, ok := appAuthAttr["aws_kms_key_id"].(string); ok && v != "" { + signer, err = NewAWSKMSSigner(ctx, v) + if err != nil { + return nil, wrapErrors([]error{err}) + } + } else if v, ok := appAuthAttr["pem_file"].(string); ok && v != "" { // The Go encoding/pem package only decodes PEM formatted blocks // that contain new lines. Some platforms, like Terraform Cloud, // do not support new lines within Environment Variables. @@ -403,9 +418,10 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { // (explicit value, or default value taken from // GITHUB_APP_PEM_FILE Environment Variable) is replaced with an // actual new line character before decoding. - appPemFile = strings.ReplaceAll(v, `\n`, "\n") - } else { - return nil, wrapErrors([]error{fmt.Errorf("app_auth.pem_file must be set and contain a non-empty value")}) + signer, err = NewPEMSigner([]byte(strings.ReplaceAll(v, `\n`, "\n"))) + if err != nil { + return nil, wrapErrors([]error{err}) + } } apiPath := "" @@ -413,7 +429,7 @@ func providerConfigure(p *schema.Provider) schema.ConfigureContextFunc { apiPath = GHESRESTAPIPath } - appToken, err := GenerateOAuthTokenFromApp(baseURL.JoinPath(apiPath), appID, appInstallationID, appPemFile) + appToken, err := GenerateOAuthTokenFromApp(ctx, signer, baseURL.JoinPath(apiPath), appID, appInstallationID) if err != nil { return nil, wrapErrors([]error{err}) } diff --git a/github/signers.go b/github/signers.go new file mode 100644 index 0000000000..f7ce724204 --- /dev/null +++ b/github/signers.go @@ -0,0 +1,104 @@ +package github + +import ( + "context" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + + "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" + kmstypes "github.com/aws/aws-sdk-go-v2/service/kms/types" + "github.com/go-jose/go-jose/v3" + "github.com/go-jose/go-jose/v3/jwt" +) + +// PEMSigner signs JWTs using a local RSA private key in PEM format. +type PEMSigner struct { + signer jose.Signer +} + +// NewPEMSigner creates a PEMSigner from a PKCS1 PEM-encoded RSA private key. +func NewPEMSigner(pemData []byte) (*PEMSigner, error) { + block, _ := pem.Decode(pemData) + if block == nil { + return nil, errors.New("no decodeable PEM data found") + } + + privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + + signer, err := jose.NewSigner( + jose.SigningKey{Algorithm: jose.RS256, Key: privateKey}, + (&jose.SignerOptions{}).WithType("JWT"), + ) + if err != nil { + return nil, err + } + + return &PEMSigner{signer: signer}, nil +} + +func (s *PEMSigner) SignJWT(_ context.Context, claims jwt.Claims) (string, error) { + return jwt.Signed(s.signer).Claims(claims).CompactSerialize() +} + +// AWSKMSClient abstracts the AWS KMS Sign API for testability. +type AWSKMSClient interface { + Sign(ctx context.Context, params *kms.SignInput, optFns ...func(*kms.Options)) (*kms.SignOutput, error) +} + +// AWSKMSSigner signs JWTs by delegating to AWS KMS. +// The private key never leaves the KMS boundary. +type AWSKMSSigner struct { + client AWSKMSClient + keyID string +} + +// NewAWSKMSSigner creates an AWSKMSSigner using the default AWS credential chain. +func NewAWSKMSSigner(ctx context.Context, keyID string) (*AWSKMSSigner, error) { + cfg, err := config.LoadDefaultConfig(ctx) + if err != nil { + return nil, fmt.Errorf("failed to load AWS config: %w", err) + } + return &AWSKMSSigner{ + client: kms.NewFromConfig(cfg), + keyID: keyID, + }, nil +} + +func (s *AWSKMSSigner) SignJWT(ctx context.Context, claims jwt.Claims) (string, error) { + headerJSON, err := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT"}) + if err != nil { + return "", err + } + + claimsJSON, err := json.Marshal(claims) + if err != nil { + return "", err + } + + signingInput := base64.RawURLEncoding.EncodeToString(headerJSON) + "." + + base64.RawURLEncoding.EncodeToString(claimsJSON) + + digest := sha256.Sum256([]byte(signingInput)) + + out, err := s.client.Sign(ctx, &kms.SignInput{ + KeyId: aws.String(s.keyID), + Message: digest[:], + MessageType: kmstypes.MessageTypeDigest, + SigningAlgorithm: kmstypes.SigningAlgorithmSpecRsassaPkcs1V15Sha256, + }) + if err != nil { + return "", fmt.Errorf("KMS signing failed: %w", err) + } + + return signingInput + "." + base64.RawURLEncoding.EncodeToString(out.Signature), nil +} diff --git a/go.mod b/go.mod index 9580c26f8f..5bb7a3e6aa 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,9 @@ module github.com/integrations/terraform-provider-github/v6 go 1.26 require ( + github.com/aws/aws-sdk-go-v2 v1.41.5 + github.com/aws/aws-sdk-go-v2/config v1.32.15 + github.com/aws/aws-sdk-go-v2/service/kms v1.50.4 github.com/go-jose/go-jose/v3 v3.0.4 github.com/google/go-github/v84 v84.0.0 github.com/google/uuid v1.6.0 @@ -19,6 +22,17 @@ require ( github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/agext/levenshtein v1.2.2 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.14 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect + github.com/aws/smithy-go v1.24.2 // indirect github.com/cloudflare/circl v1.6.1 // indirect github.com/fatih/color v1.18.0 // indirect github.com/golang/protobuf v1.5.4 // indirect diff --git a/go.sum b/go.sum index abe9bc63b9..60543d8199 100644 --- a/go.sum +++ b/go.sum @@ -9,6 +9,34 @@ github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= +github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= +github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/config v1.32.15 h1:i7rHbaySnBXGvCkDndaBU8f3EAlRVgViwNfkwFUrXgE= +github.com/aws/aws-sdk-go-v2/config v1.32.15/go.mod h1:yLJzL0IkI9+4BwjPSOueyHzppJj3t0dhK5tbmmcFk5Q= +github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA= +github.com/aws/aws-sdk-go-v2/service/kms v1.50.4 h1:PgD1y0ZagPokGIZPmejCBUySBzOFDN+leZxCOfb1OEQ= +github.com/aws/aws-sdk-go-v2/service/kms v1.50.4/go.mod h1:FfXDb5nXrsoGgxsBFxwxr3vdHXheC2tV+6lmuLghhjQ= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=