Skip to content

Sign with AWS KMS

Sign a release manifest with a key held in AWS KMS, where the private half never leaves AWS: every signature is a remote kms:Sign call. The backend is the standalone module gitlab.com/phpboyscout/go/signing-aws-kms; it implements signing.Backend and registers as aws-kms.

This mirrors Sign with the local backend. Only the backend module and the key setup differ. See Backends and the per-provider module pattern for why the AWS SDK lives in its own module.

Prerequisites

  • A KMS key of one of the two supported specs, matching the path you are signing for:

    • RSA_4096 SIGN_VERIFY for the OpenPGP manifest path this guide covers. Use 4096 bits; the verify side rejects RSA below 3072.
    • ECC_NIST_EDWARDS25519 for the minisign artefact path, which the Rust consumers speak.

    A key does one job or the other, never both, because the OpenPGP path is RSA-only and minisign is Ed25519-only. A project publishing both holds two KMS keys. Any other key spec returns ErrUnsupportedKMSKeyType. - AWS credentials resolvable by the SDK default chain (environment, ~/.aws/credentials or AWS_PROFILE, an IAM role, or OIDC web identity). - The IAM principal needs kms:GetPublicKey and kms:Sign on the key. - The key's ARN or alias.

Add the backend

go get gitlab.com/phpboyscout/go/signing-aws-kms
import (
    "gitlab.com/phpboyscout/go/signing"
    _ "gitlab.com/phpboyscout/go/signing-aws-kms" // blank import registers "aws-kms"
)

The blank import runs the backend's init(), which registers it with the signing registry under the name aws-kms.

Resolve a signer

backend, err := signing.Get("aws-kms")
if err != nil {
    return err
}

signer, err := backend.NewSigner(ctx, "arn:aws:kms:eu-west-2:123456789012:key/abcd-…")
if err != nil {
    return err
}

keyID is the KMS key ARN or alias.

The registered backend signs in eu-west-2 unless you name another region, and it does not read AWS_REGION or the region in your shared AWS config. The default reaches the AWS SDK as an explicit region, which outranks both. It matches the terraform-aws-signing-kms module's default, so the house release path works with no region configured. Credentials still come from the SDK's default chain.

To sign somewhere else, name the region with the --kms-region CLI flag (see below) or awskms.WithRegion, or build the backend with awskms.NewFromConfig or awskms.NewFromSource so the region comes from a configuration you resolved yourself. Get this wrong and the symptom points at the key rather than the region, because KMS in eu-west-2 answers as though a key from another region does not exist.

Sign a manifest and publish the key

This part is identical for every backend, because the signer is a stdlib crypto.Signer:

import "gitlab.com/phpboyscout/go/signing/openpgpkey"

pub, err := openpgpkey.ArmoredPublicKey(signer, "Release", "release@example.com", time.Now())
// publish `pub`: embed it in consumers, or serve it via WKD
//   (see "Publish keys via WKD")

sig, err := openpgpkey.DetachSign(signer, pub, bytes.NewReader(manifest), time.Now())
// ship `manifest`, `sig`, and `pub` alongside the release

Consumers verify with signing/verify exactly as in Verify a release.

Programmatic configuration (region, logger)

Outside the registry, construct a configured signer or backend directly with functional options. Importing the package has already registered the default aws-kms backend, so a configured one is used directly rather than registered again:

import (
    "log/slog"

    awskms "gitlab.com/phpboyscout/go/signing-aws-kms"
)

// region + logger via options
signer, err := awskms.NewSigner(ctx, "us-east-1", keyARN,
    awskms.WithLogger(slog.Default()))

// or a configured backend, used without the registry
backend := awskms.New(awskms.WithRegion("us-east-1"))
signer, err = backend.NewSigner(ctx, keyARN)

Do not pass awskms.New(...) to signing.Register. Its name is aws-kms, which the package's own init() registered on import, and Register panics on a duplicate name. For a second, differently named AWS backend use awskms.NewDefault(name, opts...), or signing.NewInstance("aws-kms", "prod") when the eu-west-2 default will do. A minted instance starts from the default region; a CLI such as signing-cli sets it from the selector (--backend 'aws-kms:prod?kms-region=…'), not from a top-level --kms-region.

The optional *slog.Logger emits DEBUG-level diagnostics carrying only non-secret identifiers (key id, region, key bit-length, algorithm). It never logs credentials, the digest or signature bytes.

CLI flag

The backend implements an optional flag-registration interface that a CLI front-end type-asserts for, and contributes --kms-region through it. The canonical CLI is:

gtb sign --backend aws-kms --key-id <arn|alias> --kms-region <region> …

Credentials and IAM

No credential logic lives in the backend. It relies entirely on the AWS SDK default chain. For CI, OIDC web-identity federation (AWS_WEB_IDENTITY_TOKEN_FILE with AWS_ROLE_ARN) is the recommended keyless path. Scope the role's trust policy to the specific pipeline (ref or tag) so a leaked ARN can't be assumed elsewhere.

Limitations

  • RSA, ECDSA P-256 and Ed25519 only. Any other KMS key spec returns ErrUnsupportedKMSKeyType. P-256 is for container image signing. This recipe covers the RSA/OpenPGP path; for Ed25519 artefact signing see Sign artefacts with minisign.
  • RSASSA-PKCS1-v1_5 only. A caller requesting RSASSA-PSS gets ErrPSSUnsupported rather than a silent scheme downgrade. PKCS#1 v1.5 is what the OpenPGP RSA signing path here uses.
  • SHA-256, SHA-384 or SHA-512 for RSA. Any other hash returns ErrUnsupportedHashFunc.
  • A 4096-byte signing message on the Ed25519 path. Exceeding it returns ErrMessageTooLarge. Sign a digest, not an artefact; minisign.Sign already does. This is the constraint that makes the prehashed variant load-bearing, as Why there are two signature formats explains.
  • Asymmetric KMS keys do not auto-rotate. Rotation means minting a new key and dual-publishing through a transition window; there is no rotate button.

See also