ext

package
v0.7.0 Latest Latest
Warning

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

Go to latest
Published: Sep 18, 2026 License: MIT Imports: 31 Imported by: 0

Documentation

Overview

Package ext is a starting point for building a temporal-proxy extension server.

The proxy delegates two decisions to servers the operator runs: whether an inbound caller may proceed (api.auth.v1.AuthService) and how a data encryption key is wrapped (api.kms.v1.EncryptionService). Implement Auth, KMS, or both and hand them to Serve for the listener, signal handling, and graceful shutdown. Registering the generated services by hand works as well; nothing here is privileged. Both are registered either way, and an unsupplied one answers Unimplemented rather than refusing the connection.

Two unrelated authentication decisions meet here and are easy to conflate. Auth is the proxy asking about a worker that connected to the gateway; WithServerAuth is about the caller of this server, which is the proxy. Answering the first does not imply enforcing the second, and a server that skips the second admits anyone who finds its port to everything but health.

The gRPC health service is always registered, and always exempt from WithServerAuth, since a probe has no credential to present. It reports SERVING from startup until shutdown begins, which makes it a liveness signal and not a readiness one: it says this process is up and answering, never that the Auth or KMS behind it can reach whatever it depends on. Wiring it to a readiness probe would report a server with an unreachable key store as ready.

The proxy fails closed, so any error from either service denies the request. Return a google.golang.org/grpc/status error: the code tells the proxy whether the caller can fix this or should retry, and a plain error arrives as Unknown.

Allow, Deny, BearerToken, and IsHealthCheckMethod cover the parts of an Auth implementation that are the same everywhere, and the first two are worth preferring to a hand-built response: an [api.auth.v1.AuthResponse] whose Decision is unset denies, so building one by hand can refuse a caller by omission.

NewKeyWrapper does as much for a KMS: it seals DEKs with an AEAD over keys a KeyLookup supplies and frames them as [api.ext.v1.KeyMaterial], so an implementation brings key material and writes no cryptography of its own. A lookup reports which version sealed each piece of material rather than being told, so a key store may rotate on its own schedule. That version and the cipher both travel with the material, leaving everything already sealed readable across a rotation or a cipher change, and every field that travels in the clear is authenticated, so material relabelled with another namespace, version, or cipher fails to open rather than opening under the wrong key.

NewSealWrapper is the same offer to a KMS that cannot hand over its keys. A KeySealer seals a DEK by calling out to whatever holds it, an HSM or a key service, and the wrapper supplies the framing and nothing else, so the version and opaque bytes the implementation reports on the way in reach it again on the way out. It seals nothing itself, so it cannot authenticate the fields travelling in the clear either: BindingContext encodes them, and an implementation binds them by handing those bytes to its key service as an encryption context. Material framed this way names no cipher, since the construction was the key service's own choice, and the wrapper refuses material that names one rather than assuming it can open it.

Serve listens in plaintext unless WithServerOption supplies credentials, and warns once when the first call confirms it. Both are supported, but the ends must agree, since the proxy dials plaintext when the extension server's TLS block is absent.

Index

Constants

View Source
const (
	// CipherAES256GCM selects AES-256-GCM, the default and the same cipher the
	// proxy seals payloads with.
	CipherAES256GCM = ext.KeyMaterial_CIPHER_AES_256_GCM

	// CipherChaCha20Poly1305 selects ChaCha20-Poly1305, which is worth preferring
	// where AES has no hardware support.
	CipherChaCha20Poly1305 = ext.KeyMaterial_CIPHER_CHACHA20_POLY1305

	// CipherXChaCha20Poly1305 selects XChaCha20-Poly1305, whose 24-byte nonce is
	// wide enough that random nonces need no counting.
	CipherXChaCha20Poly1305 = ext.KeyMaterial_CIPHER_XCHACHA20_POLY1305
)

Variables

This section is empty.

Functions

func Allow added in v0.5.0

func Allow() *auth.AuthResponse

Allow returns the response that admits a caller, and is the only response that does. Prefer it to building one by hand: a response whose Decision is unset denies, so a zero value is a refusal rather than an oversight.

func BearerToken added in v0.5.0

func BearerToken(req *auth.AuthRequest, header string) (string, error)

BearerToken returns the token the caller presented on header, with the "Bearer " scheme stripped. header is matched case-insensitively, as is the scheme.

Every failure is an google.golang.org/grpc/codes.Unauthenticated status error, ready to return from Auth.Authenticate: no credential on that header, more than one value, or a value carrying some other scheme. A repeated value is refused rather than resolved by taking the first, since choosing among credentials a caller sent is how a check gets bypassed.

An implementation that would rather answer Deny, or that accepts a credential with no scheme at all, should read req.GetCredentials() directly.

func BindingContext added in v0.7.0

func BindingContext(namespace, version string, opaque []byte) ([]byte, error)

BindingContext returns the bytes binding key material to the fields that travel beside it in the clear, for a KeySealer to hand its key service as an encryption context. Material framed by NewSealWrapper names no cipher, so there is none to pass here: a sealer that wants to record which construction it used puts that in Opaque, which this binds.

func Deny added in v0.5.0

func Deny(reason string) *auth.AuthResponse

Deny returns the response that refuses a caller. The reason is recorded by the proxy and withheld from the caller, so write it for whoever operates this server: it may name subjects and internal systems.

Use this for a caller judged and found wanting, and an error for a verdict never reached, such as an unreachable backend. Both deny, but an error keeps its status code, which is what tells a worker whether retrying could help.

func IsHealthCheckMethod added in v0.5.0

func IsHealthCheckMethod(full string) bool

IsHealthCheckMethod reports whether full, a gRPC full method name as it arrives in [api.auth.v1.Target], is one an implementation will usually admit without a credential: the gRPC health methods, and GetSystemInfo, which is the first call an SDK client makes on connect and so decides whether it can connect at all.

Whether to admit them is policy and stays with the implementation, which is why this reports rather than decides. Refusing them is a defensible choice; it makes the proxy look unhealthy to anything probing it, and makes an unauthenticated client fail at dial instead of on its first real call.

func NewAES256GCM added in v0.7.0

func NewAES256GCM(key []byte) (cipher.AEAD, error)

NewAES256GCM returns AES-256-GCM over a 32-byte key.

func NewChaCha20Poly1305 added in v0.7.0

func NewChaCha20Poly1305(key []byte) (cipher.AEAD, error)

NewChaCha20Poly1305 returns ChaCha20-Poly1305 over a 32-byte key.

func NewXChaCha20Poly1305 added in v0.7.0

func NewXChaCha20Poly1305(key []byte) (cipher.AEAD, error)

NewXChaCha20Poly1305 returns XChaCha20-Poly1305 over a 32-byte key.

func Serve

func Serve(ctx context.Context, opts ...Option) error

Serve runs an extension server until ctx is cancelled or the process is signalled, then shuts down and returns nil. A non-nil return means the server never started, never that a caller was turned away.

Both generated services are registered whether or not WithAuth and WithKMS were given, and one left unset answers Unimplemented. Defaults are :8900 on every interface, a five second shutdown grace period, and the transport and limits WithServerOption describes. Serve blocks and installs its own handler for [signals], so a caller with no lifecycle of its own can pass context.Background.

Types

type Auth

type Auth interface {
	Authenticate(context.Context, *auth.AuthRequest) (*auth.AuthResponse, error)
}

Auth decides whether an inbound caller of the proxy may proceed. Register an implementation with WithAuth.

The request carries what the proxy knows about the call. Its credentials are what the proxy lifted from the caller's stream, one entry per configured credential header the caller actually sent, so an empty slice means it presented none; its target is what the call is addressing. The proxy's own credential to this server is not among the credentials and stays in the request metadata, alongside the caller's other metadata.

Answer with a response whose Decision is set: only DECISION_ALLOW admits, so an unset decision denies rather than admits by accident. Reason is for whoever operates this server, and the proxy keeps it out of what the rejected caller is told. Return an error only when no verdict was reached, such as an unreachable backend; the proxy denies either way, but an error keeps its status code, so google.golang.org/grpc/codes.Unavailable tells a worker to retry where a denial does not.

Implementations must be safe for concurrent use and must not block indefinitely, since a caller is waiting and the proxy denies on timeout.

type CipherFunc added in v0.7.0

type CipherFunc func(key []byte) (cipher.AEAD, error)

CipherFunc builds an AEAD over a wrapping key. Register one with WithCipherFunc to seal with a cipher this package does not ship.

type CipherID added in v0.7.0

type CipherID = ext.KeyMaterial_Cipher

CipherID names the AEAD that sealed a piece of key material. It travels in the material, so a server that changes cipher still opens what the previous one sealed.

func MustCipherID added in v0.7.0

func MustCipherID(id int) CipherID

MustCipherID turns id into a CipherID for WithCipherFunc. It panics unless id falls in the range reserved for ciphers a server registers itself, 128 through math.MaxInt32.

type CredentialCheck

type CredentialCheck func(string) bool

CredentialCheck reports whether a credential presented to this server is valid. WithServerAuth installs it and describes the checks made first.

type KMS

type KMS interface {
	Wrap(context.Context, string, []byte) ([]byte, error)
	Unwrap(context.Context, []byte) ([]byte, error)
}

KMS wraps and unwraps the proxy's data encryption keys. Register an implementation with WithKMS.

Only key material crosses the wire. The plaintext handed to Wrap is a DEK, never a payload, so an implementation is free to make each call a round trip to an HSM; the proxy caches the DEK and does the bulk encryption itself.

Wrap receives the namespace, so an implementation may hold a distinct key per namespace. Unwrap does not: it gets only the ciphertext, so whatever identifies the key has to be inside what Wrap returned, usually an opaque header framed around it. That makes Unwrap's input a durable format worth versioning, and retiring a key destroys every payload it wrapped.

A google.golang.org/grpc/status error is passed through with its code intact, so an implementation that can tell a bad ciphertext from an unreachable backend can say which it was; any other error takes the code documented on the method that called it.

Implementations must be safe for concurrent use.

func NewKeyWrapper added in v0.7.0

func NewKeyWrapper(lookup KeyLookup, opts ...KeyWrapperOption) (KMS, error)

NewKeyWrapper returns a KMS that seals DEKs with an AEAD over keys from lookup and frames them as ext.KeyMaterial, so an extension server supplies key material and nothing else.

New material is sealed with AES-256-GCM unless WithCipher says otherwise, and carries both the cipher that sealed it and the key version lookup reported at the time. Opening reads those from the material rather than from the configuration, so changing cipher, or a key store rotating underneath, leaves everything already sealed readable.

Ciphers are registered during construction only, so the returned KMS never changes afterwards and may be shared by any number of goroutines.

func NewSealWrapper added in v0.7.0

func NewSealWrapper(s KeySealer) (KMS, error)

NewSealWrapper returns a KMS that frames what a KeySealer seals as ext.KeyMaterial, so an implementation whose key service will not release its keys still writes no framing of its own.

type Key added in v0.7.0

type Key struct {
	// Bytes is the key itself, 32 bytes for every cipher this package ships.
	Bytes []byte

	// Version addresses Bytes again later. It is recorded in the key material
	// when sealing, and ignored when opening, where the version is already
	// known and is the one the request named.
	Version string
}

Key is a wrapping key and the version that addresses it.

type KeyLookup added in v0.7.0

type KeyLookup func(context.Context, KeyRequest) (Key, error)

KeyLookup supplies the wrapping keys NewKeyWrapper seals with. It is the only thing NewKeyWrapper cannot supply for itself.

A lookup must answer for every version it ever reported, not only the current one: forgetting a version destroys every payload sealed under it. Returning a google.golang.org/grpc/status error passes its code through to the proxy, which is how an unreachable key store is distinguished from a version that will never resolve.

A lookup must be safe for concurrent use.

type KeyRequest added in v0.7.0

type KeyRequest struct {
	// Namespace is the pre-translation (local) namespace the DEK belongs to.
	Namespace string

	// Version is the key version being asked for, and is empty when the
	// question is "whichever key is current". Sealing leaves it empty; opening
	// sets it to whatever the key material carried, which is itself empty for a
	// lookup that does not version its keys, so an unversioned store can ignore
	// this field entirely.
	Version string
}

KeyRequest names the key a KeyLookup is being asked for.

type KeySealer added in v0.7.0

type KeySealer interface {
	Seal(context.Context, SealRequest) (SealedKey, error)
	Open(context.Context, OpenRequest) ([]byte, error)
}

KeySealer seals and opens DEKs through a key service that will not hand over its keys. Hand one to NewSealWrapper.

The wrapper authenticates nothing, and cannot: it holds no key, and the version and opaque bytes it would bind are chosen by Seal, so they do not exist until the call it would bind them to has already happened. An implementation that ignores BindingContext produces material whose namespace, version, and opaque can be swapped by anyone able to write a payload's metadata. Passing those bytes to the key service as an encryption context is what makes relabelled material fail to open instead.

A google.golang.org/grpc/status error is passed through with its code intact. Implementations must be safe for concurrent use.

type KeyWrapperOption added in v0.7.0

type KeyWrapperOption interface {
	// contains filtered or unexported methods
}

KeyWrapperOption configures a key wrapper during construction.

func WithCipher added in v0.7.0

func WithCipher(id CipherID) KeyWrapperOption

WithCipher seals new material with id instead of AES-256-GCM. It has no bearing on opening, which uses whatever cipher the material names.

func WithCipherFunc added in v0.7.0

func WithCipherFunc(id CipherID, fn CipherFunc) KeyWrapperOption

WithCipherFunc registers fn as the constructor for id, replacing whatever was registered before, including a built-in.

Ids from 128 up are reserved for exactly this and will never be assigned by ext.KeyMaterial_Cipher, so a cipher registered there cannot collide with one added later; MustCipherID builds one. An id below that is accepted, since replacing a built-in with a stricter construction of the same cipher is reasonable, but reusing a built-in id for a different cipher makes material that other servers will misread.

type OpenRequest added in v0.7.0

type OpenRequest struct {
	Namespace  string
	Version    string
	Opaque     []byte
	Ciphertext []byte
}

OpenRequest carries back everything the material held, so a KeySealer can find its key and rebuild the encryption context it sealed under with ext.BindingContext(req.Namespace, req.Version, req.Opaque).

type Option

type Option func(*options)

Option configures the server built by Serve. The set is closed: options is unexported, so a caller cannot name it.

func WithAddr

func WithAddr(hostPort string) Option

WithAddr sets the address to listen on, defaulting to :8900 on every interface. Narrow it to loopback where the proxy reaches this server over one: an extension server can admit callers and unwrap key material, so it should not be published needlessly. Ignored when WithListener supplies a listener.

func WithAuth

func WithAuth(a Auth) Option

WithAuth registers an implementation of api.auth.v1.AuthService. Without it the service answers Unimplemented, and since the proxy fails closed, that denies every caller rather than admitting them.

func WithKMS

func WithKMS(kms KMS) Option

WithKMS registers an implementation of api.kms.v1.EncryptionService. Without it the service answers Unimplemented, failing every Seal and, more consequentially, every Open of a payload already sealed under it.

func WithListener

func WithListener(lis net.Listener) Option

WithListener serves on lis instead of a listener opened from WithAddr. Use it when something else owns the socket: a test that would rather not bind a port, or a process handed one by its supervisor. The server closes lis during shutdown, so a caller must not.

func WithLogger

func WithLogger(l logger.Logger) Option

WithLogger sets the logger for the server's lifecycle, defaulting to logger.Default. Handlers are not given it. A nil logger is ignored rather than installed, matching logger.SetDefault; the alternative is a panic on the first line the server logs.

func WithServerAuth

func WithServerAuth(header string, check CredentialCheck) Option

WithServerAuth guards this server's unary methods with check, which receives the single value of the header metadata. It authenticates the proxy to this server, unlike the Auth service, which is how the proxy asks about somebody else. Unary covers both generated services, since every method on them is unary; adding a streaming method to either proto means pairing this with a grpc.StreamServerInterceptor.

The health service is exempt, and deliberately: a probe has no credential to give, and guarding it would withhold nothing anyway, because Watch reports the same status over a stream that no unary interceptor sees.

A call is rejected with Unauthenticated unless the header is present exactly once and check accepts its value. Repeats are refused rather than searched, so a caller cannot spray guesses in one call. Compare in constant time (crypto/subtle.ConstantTimeCompare) for a shared secret. A nil check installs nothing and leaves the server open, while a non-nil one with an empty header would reject everything, so Serve treats it as a configuration error and declines to start.

func WithServerOption

func WithServerOption(opts ...grpc.ServerOption) Option

WithServerOption adds gRPC server options, and is the escape hatch for anything this package does not name: TLS via grpc.Creds, a stats handler, a raised limit.

Options accumulate, and Serve starts with insecure credentials, 128 concurrent streams, a 1MiB receive limit sized for key material rather than payloads, and keepalive settings that floor client ping intervals. An option given here is applied after those, so it wins for any setting gRPC resolves to one value: serving TLS is grpc.Creds(credentials.NewTLS(...)), with nothing to clear first.

Interceptor order is visible. The guard WithServerAuth installs is chained ahead of anything added here, so a grpc.ChainUnaryInterceptor sees only admitted calls. grpc.UnaryInterceptor is gRPC's own exception: prepended ahead of the whole chain, it observes calls about to be rejected.

func WithShutdownTimeout

func WithShutdownTimeout(t time.Duration) Option

WithShutdownTimeout bounds how long shutdown waits for in-flight calls before dropping connections. It defaults to five seconds and is clamped to a 50ms floor, so a zero or negative value still lets an already-answered call flush. Set it below the grace period of whatever supervises the process; overrunning that trades the graceful shutdown for a SIGKILL.

A client watching the health service holds the drain open until this expires, because a Watch ends with its stream rather than with the status going NOT_SERVING. Expect the bound to be reached, not merely available, wherever something watches.

type SealRequest added in v0.7.0

type SealRequest struct {
	// Namespace is the pre-translation (local) namespace the DEK belongs to.
	Namespace string

	// DEK is the key to seal. It is key material, never a payload.
	DEK []byte
}

SealRequest names the DEK a KeySealer is being asked to seal. It carries no opaque bytes: the sealer chooses those and reports them in SealedKey.

type SealedKey added in v0.7.0

type SealedKey struct {
	// Ciphertext is the sealed DEK, in whatever form the key service returned.
	Ciphertext []byte

	// Version identifies the key that sealed Ciphertext, so a key service that
	// rotates can find the same one again. Empty means it does not version.
	Version string

	// Opaque belongs to the sealer. Nothing here reads it.
	Opaque []byte
}

SealedKey is what a KeySealer produced, and everything it gets back when asked to open the same material again. Neither slice is copied, so a sealer handing back a pooled buffer corrupts material it has already returned.

Jump to

Keyboard shortcuts

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