CLI tool and Go library that imports a GitHub App private key (PEM) into AWS KMS as non-extractable key material.
A GitHub App's private key is powerful: anyone who holds it can impersonate the app indefinitely. Stored as a plaintext secret, that key is a standing liability — every place it is copied is another way to lose it.
Importing the key into AWS KMS removes that attack surface entirely. The key
material lives inside the KMS HSM, where it cannot be read back out; the
application signs GitHub App JWTs through the KMS Sign API and never touches
the key itself. The catch is that importing key material into KMS is a fiddly,
cryptographically-unforgiving sequence — which is why the posture is so often
skipped. kms-import reduces it to a single command.
Once the key material is in KMS, no other copy of it needs to exist anywhere. Import it, confirm it, and destroy your local PEM: the canonical private key now lives only inside KMS, governed by IAM, audited by CloudTrail, and rotatable by a single alias update.
kms-import does not create KMS keys; it only pushes key material into one that
already exists with EXTERNAL origin. Create a compatible key, import your
GitHub App PEM into it, then throw the PEM away.
-
Install the binary (see Installation for all the options, including verified release downloads):
mise use -g github:chinmina/kms-import
-
Create a compatible KMS key. It must have
EXTERNALorigin (so its material can be imported), key specRSA_2048(the spec GitHub issues App keys in), andSIGN_VERIFYusage (the key signs JWTs; it does not encrypt):aws kms create-key --origin EXTERNAL --key-spec RSA_2048 --key-usage SIGN_VERIFY
This is the bare minimum. In production you would provision the key — and its alias, tags, and resource policy — with CloudFormation or Terraform. Do that, but import the key material with
kms-import, not with your IaC. Terraform'saws_kms_external_keytakes the raw key material and persists it: "all arguments including the key material will be stored in the raw state as plain text" (provider docs). That recreates exactly the extra plaintext copy of the private key this approach exists to eliminate. (CloudFormation cannot import external key material at all.) -
Import the key material into the key by ID or ARN:
kms-import --key-file app.pem --key-arn arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab
On success it prints the resolved key ID and resulting state:
Imported key material — key ID: 1234abcd-12ab-34cd-56ef-1234567890ab, state: Enabled -
Destroy the local PEM. The key material now lives only in KMS. Grant the application
kms:Signon the key and delete every other copy of the PEM.
Pre-built binaries for Linux, macOS, and Windows (amd64/arm64) are published to GitHub Releases. Every artifact carries a build-provenance attestation — see Verifying releases.
mise (recommended)
mise installs directly from GitHub Releases via its
GitHub backend. It
verifies the artifact's checksum and, when the
github_attestations setting
is enabled (currently the default), its build-provenance attestation:
mise use -g github:chinmina/kms-importOr pin a version for a project in its mise.toml:
[tools]
"github:chinmina/kms-import" = "1.0.0"Install script
Each release includes a self-contained installer script (generated with binstaller) that detects your platform and verifies the download against the release checksums embedded in the script — no network fetch of the checksum file:
curl -fsSL https://github.com/chinmina/kms-import/releases/latest/download/install.sh | shIt installs to ~/.local/bin by default; pass -b for a different directory
and an optional tag to pin a version:
curl -fsSL https://github.com/chinmina/kms-import/releases/latest/download/install.sh \
| sh -s -- -b /usr/local/bin v1.0.0The script itself carries a build-provenance attestation, so instead of piping
straight to sh you can prove it was produced by this repository's release
workflow first (requires an authenticated GitHub CLI):
curl -fsSL -O https://github.com/chinmina/kms-import/releases/latest/download/install.sh
gh attestation verify install.sh --repo chinmina/kms-import
sh install.shManual download
Download the archive for your platform from the
releases page, verify its
provenance, and put the binary on your PATH:
OS=linux ARCH=amd64 # or darwin/windows, arm64
curl -fsSLO "https://github.com/chinmina/kms-import/releases/latest/download/kms-import_${OS}_${ARCH}.tar.gz"
gh attestation verify "kms-import_${OS}_${ARCH}.tar.gz" --repo chinmina/kms-import
tar -xzf "kms-import_${OS}_${ARCH}.tar.gz" kms-import
install -m 0755 kms-import ~/.local/bin/Windows archives are .zip. See Verifying releases for
what the attestation proves and for checksum-only verification.
go install
Build from source with Go 1.26+:
go install github.com/chinmina/kms-import/cmd/kms-import@latestSource builds are not stamped with a release version, so --version reports
dev.
The Quick start shows a basic invocation. Under the hood
kms-import reads the PEM, converts it to PKCS#8 DER, fetches wrapping
parameters from KMS, encrypts the key material under the returned wrapping key,
and calls ImportKeyMaterial. The full flag set follows.
Exactly one target flag (--key-id or --key-arn) is required; supplying both,
or neither, is an error. All other flags are optional except --key-file.
| Flag | Type | Default | Description |
|---|---|---|---|
--key-file |
string | — | Required. Path to the PEM-encoded private key file. Accepts BEGIN RSA PRIVATE KEY (PKCS#1) and BEGIN PRIVATE KEY (PKCS#8); any other header is an error. |
--key-id |
string | — | Target KMS key ID. Mutually exclusive with --key-arn. |
--key-arn |
string | — | Target KMS key ARN. Mutually exclusive with --key-id. |
--expires |
string | none (key material does not expire) | Expiry for the imported key material as an RFC 3339 timestamp (e.g. 2027-01-01T00:00:00Z). A malformed or already-passed value is rejected before any AWS call is made. |
--profile |
string | SDK default | AWS named profile to use. |
--region |
string | SDK default | AWS region to use. |
--json |
bool | false |
Emit the result as a single JSON object on stdout and suppress all other output. |
--help, -h |
bool | false |
Show help. |
--version, -v |
bool | false |
Print the version. |
Target selection. Exactly one of --key-id / --key-arn must be given
(mutually exclusive). The KMS import APIs accept only a key ID or key ARN — not
an alias — so there is no --alias flag; see the rotation workflow below for how
aliases fit in.
Credentials. Credentials and region resolve through the standard AWS SDK
chain (environment variables, shared config/credentials files, IMDS). --profile
and --region override the corresponding defaults; when omitted, the SDK
defaults apply.
Expiry and reimport. By default the imported material does not expire
(KEY_MATERIAL_DOES_NOT_EXPIRE). With --expires, the material is set to expire
at the supplied time (KEY_MATERIAL_EXPIRES + ValidTo). The same invocation
performs both the initial import and a reimport after material has expired or
been deleted — there is no separate mode or flag. AWS requires a reimport to use
the same key material as the original.
JSON output. With --json, stdout carries only the result object:
{"keyId":"1234abcd-12ab-34cd-56ef-1234567890ab","keyState":"Enabled"}Errors. Any AWS API failure exits non-zero with the error written to stderr;
stdout stays empty (including in --json mode). Input errors — unreadable
key file, undecodable PEM, unsupported PEM header, invalid --expires — also
exit non-zero with a descriptive message.
The wrapping algorithm is fixed at RSA_AES_KEY_WRAP_SHA_256 with a RSA_4096
wrapping key and is not configurable. This is AWS's current recommendation for
importing asymmetric private key material; it avoids the size constraints that
affect the pure RSAES_OAEP_* algorithms with RSA key material. Only RSA 2048
key material (the GitHub App key spec) is supported.
The import logic is exposed as a Go library that builds no SDK client and never
calls os.Exit; the caller injects a KMSClient (the AWS SDK *kms.Client
satisfies it). The CLI is also mountable as a subcommand of another urfave/cli
v3 application via cli.Command().
import (
"context"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/kms"
"github.com/chinmina/kms-import/pkg/kmsimport"
)
cfg, _ := config.LoadDefaultConfig(ctx)
der, _ := kmsimport.KeyMaterialFromPEM(pemBytes) // PEM (PKCS#1 or PKCS#8) → PKCS#8 DER
res, err := kmsimport.Import(ctx,
kmsimport.WithClient(kms.NewFromConfig(cfg)),
kmsimport.WithKeyID("1234abcd-12ab-34cd-56ef-1234567890ab"),
kmsimport.WithKeyMaterial(der),
)Storing a GitHub App private key in KMS rather than as a plaintext secret is a materially stronger posture:
- Non-extractability. Imported key material lives inside the KMS HSM
boundary. There is no API to read it back, it never appears in plaintext
outside the HSM, and no AWS operator can retrieve it. The application calls
kms:Signand receives only signature bytes — the key itself never moves. - IAM and resource-policy access control. Every use of the key is gated by both the caller's IAM policy and the KMS key's resource policy. Access is least-privilege and centrally revocable without touching the application, in contrast to a raw secret that grants full impersonation to anyone who can read it.
- CloudTrail audit visibility. KMS records every
GetParametersForImport,ImportKeyMaterial, and subsequentSigncall in CloudTrail, giving a complete, tamper-evident audit trail of who used the key and when. A plaintext secret read leaves no comparable record of subsequent use. - Alias-based rotation. Referencing the key through a KMS alias makes rotation a single pointer update (see below) with no application restart or configuration change, and no window where the old and new keys are both live in application config.
For operational revocation of a compromised key, revoke the GitHub App private
key in GitHub directly — that is faster and more direct than waiting for KMS key
material to expire. The --expires flag exists for organisational compliance
requirements, not for emergency revocation.
kms-import needs exactly two KMS actions: kms:GetParametersForImport and
kms:ImportKeyMaterial. Because KMS authorizes key operations through the key's
resource policy, the importing principal must be granted these actions in
both the caller's IAM policy and the KMS key's resource policy. The two
fragments are separated below.
Replace the account ID, region, key ID, and role ARN with your own values.
Attach this to the principal (role/user) that runs kms-import. It is scoped to
the specific key ARN. The GetParametersForImport statement is additionally
constrained to the wrapping algorithm and key spec this tool uses; the
ImportKeyMaterial statement may optionally be constrained with
kms:ExpirationModel / kms:ValidTo if you want to mandate (non-)expiry.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "KMSGetWrappingParameters",
"Effect": "Allow",
"Action": "kms:GetParametersForImport",
"Resource": "arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab",
"Condition": {
"StringEquals": {
"kms:WrappingAlgorithm": "RSA_AES_KEY_WRAP_SHA_256",
"kms:WrappingKeySpec": "RSA_4096"
}
}
},
{
"Sid": "KMSImportKeyMaterial",
"Effect": "Allow",
"Action": "kms:ImportKeyMaterial",
"Resource": "arn:aws:kms:us-east-1:111122223333:key/1234abcd-12ab-34cd-56ef-1234567890ab"
}
]
}Add this statement to the resource policy of the target KMS key, granting the
importing principal the same two actions. Within a key policy, "Resource": "*"
refers to the key the policy is attached to.
{
"Sid": "AllowKeyMaterialImport",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::111122223333:role/KeyImporter"
},
"Action": [
"kms:GetParametersForImport",
"kms:ImportKeyMaterial"
],
"Resource": "*"
}Note on alias scoping. The
kms:RequestAliascondition key — which scopes access to requests that name the key by a particular alias — applies only to cryptographic operations,DescribeKey, andGetPublicKey. It has no effect onGetParametersForImportorImportKeyMaterial; a policy that tried to scope these import actions by alias would never match and the permission would be ineffective. Scope the import permissions by the key ARN instead, as shown above. (Alias scoping is still the right pattern for the runtimekms:Signpermission the application uses.)
The recommended production pattern is to always reference the KMS key through an alias, so the application never needs to know the underlying key ID. To rotate to a new GitHub App private key:
-
Create a new KMS key with
EXTERNALorigin via your IaC (CDK/Terraform). Do not point the alias at it yet. -
Import the new private key into the new key by its ID or ARN:
kms-import --key-file new-app.pem --key-arn arn:aws:kms:us-east-1:111122223333:key/<new-key-id>
Import always targets a specific key generation by ID or ARN — the alias still points at the old key at this stage, and the import APIs do not accept an alias anyway.
-
Verify the new key reached
Enabled(thekms-importconfirmation, oraws kms describe-key). -
Update the alias to point at the new key (via IaC or
aws kms update-alias). The application picks up the new key on its next signing call — no restart, redeploy, or configuration change required. -
Retire the old key once you are satisfied the new key is in use.
Because signing always goes through the alias, the cutover is atomic from the application's perspective and requires no downtime.
Release artifacts — the binary archives and the generated install.sh — are
published to GitHub Releases
and carry a build-provenance attestation
(SLSA) generated by the release workflow with Sigstore
keyless signing — there is no long-lived signing key. Each artifact is bound, by
digest, to the source commit and the workflow that produced it.
To verify a downloaded artifact, install the GitHub CLI (≥ 2.49.0) and run:
# e.g. ARTIFACT=kms-import_linux_amd64.tar.gz
gh attestation verify "$ARTIFACT" --repo chinmina/kms-importA successful result prints the provenance summary (repository, commit, and the release workflow that built it). To additionally pin the signing workflow:
gh attestation verify "$ARTIFACT" --repo chinmina/kms-import \
--signer-workflow chinmina/kms-import/.github/workflows/release.ymlThe attestation is a Sigstore bundle, so cosign
can verify it too. checksums.txt is still published — check your archive
against it with sha256sum --check checksums.txt.