encryptionawskms

package module
v0.3.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 24, 2026 License: MIT Imports: 18 Imported by: 1

README

encryption-aws-kms

Open OpenPGP messages, and sign OpenPGP certificates, with keys AWS KMS will not let you read

Go Reference Pipeline Coverage phpboyscout Go toolkit

Part of the phpboyscout Go toolkit — small, framework-free Go modules extracted from go-tool-base. Documented with the parent module at encryption.go.phpboyscout.uk


encryption does the OpenPGP format work — the RFC 6637 key derivation, the RFC 3394 key unwrap, packet framing, certificate assembly — with a build graph containing nothing but the standard library.

This module supplies the only two operations in that path that need a private key, and nothing else. It is a sibling module so a consumer who does not use AWS never inherits the SDK.

The two operations

import (
    "gitlab.com/phpboyscout/go/encryption"
    "gitlab.com/phpboyscout/go/encryption/certificate"
    kmsenc "gitlab.com/phpboyscout/go/encryption-aws-kms"
)

// Opening a message: one kms:DeriveSharedSecret call.
deriver, err := kmsenc.NewDeriverForKey(ctx, client, client, "alias/security-contact-v1-encrypt")

// Assembling a certificate: two kms:Sign calls, through crypto.Signer.
signer, err := kmsenc.NewSigner(ctx, client, client, "alias/security-contact-v1-certify")

der, err := certificate.Certificate{
    UserID:  "Security <security@example.com>",
    Created: created,
    Subkey:  subkey,
}.Assemble(signer.WithContext(ctx))

No private key material exists in your process at any point. There is no code path here that could export one, because KMS offers no API that would allow it.

Runtime selection

Blank-import it and the parent's registry resolves --backend aws-kms:

import _ "gitlab.com/phpboyscout/go/encryption-aws-kms"

That is the whole interface — registration happens in init(). See Select a key service at runtime.

Two keys, not one

An ECC_NIST_P256 KEY_AGREEMENT key for encryption, and an RSA_4096 SIGN_VERIFY key for certification. An agreement key cannot sign its own binding signature, so a certificate structurally needs both — and RSA because an OpenPGP RSA signature is PKCS#1 v1.5, which is what KMS produces. PSS is refused explicitly: KMS will make one, and no OpenPGP implementation accepts it.

Constraints worth knowing before you provision:

Curves NIST P-256/384/521 (and SM2 in China). Not X25519 — KMS does not offer it for agreement
Encryption key Cannot be RSA. OpenPGP uses PKCS#1 v1.5 for session keys; KMS offers only OAEP
Operation Human, with MFA. The roles carry no OIDC trust

Documentation

Everything lives with the parent module, because a provider documented separately is a provider whose guide drifts from the thing it plugs into:

The Go API is on pkg.go.dev.

Testing

Generated mocks ship as a subpackage, so a consumer can drive these seams with no credentials:

import mocks "gitlab.com/phpboyscout/go/encryption-aws-kms/mocks"

The integration tests need real keys and make billable calls, so they are gated on INT_TEST plus an explicitly named key — there are no defaults, because a default key id means calling a key in whatever account the ambient credentials happen to point at.

Licence

MIT. See LICENSE.

Documentation

Overview

Package encryptionawskms provides AWS KMS backends for the key operations that gitlab.com/phpboyscout/go/encryption leaves to its caller.

The split exists because only one operation in the OpenPGP ECDH decryption path needs a private key at all: deriving the ECDH shared secret. Everything else — the KDF, the AES key unwrap, the message decryption — is arithmetic over public data and lives in the core module. Keeping the two apart means the core is testable against fixed vectors with no AWS involved, and this module stays small enough to audit.

Signing IS implemented here, in signer.go: a crypto.Signer over kms:Sign, which certificate assembly needs for the binding signature over the certification primary. This said the work was outstanding long after it landed.

Nothing here ever holds private key material: KMS performs the operation and returns only its result.

Index

Constants

View Source
const ServiceName = "aws-kms"

ServiceName is the identifier the zero-conf default registers under, and the one a caller selects this provider by with "--backend aws-kms".

Variables

View Source
var (
	// ErrNoSharedSecret means KMS returned a response carrying no shared
	// secret. The API does this when the caller asked for an attested response
	// (CiphertextForRecipient), which this package never does — so it indicates
	// a response shape we do not understand rather than a recoverable state.
	ErrNoSharedSecret = errors.New("kms returned no shared secret")

	// ErrInvalidPoint means the peer's public point could not be interpreted as
	// a point on the expected curve.
	ErrInvalidPoint = errors.New("invalid peer public point")

	// ErrCurveMismatch means the configured curve disagrees with the secret KMS
	// returned for the key — the key behind an alias has changed curve since
	// this Deriver was built.
	ErrCurveMismatch = errors.New("curve does not match the KMS key")

	// ErrNoCurve means a Deriver has no curve: it was built with a nil one, or
	// never built by a constructor at all.
	//
	// Distinct from ErrNotConfigured so a caller can tell "I could not work out
	// the curve" from "you did not give me a client", which want different
	// responses.
	ErrNoCurve = errors.New("no curve configured")

	// ErrNotConfigured means a dependency or identifier was missing at
	// construction — a nil client, an empty key id. A wiring fault, nothing to
	// do with curves.
	ErrNotConfigured = errors.New("deriver is not configured")

	// ErrInvalidResponse means KMS, or something standing in for it, returned a
	// response this package cannot read: a nil output alongside a nil error, or
	// a public key that is not a SubjectPublicKeyInfo. Usually transport,
	// permissions or a substituted implementation — not a property of the key.
	ErrInvalidResponse = errors.New("unreadable response from kms")

	// ErrKeyRotated means the alias resolved to a different key than it did on
	// a previous derivation. The secret may be perfectly valid — it is simply
	// for a different key, so anything encrypted to the old certificate will
	// not open, and the cause is the alias rather than the message.
	ErrKeyRotated = errors.New("kms key behind the alias has changed")

	// ErrKeyUnusable means the KMS key cannot perform ECDH key agreement: wrong
	// KeyUsage, no ECDH among its agreement algorithms, or not an
	// elliptic-curve key at all.
	ErrKeyUnusable = errors.New("kms key cannot perform ECDH key agreement")
)

Errors reported by this package.

View Source
var ErrDigestLength = errors.New("digest length does not match the hash")

ErrDigestLength means the digest handed to the signer is not the length its hash produces. KMS rejects a mismatched digest, but it does so as a ValidationException that says nothing about which side is wrong.

View Source
var ErrUnsupported = encryption.ErrUnsupported

ErrUnsupported is reported for a hash this package cannot map to a KMS signing algorithm, or a signature form it does not produce.

It is the core encryption.ErrUnsupported, re-exported, so a caller matching the sentinel it already knows catches this provider's unsupported cases too. Two independently declared errors with identical text is how a caller comes to handle "unsupported" for one backend and silently miss it for another.

Functions

func NewServiceDefault added in v0.2.0

func NewServiceDefault(name string) (encryption.KeyService, error)

NewServiceDefault builds a named service that resolves its own configuration from the ambient AWS chain — profile, SSO session or instance role — the first time an operation needs one, lazily and memoised, via go/awsclient. This is the rung the blank-import default uses. There is no default region: awsclient surfaces an empty one as an error, because guessing means calling a key in an account nobody named.

func NewServiceFromClient added in v0.2.0

func NewServiceFromClient(name string, client kmsAPI) (encryption.KeyService, error)

NewServiceFromClient builds a named service around a KMS client the caller already holds — a shared client, a custom endpoint, an assumed-role client. The client is used as-is.

func NewServiceFromConfig added in v0.2.0

func NewServiceFromConfig(name string, cfg aws.Config) (encryption.KeyService, error)

NewServiceFromConfig builds a named service around an aws.Config the caller resolved. The client is constructed here with kms.NewFromConfig, which does no I/O. The region is checked now rather than left to fail per call, because a config with no region names a key in an account nobody chose.

func NewServiceFromSource added in v0.3.0

func NewServiceFromSource(name string, src configSource) (encryption.KeyService, error)

NewServiceFromSource builds a named service that resolves its AWS configuration through the supplied source. It is the sharing seam (org 0002 L-5, 0003 P-5): a consumer resolves one chain with go/awsclient and hands the same source to the config, signing and encryption adapters, so one credential resolution serves all three.

src := awsclient.Ambient(awsclient.WithRegion("eu-west-2"))
svc, err := encryptionawskms.NewServiceFromSource("aws-kms", src)

Types

type Deriver

type Deriver struct {
	// contains filtered or unexported fields
}

Deriver performs the ECDH agreement for an OpenPGP message inside KMS.

It holds the curve because the value it is given — the ephemeral point from a message's PKESK packet — carries no curve of its own; it is a bare uncompressed point, and KMS wants a DER SubjectPublicKeyInfo. Re-encoding is the only translation that happens here.

Prefer NewDeriverForKey, which reads the curve from the key itself and checks the key can perform ECDH at all. A curve supplied by hand is a constant that can drift from the key it describes, and the resulting failure names the peer's point — sending whoever debugs it after the sender's message rather than the local configuration.

func NewDeriver

func NewDeriver(api SharedSecretAPI, keyID string, curve elliptic.Curve) (*Deriver, error)

NewDeriver returns a Deriver for a KMS key whose curve the caller already knows.

keyID may be a key id, ARN or alias. curve must be the curve of that key — P-256, P-384 or P-521, the NIST curves KMS supports for KEY_AGREEMENT. A nil curve is rejected here rather than panicking later, because the realistic source of one is a lookup that returned nothing for an unrecognised key spec.

func NewDeriverForKey

func NewDeriverForKey(
	ctx context.Context,
	api SharedSecretAPI,
	pub PublicKeyAPI,
	keyID string,
) (*Deriver, error)

NewDeriverForKey returns a Deriver whose curve is read from the KMS key itself, and which refuses a key that cannot perform ECDH.

This is the constructor to reach for. The alternative — passing the curve as a constant — is a one-line configuration that silently disagrees with a rotated or re-created key, and the mismatch surfaces as an error about the sender's point rather than about local wiring.

The curve is resolved once, here. The key id is not: it is re-sent on every derivation, so an alias repointed at another key of the SAME curve keeps working, which is the ordinary rotation. A rotation that changes curve is detected rather than tolerated — the first derivation returns ErrCurveMismatch naming the key, and the Deriver must be rebuilt. Those two behaviours cannot both be had from one cached value, so the choice here is to survive the common rotation and fail loudly on the rare one.

func (*Deriver) CoordinateBytes

func (d *Deriver) CoordinateBytes() int

CoordinateBytes reports the curve's coordinate length, which is both the expected length of a derived secret and the value the core package needs in its KDF parameters. It reports 0 for a Deriver with no curve, rather than panicking: Curve() returns nil and DeriveSharedSecret returns an error in that state, and an accessor that crashes where its siblings report is a trap for a caller assembling KDF parameters before its first derivation.

func (*Deriver) Curve

func (d *Deriver) Curve() elliptic.Curve

Curve reports the curve this Deriver is configured for.

func (*Deriver) DeriveSharedSecret

func (d *Deriver) DeriveSharedSecret(ctx context.Context, peerPoint []byte) ([]byte, error)

DeriveSharedSecret returns the raw ECDH shared secret for peerPoint, which is the ephemeral public point from the message's PKESK packet in uncompressed form (0x04 || X || Y).

The result is the bare x-coordinate of the shared point, left-padded to the curve's byte length — exactly the input the OpenPGP KDF expects. That equivalence is the reason this whole approach works, and it was verified byte-for-byte against a live KMS key rather than inferred from the documentation.

KMS performs cofactor ECDH (SP 800-56A). NIST curves have cofactor h=1, so that is ordinary ECDH here.

func (*Deriver) PublicKey added in v0.2.0

func (d *Deriver) PublicKey() (*ecdsa.PublicKey, error)

PublicKey returns the encryption key's public half, which a certificate must carry as its subkey.

Available only on a Deriver built by NewDeriverForKey: NewDeriver is told the curve rather than reading the key, so it has nothing to return. That is reported rather than returning nil, because a nil public key would surface much later as an unreadable subkey packet.

type PublicKeyAPI

type PublicKeyAPI interface {
	GetPublicKey(
		ctx context.Context,
		params *kms.GetPublicKeyInput,
		optFns ...func(*kms.Options),
	) (*kms.GetPublicKeyOutput, error)
}

PublicKeyAPI reports a KMS key's public half. Kept separate from SharedSecretAPI so a caller who already knows the curve need not implement it.

type SharedSecretAPI

type SharedSecretAPI interface {
	DeriveSharedSecret(
		ctx context.Context,
		params *kms.DeriveSharedSecretInput,
		optFns ...func(*kms.Options),
	) (*kms.DeriveSharedSecretOutput, error)
}

SharedSecretAPI is the slice of the KMS API this package needs to derive a shared secret.

Declared here, narrowly, rather than accepting *kms.Client: it is the whole of what a caller must provide to substitute KMS in a test, and a one-method interface is one method's worth of fake.

type Signer

type Signer struct {
	// contains filtered or unexported fields
}

Signer makes signatures with a KMS-held key, satisfying crypto.Signer.

The private half never leaves KMS: this type sends a digest and receives a signature, which is the whole of its job.

crypto.Signer rather than an interface of our own, because that is what the ecosystem already speaks — go-crypto assembles an OpenPGP certificate given one, and x509.CreateCertificate and tls.Certificate take one too. A bespoke signing interface would have obliged every consumer to write the assembly itself.

func NewSigner

func NewSigner(ctx context.Context, api SignerAPI, pub PublicKeyAPI, keyID string) (*Signer, error)

NewSigner returns a Signer for a KMS key, checking at construction that the key can actually sign.

The usage check matters for the same reason it does on the deriver: pointing at the wrong key of the right type wires up cleanly and fails later, per signature, inside whatever was assembling a certificate at the time.

func (*Signer) Public added in v0.2.0

func (s *Signer) Public() crypto.PublicKey

Public returns the certification key's public half, so a certificate can carry it.

func (*Signer) Sign added in v0.2.0

func (s *Signer) Sign(_ io.Reader, digest []byte, opts crypto.SignerOpts) ([]byte, error)

Sign signs a precomputed digest in KMS, implementing crypto.Signer.

The digest is precomputed because that is the contract: crypto.Signer is handed a hash of the message, and KMS is asked to sign it with MessageType DIGEST. It suits OpenPGP, which hashes more than the material being signed — the signature's own preamble and a trailer go in too — so there is no message KMS could be given that would produce the right digest.

The io.Reader is ignored, as it is by every remote signer: KMS chooses its own randomness, and PKCS#1 v1.5 signing is deterministic in any case.

func (*Signer) WithContext added in v0.2.0

func (s *Signer) WithContext(ctx context.Context) *Signer

WithContext returns a copy of the signer whose Sign calls use ctx.

crypto.Signer.Sign takes no context, but the call it makes here is a network request that a caller must be able to cancel or bound. Carrying the context on the signer rather than in the method is how every remote signer resolves that; a copy rather than a mutation so one signer can be shared across concurrent requests with different deadlines.

type SignerAPI

type SignerAPI interface {
	Sign(
		ctx context.Context,
		params *kms.SignInput,
		optFns ...func(*kms.Options),
	) (*kms.SignOutput, error)
}

SignerAPI is the slice of the KMS API certificate assembly needs.

Separate from SharedSecretAPI because they are different keys with different roles: the certification primary signs, the encryption subkey agrees. A caller wiring only decryption should not have to supply a signer at all.

Directories

Path Synopsis

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL