diff --git a/README.md b/README.md
index aa51a98..d86cfd6 100644
--- a/README.md
+++ b/README.md
@@ -143,6 +143,9 @@ The [Temporal Cloud example](examples/cloud) is the quickest way to see the prox
carry no Cloud configuration talk plaintext to `localhost:7233`, and the proxy adds TLS, the API key, and the Namespace
rewrite on the way to Cloud. Follow its README to run it end to end.
+The [KMS extension server example](examples/kms) shows the pluggable key management path end to end: a local dev server,
+a key provider you run, and workflow payloads that the Temporal Service only ever stores as ciphertext.
+
## Terms
| Term | Meaning |
diff --git a/examples/cloud/go.mod b/examples/cloud/go.mod
deleted file mode 100644
index 9015b13..0000000
--- a/examples/cloud/go.mod
+++ /dev/null
@@ -1,32 +0,0 @@
-module github.com/temporalio/temporal-proxy/examples/cloud
-
-go 1.26.4
-
-require go.temporal.io/sdk v1.46.0
-
-require (
- github.com/davecgh/go-spew v1.1.1 // indirect
- github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect
- github.com/gogo/protobuf v1.3.2 // indirect
- github.com/golang/mock v1.6.0 // indirect
- github.com/google/uuid v1.6.0 // indirect
- github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect
- github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 // indirect
- github.com/nexus-rpc/nexus-proto-annotations v0.1.0 // indirect
- github.com/nexus-rpc/sdk-go v0.6.0 // indirect
- github.com/pmezard/go-difflib v1.0.0 // indirect
- github.com/robfig/cron v1.2.0 // indirect
- github.com/stretchr/objx v0.5.2 // indirect
- github.com/stretchr/testify v1.10.0 // indirect
- go.temporal.io/api v1.63.0 // indirect
- golang.org/x/net v0.55.0 // indirect
- golang.org/x/sync v0.20.0 // indirect
- golang.org/x/sys v0.45.0 // indirect
- golang.org/x/text v0.37.0 // indirect
- golang.org/x/time v0.3.0 // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
- google.golang.org/grpc v1.82.1 // indirect
- google.golang.org/protobuf v1.36.11 // indirect
- gopkg.in/yaml.v3 v3.0.1 // indirect
-)
diff --git a/examples/go.mod b/examples/go.mod
new file mode 100644
index 0000000..758fd54
--- /dev/null
+++ b/examples/go.mod
@@ -0,0 +1,40 @@
+module github.com/temporalio/temporal-proxy/examples
+
+go 1.26.4
+
+// The examples build against this working tree because pkg/api/kms/v1, which the
+// kms example serves, is not in a released tag yet. Outside this repository, drop
+// this line and require a released version of the proxy instead.
+replace github.com/temporalio/temporal-proxy => ../
+
+require (
+ github.com/temporalio/temporal-proxy v0.2.0
+ go.temporal.io/sdk v1.47.0
+ google.golang.org/grpc v1.82.1
+)
+
+require (
+ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
+ github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect
+ github.com/gogo/protobuf v1.3.2 // indirect
+ github.com/golang/mock v1.6.0 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
+ github.com/nexus-rpc/nexus-proto-annotations v0.1.0 // indirect
+ github.com/nexus-rpc/sdk-go v0.6.0 // indirect
+ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
+ github.com/robfig/cron v1.2.0 // indirect
+ github.com/stretchr/objx v0.5.3 // indirect
+ github.com/stretchr/testify v1.11.1 // indirect
+ go.temporal.io/api v1.63.4 // indirect
+ golang.org/x/net v0.57.0 // indirect
+ golang.org/x/sync v0.22.0 // indirect
+ golang.org/x/sys v0.47.0 // indirect
+ golang.org/x/text v0.40.0 // indirect
+ golang.org/x/time v0.15.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260729162451-8efbd57d26e0 // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 // indirect
+ google.golang.org/protobuf v1.36.11 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/examples/cloud/go.sum b/examples/go.sum
similarity index 75%
rename from examples/cloud/go.sum
rename to examples/go.sum
index 299c4ce..ffc3e89 100644
--- a/examples/cloud/go.sum
+++ b/examples/go.sum
@@ -1,7 +1,7 @@
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
-github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a h1:yDWHCSQ40h88yih2JAcL6Ls/kVkSE8GFACTGVnMPruw=
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a/go.mod h1:7Ga40egUymuWXxAe151lTNnCv97MddSOVsjpPPkityA=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
@@ -20,8 +20,12 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0=
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0 h1:asbCHRVmodnJTuQ3qamDwqVOIjwqUPTYmYuemVOx+Ys=
-github.com/grpc-ecosystem/grpc-gateway/v2 v2.22.0/go.mod h1:ggCgvZ2r7uOoQjOyu2Y1NhHmEPPzzuhWgcza5M1Ji1I=
+github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns=
+github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -32,16 +36,18 @@ github.com/nexus-rpc/nexus-proto-annotations v0.1.0 h1:2fELd+9sqUtNu6Fg//pw8YFsx
github.com/nexus-rpc/nexus-proto-annotations v0.1.0/go.mod h1:n3UjF1bPCW8llR8tHvbxJ+27yPWrhpo8w/Yg1IOuY0Y=
github.com/nexus-rpc/sdk-go v0.6.0 h1:QRgnP2zTbxEbiyWG/aXH8uSC5LV/Mg1fqb19jb4DBlo=
github.com/nexus-rpc/sdk-go v0.6.0/go.mod h1:FHdPfVQwRuJFZFTF0Y2GOAxCrbIBNrcPna9slkGKPYk=
-github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
-github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
+github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
@@ -57,10 +63,10 @@ go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfC
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
-go.temporal.io/api v1.63.0 h1:YZFOTA0/thRUIUC4qunAWdHhPh/IG4vy/+WjfEvT+ZE=
-go.temporal.io/api v1.63.0/go.mod h1:0k75tRljEuELWGeXjEZZO7zYqBln4+1FrG6+IMOMy7Q=
-go.temporal.io/sdk v1.46.0 h1:zD2l907+4iVkLsnJZwFj/oIIjYsoqyjsHlKO/3tDKoU=
-go.temporal.io/sdk v1.46.0/go.mod h1:x3v/9ImVh469kiHspoq1xgLdPnetbfuCAm+Y1+sUtIo=
+go.temporal.io/api v1.63.4 h1:p4dVIAP3dJop0MfcyH9QSzjU7+V/ttLDhxFhSRUar58=
+go.temporal.io/api v1.63.4/go.mod h1:SrlW2JMwVlDP4nRWSNznUFqnSHd+YeMDS1BkYo63HCQ=
+go.temporal.io/sdk v1.47.0 h1:lZ39w1+uWSjHTL0F3mSc0t4XUnKX8CCcWxqSiLbaHnc=
+go.temporal.io/sdk v1.47.0/go.mod h1:ilKs0twgP4JpP8pfhIgZumnOEyBiYn6ZO/ta//NnKMU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
@@ -72,29 +78,29 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
-golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
-golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
+golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
+golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
-golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
+golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
-golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
+golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
-golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
-golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
-golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
+golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
@@ -108,8 +114,12 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
+google.golang.org/genproto/googleapis/api v0.0.0-20260729162451-8efbd57d26e0 h1:ybvH/ZpOcpCrjtkb7oW/fdlzbEmRVeumw19SRQmNFKU=
+google.golang.org/genproto/googleapis/api v0.0.0-20260729162451-8efbd57d26e0/go.mod h1:HJ9MpJLeDSstBkx1LILTpd5f41ADSMZcTPypw02qEGw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0 h1:mJiOtnGp0k/BcSgdu03G2NwnscCfCH+h2QKUBZr18KI=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260729162451-8efbd57d26e0/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
diff --git a/examples/kms/.gitignore b/examples/kms/.gitignore
new file mode 100644
index 0000000..c5c0df8
--- /dev/null
+++ b/examples/kms/.gitignore
@@ -0,0 +1,2 @@
+# Generated by "go run ./gencerts". Throwaway key material, never committed.
+certs/
diff --git a/examples/kms/README.md b/examples/kms/README.md
new file mode 100644
index 0000000..b82f381
--- /dev/null
+++ b/examples/kms/README.md
@@ -0,0 +1,374 @@
+# KMS extension server example
+
+This example encrypts every payload the proxy moves to a Temporal Service, the same envelope encryption the Cloud
+example uses, but the key that wraps each data encryption key (DEK) lives in a small gRPC service you run yourself
+instead of a cloud KMS. The proxy talks to that service, an extension server, only to wrap and unwrap DEKs; it never
+sends payload plaintext there.
+
+```mermaid
+flowchart LR
+ client["worker / starter
plaintext gRPC
cleartext payloads"]
+ proxy["proxy (local)
gateway :7234"]
+ dev["temporal server start-dev
:7233"]
+ kms["extension server
go run ./server
:9443"]
+
+ client -->|"cleartext payloads"| proxy
+ proxy -->|"sealed payloads"| dev
+ proxy -.->|"wrap/unwrap DEK over TLS + Bearer
key material only, never payloads"| kms
+```
+
+## Prerequisites
+
+- Go and a checkout of this repository; the proxy and the example both run from source.
+- The `temporal` CLI, for the dev server and for inspecting workflow history.
+- No cloud account and no credentials: everything in this example runs on localhost.
+
+Everything under `examples` is one Go module, and its `go.mod` builds the proxy from this working tree with a `replace`
+directive; if you copy this example into another tree, drop that `replace` line and require a released proxy version
+instead.
+
+## Generate certificates
+
+```bash
+cd examples/kms
+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
+```
+
+This writes a throwaway certificate authority and a server certificate for the extension server into `certs/`
+(gitignored). The CA's private key is discarded right after it signs the server certificate, so nothing else can ever be
+signed by it, and the CA is never installed in any system trust store; the proxy is pointed at the CA file directly
+through `config.yaml`.
+
+## Environment
+
+```bash
+export KMS_API_KEY=example-token
+export KMS_MASTER_SECRET=example-master-secret
+```
+
+The proxy only needs `KMS_API_KEY`, the bearer token it presents to the extension server. The extension server needs
+both: the same API key to authenticate callers, and the master secret every namespace's wrapping key is derived from.
+
+## Run
+
+Open four terminals.
+
+Terminal 1, in `examples/kms`, starts the dev server:
+
+```bash
+temporal server start-dev
+```
+
+Terminal 2, in `examples/kms`, starts the extension server:
+
+```bash
+KMS_API_KEY=example-token KMS_MASTER_SECRET=example-master-secret go run ./server
+```
+
+```text
+2026/07/29 15:26:00 extension server listening on 127.0.0.1:9443 over TLS
+```
+
+Terminal 3, from the repository root, starts the proxy:
+
+```bash
+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"}
+```
+
+The two `Running with insecure credentials` warnings are expected, not a sign anything is broken: they describe the
+local gateway and an internal socket, the plaintext hops between the worker/starter and the proxy on this machine. They
+say nothing about whether payloads get sealed or about the TLS connection to the extension server; both of those are
+unaffected.
+
+Terminal 4, in `examples/kms`, starts the worker:
+
+```bash
+go run ./worker
+```
+
+```text
+2026/07/29 15:26:27 worker listening on task queue "kms-example" (namespace "default")
+```
+
+With all four running, start the workflow from `examples/kms`:
+
+```bash
+go run ./starter
+```
+
+```text
+2026/07/29 15:26:37 started workflow id=kms-example-greeting runID=019faf57-d74f-7119-b9ff-70e7740fcfab
+Hello, Temporal!
+```
+
+Your run ID and timestamps will differ.
+
+## What just happened
+
+The worker and starter never saw an encryption key: they dialed the proxy at `127.0.0.1:7234` in plaintext and exchanged
+the plain string `"Temporal"` and the plain string `"Hello, Temporal!"`. For the workflow's first payload, the proxy:
+
+1. generated a random 32-byte DEK;
+2. called the extension server's `Encrypt` RPC over TLS, with a bearer token, asking it to wrap that DEK under the
+ namespace `default` - this is the only thing that crossed the wire to `127.0.0.1:9443`, no payload plaintext ever
+ went there;
+3. sealed the payload locally with the DEK and recorded the wrapped DEK, and the key's URI, in the payload's metadata;
+ and
+4. sent the sealed payload on to the dev server at `127.0.0.1:7233`.
+
+Every later payload in the same run, the activity's input and result and the workflow's own result, reused the cached
+DEK instead of asking the extension server to wrap a new one; see "The cache is working" below.
+
+## See the ciphertext
+
+> [!NOTE]
+>
+> If `TEMPORAL_API_KEY` is set in your shell, perhaps from running the Cloud example, the commands below fail with
+> `tls: first record does not look like a TLS handshake`. The CLI turns on TLS whenever an API key is present, and
+> everything here is plaintext on localhost. Unset it for this shell, or prefix each command with
+> `env -u TEMPORAL_API_KEY`.
+
+Straight to the dev server, bypassing the proxy, the payload is still sealed:
+
+```bash
+temporal workflow show --workflow-id kms-example-greeting --namespace default --address 127.0.0.1:7233
+```
+
+```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
+
+Results:
+ Status COMPLETED
+ Result {"metadata":{"encoding":"YmluYXJ5L2VuY3J5cHRlZA==","encryption-dek":"QVFBSFpHVm1ZWFZzZElKVTRMRXZMSTIvVVVjeXJ2Z281M1RKbUxZK2tYRWJRRkVuVEluN1ovU3RLZUdQd0NodmxEc2lINjZZRjkxYVNRWFJPOTh1Qkx6OEl1T3BVQT09","encryption-key-id":"ZXh0ZW5zaW9uOi8va21zL3BheWxvYWRz"},"data":"BKaq27qe7704D5tnl23NI5szspr6NE7FeLk4r/Ur3zPq+XRDBXIuOswCinmTp34+9C5PrFcWrw+t13zfnRDobEy14CKSwT28"}
+ ResultEncoding binary/encrypted
+```
+
+Through the proxy, the same execution comes back in the clear (only the `Results` block is shown below):
+
+```bash
+temporal workflow show --workflow-id kms-example-greeting --namespace default --address 127.0.0.1:7234
+```
+
+```text
+Results:
+ Status COMPLETED
+ Result "Hello, Temporal!"
+ ResultEncoding json/plain
+```
+
+The Web UI at shows the same completed execution directly against the dev server, so it is
+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:
+
+```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
+```
+
+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.
+
+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.
+
+## How the provider works
+
+The whole provider is the `server` command you ran in terminal 2. The crypto and the ciphertext framing live in
+`server/keyring.go`, the gRPC surface in `server/service.go`, and the bearer token check in `server/interceptor.go`.
+Start there if you are writing one of these against a real key service.
+
+Every ciphertext the provider produces is a self-contained frame:
+
+```text
+[ version: 1 byte ][ namespace length: 2 bytes ][ namespace ][ nonce: 12 bytes ][ ciphertext + GCM tag ]
+```
+
+`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.
+
+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.
+
+## When it breaks
+
+**Wrong credentials.** Restarting the extension server with a different `KMS_API_KEY` while the proxy still holds the
+old one does not fail right away: the proxy caches DEKs, so a run within the cache window keeps succeeding without
+another call to the extension server. Left alone, that window closes on its own after roughly 45 minutes (the `1h`
+`duration` minus the `15m` `renewBefore` head start), when the proxy wraps a fresh DEK and finally calls an extension
+server that no longer accepts the old credential; a broken credential can stay invisible for most of an hour. To see the
+failure immediately instead of waiting, restart the proxy, which clears the cache and forces a fresh `Encrypt` call,
+which the extension server now rejects:
+
+```text
+start workflow: rpc error: code = Unauthenticated desc = failed to encrypt payload: failed to encrypt message in
+namespace: default, failed to encrypt DEK, id: extension://kms/payloads, err: rpc error: code = Unauthenticated
+desc = invalid credentials
+```
+
+The workflow never starts; no execution shows up in `temporal workflow list`. The error surfaces only on the starter's
+side. The proxy's own log stays silent about it.
+
+**Wrong certificate name.** Removing `serverName: localhost` from `config.yaml`'s extension server block does not stop
+the proxy from starting: nothing dials the extension server until the first payload needs a key, so a mismatched server
+name is invisible at boot. The first workflow start after that just hangs and times out, rather than failing with a
+clear error:
+
+```text
+start workflow: context deadline exceeded
+```
+
+Again the proxy's log has nothing to say, and no execution is created either. The real problem only surfaces if you turn
+on gRPC's own logging (`GRPC_GO_LOG_SEVERITY_LEVEL=info GRPC_GO_LOG_VERBOSITY_LEVEL=2`), where the proxy retries every
+couple of seconds with:
+
+```text
+x509: cannot validate certificate for 127.0.0.1 because it doesn't contain any IP SANs
+```
+
+which is exactly the mismatch the comment above `serverName` in `config.yaml` describes: the certificate carries
+`DNS:localhost` and no IP address, so verifying the dialed `127.0.0.1` fails without that setting. Put the line back and
+restart the proxy to recover.
+
+## What this is not
+
+This provider is a teaching aid, not a key manager:
+
+- the master secret lives in a plain environment variable (`KMS_MASTER_SECRET`);
+- that secret also has to be actual secret material: HKDF extracts entropy from it rather than adding any, so it needs
+ to be at least 32 bytes of cryptographic randomness, for example from `openssl rand -base64 32`, not a human-chosen
+ 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
+- 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
+choice for this example and matches what the proxy sends. `config.yaml`'s `tls` block for an extension server also
+accepts `cert` and `key`, so a real extension server would likely add mutual TLS, requiring a client certificate
+alongside the token.
+
+For a real deployment, point the proxy's `encryption` block at one of the built-in schemes instead: `awskms://`,
+`azurekeyvault://`, or `gcpkms://`.
+
+## Running it again
+
+The starter always uses the fixed workflow ID `kms-example-greeting`. Run it again after the previous run has completed
+and nothing special happens: the server's default workflow ID reuse policy allows a new run once the old one has closed,
+so you get a fresh run ID and `Hello, Temporal!` again with no cleanup required.
+
+The case worth knowing about is a still-open execution, for example one started while the worker was stopped. Here
+neither the SDK nor the CLI report an already-started error the way you might expect: `ExecuteWorkflow` and
+`temporal workflow start` both attach to the open run instead of rejecting the request, so the starter just blocks
+waiting for a result that will not arrive until a worker picks the run up. If that happens, either bring the worker back
+so the open run can finish, clear it directly with:
+
+```bash
+temporal workflow terminate --workflow-id kms-example-greeting --namespace default --address 127.0.0.1:7234
+```
+
+or restart the dev server, whose state lives entirely in memory.
+
+## Optional: per-namespace keys
+
+Restarting the dev server below discards the `kms-example-greeting` execution from the sections above, since its state
+lives entirely in memory; that is fine for this section, which stands on its own, but do it only once you are done
+inspecting that execution.
+
+Start the dev server with a second namespace:
+
+```bash
+temporal server start-dev -n payments
+```
+
+This pre-creates both `default` and `payments` on the same server. Add an override to `config.yaml` so the proxy uses a
+distinct key for `payments`:
+
+```yaml
+encryption:
+ enabled: true
+ cacheSize: 200
+ default:
+ uri: extension://kms/payloads
+ duration: 1h
+ renewBefore: 15m
+ overrides:
+ payments:
+ uri: extension://kms/payments-payloads
+ duration: 1h
+ renewBefore: 15m
+```
+
+The override's URI has to differ from the default key's URI even though both point at the same extension server: as
+noted above, reusing `extension://kms/payloads` for both fails proxy startup with
+`duplicate key id: extension://kms/payloads`. Restart the proxy, then start a workflow against the new namespace; no
+worker is needed for this check, since the key exchange happens on the way in:
+
+```bash
+temporal workflow start --address 127.0.0.1:7234 --namespace payments --task-queue kms-example \
+ --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:
+
+```text
+wrapped a DEK: namespace=default plaintext=32B ciphertext=70B
+wrapped a DEK: namespace=payments plaintext=32B ciphertext=71B
+```
+
+Same master secret, same extension server connection, but a different derived 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/config.yaml b/examples/kms/config.yaml
new file mode 100644
index 0000000..5f0044b
--- /dev/null
+++ b/examples/kms/config.yaml
@@ -0,0 +1,57 @@
+# Proxy configuration for the extension server KMS example.
+#
+# Workers connect to the gateway in plaintext with no credentials and exchange
+# cleartext payloads. The proxy seals every payload before it reaches the
+# Temporal Service and opens it again on the way back, wrapping the data
+# encryption keys through the extension server rather than a cloud KMS.
+#
+# Driven by two environment variables, both also read by the extension server:
+# KMS_API_KEY bearer token the proxy presents to the extension server
+# KMS_MASTER_SECRET the extension server's key derivation secret (server only)
+#
+# The proxy runs from the repository root, so paths here are relative to it.
+hostPort: 127.0.0.1:7234 # the dev server holds 7233
+
+routing:
+ # One upstream serves everything, including the namespace-less calls the SDK
+ # makes on connect (GetSystemInfo): with no system upstream set they fall
+ # through to the default. That works because this upstream's hostPort is a
+ # literal address; a templated one would need its own system upstream, having
+ # no namespace to resolve the template from.
+ default: local
+
+upstreams:
+ - name: local
+ hostPort: 127.0.0.1:7233 # temporal server start-dev
+
+# The pluggable key management backend. The proxy builds this connection when it
+# builds its key policies, so a malformed address, credentials without TLS, or an
+# unreadable ca file all fail at startup. Peer verification is later: gRPC connects
+# lazily, so a ca that simply does not match the server's certificate is not
+# discovered until the first payload needs a key.
+extensionServers:
+ - name: kms
+ hostPort: 127.0.0.1:9443
+ tls:
+ # A file path, read when the connection is built.
+ ca: examples/kms/certs/ca.pem
+ # Load bearing: the certificate carries DNS:localhost and no IP address,
+ # so verifying against the dialed 127.0.0.1 would fail without this.
+ serverName: localhost
+ # Sent as "authorization: Bearer ". Credentials require TLS, both
+ # because the config rejects them without it and because gRPC refuses to put
+ # a credential on a plaintext connection.
+ credentials:
+ static:
+ apiKey: ${KMS_API_KEY}
+
+# Envelope encryption. Each DEK is wrapped by the extension server key addressed
+# below: "kms" names the server above, and "payloads" is a proxy-side key name
+# recorded in every sealed payload so the same key is chosen when opening it. The
+# extension server never receives that name; it selects keys by namespace.
+encryption:
+ enabled: true
+ default:
+ uri: extension://kms/payloads
+ duration: 1h
+ renewBefore: 15m
diff --git a/examples/kms/gencerts/main.go b/examples/kms/gencerts/main.go
new file mode 100644
index 0000000..7dc19a6
--- /dev/null
+++ b/examples/kms/gencerts/main.go
@@ -0,0 +1,117 @@
+// Command gencerts writes a throwaway CA and a server certificate for the
+// example's extension server into certs/.
+//
+// The CA is private to this example: it is never installed in a system trust
+// store, and the proxy is pointed at it explicitly through the tls.ca field in
+// config.yaml. Its private key is discarded once the server certificate is
+// signed, so nothing else can ever be signed by it.
+package main
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/pem"
+ "flag"
+ "log"
+ "math/big"
+ "os"
+ "path/filepath"
+ "time"
+)
+
+// validFor is generous so a checkout left alone for a while does not fail with a
+// confusing expiry error.
+const validFor = 365 * 24 * time.Hour
+
+func main() {
+ dir := flag.String("dir", "certs", "directory to write ca.pem, server.pem and server-key.pem into")
+ flag.Parse()
+
+ if err := run(*dir); err != nil {
+ log.Fatalf("gencerts: %v", err)
+ }
+}
+
+func run(dir string) error {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+
+ caKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ return err
+ }
+
+ caTmpl := &x509.Certificate{
+ SerialNumber: big.NewInt(1),
+ Subject: pkix.Name{CommonName: "temporal-proxy kms example CA"},
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: time.Now().Add(validFor),
+ KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
+ BasicConstraintsValid: true,
+ IsCA: true,
+ }
+
+ caDER, err := x509.CreateCertificate(rand.Reader, caTmpl, caTmpl, &caKey.PublicKey, caKey)
+ if err != nil {
+ return err
+ }
+
+ ca, err := x509.ParseCertificate(caDER)
+ if err != nil {
+ return err
+ }
+
+ leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ return err
+ }
+
+ // DNSNames carries "localhost" and no IP address on purpose. config.yaml
+ // dials 127.0.0.1, so verification only succeeds because it also sets
+ // tls.serverName to localhost. Adding an IP SAN here would make that setting
+ // look optional.
+ leafTmpl := &x509.Certificate{
+ SerialNumber: big.NewInt(2),
+ Subject: pkix.Name{CommonName: "localhost"},
+ DNSNames: []string{"localhost"},
+ NotBefore: time.Now().Add(-time.Hour),
+ NotAfter: time.Now().Add(validFor),
+ KeyUsage: x509.KeyUsageDigitalSignature,
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+ }
+
+ leafDER, err := x509.CreateCertificate(rand.Reader, leafTmpl, ca, &leafKey.PublicKey, caKey)
+ if err != nil {
+ return err
+ }
+
+ leafKeyDER, err := x509.MarshalPKCS8PrivateKey(leafKey)
+ if err != nil {
+ return err
+ }
+
+ files := []struct {
+ name string
+ block *pem.Block
+ perm os.FileMode
+ }{
+ {name: "ca.pem", block: &pem.Block{Type: "CERTIFICATE", Bytes: caDER}, perm: 0o644},
+ {name: "server.pem", block: &pem.Block{Type: "CERTIFICATE", Bytes: leafDER}, perm: 0o644},
+ {name: "server-key.pem", block: &pem.Block{Type: "PRIVATE KEY", Bytes: leafKeyDER}, perm: 0o600},
+ }
+
+ for _, f := range files {
+ path := filepath.Join(dir, f.name)
+ if err := os.WriteFile(path, pem.EncodeToMemory(f.block), f.perm); err != nil {
+ return err
+ }
+
+ log.Printf("wrote %s", path)
+ }
+
+ return nil
+}
diff --git a/examples/kms/greeting.go b/examples/kms/greeting.go
new file mode 100644
index 0000000..f695ce7
--- /dev/null
+++ b/examples/kms/greeting.go
@@ -0,0 +1,43 @@
+// Package kms contains a minimal workflow used by the extension server KMS
+// example: a worker and a starter share the workflow, activity, and task queue
+// defined here.
+package kms
+
+import (
+ "context"
+ "time"
+
+ "go.temporal.io/sdk/workflow"
+)
+
+const (
+ // TaskQueue is the task queue the worker listens on and the starter targets.
+ TaskQueue = "kms-example"
+
+ // Namespace is the namespace the dev server creates by default. It is also
+ // what the provider derives its wrapping key from.
+ Namespace = "default"
+
+ // ProxyHostPort is the proxy's gateway. The dev server holds 7233, so the
+ // gateway listens next to it on 7234.
+ ProxyHostPort = "127.0.0.1:7234"
+)
+
+// GreetingWorkflow runs ComposeGreeting and returns its result.
+func GreetingWorkflow(ctx workflow.Context, name string) (string, error) {
+ ctx = workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
+ StartToCloseTimeout: time.Minute,
+ })
+
+ var greeting string
+ if err := workflow.ExecuteActivity(ctx, ComposeGreeting, name).Get(ctx, &greeting); err != nil {
+ return "", err
+ }
+
+ return greeting, nil
+}
+
+// ComposeGreeting returns a greeting for name.
+func ComposeGreeting(_ context.Context, name string) (string, error) {
+ return "Hello, " + name + "!", nil
+}
diff --git a/examples/kms/server/interceptor.go b/examples/kms/server/interceptor.go
new file mode 100644
index 0000000..b3438c1
--- /dev/null
+++ b/examples/kms/server/interceptor.go
@@ -0,0 +1,45 @@
+package main
+
+import (
+ "context"
+ "crypto/subtle"
+
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/metadata"
+ "google.golang.org/grpc/status"
+)
+
+const (
+ // authHeader and bearerPrefix are what the proxy's static credential sends:
+ // its header and scheme default to "authorization" and "Bearer".
+ authHeader = "authorization"
+ bearerPrefix = "Bearer "
+)
+
+// authInterceptor requires every call to present "Bearer " in the
+// authorization header. The proxy attaches this from its credentials block, and
+// gRPC refuses to send such a credential over a plaintext connection, so this
+// pairs with the server's TLS configuration rather than replacing it.
+func authInterceptor(token string) grpc.UnaryServerInterceptor {
+ want := []byte(bearerPrefix + token)
+
+ return func(ctx context.Context, req any, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
+ md, ok := metadata.FromIncomingContext(ctx)
+ if !ok {
+ return nil, status.Error(codes.Unauthenticated, "missing metadata")
+ }
+
+ got := md.Get(authHeader)
+ if len(got) != 1 {
+ return nil, status.Error(codes.Unauthenticated, "missing credentials")
+ }
+
+ // Constant time so a rejected call leaks nothing about the real token.
+ if subtle.ConstantTimeCompare([]byte(got[0]), want) != 1 {
+ return nil, status.Error(codes.Unauthenticated, "invalid credentials")
+ }
+
+ return handler(ctx, req)
+ }
+}
diff --git a/examples/kms/server/keyring.go b/examples/kms/server/keyring.go
new file mode 100644
index 0000000..133c8b6
--- /dev/null
+++ b/examples/kms/server/keyring.go
@@ -0,0 +1,141 @@
+package main
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/hkdf"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/binary"
+ "errors"
+ "fmt"
+ "math"
+)
+
+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
+
+ // 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/"
+)
+
+// 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.
+type keyring struct {
+ secret []byte
+}
+
+// newKeyring returns a keyring over secret. An empty secret is an error rather
+// than a silently weak key.
+//
+// secret should be at least 32 bytes of cryptographic randomness (for example
+// from "openssl rand -base64 32"), not a human-chosen passphrase: HKDF extracts
+// entropy from secret rather than adding any, so a guessable secret makes every
+// derived per-namespace key guessable too. newKeyring only checks that secret is
+// non-empty; it cannot tell a random secret from a memorable one.
+func newKeyring(secret []byte) (*keyring, error) {
+ if len(secret) == 0 {
+ return nil, errors.New("server: master secret is required")
+ }
+
+ return &keyring{secret: secret}, nil
+}
+
+// wrap seals dek under the key derived for namespace.
+//
+// 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.
+func (k *keyring) wrap(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)
+ 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(ciphertext []byte) ([]byte, error) {
+ if len(ciphertext) < headerSize {
+ return nil, errors.New("server: ciphertext is too short to hold a header")
+ }
+
+ if ciphertext[0] != formatVersion {
+ return nil, fmt.Errorf("server: unsupported ciphertext version: %#x", ciphertext[0])
+ }
+
+ nsLen := int(binary.BigEndian.Uint16(ciphertext[1:headerSize]))
+ if len(ciphertext) < headerSize+nsLen {
+ return nil, errors.New("server: ciphertext is truncated inside its namespace")
+ }
+
+ namespace := string(ciphertext[headerSize : headerSize+nsLen])
+ sealed := ciphertext[headerSize+nsLen:]
+
+ gcm, err := k.cipher(namespace)
+ if err != nil {
+ return nil, err
+ }
+
+ if len(sealed) < gcm.NonceSize() {
+ return nil, errors.New("server: ciphertext is truncated inside its nonce")
+ }
+
+ dek, err := gcm.Open(nil, sealed[:gcm.NonceSize()], sealed[gcm.NonceSize():], []byte(namespace))
+ if err != nil {
+ return nil, fmt.Errorf("server: failed to open ciphertext for namespace %q: %w", namespace, 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)
+ if err != nil {
+ return nil, fmt.Errorf("server: failed to derive a key for namespace %q: %w", namespace, err)
+ }
+
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return nil, fmt.Errorf("server: failed to create cipher: %w", err)
+ }
+
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, fmt.Errorf("server: failed to create GCM: %w", err)
+ }
+
+ return gcm, nil
+}
diff --git a/examples/kms/server/main.go b/examples/kms/server/main.go
new file mode 100644
index 0000000..5d3729d
--- /dev/null
+++ b/examples/kms/server/main.go
@@ -0,0 +1,84 @@
+// Command server is the example's extension server: a gRPC service implementing
+// api.kms.v1.EncryptionService that wraps and unwraps the proxy's data encryption
+// keys. Only key material crosses the wire; payload plaintext never reaches it.
+//
+// It reads two secrets from the environment. KMS_API_KEY is the bearer token
+// every call must present, matching the proxy's credentials block.
+// KMS_MASTER_SECRET is the secret every namespace's wrapping key is derived
+// from. Both are required.
+//
+// The provider it serves lives alongside it: keyring.go derives one AES-256-GCM
+// key per namespace from the master secret and frames the ciphertext, service.go
+// is the gRPC surface, and interceptor.go is the bearer token check. That 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 losing
+// the secret loses every payload sealed under it. A real provider fronts an HSM
+// or a key service.
+package main
+
+import (
+ "crypto/tls"
+ "flag"
+ "log"
+ "net"
+ "os"
+
+ "github.com/temporalio/temporal-proxy/pkg/api/kms/v1"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials"
+)
+
+func main() {
+ listen := flag.String("listen", "127.0.0.1:9443", "address to serve on")
+ certFile := flag.String("cert", "certs/server.pem", "PEM server certificate")
+ keyFile := flag.String("key", "certs/server-key.pem", "PEM private key matching -cert")
+ flag.Parse()
+
+ // Fail loudly on a missing secret. A provider that started without a token
+ // would serve anyone who found the port, and one without a master secret has
+ // no key to wrap with.
+ token := requireEnv("KMS_API_KEY")
+ secret := requireEnv("KMS_MASTER_SECRET")
+
+ keys, err := newKeyring([]byte(secret))
+ if err != nil {
+ log.Fatalf("failed to build keyring: %v", err)
+ }
+
+ cert, err := tls.LoadX509KeyPair(*certFile, *keyFile)
+ if err != nil {
+ log.Fatalf("failed to load key pair: %v (generate one with: go run ./gencerts)", err)
+ }
+
+ lis, err := net.Listen("tcp", *listen)
+ if err != nil {
+ log.Fatalf("failed to listen on %s: %v", *listen, err)
+ }
+
+ // TLS 1.2 is the floor the proxy's dialer enforces; two Go peers negotiate
+ // 1.3 in practice.
+ creds := credentials.NewTLS(&tls.Config{
+ Certificates: []tls.Certificate{cert},
+ MinVersion: tls.VersionTLS12,
+ })
+
+ srv := grpc.NewServer(
+ grpc.Creds(creds),
+ grpc.UnaryInterceptor(authInterceptor(token)),
+ )
+ kms.RegisterEncryptionServiceServer(srv, newService(keys))
+
+ log.Printf("extension server listening on %s over TLS", *listen)
+ if err := srv.Serve(lis); err != nil {
+ log.Fatalf("failed to serve: %v", err)
+ }
+}
+
+func requireEnv(name string) string {
+ v := os.Getenv(name)
+ if v == "" {
+ log.Fatalf("%s is required", name)
+ }
+
+ return v
+}
diff --git a/examples/kms/server/service.go b/examples/kms/server/service.go
new file mode 100644
index 0000000..bdae63c
--- /dev/null
+++ b/examples/kms/server/service.go
@@ -0,0 +1,57 @@
+package main
+
+import (
+ "context"
+ "log"
+
+ "github.com/temporalio/temporal-proxy/pkg/api/kms/v1"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+)
+
+// service implements api.kms.v1.EncryptionService over a keyring. Embedding the
+// generated unimplemented server is required by the generated interface and
+// keeps the type compiling when the service gains methods.
+type service struct {
+ kms.UnimplementedEncryptionServiceServer
+
+ keys *keyring
+}
+
+// newService returns a service that wraps DEKs with keys.
+func newService(keys *keyring) *service {
+ return &service{keys: keys}
+}
+
+// Encrypt wraps the DEK in the request under the requested namespace's key.
+//
+// An absent namespace is refused rather than defaulted: the namespace selects
+// the key, so accepting an empty one would quietly file every tenant's DEKs
+// under the same key.
+func (s *service) Encrypt(_ context.Context, req *kms.EncryptRequest) (*kms.EncryptResponse, error) {
+ if req.Namespace == "" {
+ return nil, status.Error(codes.InvalidArgument, "namespace is required")
+ }
+
+ ct, err := s.keys.wrap(req.Namespace, req.Plaintext)
+ if err != nil {
+ return nil, status.Errorf(codes.Internal, "failed to wrap key material: %v", err)
+ }
+
+ log.Printf("wrapped a DEK: namespace=%s plaintext=%dB ciphertext=%dB", req.Namespace, len(req.Plaintext), len(ct))
+
+ return &kms.EncryptResponse{Ciphertext: ct}, nil
+}
+
+// Decrypt unwraps key material previously produced by Encrypt. The request
+// carries no namespace, which is why wrap puts one inside the ciphertext.
+func (s *service) Decrypt(_ context.Context, req *kms.DecryptRequest) (*kms.DecryptResponse, error) {
+ pt, err := s.keys.unwrap(req.Ciphertext)
+ if err != nil {
+ return nil, status.Errorf(codes.InvalidArgument, "failed to unwrap key material: %v", err)
+ }
+
+ log.Printf("unwrapped a DEK: ciphertext=%dB plaintext=%dB", len(req.Ciphertext), len(pt))
+
+ return &kms.DecryptResponse{Plaintext: pt}, nil
+}
diff --git a/examples/kms/starter/main.go b/examples/kms/starter/main.go
new file mode 100644
index 0000000..55a5bda
--- /dev/null
+++ b/examples/kms/starter/main.go
@@ -0,0 +1,45 @@
+// Command starter starts one GreetingWorkflow through the proxy and prints the
+// result. The workflow id is fixed so the README can point the temporal CLI at
+// the same execution.
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+
+ "go.temporal.io/sdk/client"
+
+ "github.com/temporalio/temporal-proxy/examples/kms"
+)
+
+// workflowID is fixed so the README's "temporal workflow show" commands can name
+// it without copying an id out of the log.
+const workflowID = "kms-example-greeting"
+
+func main() {
+ c, err := client.Dial(client.Options{
+ HostPort: kms.ProxyHostPort,
+ Namespace: kms.Namespace,
+ })
+ if err != nil {
+ log.Fatalf("dial proxy: %v", err)
+ }
+ defer c.Close()
+
+ run, err := c.ExecuteWorkflow(context.Background(), client.StartWorkflowOptions{
+ ID: workflowID,
+ TaskQueue: kms.TaskQueue,
+ }, kms.GreetingWorkflow, "Temporal")
+ if err != nil {
+ log.Fatalf("start workflow: %v", err)
+ }
+ log.Printf("started workflow id=%s runID=%s", run.GetID(), run.GetRunID())
+
+ var greeting string
+ if err := run.Get(context.Background(), &greeting); err != nil {
+ log.Fatalf("get result: %v", err)
+ }
+
+ fmt.Println(greeting)
+}
diff --git a/examples/kms/worker/main.go b/examples/kms/worker/main.go
new file mode 100644
index 0000000..7fbe673
--- /dev/null
+++ b/examples/kms/worker/main.go
@@ -0,0 +1,33 @@
+// Command worker runs a Temporal worker that connects to the proxy and serves
+// the kms-example task queue. It exchanges cleartext payloads and knows nothing
+// about encryption; the proxy seals them on the way to the Temporal Service.
+package main
+
+import (
+ "log"
+
+ "go.temporal.io/sdk/client"
+ "go.temporal.io/sdk/worker"
+
+ "github.com/temporalio/temporal-proxy/examples/kms"
+)
+
+func main() {
+ c, err := client.Dial(client.Options{
+ HostPort: kms.ProxyHostPort,
+ Namespace: kms.Namespace,
+ })
+ if err != nil {
+ log.Fatalf("dial proxy: %v", err)
+ }
+ defer c.Close()
+
+ w := worker.New(c, kms.TaskQueue, worker.Options{})
+ w.RegisterWorkflow(kms.GreetingWorkflow)
+ w.RegisterActivity(kms.ComposeGreeting)
+
+ log.Printf("worker listening on task queue %q (namespace %q)", kms.TaskQueue, kms.Namespace)
+ if err := w.Run(worker.InterruptCh()); err != nil {
+ log.Fatalf("run worker: %v", err)
+ }
+}