Skip to content

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.

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.

  1. Follow GitHub’s instructions to generate a private key for the GitHub application in use.

  2. Create a KMS key with EXTERNAL origin, key spec RSA_2048, and key usage SIGN_VERIFY.

    EXTERNAL origin allows the material to be imported. RSA_2048 is the spec GitHub issues App keys in. SIGN_VERIFY is required: a signing key cannot encrypt, and KMS rejects the attempt.

    The key is created in the PendingImport state. Every Sign call fails until material is imported.

  3. 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.

  4. 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:GetParametersForImport and kms:ImportKeyMaterial in this policy. Recovering from a key policy that excludes you requires AWS Support.

  5. 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-1234567890ab

    kms-import requires kms:GetParametersForImport and kms:ImportKeyMaterial on the key, in both the caller’s IAM policy and the key policy. Its README documents both fragments.

  6. 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.

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.

SidPurpose
RootAccessNoDelegationKeeps the key manageable by the account root user, without delegating control to every IAM principal.
AdministratorsManageAllows key management, but not signing.
ChinminaSignOnlyAllows the service to sign.
DenySignExceptChinminaDenies signing to everything else, including administrators.
DenyOutsideAccountDenies cross-account access.
example-key-policy.json
{
"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.

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 Principal element 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 Principal element does not accept partial wildcards. A condition does, so one pattern covers a role whose name carries a generated suffix.

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.

  1. Set the environment variable GITHUB_APP_PRIVATE_KEY_ARN to the ARN of the KMS key alias.

    Terminal window
    GITHUB_APP_PRIVATE_KEY_ARN=arn:aws:kms:us-east-1:123456789012:alias/chinmina-bridge
  2. 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:RequestAlias instead of the key ARN in the Resource element 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.

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.

  1. Create a replacement key with the same configuration and key policy.

  2. Generate a new private key for the GitHub App. GitHub allows both keys to be valid at once.

  3. Import the new private key into the replacement KMS key using kms-import, then destroy every local copy of the PEM file.

  4. Repoint the alias at the replacement key.

  5. Confirm the cutover in CloudTrail, then confirm that Chinmina still vends tokens.

  6. 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.