Protecting the GitHub private key
GitHub Apps make authenticated API calls by generating a JWT from a private key issued for the app. This private key is an extremely sensitive credential, as it can be used to access the full scope of actions assigned to the GitHub App.
Chinmina Bridge supports signing JWTs using AWS KMS, ensuring that key material cannot be extracted from the executing process or from the account configuration.
Creating the key
Section titled “Creating the key”Create the key empty, then import the GitHub private key into it. KMS cannot generate material for a key that must match an existing GitHub App.
Use kms-import for the import step. It reduces the wrapping and
upload sequence to a single command. Otherwise, follow the AWS key import
instructions directly.
-
Follow GitHub’s instructions to generate a private key for the GitHub application in use.
-
Create a KMS key with
EXTERNALorigin, key specRSA_2048, and key usageSIGN_VERIFY.EXTERNALorigin allows the material to be imported.RSA_2048is the spec GitHub issues App keys in.SIGN_VERIFYis required: a signing key cannot encrypt, and KMS rejects the attempt.The key is created in the
PendingImportstate. EverySigncall fails until material is imported. -
Create an alias for the key, for example
alias/chinmina-bridge.A key alias is essential to allow for key rotation. Unless you are stopped by your organisational policy, use the alias.
-
Apply a key policy that permits signing by the Chinmina task role only.
Apply the policy before importing. Key material must never exist under a policy broader than the one it will run under.
Grant the importing principal
kms:GetParametersForImportandkms:ImportKeyMaterialin this policy. Recovering from a key policy that excludes you requires AWS Support. -
Import the private key into the key material.
Import the GitHub App private key kms-import --key-file app.pem \--key-arn arn:aws:kms:us-east-1:123456789012:key/1234abcd-12ab-34cd-56ef-1234567890abkms-importrequireskms:GetParametersForImportandkms:ImportKeyMaterialon the key, in both the caller’s IAM policy and the key policy. Its README documents both fragments. -
Destroy every local copy of the PEM file.
The key material cannot be recovered from KMS. A local copy is the only remaining way to leak the private key.
Restricting access to the key
Section titled “Restricting access to the key”Granting the task role kms:Sign is not sufficient on its own. The default key
policy statement lets any IAM policy in the account confer any KMS action on
the key, so any principal in the account can be granted the ability to sign
GitHub App JWTs.
Each statement in the example policy below works with the others to restrict access to the key. Adapt the policy carefully to the target environment.
| Sid | Purpose |
|---|---|
RootAccessNoDelegation | Keeps the key manageable by the account root user, without delegating control to every IAM principal. |
AdministratorsManage | Allows key management, but not signing. |
ChinminaSignOnly | Allows the service to sign. |
DenySignExceptChinmina | Denies signing to everything else, including administrators. |
DenyOutsideAccount | Denies cross-account access. |
{ "Version": "2012-10-17", "Id": "chinmina-signing-key", "Statement": [ { "Sid": "RootAccessNoDelegation", "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::123456789012:root" }, "Action": "kms:*", "Resource": "*", "Condition": { "StringEquals": { "aws:PrincipalType": "Account" } } }, { "Sid": "AdministratorsManage", "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::123456789012:root" }, "Action": [ "kms:CancelKeyDeletion", "kms:CreateAlias", "kms:DeleteAlias", "kms:DeleteImportedKeyMaterial", "kms:Describe*", "kms:Disable*", "kms:Enable*", "kms:Get*", "kms:ImportKeyMaterial", "kms:List*", "kms:PutKeyPolicy", "kms:RevokeGrant", "kms:ScheduleKeyDeletion", "kms:TagResource", "kms:UntagResource", "kms:UpdateAlias", "kms:UpdateKeyDescription" ], "Resource": "*", "Condition": { "ArnLike": { "aws:PrincipalArn": "arn:aws:iam::123456789012:role/chinmina-admin-*" } } }, { "Sid": "ChinminaSignOnly", "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::123456789012:root" }, "Action": "kms:Sign", "Resource": "*", "Condition": { "ArnLike": { "aws:PrincipalArn": "arn:aws:iam::123456789012:role/chinmina-service-*" } } }, { "Sid": "DenySignExceptChinmina", "Effect": "Deny", "Principal": { "AWS": "*" }, "Action": "kms:Sign", "Resource": "*", "Condition": { "ArnNotLike": { "aws:PrincipalArn": "arn:aws:iam::123456789012:role/chinmina-service-*" } } }, { "Sid": "DenyOutsideAccount", "Effect": "Deny", "Principal": { "AWS": "*" }, "Action": "kms:*", "Resource": "*", "Condition": { "StringNotEquals": { "aws:PrincipalAccount": "123456789012" } } } ]}Substitute your own account ID and role name patterns. Administrators
provisioned by IAM Identity Center match a pattern like
role/aws-reserved/sso.amazonaws.com/*/AWSReservedSSO_Administrator_*. Wildcard
the region segment: it is absent when the identity source is hosted in
us-east-1.
Why the principal is never named directly
Section titled “Why the principal is never named directly”Every statement names the account as the Principal and identifies the real
caller with an aws:PrincipalArn condition. Two reasons:
- A role ARN in a
Principalelement is stored as the role’s internal unique ID (AROA…). Delete and recreate the role (a replaced ECS task role, or a reprovisioned SSO role) and the statement silently stops matching. - The
Principalelement does not accept partial wildcards. A condition does, so one pattern covers a role whose name carries a generated suffix.
Why the deny statement is required
Section titled “Why the deny statement is required”DenySignExceptChinmina is the final piece that ensures only the Chinmina
service can sign with the key. It closes three paths at once: IAM policies, the
key policy, and grants. An Allow-only policy leaves the IAM path
open. Any holder of kms:CreateGrant can then confer signing on an arbitrary
principal, without editing the policy. (kms:CreateGrant is purposefully absent
from the key management statement.)
An administrator can still change the policy. That is deliberate: locking
administrators out entirely risks an unmanageable key. The Deny raises the
effort required for misuse. It also produces a PutKeyPolicy event for your
security logging to alert on.
Configuring the Chinmina service
Section titled “Configuring the Chinmina service”-
Set the environment variable
GITHUB_APP_PRIVATE_KEY_ARNto the ARN of the KMS key alias.Terminal window GITHUB_APP_PRIVATE_KEY_ARN=arn:aws:kms:us-east-1:123456789012:alias/chinmina-bridge -
Allow the task role to sign with the key.
The IAM policy for the Chinmina process needs
kms:Sign, conditioned on the alias rather than the key:example-iam-policy.json {"Action": "kms:Sign","Effect": "Allow","Resource": "*","Condition": {"StringEquals": {"kms:RequestAlias": "alias/chinmina-bridge"}}}The alias name in this condition must match the alias created earlier. A mismatch fails at token-vending time with
AccessDenied, not at deployment time.Using
kms:RequestAliasinstead of the key ARN in theResourceelement allows transparent key rotation. It also enforces the rotation contract. A caller that hard-codes a key ARN is denied, rather than pinned to a key that is about to be retired.
Each additional GitHub App needs its own alias.
Enumerate them: kms:RequestAlias accepts a list.
"kms:RequestAlias": ["alias/chinmina-bridge", "alias/chinmina-concierge"]Avoid a prefix wildcard such as alias/chinmina-*. AWS warns that holders of
kms:CreateAlias or kms:UpdateAlias “can give principals permission to use
KMS keys that they didn’t otherwise have permission to use”. With a wildcard,
anyone able to create a matching alias on an unrelated key can make the task
role sign with it.
Rotating the key
Section titled “Rotating the key”KMS cannot rotate this key. Automatic and on-demand rotation are unavailable for asymmetric keys, and KMS cannot regenerate imported material.
Manual rotation replaces the key and repoints the alias. AWS documents this method, and calls it “a good choice … [for] asymmetric KMS keys”.
Import the key material and confirm the key is ready before repointing the alias. Signing requests fail against a key that is not ready.
-
Create a replacement key with the same configuration and key policy.
-
Generate a new private key for the GitHub App. GitHub allows both keys to be valid at once.
-
Import the new private key into the replacement KMS key using
kms-import, then destroy every local copy of the PEM file. -
Repoint the alias at the replacement key.
-
Confirm the cutover in CloudTrail, then confirm that Chinmina still vends tokens.
-
Delete the old private key in GitHub, then schedule the old KMS key for deletion.
The alias repoint takes effect immediately for kms:RequestAlias, which
evaluates the alias named in the request. Signing is also infrequent: a JWT is
created about once every ten minutes per instance and per app. Callers see no interruption, and
neither a restart nor a configuration change is required.
The cutover is visible in CloudTrail. When a request names an alias, the alias
is recorded in requestParameters.keyId and the resolved
key ARN in resources. Watch the resources ARN change to the replacement key
to confirm the rotation took effect.
AWS documents one trap for manually rotated asymmetric keys. Verify and
Decrypt require KeyId, and fail when an alias no longer points at the key
that performed the operation. It does not apply here: Chinmina only signs, and
GitHub verifies against the public key registered on the App. This is also why
the service is never granted kms:Verify.
