diff --git a/secrets/secrets-kubernetes/Dockerfile b/secrets/secrets-kubernetes/Dockerfile new file mode 100644 index 0000000..d8a9a5b --- /dev/null +++ b/secrets/secrets-kubernetes/Dockerfile @@ -0,0 +1,24 @@ +FROM golang:1.22 AS builder +ARG TARGETOS=linux +ARG TARGETARCH=amd64 + +WORKDIR /workspace +COPY go.* . +RUN go mod download + +COPY *.go . +COPY pkg/ pkg/ + +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -o secrets-kubernetes + +FROM gcr.io/distroless/static-debian12:debug AS debug +COPY --from=builder /workspace/secrets-kubernetes . +USER 65532:65532 +ENTRYPOINT ["/secrets-kubernetes"] + +FROM gcr.io/distroless/static-debian12:nonroot AS release +WORKDIR / +COPY --from=builder /workspace/secrets-kubernetes . +USER 65532:65532 + +ENTRYPOINT ["/secrets-kubernetes"] diff --git a/secrets/secrets-kubernetes/Makefile b/secrets/secrets-kubernetes/Makefile new file mode 100644 index 0000000..43536d6 --- /dev/null +++ b/secrets/secrets-kubernetes/Makefile @@ -0,0 +1,13 @@ +VERSION?=dev +IMG?=ghcr.io/wasmcloud/contrib/secrets-kubernetes:$(VERSION) + +build: + docker build -t $(IMG) $(PWD) + +dev-init: + kubectl apply -k deploy/dev +dev-deploy: build + kubectl -n wasmcloud-secrets rollout restart deployment --selector=app=wasmcloud-secrets +dev-logs: + while true; do kubectl -n wasmcloud-secrets logs -f -l=app=wasmcloud-secrets; sleep 1; done + diff --git a/secrets/secrets-kubernetes/deploy/base/deployment.yaml b/secrets/secrets-kubernetes/deploy/base/deployment.yaml new file mode 100644 index 0000000..700ad00 --- /dev/null +++ b/secrets/secrets-kubernetes/deploy/base/deployment.yaml @@ -0,0 +1,19 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app: wasmcloud-secrets + name: wasmcloud-secrets +spec: + selector: + matchLabels: + app: wasmcloud-secrets + template: + metadata: + labels: + app: wasmcloud-secrets + spec: + containers: + - image: wasmcloud-secrets + imagePullPolicy: IfNotPresent + name: wasmcloud-secrets diff --git a/secrets/secrets-kubernetes/deploy/base/kustomization.yaml b/secrets/secrets-kubernetes/deploy/base/kustomization.yaml new file mode 100644 index 0000000..12c8e81 --- /dev/null +++ b/secrets/secrets-kubernetes/deploy/base/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - namespace.yaml + - deployment.yaml +namespace: wasmcloud-secrets diff --git a/secrets/secrets-kubernetes/deploy/base/namespace.yaml b/secrets/secrets-kubernetes/deploy/base/namespace.yaml new file mode 100644 index 0000000..98ed914 --- /dev/null +++ b/secrets/secrets-kubernetes/deploy/base/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: wasmcloud-secrets diff --git a/secrets/secrets-kubernetes/deploy/dev/kustomization.yaml b/secrets/secrets-kubernetes/deploy/dev/kustomization.yaml new file mode 100644 index 0000000..a9d37d4 --- /dev/null +++ b/secrets/secrets-kubernetes/deploy/dev/kustomization.yaml @@ -0,0 +1,21 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ../impersonation/ + +images: + - name: wasmcloud-secrets + newName: ghcr.io/wasmcloud/contrib/secrets-kubernetes + newTag: dev + +patches: + - patch: |- + - op: replace + path: "/spec/template/spec/containers/0/args" + value: + - "--backend-seed=SXAD2NAUWO6YNEFMY4FQT7D45VLLWFOZDVHCENMPHCWA6ABBLZ4OBBKGKQ" + - "--nats-url=nats.default.svc.cluster.local:4222" + target: + kind: Deployment + namespace: wasmcloud-secrets + name: wasmcloud-secrets diff --git a/secrets/secrets-kubernetes/deploy/impersonation/README.md b/secrets/secrets-kubernetes/deploy/impersonation/README.md new file mode 100644 index 0000000..3fd01f8 --- /dev/null +++ b/secrets/secrets-kubernetes/deploy/impersonation/README.md @@ -0,0 +1,40 @@ +# RBAC example + +- `default.yaml`: Allows the secrets backend to see all secrets in the `default` namespace, without impersonation. +- `cluster-wide.yaml`: Creates an impersonation target `wasmcloud-secrets-privileged`, which can read secrets in any namespace. + +wadm snippets + +``` +spec: + policies: + - name: rust-hello-world-secrets-default + type: policy.secret.wasmcloud.dev/v1alpha1 + properties: + backend: kube + - name: rust-hello-world-secrets-impersonation + type: policy.secret.wasmcloud.dev/v1alpha1 + properties: + backend: kube + impersonate: wasmcloud-secrets-privileged + namespace: kube-system + + components: + - name: http-component + type: component + properties: + image: .... + secrets: + # secret in 'kube-system' namespace + - name: example-impersonated + properties: + policy: rust-hello-world-secrets-impersonation + key: k3s-serving + field: tls.crt + # secret in 'default' namespace + - name: example + properties: + policy: rust-hello-world-secrets-default + key: cluster-secrets + field: api-password +``` diff --git a/secrets/secrets-kubernetes/deploy/impersonation/cluster-wide.yaml b/secrets/secrets-kubernetes/deploy/impersonation/cluster-wide.yaml new file mode 100644 index 0000000..6c44ec5 --- /dev/null +++ b/secrets/secrets-kubernetes/deploy/impersonation/cluster-wide.yaml @@ -0,0 +1,46 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: wasmcloud-secrets-privileged +rules: + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "watch", "list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: wasmcloud-secrets-privileged +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: wasmcloud-secrets-privileged +subjects: + - kind: User + name: wasmcloud-secrets-privileged + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: wasmcloud-secrets-impersonation +rules: + - apiGroups: [""] + resources: ["users"] + verbs: ["impersonate"] + resourceNames: + - wasmcloud-secrets-privileged +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: wasmcloud-secrets-impersonation + namespace: wasmcloud-secrets +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: wasmcloud-secrets-impersonation +subjects: + - kind: ServiceAccount + name: "default" + namespace: "wasmcloud-secrets" diff --git a/secrets/secrets-kubernetes/deploy/impersonation/default.yaml b/secrets/secrets-kubernetes/deploy/impersonation/default.yaml new file mode 100644 index 0000000..422cac7 --- /dev/null +++ b/secrets/secrets-kubernetes/deploy/impersonation/default.yaml @@ -0,0 +1,21 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: wasmcloud-secrets-reader-default +rules: + - apiGroups: [""] + resources: ["secrets"] + verbs: ["get", "watch", "list"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: wasmcloud-secrets-reader-default +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: wasmcloud-secrets-reader-default +subjects: + - apiGroup: rbac.authorization.k8s.io + kind: User + name: "system:serviceaccount:wasmcloud-secrets:default" diff --git a/secrets/secrets-kubernetes/deploy/impersonation/kustomization.yaml b/secrets/secrets-kubernetes/deploy/impersonation/kustomization.yaml new file mode 100644 index 0000000..c8103ba --- /dev/null +++ b/secrets/secrets-kubernetes/deploy/impersonation/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ../base/ + - default.yaml + - cluster-wide.yaml diff --git a/secrets/secrets-kubernetes/go.mod b/secrets/secrets-kubernetes/go.mod new file mode 100644 index 0000000..42f4db2 --- /dev/null +++ b/secrets/secrets-kubernetes/go.mod @@ -0,0 +1,59 @@ +module github.com/wasmCloud/wasmCloud-contrib/secrets/secrets-kubernetes + +go 1.22.5 + +require ( + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/nats-io/nats-server/v2 v2.10.18 + github.com/nats-io/nats.go v1.36.0 + github.com/nats-io/nkeys v0.4.7 + k8s.io/apimachinery v0.30.1 + k8s.io/client-go v0.30.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.11.0 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-openapi/jsonpointer v0.19.6 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.22.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/gnostic-models v0.6.8 // indirect + github.com/google/gofuzz v1.2.0 // indirect + github.com/google/uuid v1.3.0 // indirect + github.com/imdario/mergo v0.3.6 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/minio/highwayhash v1.0.3 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nats-io/jwt/v2 v2.5.8 // indirect + github.com/nats-io/nuid v1.0.1 // indirect + github.com/onsi/ginkgo/v2 v2.17.1 // indirect + github.com/onsi/gomega v1.32.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/crypto v0.25.0 // indirect + golang.org/x/net v0.23.0 // indirect + golang.org/x/oauth2 v0.12.0 // indirect + golang.org/x/sys v0.22.0 // indirect + golang.org/x/term v0.22.0 // indirect + golang.org/x/text v0.16.0 // indirect + golang.org/x/time v0.5.0 // indirect + google.golang.org/appengine v1.6.7 // indirect + google.golang.org/protobuf v1.33.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.30.1 // indirect + k8s.io/klog/v2 v2.120.1 // indirect + k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect + k8s.io/utils v0.0.0-20230726121419-3b25d923346b // indirect + sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect + sigs.k8s.io/yaml v1.3.0 // indirect +) diff --git a/secrets/secrets-kubernetes/go.sum b/secrets/secrets-kubernetes/go.sum new file mode 100644 index 0000000..d1b994f --- /dev/null +++ b/secrets/secrets-kubernetes/go.sum @@ -0,0 +1,171 @@ +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= +github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= +github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= +github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= +github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/imdario/mergo v0.3.6 h1:xTNEAn+kxVO7dTZGu0CegyqKZmoWFI0rF8UxjlB2d28= +github.com/imdario/mergo v0.3.6/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +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/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/minio/highwayhash v1.0.3 h1:kbnuUMoHYyVl7szWjSxJnxw11k2U709jqFPPmIUyD6Q= +github.com/minio/highwayhash v1.0.3/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nats-io/jwt/v2 v2.5.8 h1:uvdSzwWiEGWGXf+0Q+70qv6AQdvcvxrv9hPM0RiPamE= +github.com/nats-io/jwt/v2 v2.5.8/go.mod h1:ZdWS1nZa6WMZfFwwgpEaqBV8EPGVgOTDHN/wTbz0Y5A= +github.com/nats-io/nats-server/v2 v2.10.18 h1:tRdZmBuWKVAFYtayqlBB2BuCHNGAQPvoQIXOKwU3WSM= +github.com/nats-io/nats-server/v2 v2.10.18/go.mod h1:97Qyg7YydD8blKlR8yBsUlPlWyZKjA7Bp5cl3MUE9K8= +github.com/nats-io/nats.go v1.36.0 h1:suEUPuWzTSse/XhESwqLxXGuj8vGRuPRoG7MoRN/qyU= +github.com/nats-io/nats.go v1.36.0/go.mod h1:Ubdu4Nh9exXdSz0RVWRFBbRfrbSxOYd26oF0wkWclB8= +github.com/nats-io/nkeys v0.4.7 h1:RwNJbbIdYCoClSDNY7QVKZlyb/wfT6ugvFCiKy6vDvI= +github.com/nats-io/nkeys v0.4.7/go.mod h1:kqXRgRDPlGy7nGaEDMuYzmiJCIAAWDK0IMBtDmGD0nc= +github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= +github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= +github.com/onsi/ginkgo/v2 v2.17.1 h1:V++EzdbhI4ZV4ev0UTIj0PzhzOcReJFyJaLjtSF55M8= +github.com/onsi/ginkgo/v2 v2.17.1/go.mod h1:llBI3WDLL9Z6taip6f33H76YcWtJv+7R3HigUjbIBOs= +github.com/onsi/gomega v1.32.0 h1:JRYU78fJ1LPxlckP6Txi/EYqJvjtMrDC04/MM5XRHPk= +github.com/onsi/gomega v1.32.0/go.mod h1:a4x4gW6Pz2yK1MAmvluYme5lvYTn61afQ2ETw/8n4Lg= +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/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +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= +golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= +golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +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.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/oauth2 v0.12.0 h1:smVPGxink+n1ZI5pkQa8y6fZT0RW0MgCO5bFpepy4B4= +golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba4= +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/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.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= +golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +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= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= +google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.30.1 h1:kCm/6mADMdbAxmIh0LBjS54nQBE+U4KmbCfIkF5CpJY= +k8s.io/api v0.30.1/go.mod h1:ddbN2C0+0DIiPntan/bye3SW3PdwLa11/0yqwvuRrJM= +k8s.io/apimachinery v0.30.1 h1:ZQStsEfo4n65yAdlGTfP/uSHMQSoYzU/oeEbkmF7P2U= +k8s.io/apimachinery v0.30.1/go.mod h1:iexa2somDaxdnj7bha06bhb43Zpa6eWH8N8dbqVjTUc= +k8s.io/client-go v0.30.1 h1:uC/Ir6A3R46wdkgCV3vbLyNOYyCJ8oZnjtJGKfytl/Q= +k8s.io/client-go v0.30.1/go.mod h1:wrAqLNs2trwiCH/wxxmT/x3hKVH9PuV0GGW0oDoHVqc= +k8s.io/klog/v2 v2.120.1 h1:QXU6cPEOIslTGvZaXvFWiP9VKyeet3sawzTOvdXb4Vw= +k8s.io/klog/v2 v2.120.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= +k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b h1:sgn3ZU783SCgtaSJjpcVVlRqd6GSnlTLKgpAAttJvpI= +k8s.io/utils v0.0.0-20230726121419-3b25d923346b/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= +sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= +sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= +sigs.k8s.io/yaml v1.3.0 h1:a2VclLzOGrwOHDiV8EfBGhvjHvP46CtW5j6POvhYGGo= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/secrets/secrets-kubernetes/main.go b/secrets/secrets-kubernetes/main.go new file mode 100644 index 0000000..f3b1048 --- /dev/null +++ b/secrets/secrets-kubernetes/main.go @@ -0,0 +1,167 @@ +package main + +import ( + "context" + "encoding/json" + "flag" + "log/slog" + "os" + "os/signal" + "sync" + "syscall" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nkeys" + "github.com/wasmCloud/wasmCloud-contrib/secrets/secrets-kubernetes/pkg/secrets" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + clientcorev1 "k8s.io/client-go/kubernetes/typed/core/v1" + "k8s.io/client-go/tools/clientcmd" +) + +const ( + ServiceName = "kube" +) + +type kubeSecretsServer struct{} + +type kubeApplicationPolicy struct { + Impersonate string `json:"impersonate"` + Namespace string `json:"namespace"` +} + +func parseApplicationPolicy(r *secrets.Request) (*kubeApplicationPolicy, error) { + rawPolicy, err := r.Context.Application.PolicyProperties() + if err != nil { + return nil, err + } + policy := &kubeApplicationPolicy{Namespace: "default"} + err = json.Unmarshal(rawPolicy, policy) + return policy, err +} + +func (s *kubeSecretsServer) Get(ctx context.Context, r *secrets.Request) (*secrets.SecretValue, error) { + policy, err := parseApplicationPolicy(r) + if err != nil { + return nil, secrets.ErrPolicy.With(err.Error()) + } + slog.Info("Get", slog.String("application", r.Context.Application.Name), slog.String("impersonate", policy.Impersonate), slog.String("key", r.Key), slog.String("field", r.Field)) + + if r.Key == "" { + return nil, secrets.ErrOther.With("missing secret name") + } + + if r.Field == "" { + return nil, secrets.ErrOther.With("missing secret key/field") + } + + kubeClient, err := kubeClientWithImpersonation(policy.Impersonate) + if err != nil { + return nil, secrets.ErrUpstream.With(err.Error()) + } + + kubeSecret, err := kubeClient.Secrets(policy.Namespace).Get(ctx, r.Key, metav1.GetOptions{}) + if err != nil { + return nil, secrets.ErrUpstream.With(err.Error()) + } + + kubeEntryValue, ok := kubeSecret.Data[r.Field] + if !ok { + return nil, secrets.ErrSecretNotFound + } + + return &secrets.SecretValue{ + StringSecret: string(kubeEntryValue), + Version: kubeSecret.ResourceVersion, + }, nil +} + +func kubeClientWithImpersonation(role string) (clientcorev1.CoreV1Interface, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + config, err := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, nil).ClientConfig() + if err != nil { + return nil, err + } + + if role != "" { + config.Impersonate.UserName = role + } + + kubeClientset, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, err + } + + return kubeClientset.CoreV1(), nil +} + +func main() { + var ( + natsURL = flag.String("nats-url", nats.DefaultURL, "Nats URL") + secretsBackendSeed = flag.String("backend-seed", "", "NKeys Curve Seed. Leave blank for ephemeral key, only recommended for development use") + ) + flag.Parse() + + slog.Info("Starting", slog.String("nats-url", *natsURL)) + + s := &kubeSecretsServer{} + + nc, err := nats.Connect(*natsURL) + if err != nil { + slog.Error("Couldn't setup nats client", slog.Any("error", err)) + os.Exit(1) + } + + errorCallback := func(_ *nats.Msg, err error) { + slog.Error("server error", slog.Any("error", err)) + } + + var secretsBackendKey nkeys.KeyPair + + if *secretsBackendSeed != "" { + secretsBackendKey, err = nkeys.FromSeed([]byte(*secretsBackendSeed)) + } else { + slog.Info("Creating ephemeral curve keys. DO NOT USE THIS IN PRODUCTION.") + secretsBackendKey, err = nkeys.CreateCurveKeys() + } + if err != nil { + slog.Error("Couldn't setup XKey", slog.Any("error", err)) + os.Exit(1) + } + + secretsServer, err := secrets.NewServer(ServiceName, + nc, + s, + secrets.WithKeyPair(secretsBackendKey), + secrets.WithErrorCallback(errorCallback), + ) + if err != nil { + slog.Error("Couldn't setup secrets server", slog.Any("error", err)) + os.Exit(1) + } + + mainCtx, mainCancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGUSR1, syscall.SIGUSR2, syscall.SIGHUP) + defer mainCancel() + + if err := secretsServer.Run(); err != nil { + slog.Error("Couldn't setup secrets protocol server", slog.Any("error", err)) + os.Exit(1) + } + + var wg sync.WaitGroup + wg.Add(1) + go func() { + <-mainCtx.Done() + slog.Info("Signal received. Draining...") + if err := secretsServer.Shutdown(true); err != nil { + slog.Error("Couldn't drain all messages", slog.Any("error", err)) + } else { + slog.Info("Drained all messages") + } + wg.Done() + }() + + slog.Info("Server is up") + wg.Wait() +} diff --git a/secrets/secrets-kubernetes/pkg/secrets/ed25519.go b/secrets/secrets-kubernetes/pkg/secrets/ed25519.go new file mode 100644 index 0000000..aebdcfd --- /dev/null +++ b/secrets/secrets-kubernetes/pkg/secrets/ed25519.go @@ -0,0 +1,64 @@ +package secrets + +import ( + "errors" + "fmt" + + jwt "github.com/golang-jwt/jwt/v5" + "github.com/nats-io/nkeys" +) + +var ErrEd25519Verification = errors.New("ed25519: verification error") + +// SigningMethodNats implements the Ed25519 family. +type SigningMethodNats struct{} + +// Specific instance for Ed25519 - nkeys edition +var ( + SigningMethodEd25519 *SigningMethodNats +) + +func init() { + SigningMethodEd25519 = &SigningMethodNats{} + jwt.RegisterSigningMethod(SigningMethodEd25519.Alg(), func() jwt.SigningMethod { + return SigningMethodEd25519 + }) +} + +func (m *SigningMethodNats) Alg() string { + return "Ed25519" +} + +// Verify implements token verification for the SigningMethod. +func (m *SigningMethodNats) Verify(signingString string, sig []byte, key interface{}) error { + var ed25519Key nkeys.KeyPair + var ok bool + + if ed25519Key, ok = key.(nkeys.KeyPair); !ok { + return fmt.Errorf("%w: Ed25519 sign expects nkeys.KeyPair", jwt.ErrInvalidKeyType) + } + + return ed25519Key.Verify([]byte(signingString), sig) +} + +// Sign implements token signing for the SigningMethod. +func (m *SigningMethodNats) Sign(signingString string, key interface{}) ([]byte, error) { + var ed25519Key nkeys.KeyPair + var ok bool + + if ed25519Key, ok = key.(nkeys.KeyPair); !ok { + return nil, fmt.Errorf("%w: Ed25519 sign expects nkeys.KeyPair", jwt.ErrInvalidKeyType) + } + + return ed25519Key.Sign([]byte(signingString)) +} + +func KeyPairFromIssuer() func(token *jwt.Token) (interface{}, error) { + return func(token *jwt.Token) (interface{}, error) { + iss, err := token.Claims.GetIssuer() + if err != nil { + return nil, err + } + return nkeys.FromPublicKey(iss) + } +} diff --git a/secrets/secrets-kubernetes/pkg/secrets/ed25519_test.go b/secrets/secrets-kubernetes/pkg/secrets/ed25519_test.go new file mode 100644 index 0000000..7849eef --- /dev/null +++ b/secrets/secrets-kubernetes/pkg/secrets/ed25519_test.go @@ -0,0 +1,53 @@ +package secrets + +import ( + "testing" + "time" + + jwt "github.com/golang-jwt/jwt/v5" + "github.com/nats-io/nkeys" +) + +func TestEd25519(t *testing.T) { + t.Run("SignAndVerify", func(t *testing.T) { + kp, err := nkeys.CreateAccount() + if err != nil { + t.Fatal(err) + } + pubKey, err := kp.PublicKey() + if err != nil { + t.Fatal(err) + } + + claims := jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: pubKey, + Subject: "somebody", + ID: "1", + Audience: []string{"somebody_else"}, + } + + token := jwt.NewWithClaims(SigningMethodEd25519, claims) + signedJWT, err := token.SignedString(kp) + if err != nil { + t.Fatal(err) + } + + // try opening the signed jwt + _, err = jwt.ParseWithClaims(signedJWT, &jwt.RegisteredClaims{}, KeyPairFromIssuer()) + if err != nil { + t.Error(err) + } + }) + + t.Run("Verify", func(t *testing.T) { + // jwt from wasmcloud host codebase + validJWT := "eyJ0eXAiOiJqd3QiLCJhbGciOiJFZDI1NTE5In0.eyJqdGkiOiJTakI1Zm05NzRTanU5V01nVFVjaHNiIiwiaWF0IjoxNjQ0ODQzNzQzLCJpc3MiOiJBQ09KSk42V1VQNE9ERDc1WEVCS0tUQ0NVSkpDWTVaS1E1NlhWS1lLNEJFSldHVkFPT1FIWk1DVyIsInN1YiI6Ik1CQ0ZPUE02SlcyQVBKTFhKRDNaNU80Q043Q1BZSjJCNEZUS0xKVVI1WVI1TUlUSVU3SEQzV0Q1Iiwid2FzY2FwIjp7Im5hbWUiOiJFY2hvIiwiaGFzaCI6IjRDRUM2NzNBN0RDQ0VBNkE0MTY1QkIxOTU4MzJDNzkzNjQ3MUNGN0FCNDUwMUY4MzdGOEQ2NzlGNDQwMEJDOTciLCJ0YWdzIjpbXSwiY2FwcyI6WyJ3YXNtY2xvdWQ6aHR0cHNlcnZlciJdLCJyZXYiOjQsInZlciI6IjAuMy40IiwicHJvdiI6ZmFsc2V9fQ.ZWyD6VQqzaYM1beD2x9Fdw4o_Bavy3ZG703Eg4cjhyJwUKLDUiVPVhqHFE6IXdV4cW6j93YbMT6VGq5iBDWmAg" + _, err := jwt.ParseWithClaims(validJWT, &jwt.RegisteredClaims{}, KeyPairFromIssuer()) + if err != nil { + t.Error(err) + } + }) +} diff --git a/secrets/secrets-kubernetes/pkg/secrets/server.go b/secrets/secrets-kubernetes/pkg/secrets/server.go new file mode 100644 index 0000000..7da2692 --- /dev/null +++ b/secrets/secrets-kubernetes/pkg/secrets/server.go @@ -0,0 +1,229 @@ +package secrets + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/nats-io/nats.go" + "github.com/nats-io/nkeys" +) + +type ( + ServerErrorCallback func(msg *nats.Msg, err error) + ServerContextCreator func() context.Context +) + +type Server struct { + queue *nats.Subscription + natsConn *nats.Conn + handler Handler + onError ServerErrorCallback + key nkeys.KeyPair + pubKey string + subjectMapper SubjectMapper + ctxCreator ServerContextCreator +} + +type ServerOption func(*Server) error + +func WithEphemeralKey() ServerOption { + return func(s *Server) error { + var err error + s.key, err = nkeys.CreateCurveKeys() + return err + } +} + +func WithKeyPair(kp nkeys.KeyPair) ServerOption { + return func(s *Server) error { + s.key = kp + return nil + } +} + +func WithSubjectMapper(m SubjectMapper) ServerOption { + return func(s *Server) error { + s.subjectMapper = m + return nil + } +} + +func WithErrorCallback(cb ServerErrorCallback) ServerOption { + return func(s *Server) error { + s.onError = cb + return nil + } +} + +func WithRequestContext(cb ServerContextCreator) ServerOption { + return func(s *Server) error { + s.ctxCreator = cb + return nil + } +} + +func NewServer(name string, nc *nats.Conn, handler Handler, opts ...ServerOption) (*Server, error) { + server := &Server{ + natsConn: nc, + handler: handler, + onError: func(*nats.Msg, error) {}, + ctxCreator: func() context.Context { return context.Background() }, + subjectMapper: SubjectMapper{ + Version: DefaultSecretsProtocolVersion, + Prefix: DefaultSecretsBusPrefix, + ServiceName: name, + }, + } + + for _, opt := range opts { + if err := opt(server); err != nil { + return nil, fmt.Errorf("%w: %s", ErrInvalidServerConfig, err) + } + } + + if name == "" { + return nil, fmt.Errorf("%w: missing name", ErrInvalidServerConfig) + } + + if server.natsConn == nil { + return nil, fmt.Errorf("%w: nats connection", ErrInvalidServerConfig) + } + + if server.handler == nil { + return nil, fmt.Errorf("%w: missing handler", ErrInvalidServerConfig) + } + + if server.key == nil { + return nil, fmt.Errorf("%w: missing key pair", ErrInvalidServerConfig) + } + + if server.ctxCreator == nil { + return nil, fmt.Errorf("%w: context creator", ErrInvalidServerConfig) + } + + var err error + server.pubKey, err = server.key.PublicKey() + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrInvalidServerConfig, err) + } + + return server, nil +} + +func (s *Server) Process(ctx context.Context, msg *nats.Msg) { + nakCallback := func(respErr *ResponseError) { + s.onError(msg, respErr) + + resp := Response{Error: respErr} + + data, err := json.Marshal(&resp) + if err != nil { + s.onError(msg, err) + return + } + + if err := msg.Respond(data); err != nil { + s.onError(msg, err) + } + } + + operation := s.subjectMapper.ParseOperation(msg.Subject) + switch operation { + case "get": + hostPubKey := msg.Header.Get(WasmCloudHostXkey) + if hostPubKey == "" { + nakCallback(ErrInvalidHeaders) + return + } + + rawReq, err := s.key.Open(msg.Data, hostPubKey) + if err != nil { + nakCallback(ErrDecryption) + return + } + + req := &Request{} + if err := json.Unmarshal(rawReq, &req); err != nil { + nakCallback(ErrInvalidPayload) + return + } + + if err := req.Context.IsValid(); err != nil { + nakCallback(err) + return + } + + secretValue, err := s.handler.Get(ctx, req) + if err != nil { + if respErr, ok := err.(*ResponseError); ok { + nakCallback(respErr) + } else { + nakCallback(ErrUpstream.With(err.Error())) + } + return + } + + responseKey, err := nkeys.CreateCurveKeys() + if err != nil { + nakCallback(ErrEncryption) + return + } + ephemeralPubKey, err := responseKey.PublicKey() + if err != nil { + nakCallback(ErrEncryption) + return + } + + data, err := json.Marshal(&Response{Secret: secretValue}) + if err != nil { + nakCallback(ErrInvalidPayload) + return + } + + respMsg := nats.NewMsg("") + respMsg.Header.Add(WasmCloudResponseXkey, ephemeralPubKey) + + respMsg.Data, err = responseKey.Seal(data, hostPubKey) + if err != nil { + nakCallback(ErrEncryption) + return + } + + if err := msg.RespondMsg(respMsg); err != nil { + nakCallback(ErrOther.With("failed to respond 'get'")) + } + case "server_xkey": + if err := msg.Respond([]byte(s.pubKey)); err != nil { + nakCallback(ErrInvalidRequest) + } + + default: + nakCallback(ErrInvalidRequest) + } +} + +func (s *Server) Run() error { + var queueErr error + + s.queue, queueErr = s.natsConn.QueueSubscribe( + s.subjectMapper.SecretWildcardSubject(), + s.subjectMapper.QueueGroupName(), + func(msg *nats.Msg) { + s.Process(s.ctxCreator(), msg) + }) + + return queueErr +} + +func (s *Server) Shutdown(shouldDrain bool) error { + if s.queue != nil { + if shouldDrain { + return s.queue.Drain() + } else { + return s.queue.Unsubscribe() + } + } + + return nil +} diff --git a/secrets/secrets-kubernetes/pkg/secrets/server_test.go b/secrets/secrets-kubernetes/pkg/secrets/server_test.go new file mode 100644 index 0000000..d8d344a --- /dev/null +++ b/secrets/secrets-kubernetes/pkg/secrets/server_test.go @@ -0,0 +1,337 @@ +package secrets + +import ( + "context" + "encoding/json" + "testing" + "time" + + natsserver "github.com/nats-io/nats-server/v2/test" + "github.com/nats-io/nats.go" + "github.com/nats-io/nkeys" +) + +type testHandler struct { + getFunc func(ctx context.Context, r *Request) (*SecretValue, error) +} + +func (t *testHandler) Get(ctx context.Context, r *Request) (*SecretValue, error) { + return t.getFunc(ctx, r) +} + +func natsConnectionForTest(t *testing.T) *nats.Conn { + t.Helper() + + s := natsserver.RunRandClientPortServer() + t.Cleanup(s.Shutdown) + + nc, err := nats.Connect(s.ClientURL()) + if err != nil { + t.Fatal(err) + } + + t.Cleanup(nc.Close) + + return nc +} + +func keyPairForTest(t *testing.T) nkeys.KeyPair { + t.Helper() + + kp, err := nkeys.CreateCurveKeys() + if err != nil { + t.Fatal(err) + } + + return kp +} + +func TestNewServer(t *testing.T) { + if _, err := NewServer("", nil, nil); err == nil { + t.Errorf("server name shouldn't be blank") + } + + if _, err := NewServer("kube", nil, nil); err == nil { + t.Errorf("nats client shouldn't be blank") + } + + nc := natsConnectionForTest(t) + + if _, err := NewServer("kube", nc, nil); err == nil { + t.Errorf("handler shouldn't be blank") + } + + handler := &testHandler{} + + if _, err := NewServer("kube", nc, handler); err == nil { + t.Errorf("keypair shouldn't be blank") + } + + kp := keyPairForTest(t) + + if _, err := NewServer("kube", nc, handler, WithKeyPair(kp)); err != nil { + t.Error(err) + } + + if _, err := NewServer("kube", nc, handler, WithEphemeralKey()); err != nil { + t.Error(err) + } +} + +func TestServerLoop(t *testing.T) { + t.Run("HappyPath", func(t *testing.T) { + nc := natsConnectionForTest(t) + + var handler testHandler + + server, err := NewServer("kube", nc, &handler, WithEphemeralKey()) + if err != nil { + t.Fatal(err) + } + + if err := server.Run(); err != nil { + t.Error(err) + } + + if err := server.Shutdown(false); err != nil { + t.Error(err) + } + }) + + t.Run("Drain", func(t *testing.T) { + nc := natsConnectionForTest(t) + + var handler testHandler + + server, err := NewServer("kube", nc, &handler, WithEphemeralKey()) + if err != nil { + t.Fatal(err) + } + + if err := server.Run(); err != nil { + t.Error(err) + } + + if err := server.Shutdown(true); err != nil { + t.Error(err) + } + }) + + t.Run("BrokenNats", func(t *testing.T) { + nc := natsConnectionForTest(t) + + var handler testHandler + + server, err := NewServer("kube", nc, &handler, WithEphemeralKey()) + if err != nil { + t.Fatal(err) + } + + // disconnect nats + nc.Close() + + if err := server.Run(); err == nil { + t.Error("nats should be connected") + } + }) +} + +func TestServerXkey(t *testing.T) { + nc := natsConnectionForTest(t) + + var handler testHandler + + server, err := NewServer("kube", nc, &handler, WithEphemeralKey()) + if err != nil { + t.Fatal(err) + } + + serverPubKey, err := server.key.PublicKey() + if err != nil { + t.Fatal(err) + } + + if err := server.Run(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { server.Shutdown(false) }) + + rawReply, err := nc.Request(server.subjectMapper.SecretsSubject()+".server_xkey", nil, time.Second) + if err != nil { + t.Fatal(err) + } + + if want, got := serverPubKey, string(rawReply.Data); want != got { + t.Errorf("wanted %+v, got %+v", want, got) + } +} + +func TestServerGet(t *testing.T) { + nc := natsConnectionForTest(t) + + basicGetFunc := func(ctx context.Context, r *Request) (*SecretValue, error) { + return &SecretValue{ + StringSecret: "value", + }, nil + } + + basicCheckResponse := func(t *testing.T, r Response) { + if r.Secret.StringSecret != "value" { + t.Fatal("secret value mismatch") + } + if r.Error != nil { + t.Fatal("didnt expect an error here") + } + } + + handler := &testHandler{} + + server, err := NewServer("kube", nc, handler, WithEphemeralKey()) + if err != nil { + t.Fatal(err) + } + + serverPubKey, err := server.key.PublicKey() + if err != nil { + t.Fatal(err) + } + + if err := server.Run(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { server.Shutdown(false) }) + + kp := keyPairForTest(t) + hostPubKey, err := kp.PublicKey() + if err != nil { + t.Fatal(err) + } + + reqCtx := Context{ + Application: &ApplicationContext{ + Name: "appname", + }, + EntityJwt: "eyJ0eXAiOiJqd3QiLCJhbGciOiJFZDI1NTE5In0.eyJqdGkiOiJxdmVOakZjcW51dWhQaVJUMkU1YWJXIiwiaWF0IjoxNzIxODM0ODg5LCJpc3MiOiJBQk9HQjRXNURPWDNVTzNSVldXUUdZU01WWEhSUFFZWFZaUDVVNFZGTUpEQ1lDV0FSN1M1Q1lNTyIsInN1YiI6Ik1DNUNDNFVENUxQRFo0QzdaTkFFQTRPWlEzQkVGTFNWUTc0MlczVEVUM09OS1M0RFJCVk5NNUlDIiwid2FzY2FwIjp7Im5hbWUiOiJodHRwLWhlbGxvLXdvcmxkIiwiaGFzaCI6IkNFOTAxOTJDOTlDMEIyQzYwOEIyRTJDQjYxOUE5MjUxRkI2ODE4NTZDMTU2ODFCMUJDRDYyRUVEQTJENTEyOEUiLCJ0YWdzIjpbIndhc21jbG91ZC5jb20vZXhwZXJpbWVudGFsIl0sInJldiI6MCwidmVyIjoiMC4xLjAiLCJwcm92IjpmYWxzZX0sIndhc2NhcF9yZXZpc2lvbiI6M30.8awbkvrBnRKLpz88s7GXYCW0onpKf_nNfsj7pXhCyvq8pm4y2IotrIPCdBvWqDvDouX4VAM6DQQUHuI-VdKYAA", + HostJwt: "eyJ0eXAiOiJqd3QiLCJhbGciOiJFZDI1NTE5In0.eyJqdGkiOiJuTGdta2Zud2p2Nkw1R28xSlNUdU0zIiwiaWF0IjoxNzIyMDE5OTk1LCJpc3MiOiJBQzNGU0IzT0VSQ1IzVU00WVNWUjJUQURFVlFWUTNITVpQQUtHS082QkNRSTRSNEFITFY2SVhSMiIsInN1YiI6Ik5ETlBUM0QzWVNUQzVKR0g2QVBKUDZBTVZYUVk2QklETVVXWkdTU1FXMjZWSjNINFBDRjJTU0ZSIiwid2FzY2FwIjp7Im5hbWUiOiJkZWxpY2F0ZS1icmVlemUtOTc4NSIsImxhYmVscyI6eyJzZWxmX3NpZ25lZCI6InRydWUifX0sIndhc2NhcF9yZXZpc2lvbiI6M30.5LM_GOpo-6qg0kDrIP_jswI_ZQfOILzHT-FHixvUeAf-1isamLg81S-rb84w6topfvevI6quyV3b-uHZt6q9BQ", + } + + tests := map[string]struct { + plainText bool + req Request + protocolError bool + hostKey string + getFunc func(ctx context.Context, r *Request) (*SecretValue, error) + checkResponse func(*testing.T, Response) + }{ + "blank": { + plainText: true, + protocolError: true, + }, + "happyPath": { + req: Request{ + Key: "secret", + Context: reqCtx, + }, + }, + "upstreamError": { + req: Request{ + Key: "secret", + Context: reqCtx, + }, + protocolError: true, + getFunc: func(context.Context, *Request) (*SecretValue, error) { + return nil, ErrUpstream.With("boom") + }, + checkResponse: func(t *testing.T, resp Response) { + if want, got := ErrUpstream.Error(), resp.Error.Error(); want != got { + t.Errorf("want %v, got %v", want, got) + } + }, + }, + "badSecret": { + req: Request{ + Key: "secret", + Context: reqCtx, + }, + hostKey: "badkey", + protocolError: true, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + if test.getFunc != nil { + handler.getFunc = test.getFunc + } else { + handler.getFunc = basicGetFunc + } + rawData, err := json.Marshal(&test.req) + if err != nil { + t.Fatal(err) + } + + rawReq := nats.NewMsg(server.subjectMapper.SecretsSubject() + ".get") + + if !test.plainText { + sealedData, err := kp.Seal(rawData, serverPubKey) + if err != nil { + t.Fatal(err) + } + + rawReq.Data = sealedData + hostKey := hostPubKey + if test.hostKey != "" { + hostKey = test.hostKey + } + rawReq.Header.Add(WasmCloudHostXkey, hostKey) + } + + rawReply, err := nc.RequestMsg(rawReq, time.Second) + if err != nil { + t.Fatal(err) + } + + var resp Response + + // the presence of the response header indicates if this is an encrypted response or not + // plain responses are protocol errors + responseKey := rawReply.Header.Get(WasmCloudResponseXkey) + if test.protocolError { + if responseKey != "" { + t.Error("saw encryption header on protocol error") + } + + if err := json.Unmarshal(rawReply.Data, &resp); err != nil { + t.Fatal(err) + } + + if resp.Error == nil { + t.Fatal("Expected an error but got none") + } + + if test.checkResponse != nil { + test.checkResponse(t, resp) + } + return + } + + if !test.protocolError && responseKey == "" { + t.Error("missing encryption header") + } + + rawResponse, err := kp.Open(rawReply.Data, responseKey) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(rawResponse, &resp); err != nil { + t.Fatal(err) + } + + if test.checkResponse != nil { + test.checkResponse(t, resp) + } else { + basicCheckResponse(t, resp) + } + }) + } +} diff --git a/secrets/secrets-kubernetes/pkg/secrets/types.go b/secrets/secrets-kubernetes/pkg/secrets/types.go new file mode 100644 index 0000000..5c83d93 --- /dev/null +++ b/secrets/secrets-kubernetes/pkg/secrets/types.go @@ -0,0 +1,317 @@ +package secrets + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + jwt "github.com/golang-jwt/jwt/v5" +) + +const ( + DefaultSecretsBusPrefix = "wasmcloud.secrets" + DefaultSecretsProtocolVersion = "v1alpha1" + WasmCloudHostXkey = "WasmCloud-Host-Xkey" + WasmCloudResponseXkey = "Server-Response-Xkey" +) + +var ( + ErrInvalidServerConfig = errors.New("invalid server configuration") + + ErrSecretNotFound = newResponseError("SecretNotFound", false) + ErrInvalidRequest = newResponseError("InvalidRequest", false) + ErrInvalidHeaders = newResponseError("InvalidHeaders", false) + ErrInvalidPayload = newResponseError("InvalidPayload", false) + ErrEncryption = newResponseError("EncryptionError", false) + ErrDecryption = newResponseError("DecryptionError", false) + + ErrInvalidEntityJWT = newResponseError("InvalidEntityJWT", true) + ErrInvalidHostJWT = newResponseError("InvalidHostJWT", true) + ErrUpstream = newResponseError("UpstreamError", true) + ErrPolicy = newResponseError("PolicyError", true) + ErrOther = newResponseError("Other", true) +) + +type ResponseError struct { + Tip string + HasMessage bool + Message string +} + +func (re ResponseError) With(msg string) *ResponseError { + otherError := re + otherError.Message = msg + return &otherError +} + +func (re ResponseError) Error() string { + return re.Tip +} + +func (re *ResponseError) UnmarshalJSON(data []byte) error { + serdeSpecial := make(map[string]string) + if err := json.Unmarshal(data, &serdeSpecial); err != nil { + var msg string + if err := json.Unmarshal(data, &msg); err != nil { + return err + } + *re = *ErrOther.With(msg) + return nil + } + if len(serdeSpecial) != 1 { + return errors.New("couldn't parse ResponseError") + } + for k, v := range serdeSpecial { + *re = ResponseError{Tip: k, HasMessage: v != "", Message: v} + break + } + + return nil +} + +func (re *ResponseError) MarshalJSON() ([]byte, error) { + if re == nil { + return nil, nil + } + + if !re.HasMessage { + return json.Marshal(re.Tip) + } + + serdeSpecial := make(map[string]string) + serdeSpecial[re.Tip] = re.Message + + return json.Marshal(serdeSpecial) +} + +func newResponseError(tip string, hasMessage bool) *ResponseError { + return &ResponseError{Tip: tip, HasMessage: hasMessage} +} + +// SubjectMapper helps manipulating NATS subjects +type SubjectMapper struct { + Prefix string + Version string + ServiceName string +} + +func (s SubjectMapper) QueueGroupName() string { + return fmt.Sprintf("%s.%s", s.Prefix, s.ServiceName) +} + +func (s SubjectMapper) SecretsSubject() string { + return fmt.Sprintf("%s.%s.%s", s.Prefix, s.Version, s.ServiceName) +} + +func (s SubjectMapper) SecretWildcardSubject() string { + return fmt.Sprintf("%s.>", s.SecretsSubject()) +} + +func (s SubjectMapper) ParseOperation(subject string) string { + prefix := s.SecretsSubject() + "." + if !strings.HasPrefix(subject, prefix) { + return "" + } + + return strings.TrimPrefix(subject, prefix) +} + +type ApplicationContext struct { + Policy string `json:"policy"` + Name string `json:"name"` +} + +type applicationContextPolicy struct { + Type string `json:"type"` + Properties json.RawMessage `json:"properties"` +} + +func (a ApplicationContext) PolicyProperties() (json.RawMessage, error) { + policy := &applicationContextPolicy{} + err := json.Unmarshal([]byte(a.Policy), policy) + return policy.Properties, err +} + +type Context struct { + /// The application the entity belongs to. + /// TODO: should this also be a JWT, but signed by the host? + Application *ApplicationContext `json:"application,omitempty"` + /// The component or provider's signed JWT. + EntityJwt string `json:"entity_jwt"` + /// The host's signed JWT. + HostJwt string `json:"host_jwt"` +} + +func (ctx Context) IsValid() *ResponseError { + if _, _, err := ctx.EntityCapabilities(); err != nil { + return err + } + + if _, _, err := ctx.HostCapabilities(); err != nil { + return err + } + + return nil +} + +func (ctx Context) EntityCapabilities() (*WasCap, *ComponentClaims, *ResponseError) { + token, err := jwt.ParseWithClaims(ctx.EntityJwt, &WasCap{}, KeyPairFromIssuer()) + if err != nil { + return nil, nil, ErrInvalidEntityJWT.With(err.Error()) + } + + wasCap, ok := token.Claims.(*WasCap) + if !ok { + return nil, nil, ErrInvalidEntityJWT.With("not wascap") + } + + compCap := &ComponentClaims{} + if err := json.Unmarshal(wasCap.Was, compCap); err != nil { + return nil, nil, ErrInvalidEntityJWT.With(err.Error()) + } + + return wasCap, compCap, nil +} + +func (ctx Context) HostCapabilities() (*WasCap, *HostClaims, *ResponseError) { + token, err := jwt.ParseWithClaims(ctx.HostJwt, &WasCap{}, KeyPairFromIssuer()) + if err != nil { + return nil, nil, ErrInvalidHostJWT.With(err.Error()) + } + + wasCap, ok := token.Claims.(*WasCap) + if !ok { + return nil, nil, ErrInvalidHostJWT.With("not wascap") + } + + hostCap := &HostClaims{} + if err := json.Unmarshal(wasCap.Was, hostCap); err != nil { + return nil, nil, ErrInvalidHostJWT.With(err.Error()) + } + + return wasCap, hostCap, nil +} + +type Request struct { + Key string `json:"key"` + Field string `json:"field"` + Version string `json:"version"` + Context Context `json:"context"` +} + +func (s Request) Write(w io.Writer) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(&s) +} + +func (s Request) String() string { + var b bytes.Buffer + _ = s.Write(&b) + return b.String() +} + +type ByteArray []uint8 + +func (u ByteArray) MarshalJSON() ([]byte, error) { + var result string + if u == nil { + return nil, nil + } + + result = strings.Join(strings.Fields(fmt.Sprintf("%d", u)), ",") + return []byte(result), nil +} + +type SecretValue struct { + Version string `json:"version,omitempty"` + StringSecret string `json:"string_secret,omitempty"` + BinarySecret ByteArray `json:"binary_secret,omitempty"` +} + +type Response struct { + Secret *SecretValue `json:"secret,omitempty"` + Error *ResponseError `json:"error,omitempty"` +} + +func (r Response) Write(w io.Writer) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(&r) +} + +func (r Response) String() string { + var b bytes.Buffer + _ = r.Write(&b) + return b.String() +} + +type Handler interface { + Get(ctx context.Context, r *Request) (*SecretValue, error) +} + +type ComponentClaims struct { + /// A descriptive name for this component, should not include version information or public key + Name string `json:"name"` + /// A hash of the module's bytes as they exist without the embedded signature. This is stored so wascap + /// can determine if a WebAssembly module's bytecode has been altered after it was signed + ModuleHash string `json:"hash"` + + /// List of arbitrary string tags associated with the claims + Tags []string `json:"tags"` + + /// Indicates a monotonically increasing revision number. Optional. + Rev int32 `json:"rev"` + + /// Indicates a human-friendly version string + Ver string `json:"ver"` + + /// An optional, code-friendly alias that can be used instead of a public key or + /// OCI reference for invocations + CallAlias string `json:"call_alias"` + + /// Indicates whether this module is a capability provider + Provider bool `json:"prov"` + + jwt.RegisteredClaims +} + +type CapabilityProviderClaims struct { + /// A descriptive name for the capability provider + Name string + /// A human-readable string identifying the vendor of this provider (e.g. Redis or Cassandra or NATS etc) + Vendor string + /// Indicates a monotonically increasing revision number. Optional. + Rev int32 + /// Indicates a human-friendly version string. Optional. + Ver string + /// If the provider chooses, it can supply a JSON schma that describes its expected link configuration + ConfigSchema json.RawMessage + /// The file hashes that correspond to the achitecture-OS target triples for this provider. + TargetHashes map[string]string +} + +type HostClaims struct { + /// Optional friendly descriptive name for the host + Name string + /// Optional labels for the host + Labels map[string]string +} + +type WasCap struct { + jwt.RegisteredClaims + + /// Custom jwt claims in the `wascap` namespace + Was json.RawMessage `json:"wascap,omitempty"` + + /// Internal revision number used to aid in parsing and validating claims + Revision int32 `json:"wascap_revision,omitempty"` +} + +func (w WasCap) ParseCapability(dst interface{}) error { + return json.Unmarshal(w.Was, dst) +} diff --git a/secrets/secrets-kubernetes/pkg/secrets/types_test.go b/secrets/secrets-kubernetes/pkg/secrets/types_test.go new file mode 100644 index 0000000..5ce345c --- /dev/null +++ b/secrets/secrets-kubernetes/pkg/secrets/types_test.go @@ -0,0 +1,98 @@ +package secrets + +import ( + "fmt" + "testing" + "time" + + jwt "github.com/golang-jwt/jwt/v5" + "github.com/nats-io/nkeys" +) + +func TestSubjectMapper(t *testing.T) { + // Given prefix, version, servicename + // queue group name must be '.' + // nats subscription subject must be '..' + // wildcard subscription must be the subscription subject above + '.>' + // operation name comes as '...' + + serviceName := "kube" + version := "v0" + prefix := "wasmcloud.secrets" + + s := SubjectMapper{ + Prefix: prefix, + ServiceName: serviceName, + Version: version, + } + + if want, got := fmt.Sprintf("%s.%s", prefix, serviceName), s.QueueGroupName(); got != want { + t.Errorf("QueueGroupName: want %#v, got %#v", want, got) + } + + if want, got := fmt.Sprintf("%s.%s.%s", prefix, version, serviceName), s.SecretsSubject(); got != want { + t.Errorf("SecretsSubject: want %#v, got %#v", want, got) + } + + if want, got := fmt.Sprintf("%s.%s.%s.>", prefix, version, serviceName), s.SecretWildcardSubject(); got != want { + t.Errorf("SecretsSubject: want %#v, got %#v", want, got) + } + + if want, got := "get", s.ParseOperation(fmt.Sprintf("%s.%s.%s.get", prefix, version, serviceName)); got != want { + t.Errorf("ParseOperation: want %#v, got %#v", want, got) + } + + if want, got := "", s.ParseOperation(fmt.Sprintf("%s.%s.malformed_subject", prefix, version)); got != want { + t.Errorf("ParseOperation: want %#v, got %#v", want, got) + } + + if want, got := "", s.ParseOperation("malformed_subject"); got != want { + t.Errorf("ParseOperation: want %#v, got %#v", want, got) + } +} + +func TestJWTClaims(t *testing.T) { + claims := jwt.RegisteredClaims{ + // A usual scenario is to set the expiration time relative to the current time + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: "test", + Subject: "somebody", + ID: "1", + Audience: []string{"somebody_else"}, + } + + kp, err := nkeys.CreateAccount() + if err != nil { + t.Fatal(err) + } + + token := jwt.NewWithClaims(SigningMethodEd25519, claims) + _, err = token.SignedString(kp) + if err != nil { + t.Fatal(err) + } + + validJWT := "eyJ0eXAiOiJqd3QiLCJhbGciOiJFZDI1NTE5In0.eyJqdGkiOiJTakI1Zm05NzRTanU5V01nVFVjaHNiIiwiaWF0IjoxNjQ0ODQzNzQzLCJpc3MiOiJBQ09KSk42V1VQNE9ERDc1WEVCS0tUQ0NVSkpDWTVaS1E1NlhWS1lLNEJFSldHVkFPT1FIWk1DVyIsInN1YiI6Ik1CQ0ZPUE02SlcyQVBKTFhKRDNaNU80Q043Q1BZSjJCNEZUS0xKVVI1WVI1TUlUSVU3SEQzV0Q1Iiwid2FzY2FwIjp7Im5hbWUiOiJFY2hvIiwiaGFzaCI6IjRDRUM2NzNBN0RDQ0VBNkE0MTY1QkIxOTU4MzJDNzkzNjQ3MUNGN0FCNDUwMUY4MzdGOEQ2NzlGNDQwMEJDOTciLCJ0YWdzIjpbXSwiY2FwcyI6WyJ3YXNtY2xvdWQ6aHR0cHNlcnZlciJdLCJyZXYiOjQsInZlciI6IjAuMy40IiwicHJvdiI6ZmFsc2V9fQ.ZWyD6VQqzaYM1beD2x9Fdw4o_Bavy3ZG703Eg4cjhyJwUKLDUiVPVhqHFE6IXdV4cW6j93YbMT6VGq5iBDWmAg" + t.Run("ParseWithClaims", func(t *testing.T) { + _, err := jwt.ParseWithClaims(validJWT, &jwt.RegisteredClaims{}, KeyPairFromIssuer()) + if err != nil { + t.Error(err) + } + }) + + t.Run("ComponentClaims", func(t *testing.T) { + token, err := jwt.ParseWithClaims(validJWT, &WasCap{}, KeyPairFromIssuer()) + if err != nil { + t.Fatal(err) + } + + var componentClaims ComponentClaims + wasCap := token.Claims.(*WasCap) + err = wasCap.ParseCapability(&componentClaims) + if err != nil { + t.Error(err) + } + }) +}