diff --git a/api/ext/v1/key_material.proto b/api/ext/v1/key_material.proto new file mode 100644 index 0000000..f272bd8 --- /dev/null +++ b/api/ext/v1/key_material.proto @@ -0,0 +1,41 @@ +syntax = "proto3"; + +package api.ext.v1; + +option go_package = "github.com/temporalio/temporal-proxy/pkg/api/ext/v1;ext"; + +// KeyMaterial is an optional framing for the ciphertext an extension server +// returns from Encrypt. Decrypt is handed nothing but that ciphertext, so +// anything the server needs to find the wrapping key again has to travel inside +// it. Rather than hand-roll a binary frame, a server can marshal this message as +// its ciphertext and unmarshal it on the way back. +// +// The fields other than encrypted_dek are metadata in the clear: this is +// framing, not encryption. A namespace is not a secret (it already travels in +// gRPC metadata), but a server that would rather not expose one can leave +// namespace empty and identify its key through opaque instead. +// +// Every byte of this message rides in the metadata of each payload it seals, +// and stays there for as long as the Workflow retention period. Carry what +// decrypt needs to find the key, and nothing else. +message KeyMaterial { + // encrypted_dek is the wrapped DEK, as produced by whatever wrapping the + // extension server performs. It is the one field that must be set. + bytes encrypted_dek = 1; + // version identifies which version of the wrapping key sealed + // encrypted_dek, so a server that rotates key material can select the same + // version again on decrypt. Empty means the server does not version its + // keys. + string version = 2; + // namespace is the pre-translation (local) namespace the DEK belongs to, + // copied from EncryptRequest.namespace. A server that derives a key per + // namespace needs it to derive the same key on decrypt, where the request no + // longer carries it. Empty means the server does not key off the namespace. + string namespace = 3; + // opaque belongs to the extension server. Neither the proxy nor this message + // gives it any meaning: it is round-tripped untouched, so a server that needs + // more than version and namespace to find its key can put its own encoding + // here instead of replacing this framing wholesale. Whatever goes in owns its + // own compatibility, since the proxy cannot migrate what it cannot read. + bytes opaque = 4; +} diff --git a/examples/kms/README.md b/examples/kms/README.md index 41f4172..b6ac7e3 100644 --- a/examples/kms/README.md +++ b/examples/kms/README.md @@ -35,9 +35,9 @@ go run ./gencerts ``` ```text -2026/07/29 15:31:46 wrote certs/ca.pem -2026/07/29 15:31:46 wrote certs/server.pem -2026/07/29 15:31:46 wrote certs/server-key.pem +2026/09/16 10:28:36 wrote certs/ca.pem +2026/09/16 10:28:36 wrote certs/server.pem +2026/09/16 10:28:36 wrote certs/server-key.pem ``` This writes a throwaway certificate authority and a server certificate for the extension server into `certs/` @@ -72,7 +72,7 @@ KMS_API_KEY=example-token KMS_MASTER_SECRET=example-master-secret go run ./serve ``` ```text -2026/07/29 15:26:00 extension server listening on 127.0.0.1:9443 over TLS +{"level":"info","component":"examples","addr":"127.0.0.1:9443","time":"2026-09-16T10:28:52-04:00","message":"Starting extension server"} ``` Terminal 3, from the repository root, starts the proxy: @@ -82,12 +82,12 @@ KMS_API_KEY=example-token go run ./cmd/proxy serve -c examples/kms/config.yaml ``` ```text -{"level":"info","namespace":"default","uri":"extension://kms/payloads","time":"2026-07-29T15:26:15-04:00","message":"Registering crypto key"} -{"level":"info","component":"metrics","addr":":9090","time":"2026-07-29T15:26:15-04:00","message":"Starting metrics server"} -{"level":"warn","addr":"/var/folders/6m/x_q_q9nx6ns3ygnf48xzqrn80000gn/T/127-0-0-1-7233-6689a1b6.sock","time":"2026-07-29T15:26:15-04:00","message":"Running with insecure credentials. Configure TLS for production use."} -{"level":"info","addr":"/var/folders/6m/x_q_q9nx6ns3ygnf48xzqrn80000gn/T/127-0-0-1-7233-6689a1b6.sock","time":"2026-07-29T15:26:15-04:00","message":"Starting the server"} -{"level":"warn","addr":"127.0.0.1:7234","time":"2026-07-29T15:26:15-04:00","message":"Running with insecure credentials. Configure TLS for production use."} -{"level":"info","addr":"127.0.0.1:7234","time":"2026-07-29T15:26:15-04:00","message":"Starting the server"} +{"level":"info","namespace":"default","uri":"extension://kms/payloads","time":"2026-09-16T10:29:17-04:00","message":"Registering crypto key"} +{"level":"warn","addr":"/var/folders/6m/x_q_q9nx6ns3ygnf48xzqrn80000gn/T/127-0-0-1-7233-6689a1b6.sock","time":"2026-09-16T10:29:17-04:00","message":"Running with insecure credentials. Configure TLS for production use."} +{"level":"info","addr":"/var/folders/6m/x_q_q9nx6ns3ygnf48xzqrn80000gn/T/127-0-0-1-7233-6689a1b6.sock","time":"2026-09-16T10:29:17-04:00","message":"Starting the server"} +{"level":"warn","addr":"127.0.0.1:7234","time":"2026-09-16T10:29:17-04:00","message":"Running with insecure credentials. Configure TLS for production use."} +{"level":"info","addr":"127.0.0.1:7234","time":"2026-09-16T10:29:17-04:00","message":"Starting the server"} +{"level":"info","component":"metrics","addr":":9090","time":"2026-09-16T10:29:17-04:00","message":"Starting metrics server"} ``` The two `Running with insecure credentials` warnings are expected, not a sign anything is broken: they describe the @@ -102,7 +102,7 @@ go run ./worker ``` ```text -2026/07/29 15:26:27 worker listening on task queue "kms-example" (namespace "default") +2026/09/16 10:29:29 worker listening on task queue "kms-example" (namespace "default") ``` With all four running, start the Workflow from `examples/kms`: @@ -112,7 +112,7 @@ go run ./starter ``` ```text -2026/07/29 15:26:37 started workflow id=kms-example-greeting runID=019faf57-d74f-7119-b9ff-70e7740fcfab +2026/09/16 10:29:41 started workflow id=kms-example-greeting runID=01a0aa9f-9b16-7908-946b-026931b0425d Hello, Temporal! ``` @@ -152,21 +152,21 @@ temporal workflow show --workflow-id kms-example-greeting --namespace default -- ```text Progress: ID Time Type - 1 2026-07-29T19:26:37Z WorkflowExecutionStarted - 2 2026-07-29T19:26:37Z WorkflowTaskScheduled - 3 2026-07-29T19:26:37Z WorkflowTaskStarted - 4 2026-07-29T19:26:37Z WorkflowTaskCompleted - 5 2026-07-29T19:26:37Z ActivityTaskScheduled - 6 2026-07-29T19:26:37Z ActivityTaskStarted - 7 2026-07-29T19:26:37Z ActivityTaskCompleted - 8 2026-07-29T19:26:37Z WorkflowTaskScheduled - 9 2026-07-29T19:26:37Z WorkflowTaskStarted - 10 2026-07-29T19:26:37Z WorkflowTaskCompleted - 11 2026-07-29T19:26:37Z WorkflowExecutionCompleted + 1 2026-09-16T14:29:41Z WorkflowExecutionStarted + 2 2026-09-16T14:29:41Z WorkflowTaskScheduled + 3 2026-09-16T14:29:41Z WorkflowTaskStarted + 4 2026-09-16T14:29:41Z WorkflowTaskCompleted + 5 2026-09-16T14:29:41Z ActivityTaskScheduled + 6 2026-09-16T14:29:41Z ActivityTaskStarted + 7 2026-09-16T14:29:41Z ActivityTaskCompleted + 8 2026-09-16T14:29:41Z WorkflowTaskScheduled + 9 2026-09-16T14:29:41Z WorkflowTaskStarted + 10 2026-09-16T14:29:41Z WorkflowTaskCompleted + 11 2026-09-16T14:29:41Z WorkflowExecutionCompleted Results: Status COMPLETED - Result {"metadata":{"encoding":"YmluYXJ5L2VuY3J5cHRlZA==","encryption-dek":"QVFBSFpHVm1ZWFZzZElKVTRMRXZMSTIvVVVjeXJ2Z281M1RKbUxZK2tYRWJRRkVuVEluN1ovU3RLZUdQd0NodmxEc2lINjZZRjkxYVNRWFJPOTh1Qkx6OEl1T3BVQT09","encryption-key-id":"ZXh0ZW5zaW9uOi8va21zL3BheWxvYWRz"},"data":"BKaq27qe7704D5tnl23NI5szspr6NE7FeLk4r/Ur3zPq+XRDBXIuOswCinmTp34+9C5PrFcWrw+t13zfnRDobEy14CKSwT28"} + Result {"metadata":{"encoding":"YmluYXJ5L2VuY3J5cHRlZA==","encryption-dek":"Q2pBR0xmbDdOMGR6ajdVS1prQitIR216SGpRQnhCaElXT2dtWlBIS3M3MERRSXpoaDRGYXNnNHU4VWJjaTZ2S2tnZ1NBVEVhQjJSbFptRjFiSFFpRE9zdDFsZmtZVXZRRUtLNzFRPT0=","encryption-key-id":"ZXh0ZW5zaW9uOi8va21zL3BheWxvYWRz"},"data":"5GOyox0AOodFetx5rzEuzsPBOBpJ1esP4HVaWK6FxGAU8XNg/LZuxtPl8wDYoQ4PKaQRhT2QbbQbf0SxuY+5VSYYn+n2Q7hm"} ResultEncoding binary/encrypted ``` @@ -188,59 +188,91 @@ another way to browse the sealed history without running the CLI twice. ## The cache is working -Across the whole run above, the extension server's log shows a single wrap, and a handful of unwraps: +Across the whole run above, the extension server's log shows a single wrap and a single unwrap: ```text -wrapped a DEK: namespace=default plaintext=32B ciphertext=70B -unwrapped a DEK: ciphertext=70B plaintext=32B -unwrapped a DEK: ciphertext=70B plaintext=32B -unwrapped a DEK: ciphertext=70B plaintext=32B -unwrapped a DEK: ciphertext=70B plaintext=32B -unwrapped a DEK: ciphertext=70B plaintext=32B +{"level":"info","component":"examples","time":"2026-09-16T10:29:41-04:00","message":"Wrapped a DEK"} +{"level":"info","component":"examples","time":"2026-09-16T10:29:41-04:00","message":"Unwrapped DEK"} ``` -The one wrap is the number to watch, and it is the same on every run. The unwrap count is not: expect a few, varying run -to run. The read-side cache is filled after the first miss and has no single-flight, so payloads opened concurrently can -all miss it together and each ask the extension server for the same DEK. +The one wrap is the number to watch, and it is the same on every run. The unwrap count is not: this run needed one, and +reading the whole history back through the proxy afterwards added none, since that DEK was already cached. Expect a few +on a busier run. The read-side cache is filled after the first miss and has no single-flight, so payloads opened +concurrently can all miss it together and each ask the extension server for the same DEK. Meanwhile the Workflow's history carries four payloads sealed under that one DEK: the Workflow input, the Activity's -input and result, and the Workflow's result each carry an `encryption-dek` entry in their metadata. Two separate -settings in `config.yaml` are behind that, and they govern different sides of the exchange. `duration: 1h` is how long -the proxy reuses one sealed-side DEK before wrapping a fresh one, which is why sealing four payloads in this run only -cost one wrap call. `cacheSize: 200` is unrelated to sealing: it bounds a separate LRU of unwrapped DEKs the proxy keeps -for reading history back, keyed by the encrypted DEK it already opened once. `renewBefore: 15m` is how far ahead of -`duration`'s expiry the proxy starts rotating in a replacement sealed-side DEK. +input and result, and the Workflow's result each carry an `encryption-dek` entry in their metadata. Three separate +settings are behind that, and they govern different sides of the exchange. `duration: 1h` is how long the proxy reuses +one sealed-side DEK before wrapping a fresh one, which is why sealing four payloads in this run only cost one wrap call. +`renewBefore: 15m` is how far ahead of `duration`'s expiry the proxy starts rotating in a replacement sealed-side DEK. +`cacheSize` is unrelated to sealing: it bounds a separate LRU of unwrapped DEKs the proxy keeps for reading history +back, keyed by the encrypted DEK it already opened once, and `config.yaml` leaves it commented out to take the default +of 100. ## How the provider works -The whole provider is the `server` command you ran in terminal 2, and almost all of it is one file. The crypto and the -ciphertext framing live in `server/keyring.go`; the gRPC surface, the bearer token check, TLS, and graceful shutdown all -come from `pkg/ext`. `keyring`'s `Wrap` and `Unwrap` are what satisfy `ext.KMS`, and `server/main.go` hands them to -`ext.Serve` alongside the token check and the certificate. Start with `keyring.go` if you are writing one of these -against a real key service: it is the part you have to replace. +The whole provider is the `server` command you ran in terminal 2, and almost all of it is one file. The crypto lives in +`server/keyring.go`; the gRPC surface, the bearer token check, TLS, and graceful shutdown all come from `pkg/ext`. +`keyring`'s `Wrap` and `Unwrap` are what satisfy `ext.KMS`, and `server/main.go` hands them to `ext.Serve` alongside the +token check and the certificate. Start with `keyring.go` if you are writing one of these against a real key service: it +is the part you have to replace. + +`Decrypt` is handed nothing but the ciphertext `Encrypt` returned, no Namespace and no other context, so anything needed +to find the wrapping key again has to travel inside it. Rather than hand-roll a binary frame, this provider returns +`KeyMaterial`, the optional framing the proxy ships for exactly that (`api/ext/v1/key_material.proto`, Go package +`pkg/api/ext/v1`), and unmarshals it on the way back: + +| Field | What this provider puts in it | +| --------------- | ----------------------------------------------------------------------------------------- | +| `encrypted_dek` | the DEK sealed with AES-256-GCM: 48 bytes, 32 of DEK plus a 16-byte tag | +| `version` | `1`, the version `Wrap` stamps; `Unwrap` derives the key from whatever version arrives | +| `namespace` | the local Namespace, which selects the derived key and doubles as the GCM additional data | +| `opaque` | the 12-byte GCM nonce, which has no field of its own and needs none | + +Only `encrypted_dek` is required. The other three are this provider's choices, they travel in the clear, and a provider +that needs none of them can leave them empty. `opaque` is the field to reach for when a key service needs something the +other two cannot express: the proxy round-trips it untouched and gives it no meaning, so whatever goes in owns its own +compatibility. + +Two properties fall out of that table. Because the Namespace is the GCM additional data, key material relabelled with a +different Namespace fails to open rather than silently decrypting under the wrong key. And because the version is an +input to the derivation rather than something `Unwrap` checks, version and Namespace together address one key, the way a +lookup against a real key service would: bumping `currentVersion` seals new payloads under a new key while everything +already sealed still opens, since each payload carries the version that derives its own key. A version that was never +issued is not a special case either. It derives a key that cannot open the material, so it fails authentication like any +other tampering. + +All of this is framing, not encryption, and you can read it. The `encryption-dek` value in the history above is base64 +twice over, once by the CLI and once by the proxy's own payload metadata: -Every ciphertext the provider produces is a self-contained frame: +```bash +echo 'Q2pBR0xmbDdOMGR6ajdVS1prQitIR216SGpRQnhCaElXT2dtWlBIS3M3MERRSXpoaDRGYXNnNHU4VWJjaTZ2S2tnZ1NBVEVhQjJSbFptRjFiSFFpRE9zdDFsZmtZVXZRRUtLNzFRPT0=' | + base64 -d | base64 -d | xxd +``` ```text -[ version: 1 byte ][ namespace length: 2 bytes ][ namespace ][ nonce: 12 bytes ][ ciphertext + GCM tag ] +00000000: 0a30 062d f97b 3747 738f b50a 6640 7e1c .0.-.{7Gs...f@~. +00000010: 69b3 1e34 01c4 1848 58e8 2664 f1ca b3bd i..4...HX.&d.... +00000020: 0340 8ce1 8781 5ab2 0e2e f146 dc8b abca .@....Z....F.... +00000030: 9208 1201 311a 0764 6566 6175 6c74 220c ....1..default". +00000040: eb2d d657 e461 4bd0 10a2 bbd5 .-.W.aK..... ``` -`Decrypt` receives nothing but that ciphertext, no Namespace and no other context, so `Unwrap` has to read the Namespace -back out of the frame before it can derive the matching key. The Namespace also doubles as the GCM additional data, so a -ciphertext relabelled with a different Namespace fails to open rather than silently decrypting under the wrong key. It -is also why the `default` Namespace's ciphertext above is 70 bytes while the `payments` Namespace's, in the optional -section below, is 71: the only difference is one extra byte of Namespace name carried inside the frame. +The version and the Namespace are legible in the right-hand column. That is also why this key material is 76 bytes while +the `payments` Namespace's, in the optional section below, is 77: the only difference is one more byte of Namespace +name. Every byte of it rides in the metadata of each payload it seals, and stays there for as long as the Workflow +retention period, so it should carry what decrypt needs to find the key and nothing else. Three things worth being honest about. First, a Namespace is not secret, but a real provider that would rather not carry -a plaintext tenant name in its ciphertexts could use an opaque key identifier instead and resolve it internally. Second, -the `payloads` segment in `config.yaml`'s key URI (`extension://kms/payloads`) never reaches the extension server; the -provider selects a key by Namespace alone, nothing else. That segment exists only on the proxy's side, as the identifier -it uses to pick the same key policy again when opening a payload later, but it does have to be globally unique across -`default` and every entry in `overrides`: two policies sharing a URI fail proxy startup with a `duplicate key id` error -(see the optional section below). Third, the Namespace the provider receives is always the local, pre-translation -Namespace, never a translated remote name; this example configures no translation so it never comes up here, but a -provider paired with Namespace translation has to key on that same local name, or its per-Namespace keys end up -misaligned with the Namespace a caller actually asked for. +a plaintext tenant name in its ciphertexts can leave `namespace` empty and put an opaque key identifier in `opaque` +instead. Second, the `payloads` segment in `config.yaml`'s key URI (`extension://kms/payloads`) never reaches the +extension server; the provider selects a key by Namespace alone, nothing else. That segment exists only on the proxy's +side, as the identifier it uses to pick the same key policy again when opening a payload later, but it does have to be +globally unique across `default` and every entry in `overrides`: two policies sharing a URI fail proxy startup with a +`duplicate key id` error (see the optional section below). Third, the Namespace the provider receives is always the +local, pre-translation Namespace, never a translated remote name; this example configures no translation so it never +comes up here, but a provider paired with Namespace translation has to key on that same local name, or its per-Namespace +keys end up misaligned with the Namespace a caller actually asked for. ## When it breaks @@ -292,7 +324,9 @@ This provider is a teaching aid, not a key manager: passphrase. A passphrase here is directly brute-forceable, and recovering it yields every per-Namespace key at once. This example's own value, `example-master-secret`, is a passphrase, and that is fine only because this is a localhost demo, not something to copy into a real deployment; -- nothing on the provider side rotates: the same secret derives the same per-Namespace key forever; and +- nothing rotates on its own: `currentVersion` is a constant, so the same secret derives the same per-Namespace key + until someone edits it. The version in the key material is what would make a real rotation survivable, since payloads + sealed under an earlier version keep opening; and - losing that secret loses every payload ever sealed under it, with no recovery path. Also worth flagging: the extension server here authenticates only with a bearer token over TLS, which is the right @@ -339,7 +373,6 @@ distinct key for `payments`: ```yaml encryption: enabled: true - cacheSize: 200 default: uri: extension://kms/payloads duration: 1h @@ -361,15 +394,31 @@ temporal workflow start --address 127.0.0.1:7234 --namespace payments --task-que --type GreetingWorkflow --workflow-id kms-payments-demo --input '"Payments"' ``` -The extension server's log now shows a second, independent key coming into play, appended below the `default` line left -over from the earlier run in "The cache is working" above: +The extension server's log shows a second wrap, appended below the pair from the earlier run in "The cache is working" +above: + +```text +{"level":"info","component":"examples","time":"2026-09-16T10:31:40-04:00","message":"Wrapped a DEK"} +``` + +The log alone cannot tell you those two wraps used different keys, since it says nothing about which Namespace asked. +The key material can: + +```bash +temporal workflow show --workflow-id kms-payments-demo --namespace payments --address 127.0.0.1:7233 -o json | + grep -o '"encryption-dek": *"[^"]*"' | head -1 | sed 's/.*: *"\(.*\)"/\1/' | base64 -d | base64 -d | xxd +``` ```text -wrapped a DEK: namespace=default plaintext=32B ciphertext=70B -wrapped a DEK: namespace=payments plaintext=32B ciphertext=71B +00000000: 0a30 7165 25c7 8741 433d 87f7 c67b 76e1 .0qe%..AC=...{v. +00000010: 0fda 495f 1521 e650 b191 4105 b3f0 88ac ..I_.!.P..A..... +00000020: 33b5 8a9c a1cf fff6 edc6 05bc 0647 52e9 3............GR. +00000030: b293 1201 311a 0870 6179 6d65 6e74 7322 ....1..payments" +00000040: 0c4c 720e a509 b212 9ee7 6c4a 1a .Lr.......lJ. ``` -Same master secret, same extension server connection, but a different derived key per Namespace. Clean up with: +The `namespace` field reads `payments` rather than `default`, one byte longer, and that is what selects the derived key. +Same master secret, same extension server connection, but a different key per Namespace. Clean up with: ```bash temporal workflow terminate --workflow-id kms-payments-demo --namespace payments --address 127.0.0.1:7234 diff --git a/examples/kms/server/keyring.go b/examples/kms/server/keyring.go index 9d2a195..c311283 100644 --- a/examples/kms/server/keyring.go +++ b/examples/kms/server/keyring.go @@ -7,31 +7,28 @@ import ( "crypto/hkdf" "crypto/rand" "crypto/sha256" - "encoding/binary" "errors" "fmt" - "math" + + extv1 "github.com/temporalio/temporal-proxy/pkg/api/ext/v1" ) const ( - // formatVersion prefixes every ciphertext so a later change to the framing is - // rejected rather than mis-parsed. - formatVersion = 0x01 - - // headerSize is the fixed part of the frame: the version byte plus the uint16 - // namespace length that follows it. - headerSize = 3 + // currentVersion is the wrapping key version Wrap stamps into new key + // material. Unwrap derives from whatever version it is handed, so bumping + // this rotates new payloads without stranding the ones already sealed. + currentVersion = "1" // keySize selects AES-256. keySize = 32 // infoPrefix domain-separates these derived keys from any other use of the // same master secret. - infoPrefix = "temporal-proxy-kek/v1/" + infoPrefix = "temporal-proxy-kek" ) -// keyring derives one wrapping key per namespace from a master secret, so a -// compromise of one namespace's key does not hand over the others. +// keyring derives one wrapping key per version and namespace from a master +// secret, so a compromise of one namespace's key does not hand over the others. type keyring struct { secret []byte } @@ -52,78 +49,83 @@ func newKeyring(secret []byte) (*keyring, error) { return &keyring{secret: secret}, nil } -// Wrap seals dek under the key derived for namespace. +// Wrap seals dek under the key derived for namespace and returns it as +// [extv1.KeyMaterial], which carries everything Unwrap needs to derive the same +// key again. // -// The namespace is written into the frame in the clear because Unwrap is handed -// nothing but ciphertext and has to derive the same key again. It doubles as the -// GCM additional data, so a ciphertext relabelled with another namespace fails -// to open. A namespace is not a secret (it already travels in gRPC metadata), -// but a provider that would rather not expose one should carry an opaque key -// identifier here and resolve it internally. +// The namespace travels in the clear, and doubles as the GCM additional data so +// key material relabelled with another namespace fails to open. A namespace is +// not a secret (it already travels in gRPC metadata), but a provider that would +// rather not expose one can leave the field empty and identify its key through +// opaque instead. func (k *keyring) Wrap(_ context.Context, namespace string, dek []byte) ([]byte, error) { - if len(namespace) > math.MaxUint16 { - return nil, fmt.Errorf("server: namespace is too long to frame: %d bytes", len(namespace)) - } - - gcm, err := k.cipher(namespace) + gcm, err := k.cipher(currentVersion, namespace) if err != nil { return nil, err } - out := make([]byte, 0, headerSize+len(namespace)+gcm.NonceSize()+len(dek)+gcm.Overhead()) - out = append(out, formatVersion) - out = binary.BigEndian.AppendUint16(out, uint16(len(namespace))) - out = append(out, namespace...) - nonce := make([]byte, gcm.NonceSize()) // crypto/rand.Read never returns an error; it crashes the program instead. _, _ = rand.Read(nonce) - out = append(out, nonce...) - - return gcm.Seal(out, nonce, dek, []byte(namespace)), nil -} -// Unwrap opens a ciphertext produced by Wrap, deriving the key from the -// namespace the frame carries. -func (k *keyring) Unwrap(_ context.Context, ciphertext []byte) ([]byte, error) { - if len(ciphertext) < headerSize { - return nil, errors.New("server: ciphertext is too short to hold a header") + material := &extv1.KeyMaterial{ + EncryptedDek: gcm.Seal(nil, nonce, dek, []byte(namespace)), + Version: currentVersion, + Namespace: namespace, + // KeyMaterial has no field for a nonce, and needs none: opaque is where a + // server puts whatever its own wrapping requires. A nonce is not secret, + // only single-use. + Opaque: nonce, } - if ciphertext[0] != formatVersion { - return nil, fmt.Errorf("server: unsupported ciphertext version: %#x", ciphertext[0]) + packed, err := material.Marshal() + if err != nil { + return nil, fmt.Errorf("server: failed to pack key material: %w", err) } - nsLen := int(binary.BigEndian.Uint16(ciphertext[1:headerSize])) - if len(ciphertext) < headerSize+nsLen { - return nil, errors.New("server: ciphertext is truncated inside its namespace") + return packed, nil +} + +// Unwrap opens key material produced by Wrap, deriving the key from the version +// and namespace it carries: the two together address one key, the way a lookup +// against a real key service would. +func (k *keyring) Unwrap(_ context.Context, ciphertext []byte) ([]byte, error) { + material, err := extv1.UnmarshalKeyMaterial(ciphertext) + if err != nil { + return nil, fmt.Errorf("server: %w", err) } - namespace := string(ciphertext[headerSize : headerSize+nsLen]) - sealed := ciphertext[headerSize+nsLen:] + // Unmarshal accepts bytes that set no fields at all, so anything arriving + // from outside gets checked before it is trusted. + if err := material.Validate(); err != nil { + return nil, fmt.Errorf("server: invalid key material: %w", err) + } - gcm, err := k.cipher(namespace) + gcm, err := k.cipher(material.GetVersion(), material.GetNamespace()) if err != nil { return nil, err } - if len(sealed) < gcm.NonceSize() { - return nil, errors.New("server: ciphertext is truncated inside its nonce") + nonce := material.GetOpaque() + if len(nonce) != gcm.NonceSize() { + return nil, fmt.Errorf("server: key material carries a %d-byte nonce, want %d", len(nonce), gcm.NonceSize()) } - dek, err := gcm.Open(nil, sealed[:gcm.NonceSize()], sealed[gcm.NonceSize():], []byte(namespace)) + dek, err := gcm.Open(nil, nonce, material.GetEncryptedDek(), []byte(material.GetNamespace())) if err != nil { - return nil, fmt.Errorf("server: failed to open ciphertext for namespace %q: %w", namespace, err) + return nil, fmt.Errorf("server: failed to open key material for namespace %q: %w", material.GetNamespace(), err) } return dek, nil } -// cipher derives the wrapping key for namespace and returns a GCM cipher over -// it. Derivation is deterministic, so a restarted provider still opens -// ciphertexts sealed before the restart. -func (k *keyring) cipher(namespace string) (cipher.AEAD, error) { - key, err := hkdf.Key(sha256.New, k.secret, nil, infoPrefix+namespace, keySize) +// cipher derives the wrapping key for version and namespace and returns a GCM +// cipher over it. Derivation is deterministic, so a restarted provider still +// opens key material sealed before the restart. +func (k *keyring) cipher(version, namespace string) (cipher.AEAD, error) { + info := fmt.Sprintf("%s/v%s/%s", infoPrefix, version, namespace) + + key, err := hkdf.Key(sha256.New, k.secret, nil, info, keySize) if err != nil { return nil, fmt.Errorf("server: failed to derive a key for namespace %q: %w", namespace, err) } diff --git a/examples/kms/server/main.go b/examples/kms/server/main.go index 40595de..8fe264b 100644 --- a/examples/kms/server/main.go +++ b/examples/kms/server/main.go @@ -8,12 +8,13 @@ // from. Both are required. // // Only the provider itself lives here, in keyring.go, which derives one -// AES-256-GCM key per namespace from the master secret and frames the -// ciphertext. The gRPC surface and the bearer token check come from -// [github.com/temporalio/temporal-proxy/pkg/ext]: keyring's Wrap and Unwrap -// satisfy [ext.KMS], and [ext.Serve] registers them, serves TLS, and shuts down -// on a signal. That split is the point of the example. The interesting part of -// writing one of these is the key handling, not the server around it. +// AES-256-GCM key per version and namespace from the master secret and returns +// the wrapped DEK as api.ext.v1.KeyMaterial. The gRPC surface and the bearer +// token check come from [github.com/temporalio/temporal-proxy/pkg/ext]: +// keyring's Wrap and Unwrap satisfy [ext.KMS], and [ext.Serve] registers them, +// serves TLS, and shuts down on a signal. That split is the point of the +// example. The interesting part of writing one of these is the key handling, +// not the server around it. // // This is enough to show the shape of the contract and it is not a key manager: // the master secret sits in an environment variable, nothing is rotated, and diff --git a/pkg/api/ext/v1/key_material.go b/pkg/api/ext/v1/key_material.go new file mode 100644 index 0000000..56cbaa8 --- /dev/null +++ b/pkg/api/ext/v1/key_material.go @@ -0,0 +1,54 @@ +package ext + +import ( + "errors" + "fmt" + + "google.golang.org/protobuf/proto" + + "github.com/temporalio/temporal-proxy/pkg/validation" +) + +// UnmarshalKeyMaterial decodes key material produced by [KeyMaterial.Marshal]. +// It does not check what it decoded: proto3 happily accepts bytes that set none +// of the fields, so call [KeyMaterial.Validate] on anything whose framing you +// did not produce yourself. +func UnmarshalKeyMaterial(raw []byte) (*KeyMaterial, error) { + km := &KeyMaterial{} + if err := proto.Unmarshal(raw, km); err != nil { + return nil, fmt.Errorf("failed to unmarshal key material: %w", err) + } + + return km, nil +} + +// Marshal validates km and returns its wire encoding, ready to hand back as an +// EncryptResponse ciphertext. +func (km *KeyMaterial) Marshal() ([]byte, error) { + if err := km.Validate(); err != nil { + return nil, err + } + + packed, err := proto.Marshal(km) + if err != nil { + return nil, fmt.Errorf("failed to marshal key material: %w", err) + } + + return packed, nil +} + +// Validate reports whether km carries a wrapped DEK, treating a nil km as one +// that does not. Every other field is optional: an extension server may version +// no keys, key off no namespace, and carry nothing of its own. +func (km *KeyMaterial) Validate() error { + return validation.Validate( + "", + validation.Field("encrypted_dek", km.GetEncryptedDek(), func(v []byte) error { + if len(v) == 0 { + return errors.New("must not be empty") + } + + return nil + }), + ) +} diff --git a/pkg/api/ext/v1/key_material.pb.go b/pkg/api/ext/v1/key_material.pb.go new file mode 100644 index 0000000..a98d434 --- /dev/null +++ b/pkg/api/ext/v1/key_material.pb.go @@ -0,0 +1,182 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: api/ext/v1/key_material.proto + +package ext + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// KeyMaterial is an optional framing for the ciphertext an extension server +// returns from Encrypt. Decrypt is handed nothing but that ciphertext, so +// anything the server needs to find the wrapping key again has to travel inside +// it. Rather than hand-roll a binary frame, a server can marshal this message as +// its ciphertext and unmarshal it on the way back. +// +// The fields other than encrypted_dek are metadata in the clear: this is +// framing, not encryption. A namespace is not a secret (it already travels in +// gRPC metadata), but a server that would rather not expose one can leave +// namespace empty and identify its key through opaque instead. +// +// Every byte of this message rides in the metadata of each payload it seals, +// and stays there for as long as the Workflow retention period. Carry what +// decrypt needs to find the key, and nothing else. +type KeyMaterial struct { + state protoimpl.MessageState `protogen:"open.v1"` + // encrypted_dek is the wrapped DEK, as produced by whatever wrapping the + // extension server performs. It is the one field that must be set. + EncryptedDek []byte `protobuf:"bytes,1,opt,name=encrypted_dek,json=encryptedDek,proto3" json:"encrypted_dek,omitempty"` + // version identifies which version of the wrapping key sealed + // encrypted_dek, so a server that rotates key material can select the same + // version again on decrypt. Empty means the server does not version its + // keys. + Version string `protobuf:"bytes,2,opt,name=version,proto3" json:"version,omitempty"` + // namespace is the pre-translation (local) namespace the DEK belongs to, + // copied from EncryptRequest.namespace. A server that derives a key per + // namespace needs it to derive the same key on decrypt, where the request no + // longer carries it. Empty means the server does not key off the namespace. + Namespace string `protobuf:"bytes,3,opt,name=namespace,proto3" json:"namespace,omitempty"` + // opaque belongs to the extension server. Neither the proxy nor this message + // gives it any meaning: it is round-tripped untouched, so a server that needs + // more than version and namespace to find its key can put its own encoding + // here instead of replacing this framing wholesale. Whatever goes in owns its + // own compatibility, since the proxy cannot migrate what it cannot read. + Opaque []byte `protobuf:"bytes,4,opt,name=opaque,proto3" json:"opaque,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *KeyMaterial) Reset() { + *x = KeyMaterial{} + mi := &file_api_ext_v1_key_material_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *KeyMaterial) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*KeyMaterial) ProtoMessage() {} + +func (x *KeyMaterial) ProtoReflect() protoreflect.Message { + mi := &file_api_ext_v1_key_material_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use KeyMaterial.ProtoReflect.Descriptor instead. +func (*KeyMaterial) Descriptor() ([]byte, []int) { + return file_api_ext_v1_key_material_proto_rawDescGZIP(), []int{0} +} + +func (x *KeyMaterial) GetEncryptedDek() []byte { + if x != nil { + return x.EncryptedDek + } + return nil +} + +func (x *KeyMaterial) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *KeyMaterial) GetNamespace() string { + if x != nil { + return x.Namespace + } + return "" +} + +func (x *KeyMaterial) GetOpaque() []byte { + if x != nil { + return x.Opaque + } + return nil +} + +var File_api_ext_v1_key_material_proto protoreflect.FileDescriptor + +const file_api_ext_v1_key_material_proto_rawDesc = "" + + "\n" + + "\x1dapi/ext/v1/key_material.proto\x12\n" + + "api.ext.v1\"\x82\x01\n" + + "\vKeyMaterial\x12#\n" + + "\rencrypted_dek\x18\x01 \x01(\fR\fencryptedDek\x12\x18\n" + + "\aversion\x18\x02 \x01(\tR\aversion\x12\x1c\n" + + "\tnamespace\x18\x03 \x01(\tR\tnamespace\x12\x16\n" + + "\x06opaque\x18\x04 \x01(\fR\x06opaqueB\xa5\x01\n" + + "\x0ecom.api.ext.v1B\x10KeyMaterialProtoP\x01Z7github.com/temporalio/temporal-proxy/pkg/api/ext/v1;ext\xa2\x02\x03AEX\xaa\x02\n" + + "Api.Ext.V1\xca\x02\n" + + "Api\\Ext\\V1\xe2\x02\x16Api\\Ext\\V1\\GPBMetadata\xea\x02\fApi::Ext::V1b\x06proto3" + +var ( + file_api_ext_v1_key_material_proto_rawDescOnce sync.Once + file_api_ext_v1_key_material_proto_rawDescData []byte +) + +func file_api_ext_v1_key_material_proto_rawDescGZIP() []byte { + file_api_ext_v1_key_material_proto_rawDescOnce.Do(func() { + file_api_ext_v1_key_material_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_api_ext_v1_key_material_proto_rawDesc), len(file_api_ext_v1_key_material_proto_rawDesc))) + }) + return file_api_ext_v1_key_material_proto_rawDescData +} + +var file_api_ext_v1_key_material_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_api_ext_v1_key_material_proto_goTypes = []any{ + (*KeyMaterial)(nil), // 0: api.ext.v1.KeyMaterial +} +var file_api_ext_v1_key_material_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_api_ext_v1_key_material_proto_init() } +func file_api_ext_v1_key_material_proto_init() { + if File_api_ext_v1_key_material_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_ext_v1_key_material_proto_rawDesc), len(file_api_ext_v1_key_material_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_api_ext_v1_key_material_proto_goTypes, + DependencyIndexes: file_api_ext_v1_key_material_proto_depIdxs, + MessageInfos: file_api_ext_v1_key_material_proto_msgTypes, + }.Build() + File_api_ext_v1_key_material_proto = out.File + file_api_ext_v1_key_material_proto_goTypes = nil + file_api_ext_v1_key_material_proto_depIdxs = nil +} diff --git a/pkg/api/ext/v1/key_material_test.go b/pkg/api/ext/v1/key_material_test.go new file mode 100644 index 0000000..7987fef --- /dev/null +++ b/pkg/api/ext/v1/key_material_test.go @@ -0,0 +1,192 @@ +package ext_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/temporalio/temporal-proxy/pkg/api/ext/v1" + "github.com/temporalio/temporal-proxy/pkg/validation" +) + +func TestKeyMaterial_MarshalRoundTrip(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + material *ext.KeyMaterial + }{ + { + name: "every field set", + material: &ext.KeyMaterial{ + EncryptedDek: []byte{0x00, 0x01, 0xff, 0xfe}, + Version: "2", + Namespace: "orders", + Opaque: []byte(`{"keyRing":"codec"}`), + }, + }, + { + name: "a wrapped DEK on its own", + material: &ext.KeyMaterial{EncryptedDek: []byte("wrapped")}, + }, + { + // A server that identifies its key entirely through opaque leaves + // both documented fields empty. + name: "a wrapped DEK identified only by opaque", + material: &ext.KeyMaterial{ + EncryptedDek: []byte("wrapped"), + Opaque: []byte{0x7f, 0x00, 0xff}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + packed, err := tt.material.Marshal() + require.NoError(t, err) + require.NotEmpty(t, packed) + + got, err := ext.UnmarshalKeyMaterial(packed) + require.NoError(t, err) + require.Equal(t, tt.material.EncryptedDek, got.EncryptedDek) + require.Equal(t, tt.material.Version, got.Version) + require.Equal(t, tt.material.Namespace, got.Namespace) + require.Equal(t, tt.material.Opaque, got.Opaque) + }) + } +} + +func TestKeyMaterial_MarshalRejectsMissingDEK(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + material *ext.KeyMaterial + }{ + { + name: "no wrapped DEK", + material: &ext.KeyMaterial{Namespace: "orders"}, + }, + { + name: "no key material at all", + material: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + packed, err := tt.material.Marshal() + require.Nil(t, packed) + + var errs validation.Errors + require.True(t, errors.As(err, &errs), "expected validation.Errors, got %T", err) + require.Equal(t, validation.Errors{ + {Field: "encrypted_dek", Message: "must not be empty"}, + }, errs) + }) + } +} + +func TestUnmarshalKeyMaterial(t *testing.T) { + t.Parallel() + + t.Run("rejects bytes that are not key material", func(t *testing.T) { + t.Parallel() + + // Field number 0 is not legal on the wire, so this can never be any + // message. + got, err := ext.UnmarshalKeyMaterial([]byte{0x00, 0x01, 0x02}) + require.Error(t, err) + require.Nil(t, got) + }) + + t.Run("accepts empty input, which then fails Validate", func(t *testing.T) { + t.Parallel() + + // proto3 cannot tell "no fields set" from "not key material at all", + // which is why Unmarshal leaves the checking to Validate. + got, err := ext.UnmarshalKeyMaterial(nil) + require.NoError(t, err) + require.Empty(t, got.EncryptedDek) + require.Error(t, got.Validate()) + }) +} + +func TestKeyMaterial_Validate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + material *ext.KeyMaterial + wantErrs []validation.Error + }{ + { + name: "every field set", + material: &ext.KeyMaterial{ + EncryptedDek: []byte("wrapped"), + Version: "2", + Namespace: "orders", + Opaque: []byte("mine"), + }, + }, + { + name: "a wrapped DEK on its own", + material: &ext.KeyMaterial{EncryptedDek: []byte("wrapped")}, + }, + { + name: "no wrapped DEK", + material: &ext.KeyMaterial{Version: "2", Namespace: "orders"}, + wantErrs: []validation.Error{ + {Field: "encrypted_dek", Message: "must not be empty"}, + }, + }, + { + // An empty slice is as unusable as a missing one, so length is what + // gets checked rather than nil-ness. + name: "an empty wrapped DEK", + material: &ext.KeyMaterial{EncryptedDek: []byte{}}, + wantErrs: []validation.Error{ + {Field: "encrypted_dek", Message: "must not be empty"}, + }, + }, + { + // opaque carries no meaning here, so it cannot stand in for the + // wrapped DEK. + name: "opaque without a wrapped DEK", + material: &ext.KeyMaterial{Opaque: []byte("mine")}, + wantErrs: []validation.Error{ + {Field: "encrypted_dek", Message: "must not be empty"}, + }, + }, + { + // A nil message reports the same failure rather than panicking, so + // a caller can validate whatever it was handed. + name: "no key material at all", + material: nil, + wantErrs: []validation.Error{ + {Field: "encrypted_dek", Message: "must not be empty"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + err := tt.material.Validate() + if len(tt.wantErrs) == 0 { + require.NoError(t, err) + return + } + + var errs validation.Errors + require.True(t, errors.As(err, &errs), "expected validation.Errors, got %T", err) + require.ElementsMatch(t, tt.wantErrs, []validation.Error(errs)) + }) + } +}