encryption

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Aug 15, 2026 License: MIT Imports: 16 Imported by: 0

README

encryption

Recover OpenPGP session keys from a raw ECDH shared secret, so the private half of an encryption key can live somewhere this process cannot reach.

cipherID, sessionKey, err := encryption.SessionKey(
    encryption.Message{
        Version:      encryption.PKESKVersion3, // only v3 carries an algorithm octet and checksum
        SharedSecret: sharedSecret,             // the bare x-coordinate, from KMS or any key service
        WrappedKey:   wrapped,                  // taken from the message's PKESK packet
    },
    encryption.KDFParams{
        CurveOID:        curveOID,    // length octet + OID, verbatim from the key packet
        Fingerprint:     fingerprint, // the recipient certificate's fingerprint
        HashID:          8,           // SHA-256
        KEKAlgID:        7,           // AES-128
        CoordinateBytes: 32,          // P-256; the exact length SharedSecret must be
    },
)

Why this exists

OpenPGP ECDH (public-key algorithm 18) derives a key-encryption key by running a KDF over the x-coordinate of the ECDH shared point, then AES-key-unwraps the session key with it. The x-coordinate is the only secret-derived input, and it is exactly what an external key service returns from a key-agreement operation — AWS KMS DeriveSharedSecret returns that value and nothing else.

So the private key never has to be in memory. Callers obtain the shared secret however they must; everything downstream happens here, locally.

That seam is not a guess. It was established by decrypting a real gpg-produced message from a shared secret computed outside any OpenPGP library, and then confirming byte-for-byte against a live KMS key.

What it does not do

  • Not X25519/X448 (algorithms 25 and 26). Those use a different construction with HKDF. Applying this KDF to them fails in ways that look like curve or key errors, so the package rejects nothing and simply is not for them.
  • No key service client. Providers live in sibling modules.
  • No message-body decryption. This package recovers the session key and stops. Decrypting the encrypted-data packet with that key is a separate job, and a full OpenPGP implementation already does it well.
  • No message parsing yet. The caller supplies the ephemeral point and the wrapped key from the PKESK packet, and the packet version.
  • Version 3 PKESK packets only. A v6 payload has a different layout — no algorithm octet, no checksum — so it is rejected rather than misread.

The payload is not trusted

The AES-KW integrity check proves the sender knew the key-encryption key — and anyone composing a message to this certificate does, because they choose the ephemeral key pair. So every field recovered from the unwrapped payload is validated before it is returned: the PKCS#5 padding, the session key's length against the algorithm identifier it travels with, and OpenPGP's own checksum.

Errors are distinguishable on purpose. ErrIntegrity means the message is not addressed to this key; ErrChecksum means the KEK was right and the payload under it is not; ErrMalformed and ErrUnsupported mean the input is not what it claims to be. A caller reporting to a human needs to tell those apart.

A property worth knowing

The KDF binds the recipient's fingerprint into its input. Certificate assembly and decryption are therefore coupled: assemble a certificate with a different fingerprint and nothing addressed to the old one will open. This is covered by a test rather than left as a remark.

Testing

testdata/vectors.json is a known-answer vector captured from a real gpg 2.4.4 message encrypted to a NIST P-256 certificate, including the intermediate values (shared secret, derived KEK, wrapped and unwrapped session key). The whole local chain is exercised from it with no key service involved.

The key material in testdata/ is a throwaway generated for this fixture, on an example.invalid address. It protects nothing.

Design

Implements the decryption half of sigillum spec 0004, KMS-backed OpenPGP certificate assembly and decryption.

Documentation

Overview

Package encryption recovers OpenPGP session keys from a raw ECDH shared secret, so that the private half of an encryption key can live somewhere this process cannot reach — a KMS, an HSM — rather than in memory.

OpenPGP ECDH (public-key algorithm 18, RFC 9580 carrying forward RFC 6637) derives a key-encryption key by running a KDF over the x-coordinate of the ECDH shared point, then AES-key-unwraps the session key with it. The x-coordinate is the only secret-derived input, and it is exactly what an external key service returns from a key-agreement operation — AWS KMS DeriveSharedSecret, for instance, returns that value and nothing else.

That is the seam this package is built around: callers obtain the shared secret however they must, and the derivation happens here, locally. Nothing in this package needs a private key, which is what makes it testable against fixed vectors with no key service involved.

The scope ends at the session key. Decrypting the message body with it is a separate job that a full OpenPGP implementation already does well, and claiming otherwise would misdescribe what a caller gets.

This is deliberately NOT the X25519/X448 construction (algorithms 25 and 26), which derives its key with HKDF over a different input. Applying this KDF to those algorithms fails in ways that look like curve or key errors.

Trust

The unwrapped payload is attacker-controlled. Whoever composes a PKESK packet chooses the ephemeral key pair, so they know the shared secret, can derive the key-encryption key, and can wrap any payload they like. The AES-KW integrity check proves the sender knew the KEK — not that they were honest. Every field recovered from the payload is therefore validated before it is returned.

Index

Constants

View Source
const (
	// TagPKESK is a public-key encrypted session key packet — the one a
	// message begins with and that ParsePKESK reads.
	TagPKESK = 1

	// TagPublicKey and TagPublicSubkey are the key packets a certificate
	// carries.
	TagPublicKey    = 6
	TagPublicSubkey = 14

	// TagUserID is the identity a certificate certifies.
	TagUserID = 13

	// TagSignature is a certification or binding signature.
	TagSignature = 2
)

OpenPGP packet tags this package names, RFC 9580 §5.

Variables

View Source
var (
	// ErrIntegrity means the AES key unwrap failed its integrity check. In
	// practice this means the shared secret was not the right one: the message
	// is addressed to a different key, or the wrong KDF inputs were supplied.
	ErrIntegrity = errors.New("key unwrap integrity check failed")

	// ErrChecksum means the unwrapped session key failed OpenPGP's own 16-bit
	// checksum. Distinct from ErrIntegrity: the unwrap succeeded, so the KEK was
	// right, and the payload underneath it is still wrong.
	ErrChecksum = errors.New("session key checksum mismatch")

	// ErrUnsupported means a hash, cipher or packet version this package does
	// not implement. Deliberately not a silent fallback to a default.
	ErrUnsupported = errors.New("unsupported algorithm")

	// ErrMalformed means the input could not be interpreted as the structure it
	// claims to be.
	ErrMalformed = errors.New("malformed input")

	// ErrNotConfigured means a key service was selected that is not registered,
	// usually because its provider package was never imported.
	ErrNotConfigured = errors.New("key service is not configured")

	// ErrRevoked means the certificate's holder has withdrawn the key it names.
	//
	// Distinct from ErrMalformed because the certificate is not malformed at
	// all — it is well-formed and says, correctly, that this key must not be
	// used. The remedy is to fetch a current certificate, not to inspect the
	// bytes of this one, and only a separate sentinel tells the operator that.
	ErrRevoked = errors.New("key is revoked")

	// ErrUnverifiableRevocation means a certificate carries a revocation-shaped
	// signature that could not be evaluated — a version this package does not
	// read, an algorithm it does not implement, or an issuer whose key it does
	// not hold.
	//
	// Reported as a note on a successfully parsed certificate rather than as a
	// refusal, and that is a deliberate split of the decision from the finding.
	//
	// Refusing would be a denial of service anyone could mount: a
	// revocation-shaped packet costs nothing to append to a published
	// certificate, and nothing verifies it before it is counted. Ignoring it
	// silently is the other extreme, and leaves a caller who could have decided
	// — one holding the revoker's key, or a published notice, or a policy of
	// refusing anything doubtful — with no way to know there was anything to
	// decide.
	//
	// So the finding is surfaced and the decision belongs to the caller. The
	// pattern is Keybase's UnverifiedRevocations, whose own comment puts it
	// best: reading keys will not verify these, "API consumers should do this
	// instead (or not, and just assume that the key is probably revoked)".
	ErrUnverifiableRevocation = errors.New("certificate carries a revocation this cannot evaluate")

	// ErrIncompleteCertificate means a certificate stopped parsing before its
	// input ran out, and what is returned came from the part that parsed.
	//
	// Also a note rather than a refusal. Everything before the stop is intact,
	// so a correspondent can still write to the holder — but a subkey past the
	// stop might have superseded the one returned, and a caller handed the
	// earlier key should know it is looking at part of a certificate.
	ErrIncompleteCertificate = errors.New("certificate did not parse to its end")

	// ErrExpired means the certificate's holder time-boxed the key it names
	// and that date has passed.
	//
	// Distinct from ErrMalformed for the same reason as ErrRevoked: the
	// certificate is well-formed and is correctly saying this key is no longer
	// to be used. The remedy is a current certificate, not an inspection of
	// this one's bytes.
	ErrExpired = errors.New("key has expired")
)

Errors reported by this package. They are distinguishable because the callers that matter — a decryption command reporting to a human, a retry policy — need to tell "the message is not addressed to this key" apart from "this message is malformed".

View Source
var ErrNoPublicKey = errors.New("key service cannot expose the encryption key's public half")

ErrNoPublicKey means a Deriver cannot expose its key's public half, so a certificate cannot be assembled for it.

Not every key service can. Decryption never needs the public half — it comes from the certificate the sender used — but assembling a certificate does, since the subkey packet IS that public half. A Deriver that only decrypts returns this from PublicKey rather than being a different, sniffed-for type.

View Source
var ErrPartialLength = fmt.Errorf("%w: partial packet lengths are not supported", ErrMalformed)

ErrPartialLength means a packet uses a partial (streaming) length, which this package does not reassemble.

It wraps ErrMalformed, so a caller matching that still matches, but is distinguishable with errors.Is: a scan looking for the packet that ends a session-key sequence needs only the tag, which ParsePacket returns alongside this error, and can settle at a streaming encrypted-data packet rather than treating it as damage. go-crypto reassembles the body once the scan hands it over.

Functions

func CurveOID added in v0.2.0

func CurveOID(c elliptic.Curve) ([]byte, bool)

CurveOID returns the OID a public-key packet carries for a NIST curve, length octet first, and whether this module knows the curve.

Exported because a certificate assembler needs it and had been hand-writing the same three OIDs a fourth time. internal/algorithm holds the one copy; this is how a consumer outside the module reaches it, and the alternative was another table to keep in step.

func DeriveKEK

func DeriveKEK(sharedSecret []byte, p KDFParams) ([]byte, error)

DeriveKEK computes the key-encryption key from a raw ECDH shared secret, following RFC 6637 §8:

Param = curve_OID_len || curve_OID || public_key_alg_ID || 03 || 01
        || KDF_hash_ID || KEK_alg_ID || "Anonymous Sender    "
        || recipient_fingerprint
MB    = Hash( 00 || 00 || 00 || 01 || ZB || Param )

and taking the leftmost octets of MB.

sharedSecret is the x-coordinate of the ECDH shared point, left-padded to the curve's byte length — which is checked against KDFParams.CoordinateBytes. It is passed in rather than computed because the private key it derives from is expected to be unreachable from this process.

func KeyServiceNames added in v0.2.0

func KeyServiceNames() []string

KeyServiceNames lists the registered services, for help text.

func RegisterKeyService added in v0.2.0

func RegisterKeyService(s KeyService)

RegisterKeyService adds s to the registry, and is called from a provider's init().

Panics on a duplicate name or an unusable service. Both are programming errors at process start-up, and failing immediately is better than a silent override that surfaces as the wrong key being used.

func ResetForTesting added in v0.2.0

func ResetForTesting()

ResetForTesting clears the registry.

The "ForTesting" suffix is the warning: it exists so a test can start from an empty registry, as go/signing's registry allows, and must never be called by production code.

func SessionKey

func SessionKey(m Message, p KDFParams) (byte, []byte, error)

SessionKey recovers the OpenPGP session key for a message, given the raw ECDH shared secret and the wrapped key taken from the message's PKESK packet.

It returns the symmetric algorithm identifier the message body is encrypted with, alongside the key itself. That identifier comes from inside the wrapped payload, not from KDFParams.KEKAlgID — the two are independent, and a P-256 key wrapping an AES-256 session key under an AES-128 KEK is ordinary.

Every field is validated before it is returned. The payload is attacker-controlled: the AES-KW integrity check proves the sender knew the KEK, which anyone composing a message to this certificate does.

Types

type Deriver added in v0.2.0

type Deriver interface {
	// DeriveSharedSecret returns the raw shared secret — the bare
	// x-coordinate, left-padded to the curve's coordinate length.
	DeriveSharedSecret(ctx context.Context, peerPoint []byte) ([]byte, error)

	// CoordinateBytes reports that length, which the KDF parameters require.
	CoordinateBytes() int

	// PublicKey returns the encryption key's public half, which a certificate
	// carries as its subkey packet, or [ErrNoPublicKey] if this key service
	// cannot expose it.
	//
	// Mandatory rather than an optional interface a caller sniffs for. That was
	// the design, and it lied: a type satisfies an interface at the TYPE level
	// while whether it can honour the call is a VALUE-level fact — the local
	// backend held a nil public half for an X25519 key yet satisfied the
	// optional PublicDeriver, so the capability check passed and the assembly
	// path dereferenced the nil. A method every Deriver must implement, refusing
	// with a sentinel when it cannot, makes "can this key be published?" a
	// question only the value can answer, asked the one way it is answerable.
	//
	// The curve is not a separate accessor: it rides on the returned key's Curve
	// field, so there is no errorless Curve() to dereference a nil public half
	// through — which is precisely how the X25519 panic reached the operator. A
	// caller that cannot get a key gets an error, not a curve for a key that
	// is not there.
	//
	// Decrypt-only implementations embed [NoPublicKey] to satisfy this with the
	// refusal.
	PublicKey() (*ecdsa.PublicKey, error)
}

Deriver performs the ECDH agreement with a key this process cannot read, and can hand back the key's public half.

type KDFParams

type KDFParams struct {
	// CurveOID is the curve identifier exactly as it appears in the public-key
	// packet — a length octet followed by the OID bytes. It is passed through
	// verbatim rather than re-encoded, because the KDF hashes these bytes and
	// any re-encoding would silently change the result.
	//
	// The leading length octet is load-bearing and is checked: a parser that
	// strips it is a common shape, and the resulting KEK would be wrong in a
	// way that looks like the message being addressed elsewhere.
	CurveOID []byte

	// Fingerprint is the recipient key's fingerprint.
	//
	// This is what couples certificate assembly to decryption: change the
	// certificate and every message addressed to the old one stops opening, so
	// the two cannot be developed or validated independently.
	Fingerprint []byte

	// HashID is the OpenPGP hash algorithm identifier used by the KDF.
	HashID byte

	// KEKAlgID is the OpenPGP symmetric algorithm identifier whose key size
	// determines how much of the hash output becomes the KEK. It describes the
	// key-wrapping cipher and is unrelated to the cipher the message body uses.
	KEKAlgID byte

	// CoordinateBytes is the curve's coordinate length, and therefore the exact
	// length the shared secret must be.
	//
	// Required, because it cannot be inferred: the KDF hashes the shared secret
	// verbatim, so a secret that lost a leading zero octet — which happens to
	// roughly one agreement in 256 for any caller deriving it through
	// big.Int.Bytes() — produces a plausible but wrong KEK, and the failure
	// surfaces as ErrIntegrity on a message that is perfectly fine.
	CoordinateBytes int
}

KDFParams carries the key-derivation inputs that OpenPGP binds into the KEK.

These come from the recipient's public-key packet rather than from the message, which is the point: the derivation is bound to a specific certificate, so a session key can only be recovered by someone who knows which certificate the message was addressed to.

type KeyRef added in v0.2.0

type KeyRef struct {
	// ServiceName is the registered key-service instance that holds the key.
	ServiceName string

	// KeyID identifies the key within that service.
	KeyID string
}

KeyRef names a key by the service instance that holds it and the key's id within that service.

It is the estate's one portable representation of an instance-scoped key. Once a provider can be registered as several named instances — a certify key in one account, an encrypt key in another — a bare key id is no longer globally meaningful: it must be paired with the name of the service instance that resolves it, the name a caller passes to LookupKeyService. Both fields are required, and this pass gives KeyRef no behaviour beyond that identity.

func (KeyRef) Validate added in v0.2.0

func (r KeyRef) Validate() error

Validate reports whether both fields are present. A KeyRef missing either is malformed, because neither half addresses a key without the other.

type KeyService added in v0.2.0

type KeyService interface {
	// Name is the identifier a caller selects this service by.
	Name() string

	// Deriver returns something that can perform the ECDH agreement for keyID.
	// Configuration beyond the key's identity — a region, an endpoint — comes
	// from the provider's own environment, unless this is a named instance a
	// consumer constructed with an injected config or client (see the provider's
	// constructors).
	Deriver(ctx context.Context, keyID string) (Deriver, error)

	// Signer returns something that can sign with keyID, for certificate
	// assembly. A service that only does key agreement may return an error.
	//
	// ctx governs both building the signer and every signature it later makes.
	// That has to be stated rather than left to each provider, because
	// crypto.Signer.Sign takes no context of its own — so the only one
	// available is the one captured here, and the choice is not the provider's
	// to make differently. A caller who passes a start-up deadline or a request
	// scope will find later signatures failing on it, surfacing as an error
	// from the key service rather than from their own expired context. Pass a
	// context whose lifetime matches the signing you intend to do.
	//
	// crypto.Signer rather than an interface of this package's own: it is what
	// the standard library and go-crypto already take, so a provider that
	// implements it can drive certificate assembly, X.509 issuance and TLS
	// without an adapter written for each.
	Signer(ctx context.Context, keyID string) (crypto.Signer, error)
}

KeyService is a source of the two operations this package cannot perform itself, named so a caller can select one at runtime.

A command-line tool has to turn "--backend aws-kms" into something that can derive a shared secret. Wiring that with a direct import would make every consumer depend on every provider — including the cloud SDKs of the ones they do not use — so providers register themselves instead and a consumer blank-imports the one it wants.

func LookupKeyService added in v0.2.0

func LookupKeyService(name string) (KeyService, error)

LookupKeyService returns the service registered under name.

The error names what is available, because the usual cause is a provider that was never blank-imported — and "unknown backend" without a list leaves the reader guessing whether they misspelled it or missed an import.

func ReplaceKeyService added in v0.2.0

func ReplaceKeyService(name string, replacement KeyService) (previous KeyService, err error)

ReplaceKeyService swaps the service registered under name, returning the one it replaced.

The deliberate counterpart to RegisterKeyService's duplicate panic. A provider blank-imported to register a zero-conf default panics if a second package claims the same name — the guard that stops the wrong key being used. A consumer that WANTS to swap that default for a configured or injected instance says so explicitly here. Unlike Register, this returns an error rather than panicking, because it is an explicit runtime call a consumer can handle, not an init-time side effect.

It requires an existing entry — there is nothing to replace otherwise — and the name must be the replacement's own, since a mismatch is a wiring mistake. It is startup-only: after package init and before serving requests. The swap is atomic with respect to the map, but a caller that already looked the old service up keeps it, so this is not a live, process-wide transition. The returned previous value is for TEST RESTORATION only — KeyService has no dispose contract and the old service may still be in use, so it must not be closed on the strength of a successful replacement.

type Message

type Message struct {
	// Version is the PKESK packet version the wrapped key came from. Only
	// PKESKVersion3 can be parsed; it is required rather than assumed because a
	// version 6 payload has a different layout and would be misread.
	Version PKESKVersion

	// SharedSecret is the raw ECDH shared secret: the x-coordinate of the
	// shared point, left-padded to the curve's coordinate length.
	SharedSecret []byte

	// WrappedKey is the AES-key-wrapped session key from the packet.
	WrappedKey []byte
}

Message is the part of a Public-Key Encrypted Session Key packet this package needs.

A struct rather than three positional arguments, because two of them are []byte and sat next to each other: transposing them compiled cleanly and failed at runtime with an error describing the message rather than the call. Named slice types do not fix that — a plain []byte is assignable to them — so the field names are what make a mistake visible at the call site.

type NoPublicKey added in v0.2.0

type NoPublicKey struct{}

NoPublicKey is embedded by a Deriver that only decrypts, to satisfy PublicKey with the standard refusal.

type decryptOnly struct {
	encryption.NoPublicKey
	// ... the deriving fields
}

This keeps the refusal in one place instead of each decrypt-only implementation writing its own, and keeps PublicKey a compile-time obligation on every Deriver — a new one cannot forget the method, only choose the refusing form of it.

func (NoPublicKey) PublicKey added in v0.2.0

func (NoPublicKey) PublicKey() (*ecdsa.PublicKey, error)

PublicKey refuses, because the embedding Deriver cannot expose a public half.

type PKESK added in v0.2.0

type PKESK struct {
	// Version is the packet version. Only PKESKVersion3 can be parsed.
	Version PKESKVersion

	// KeyID is the eight-octet identifier of the key the message is addressed
	// to, or all zeroes for an anonymous recipient.
	KeyID [8]byte

	// EphemeralPoint is the sender's ephemeral public point, uncompressed.
	EphemeralPoint []byte

	// WrappedKey is the AES-key-wrapped session key.
	WrappedKey []byte
}

PKESK is the part of a Public-Key Encrypted Session Key packet a caller needs in order to recover the session key.

Exposed because every consumer needs it and the alternative is each of them re-deriving the packet layout from the specification. That is exactly the knowledge this package exists to hold, and hand-rolled copies drift: the fields are at fixed offsets only until a version changes, and a parser that reads one octet wrong produces a plausible-looking ephemeral point rather than an error.

func ParsePKESK added in v0.2.0

func ParsePKESK(body []byte) (PKESK, error)

ParsePKESK reads a version 3 ECDH PKESK packet body — the packet without its framing header.

It takes the body rather than a whole packet because packet framing is the caller's concern: a message may arrive armoured or binary, old-format or new, and this package does not own that layer.

type PKESKVersion

type PKESKVersion byte

PKESKVersion is the version of the Public-Key Encrypted Session Key packet a wrapped key came from.

A named type rather than a bare byte so a caller states which packet it parsed, and cannot pass a length, an algorithm id or a count by accident.

const PKESKVersion3 PKESKVersion = 3

PKESKVersion3 is the only Public-Key Encrypted Session Key packet version this package can parse.

Version 6 (RFC 9580) carries no symmetric-algorithm octet and no checksum inside the encrypted payload — the algorithm comes from the SEIPDv2 packet instead — so the same bytes mean different things. Parsing a v6 payload with v3 rules silently misreads the key's first octet as a cipher identifier, so the version is required from the caller rather than guessed.

type Packet added in v0.2.0

type Packet struct {
	// Tag says what the packet is — TagPKESK, TagPublicSubkey and so on.
	Tag byte

	// Body is the packet's contents, without its header. This is what
	// ParsePKESK and Fingerprint take.
	Body []byte

	// Rest is everything after this packet, so a caller can walk a sequence
	// without tracking offsets. Empty at the end of the input.
	Rest []byte
}

Packet is one OpenPGP packet read from a byte slice.

func ParsePacket added in v0.2.0

func ParsePacket(data []byte) (Packet, error)

ParsePacket reads the first packet from data.

Both header forms are handled: the RFC 9580 form a modern implementation writes, and the legacy CTB that older senders and some hardware still emit. A reader that handles only one silently fails on whatever it was not built for.

Body aliases data rather than copying it. Nothing here mutates the input, and the caller usually wants to parse the whole sequence without allocating a copy of each packet.

Directories

Path Synopsis
internal
algorithm
Package algorithm holds the OpenPGP algorithm identifiers and the sizes they imply, shared by the packages that read and write them.
Package algorithm holds the OpenPGP algorithm identifiers and the sizes they imply, shared by the packages that read and write them.
num
Package num narrows Go's int to the fixed-width fields OpenPGP defines, reporting when a value will not fit rather than silently wrapping.
Package num narrows Go's int to the fixed-width fields OpenPGP defines, reporting when a value will not fit rather than silently wrapping.
pgptest
Package pgptest builds OpenPGP fixtures with an independent implementation, for tests in this module.
Package pgptest builds OpenPGP fixtures with an independent implementation, for tests in this module.
Package keyservicetest is the conformance suite every Deriver implementation must pass.
Package keyservicetest is the conformance suite every Deriver implementation must pass.
Package local is the on-disk PEM private key backend for the encryption key service.
Package local is the on-disk PEM private key backend for the encryption key service.
Package packetwalk walks a stream of packets so that every way a walk can end is a named outcome the caller must handle.
Package packetwalk walks a stream of packets so that every way a walk can end is a named outcome the caller must handle.

Jump to

Keyboard shortcuts

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