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) + } +}