From da1ce9bd9aaf285e546f14fc1c1bdce0aafdff77 Mon Sep 17 00:00:00 2001 From: "David Muto (pseudomuto)" Date: Wed, 29 Jul 2026 18:21:31 -0400 Subject: [PATCH] [examples]: Add a KMS extension server example The proxy can wrap DEKs through an operator-run gRPC service implementing api.kms.v1.EncryptionService, but nothing in the repository showed how to build one, so the whole contract was left for a reader to infer from proto comments. This adds examples/kms: a runnable extension server, a proxy config pointing at it over authenticated TLS, and a greeting workflow driven through the proxy against a local dev server. The server derives one AES-256-GCM key per namespace from a master secret and frames its ciphertext so it is self-describing, which is what the contract requires: DecryptRequest carries no namespace, so the ciphertext has to carry its own key selector. The README walks through reading one execution twice, against the Temporal Service to see ciphertext and through the proxy to see it in the clear. The extension hop uses a private CA and a bearer token rather than plaintext, since credentials require TLS both by config validation and because gRPC refuses to put a per-RPC credential on an insecure connection. `go run ./gencerts` mints the throwaway CA; nothing is installed in a system trust store and no key material is committed. --- README.md | 3 + examples/cloud/go.mod | 32 --- examples/go.mod | 40 +++ examples/{cloud => }/go.sum | 54 +++-- examples/kms/.gitignore | 2 + examples/kms/README.md | 374 +++++++++++++++++++++++++++++ examples/kms/config.yaml | 57 +++++ examples/kms/gencerts/main.go | 117 +++++++++ examples/kms/greeting.go | 43 ++++ examples/kms/server/interceptor.go | 45 ++++ examples/kms/server/keyring.go | 141 +++++++++++ examples/kms/server/main.go | 84 +++++++ examples/kms/server/service.go | 57 +++++ examples/kms/starter/main.go | 45 ++++ examples/kms/worker/main.go | 33 +++ 15 files changed, 1073 insertions(+), 54 deletions(-) delete mode 100644 examples/cloud/go.mod create mode 100644 examples/go.mod rename examples/{cloud => }/go.sum (75%) create mode 100644 examples/kms/.gitignore create mode 100644 examples/kms/README.md create mode 100644 examples/kms/config.yaml create mode 100644 examples/kms/gencerts/main.go create mode 100644 examples/kms/greeting.go create mode 100644 examples/kms/server/interceptor.go create mode 100644 examples/kms/server/keyring.go create mode 100644 examples/kms/server/main.go create mode 100644 examples/kms/server/service.go create mode 100644 examples/kms/starter/main.go create mode 100644 examples/kms/worker/main.go 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) + } +}