diff --git a/.github/workflows/openbao-jwt-plugin.yml b/.github/workflows/openbao-jwt-plugin.yml new file mode 100644 index 000000000..c00394c96 --- /dev/null +++ b/.github/workflows/openbao-jwt-plugin.yml @@ -0,0 +1,85 @@ +# SPDX-FileCopyrightText: Copyright (c) NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Build and test the OpenBao JWT secrets plugin. +# +# This module is deliberately NOT in go.work.bazel and has no BUILD.bazel, so +# the Bazel matrix in bazel.yml never sees it. That is the point: its graph is +# 278 modules including hashicorp/vault/api and hashicorp/vault/sdk, none of +# which the root module uses today. Joining the root graph would put all of it +# into minimal version selection for every service in the repository, to build +# one plugin binary that ships inside a single image. +# +# The cost of that isolation is that nothing else would compile or test this +# code, and NVIDIA now owns modifications to it. Hence this job: plain go, +# scoped to the one directory, so the tests actually run. + +name: openbao-jwt-plugin + +on: + push: + branches: [main] + paths: + - 'infra/openbao/plugins/vault-plugin-secrets-jwt/**' + - '.github/workflows/openbao-jwt-plugin.yml' + pull_request: + branches: [main] + paths: + - 'infra/openbao/plugins/vault-plugin-secrets-jwt/**' + - '.github/workflows/openbao-jwt-plugin.yml' + merge_group: + types: [checks_requested] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: openbao-jwt-plugin-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + shell: bash + +jobs: + build-test: + name: build and test + runs-on: ubuntu-latest + defaults: + run: + working-directory: infra/openbao/plugins/vault-plugin-secrets-jwt + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: infra/openbao/plugins/vault-plugin-secrets-jwt/go.mod + cache-dependency-path: infra/openbao/plugins/vault-plugin-secrets-jwt/go.sum + + - name: Build + run: go build ./... + + - name: Test + run: go test ./... + + # The plugin must not regain a dependency on friendlyid-go. That project + # carries no license and is not redistributable; plugin/friendlyid.go is + # the independently authored replacement. A `go get` that reintroduces it + # would be a licensing regression, not a build failure, so nothing else + # would catch it. + - name: Assert no unlicensed dependency + run: | + # Capture first rather than piping into grep. Inside `if`, a failing + # `go list` would otherwise read as "dependency absent", and under + # pipefail grep can close the pipe on a match and leave go list + # killed by SIGPIPE. Either way the gate would pass while blind. + modules="$(go list -m all)" || { + echo "::error::failed to enumerate the Go module graph" >&2 + exit 1 + } + if grep -Fq 'github.com/mariuszs/friendlyid-go' <<<"$modules"; then + echo "::error::friendlyid-go is back in the module graph; it carries no license and cannot be redistributed" >&2 + exit 1 + fi + echo "friendlyid-go absent from the module graph" diff --git a/NOTICE b/NOTICE index 44db63899..b30d3cc14 100644 --- a/NOTICE +++ b/NOTICE @@ -11,6 +11,7 @@ The following third-party licenses are included in this repository: deploy/helm/container-cache/NOTICE deploy/helm/nats/NOTICE deploy/helm/openbao/NOTICE + infra/openbao/plugins/vault-plugin-secrets-jwt/NOTICE src/compute-plane-services/byoo-otel-collector/NOTICE src/compute-plane-services/ess-agent/NOTICE src/compute-plane-services/image-credential-helper/vendor/dario.cat/mergo/LICENSE diff --git a/dependencies.md b/dependencies.md index 0e455bc41..01165ce8b 100644 --- a/dependencies.md +++ b/dependencies.md @@ -224,6 +224,7 @@ Generated by `go run ./tools/collect-dependencies`. Refresh: `go run ./tools/col - `Go`: `github.com/golang/mock` - `Go`: `github.com/google/btree` - `Go`: `github.com/google/cel-go` +- `Go`: `github.com/google/certificate-transparency-go` - `Go`: `github.com/google/gnostic-models` - `Go`: `github.com/google/go-containerregistry` - `Go`: `github.com/google/go-tpm` @@ -241,6 +242,7 @@ Generated by `go run ./tools/collect-dependencies`. Refresh: `go run ./tools/col - `Go`: `github.com/inconshreveable/mousetrap` - `Go`: `github.com/ionos-cloud/sdk-go/v6` - `Go`: `github.com/jmespath/go-jmespath` +- `Go`: `github.com/joshlf/go-acl` - `Go`: `github.com/klauspost/compress` - `Go`: `github.com/kylelemons/godebug` - `Go`: `github.com/lightstep/go-expohisto` @@ -332,6 +334,7 @@ Generated by `go run ./tools/collect-dependencies`. Refresh: `go run ./tools/col - `Go`: `github.com/outcaste-io/ristretto` - `Go`: `github.com/pb33f/jsonpath` - `Go`: `github.com/pb33f/ordered-map/v2` +- `Go`: `github.com/petermattis/goid` - `Go`: `github.com/prometheus/alertmanager` - `Go`: `github.com/prometheus/client_golang` - `Go`: `github.com/prometheus/client_golang/exp` @@ -347,12 +350,14 @@ Generated by `go run ./tools/collect-dependencies`. Refresh: `go run ./tools/col - `Go`: `github.com/run-ai/karta` - `Go`: `github.com/santhosh-tekuri/jsonschema/v5` - `Go`: `github.com/santhosh-tekuri/jsonschema/v6` +- `Go`: `github.com/sasha-s/go-deadlock` - `Go`: `github.com/scaleway/scaleway-sdk-go` - `Go`: `github.com/spf13/afero` - `Go`: `github.com/spf13/cobra` - `Go`: `github.com/spiffe/go-spiffe/v2` - `Go`: `github.com/stackitcloud/stackit-sdk-go/core` - `Go`: `github.com/synadia-io/callout.go` +- `Go`: `github.com/tink-crypto/tink-go/v2` - `Go`: `github.com/tklauser/numcpus` - `Go`: `github.com/ua-parser/uap-go` - `Go`: `github.com/vishvananda/netlink` @@ -481,6 +486,7 @@ Generated by `go run ./tools/collect-dependencies`. Refresh: `go run ./tools/col - `Go`: `google.golang.org/grpc/cmd/protoc-gen-go-grpc` - `Go`: `gopkg.in/go-jose/go-jose.v2` - `Go`: `gopkg.in/ini.v1` +- `Go`: `gopkg.in/square/go-jose.v2` - `Go`: `gopkg.in/yaml.v2` - `Go`: `gopkg.in/yaml.v3` - `Go`: `gotest.tools/v3` @@ -1148,6 +1154,7 @@ Generated by `go run ./tools/collect-dependencies`. Refresh: `go run ./tools/col - `Go`: `github.com/gosuri/uitable` - `Go`: `github.com/gregjones/httpcache` - `Go`: `github.com/hashicorp/go-hclog` +- `Go`: `github.com/hashicorp/go-hmac-drbg` - `Go`: `github.com/hashicorp/go-metrics` - `Go`: `github.com/hashicorp/go-msgpack/v2` - `Go`: `github.com/hashicorp/go-syslog` @@ -1395,6 +1402,7 @@ Generated by `go run ./tools/collect-dependencies`. Refresh: `go run ./tools/col - `Go`: `github.com/hashicorp/go-gatedio` - `Go`: `github.com/hashicorp/go-immutable-radix` - `Go`: `github.com/hashicorp/go-kms-wrapping/entropy/v2` +- `Go`: `github.com/hashicorp/go-kms-wrapping/v2` - `Go`: `github.com/hashicorp/go-memdb` - `Go`: `github.com/hashicorp/go-multierror` - `Go`: `github.com/hashicorp/go-plugin` @@ -1402,9 +1410,12 @@ Generated by `go run ./tools/collect-dependencies`. Refresh: `go run ./tools/col - `Go`: `github.com/hashicorp/go-rootcerts` - `Go`: `github.com/hashicorp/go-secure-stdlib/awsutil` - `Go`: `github.com/hashicorp/go-secure-stdlib/base62` +- `Go`: `github.com/hashicorp/go-secure-stdlib/cryptoutil` - `Go`: `github.com/hashicorp/go-secure-stdlib/mlock` - `Go`: `github.com/hashicorp/go-secure-stdlib/parseutil` - `Go`: `github.com/hashicorp/go-secure-stdlib/password` +- `Go`: `github.com/hashicorp/go-secure-stdlib/permitpool` +- `Go`: `github.com/hashicorp/go-secure-stdlib/plugincontainer` - `Go`: `github.com/hashicorp/go-secure-stdlib/strutil` - `Go`: `github.com/hashicorp/go-secure-stdlib/tlsutil` - `Go`: `github.com/hashicorp/go-sockaddr` diff --git a/infra/openbao/.license-header.txt b/infra/openbao/.license-header.txt new file mode 100644 index 000000000..b79b03d27 --- /dev/null +++ b/infra/openbao/.license-header.txt @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + diff --git a/infra/openbao/Dockerfile b/infra/openbao/Dockerfile new file mode 100644 index 000000000..1a558980b --- /dev/null +++ b/infra/openbao/Dockerfile @@ -0,0 +1,35 @@ +# The JWT secrets plugin is compiled here rather than copied in prebuilt. +# +# It used to come from scripts/build-jwt-plugin.sh, which cloned a fork hosted +# outside this repository at a pinned revision and dropped the binaries into +# files/plugins/. That made the image unbuildable from public source, so the +# binaries had to reach the build some other way: committed to git, or injected +# as a private overlay the way the cassandra image takes its exporter jar. +# +# Neither is needed now. The plugin source lives at plugins/ inside this build +# context, so the stage below compiles it from the same commit that produces +# the image. Nothing is vendored and nothing is injected, so `docker build .` +# here produces the same image as the release pipeline. +ARG BAO_VERSION=2.5.5 +ARG GO_VERSION=1.25 + +FROM golang:${GO_VERSION}-alpine AS plugin-build +ARG TARGETARCH +WORKDIR /src +# Module files first so dependency download caches independently of source edits. +COPY plugins/vault-plugin-secrets-jwt/go.mod plugins/vault-plugin-secrets-jwt/go.sum ./ +RUN go mod download +COPY plugins/vault-plugin-secrets-jwt/ ./ +# CGO_ENABLED=0 for a static binary: the plugin is exec'd by the OpenBao server +# inside a distro image it was not linked against, so it must not need a +# dynamic loader. -trimpath keeps build paths out of the binary. +RUN GOOS=linux GOARCH="${TARGETARCH}" CGO_ENABLED=0 \ + go build -trimpath -o /out/vault-plugin-secrets-jwt ./cmd/vault-plugin-secrets-jwt + +FROM openbao/openbao:${BAO_VERSION} + +# hadolint ignore=DL3018 +RUN apk add --no-cache curl jq bash && \ + mkdir -p /openbao/plugins + +COPY --from=plugin-build --chmod=0555 /out/vault-plugin-secrets-jwt /openbao/plugins/vault-plugin-secrets-jwt diff --git a/infra/openbao/README.md b/infra/openbao/README.md new file mode 100644 index 000000000..424cc6597 --- /dev/null +++ b/infra/openbao/README.md @@ -0,0 +1,73 @@ +# NVCF OpenBao + +Container image used by NVCF deployments to run [OpenBao](https://openbao.org/), bundled with the additional vault plugin(s) NVCF expects at runtime. + +## Overview + +This repository ships: + +- A multi-arch container image definition (`Dockerfile`) layered on top of `openbao/openbao` +- A directory (`files/plugins/`) where the user supplies the vault plugin binary at build time + +## Plugin binaries + +The image expects an OS-specific plugin binary at build time, placed at: + +- `files/plugins/vault-plugin-secrets-jwt-linux-amd64` (for `--platform linux/amd64`) +- `files/plugins/vault-plugin-secrets-jwt-linux-arm64` (for `--platform linux/arm64`) + +The plugin is built from source in this repository, at +`plugins/vault-plugin-secrets-jwt`. Nothing needs to be cloned or placed by +hand, and no binaries are committed. + +The image build compiles it in a Dockerfile build stage, so `docker build .` +here produces the same image as the release pipeline: + +```bash +docker build --build-arg TARGETARCH=amd64 -t nvcf-openbao:local . +``` + +To produce the binaries outside an image build, for local inspection or to run +the verifier: + +```bash +scripts/build-jwt-plugin.sh # writes both arch binaries to files/plugins/ +scripts/verify-jwt-plugin.sh # asserts module path, target, toolchain, deps +``` + +`files/plugins/` is gitignored apart from `.gitkeep`; see +`files/plugins/PROVENANCE.md` for the dependency floors the verifier enforces. + +## Prerequisites + +- Docker or another OCI-compatible builder (with `buildx` for multi-arch) +- A built copy of `vault-plugin-secrets-jwt` for each platform you target, placed in `files/plugins/` + +## Building the container + +The `Dockerfile` defaults to the `openbao/openbao:2.5.5` base image. Override the `BAO_VERSION` build-arg to track a different upstream tag. + +```bash +docker build \ + --build-arg TARGETARCH=amd64 \ + --build-arg BAO_VERSION=2.5.5 \ + -t //nvcf-openbao: . +``` + +For multi-arch builds: + +```bash +docker buildx build \ + --platform linux/amd64,linux/arm64 \ + --build-arg BAO_VERSION=2.5.5 \ + -t //nvcf-openbao: \ + --push . +``` + +## Image contents + +At runtime the image provides: + +- The upstream OpenBao server (`/usr/local/bin/bao`) +- Alpine packages `curl`, `jq`, and `bash` (used by entrypoint scripts in consumers such as the migrations Job) +- `/openbao/plugins/vault-plugin-secrets-jwt` - the JWT secrets plugin built from `outfoxx/vault-plugin-secrets-jwt` diff --git a/infra/openbao/files/plugins/.gitignore b/infra/openbao/files/plugins/.gitignore new file mode 100644 index 000000000..76ab85dd3 --- /dev/null +++ b/infra/openbao/files/plugins/.gitignore @@ -0,0 +1 @@ +vault-plugin-secrets-jwt-linux-* diff --git a/infra/openbao/files/plugins/.gitkeep b/infra/openbao/files/plugins/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/infra/openbao/files/plugins/PROVENANCE.md b/infra/openbao/files/plugins/PROVENANCE.md new file mode 100644 index 000000000..1cf3fdb3a --- /dev/null +++ b/infra/openbao/files/plugins/PROVENANCE.md @@ -0,0 +1,35 @@ +# JWT plugin provenance + +The `vault-plugin-secrets-jwt` binaries in this directory are build output. +They are not committed; `.gitignore` excludes them and only `.gitkeep` ships. + +## Source + +`../../plugins/vault-plugin-secrets-jwt` in this repository. The plugin is a +modified copy of the Apache-2.0 project `outfoxx/vault-plugin-secrets-jwt`; +that directory's `NOTICE` enumerates every NVIDIA change. + +The image build compiles the plugin from that source in a Dockerfile build +stage, so the binary and the image come from the same commit. Nothing is +fetched from outside this repository at build time. + +## Dependency floors + +Held deliberately, not incidental to a `go mod tidy`: + +- `golang.org/x/net v0.55.0` - security floor +- `github.com/hashicorp/vault/api v1.15.0` +- `github.com/hashicorp/vault/sdk v0.15.2` +- `google.golang.org/grpc v1.69.4` +- `github.com/go-jose/go-jose/v4 v4.0.4` + +The vault and grpc pins keep compatibility with the previously shipped plugin +binary. `scripts/verify-jwt-plugin.sh` asserts them against the built artifact. + +## Local build + + scripts/build-jwt-plugin.sh # writes both arch binaries here + scripts/verify-jwt-plugin.sh # asserts module path, target, toolchain, deps + +Hashes are reported by the verifier rather than pinned: any source change in +this repository legitimately changes them. diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/AGENTS.md b/infra/openbao/plugins/vault-plugin-secrets-jwt/AGENTS.md new file mode 100644 index 000000000..c777ba166 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/AGENTS.md @@ -0,0 +1,30 @@ +# vault-plugin-secrets-jwt + +Third-party code. This is a modified copy of +[outfoxx/vault-plugin-secrets-jwt](https://github.com/outfoxx/vault-plugin-secrets-jwt), +Apache-2.0, Copyright 2021 Outfox, Inc. Read `NOTICE` before changing anything +here; it enumerates every NVIDIA modification, which Apache-2.0 section 4(b) +requires us to keep accurate. + +## Rules + +Do not run the repository copyright stamper over this directory. Files that +carry the upstream Outfox header must keep it. Add an NVIDIA header only to a +file NVIDIA authored, and record the change in `NOTICE` in the same commit. + +The Go module path is +`github.com/NVIDIA/nvcf/infra/openbao/plugins/vault-plugin-secrets-jwt`, which +deliberately differs from the upstream path. We do not track upstream, so the +path was changed to resolve inside this repository rather than to keep diffs +against Outfox readable. + +There is no dependency on `github.com/mariuszs/friendlyid-go` and there must +not be one. That project carries no license and is not redistributable; +`plugin/friendlyid.go` is the independently authored replacement. + +## Build and test + + go build ./... + go test ./... + +The image build that consumes this lives in `infra/openbao`. diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/CLAUDE.md b/infra/openbao/plugins/vault-plugin-secrets-jwt/CLAUDE.md new file mode 100644 index 000000000..43c994c2d --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/HEADER b/infra/openbao/plugins/vault-plugin-secrets-jwt/HEADER new file mode 100644 index 000000000..d1debef88 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/HEADER @@ -0,0 +1,14 @@ +Copyright 2021 Outfox, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/LICENSE b/infra/openbao/plugins/vault-plugin-secrets-jwt/LICENSE new file mode 100644 index 000000000..5471dc103 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/LICENSE @@ -0,0 +1,203 @@ + + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/NOTICE b/infra/openbao/plugins/vault-plugin-secrets-jwt/NOTICE new file mode 100644 index 000000000..0a85bdd20 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/NOTICE @@ -0,0 +1,66 @@ +vault-plugin-secrets-jwt +======================== + +This directory contains a modified copy of: + + vault-plugin-secrets-jwt + https://github.com/outfoxx/vault-plugin-secrets-jwt + Copyright 2021 Outfox, Inc. + Licensed under the Apache License, Version 2.0 + +The original Apache License 2.0 text is retained in LICENSE, and the original +per-file copyright header is retained in HEADER and in every file carrying it. +Upstream-originated files not listed below are unmodified and remain under +Outfox, Inc. copyright. Files added by NVIDIA are listed below and are not +covered by that statement. + +Modifications by NVIDIA CORPORATION & AFFILIATES +------------------------------------------------ + +As required by section 4(b) of the Apache License, Version 2.0, the following +changes were made to the original work: + +1. plugin/path_roles.go: the guard rejecting a `sub` key in a role's `claims` + field is disabled, so `sub` may be configured per role. + +2. plugin/path_sign.go: a `logical.ReadOperation` is registered on the signing + path, so a token can be issued from the role's stored claims via a read. + +3. plugin/path_roles_test.go, plugin/path_sign_test.go: coverage for (1) and + (2). + +4. plugin/friendlyid.go and plugin/friendlyid_test.go (added by NVIDIA): + base62 UUID encoding, written independently from the base62 definition. + These replace the previous dependency on github.com/mariuszs/friendlyid-go, + which carries no license and is therefore not redistributable. No source + was copied from that project. + +5. plugin/util.go: unique-id generation now calls the encoder in (4) instead of + the removed dependency. + +6. go.mod, go.sum: the module path was changed to + github.com/NVIDIA/nvcf/infra/openbao/plugins/vault-plugin-secrets-jwt so the + module resolves inside this repository. The friendlyid-go requirement was + removed. Go, Vault, and security-sensitive dependency versions were updated. + +7. plugin/config.go: the unsupported-signature-algorithm error is returned + rather than discarded. It previously reported success, so the key was never + rotated and the caller could not detect it. + +8. plugin/util.go: token ids are generated from a version 4 UUID rather than a + version 1 UUID. A v1 UUID encodes the host MAC address and creation + timestamp, and this id is published as the token's `jti`. + +9. AGENTS.md and CLAUDE.md (added by NVIDIA): repository guidance describing + this directory's third-party origin and the rules for changing it. They are + not part of the distributed plugin and carry no copyright header. + +10. Upstream project machinery not applicable to this repository was omitted: + GitHub Actions workflows, goreleaser configuration, the upstream Dockerfile, + install script, Makefile and linter configuration. Source, tests, and + license material were retained in full. + +Files carrying an NVIDIA copyright header are NVIDIA-authored. Every +upstream-originated file retains the upstream Outfox, Inc. header. AGENTS.md +and CLAUDE.md carry neither, being repository guidance rather than distributed +source. diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/README.md b/infra/openbao/plugins/vault-plugin-secrets-jwt/README.md new file mode 100644 index 000000000..b2169cd99 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/README.md @@ -0,0 +1,315 @@ +# Vault Plugin: JWT Secrets +### A [Hashicorp Vault](https://www.github.com/hashicorp/vault) secrets plugin for generating and verifying JSON Web Tokens + +* [Overview](#overview) +* [Encryption And Key Managment](#encryption-and-key-management) +* [Usage](#usage) + * [Quick Start](#quick-start) + * [Container](#container) + * [Configuration](#configuration) + * [Roles](#roles) + * [Signing](#signing) +* [Implementation Notes](#implementation-notes) +* [Contributors](#contributors) +* [Links](#quick-links) + +# Overview + +This plugin provides the ability to generate signed [JSON Web Tokens](https://jwt.io) (JWTs) without the signing keys +ever leaving Vault. + +The plugin works by providing a service to sign JWTs using internal private key(s). +Simultaneously the plugin provides a [JSON Web Key](https://www.ietf.org/rfc/rfc7517.txt) +RFC compliant HTTP endpoint to publish public verification keys. + +The plugin explicitly does not support verifying JWTs as a service; instead relying on clients to +fetch the verification keys via HTTP and verify JWTs locally. This dramatically reduces traffic to +Vault as well as allows clients to use standard client libraries for verification. + +### âš ī¸ Early Access +The plugin is still under early development and should be tested thoroughly before being used in +any environment. + +# Encryption and Key Management + +## Automatic Key Rotation + +The plugin automatically rotates signing keys and publishes a history of previous keys for +verification. The rotation schedule is configurable and ensures the keys will be available for +verification as long as any JWTs signed with them are valid. + +## Supported Algorithms + +The plugin supports a subset of the asymmetric encryption algorithms outlined in the JWT +specification. + +* ES256 +* ES384 +* ES512 +* RS256 +* RS384 +* RS512 + +Note: Due to its reliance on asymmetric encryption, the plugin will not support symmetric algorithms. + +# Usage + +## Quick Start +The plugin needs to be built and installed into your Vault instance's plugin directory prior +to any attempt at usage. A prepackaged container is available see [Container](#container). + +1. Register the plugin + + +```bash +export PLUGIN_SHA=$(sha256sum $VAULT_PLUGIN_PATH/vault-plugin-secrets-jwt | cut -d ' ' -f1) +``` + +```bash +vault plugin register -sha256=$PLUGIN_SHA -command=vault-plugin-secrets-jwt secret jwt +``` + +2. Enable the plugin + +```bash +vault secrets enable jwt +``` + +3. Create a role specifying the issuer (`iss`) claim of generated JWTs + +```bash +vault write jwt/roles/test-role issuer=test.example.com +``` + +4. Sign a JWT (with default claims) + +```bash +vault write -f jwt/sign/test-role +``` + +5. Retrieve JWKs for verification + +```bash +curl https://$VAULT_ADDRESS/v1/jwt/jwks +``` + +## Container + +A containerized version of Vault with the plugin pre-packaged inside is available for testing at +`https://hub.docker.com/r/outfoxx/vault`. + +You can easily start a server in dev mode, that has the plugin enabled, using: +```bash +docker run --rm -P -e VAULT_DEV_ROOT_TOKEN_ID=root outfoxx/vault +``` + +## Configuration + +The plugin has a usable (although probably not useful) default configuration. Although prior to usage +roles must be configured. + +### 🔸 Allowed Claims + +The plugin requires that any claims provided during role creation or JWT signing be explicitly +allowed in the configuration. By default, only the audience (`aud`) claim is allowed. + +Allow `aud` and `groups` claims: + +```bash +vault write jwt/config allowed_claims="aud" allowed_claims="groups" +``` + +â„šī¸ The `allowed_claims` field is a list, passing multiple values to `vault` cli allows you to +create a list. + +### 🔸 Allowed Headers + +The plugin requires that any headers provided during role creation be explicitly +allowed in the configuration. + +Allow `iss` and `path` claims: + +```bash +vault write jwt/config allowed_headers="iss" allowed_headers="path" +``` + +â„šī¸ The `allowed_headers` field is a list, passing multiple values to `vault` cli allows you to +create a list. + +### 🔸 Signature Algorithm + +The plugin allows configuration of the signature algorithm used to sign JWTs. By default, the +`ES256`algorithm is used. + +```bash +vault write jwt/config sig_alg=RS256 +``` + +When using an RSA algorithm (e.g. `RS256`) you can also select the size of the RSA key that +is generated. By default, a `2048` bit key is generated. + +```bash +vault write jwt/config sig_alg=RS256 rsa_key_bits=4096 +``` + +### 🔸 Key Rotation + +Key rotation is automatically done by the plugin. You can configure the key rotation period to +whatever duration you wish. + +```bash +vault write jwt/config key_ttl=12h0s +``` + +When keys are rotated the previous keys are kept to allow verification. Verification keys +are pruned at a time after which all generated tokens have expired. + +### 🔸 Token TTL + +Each generated JWT has a finite expiration. Configure the TTL used to determine each token's +expiration with the `token_ttl` field. By default, each token expires after `3m0s`. + +```bash +vault write jwt/config token_ttl=3m +``` + +### 🔸 Audience & Subject Restrictions + +The plugin can be configured to restrict the audience (`aud`) and subject (`sub`) claims to +those matching a specific pattern. By default, both claims are unrestricted. + +```bash +vault write jwt/config subject_pattern=*.example.com +``` + +```bash +vault write jwt/config audience_pattern=*.example.com +``` + +Additionally, the audience (`aud`) claim (which is a list of stings) can be restricted to +a maximum length. By default, audience length is unrestricted. + +```bash +vault write jwt/config max_audiences=2 +``` + +### 🔸 Generated Reserved Claims + +The issuer (`iss`) claim for generated tokens can be specified in the configuration. By +default, no issuer claim is added. + +```bash +vault write jwt/config issuer=vault.example.com +``` + +The "unique token id" (`jti`) claim can be enabled/disabled. By default, a "unique token id" claim is added. + +```bash +vault write jwt/config set_jti=true +``` + +The "not before" (`nbf`) claim can be enabled/disabled. By default, a "not before" claim is added. + +```bash +vault write jwt/config set_nbf=true +``` + +The "issued at" (`iat`) claim can be enabled/disabled. By default, an "issued at" claim is added. + +```bash +vault write jwt/config set_iat=true +``` + +## Roles + +Before signing a JWT a role must be configured. + +### 🔸 Issuer + +When creating a role a value for the `issuer` field must be provided. The role issuer field specifies the +issuer (`iss`) claim for signed JWTs. This is the only method of providing the issuer claim for JWTs. + +```bash +vault write jwt/roles/test-role issuer=test.example.com +``` + +### 🔸 Other Claims + +Roles can additionally include any other claims that are allowed by the configuration. + +```bash +echo claims '{"claims": {"groups":"test-group"}}' | vault write jwt/roles/test-role - +``` + +âš ī¸ Due to deficiencies of the `vault` cli, you need to pass `claims` in as JSON. + +â„šī¸ Any claims set in a role's `claims` field must be explicitly allowed in the +plugin's configuration and can no longer be set during a sign request. + +### 🔸 Other Headers + +Roles can additionally include any other headers that are allowed by the configuration. + +```bash +echo claims '{"headers": {"iss":"some-key-issuer"}}' | vault write jwt/roles/test-role - +``` + +âš ī¸ Due to deficiencies of the `vault` cli, you need to pass `headers` in as JSON. + +â„šī¸ Any headers set in a role's `headers` field must be explicitly allowed in the +plugin's configuration. + +### 🔸 Audience & Subject Restrictions + +The role can be configured to restrict the audience (`aud`) and subject (`sub`) claims to +those matching a specific pattern; this restriction is in addition to the pattern restrictions +defined in the configuration. By default, both claims are unrestricted. + +```bash +vault write jwt/roles/test-role subject_pattern=*.example.com +``` + +```bash +vault write jwt/roles/test-role audience_pattern=*.example.com +``` + +## Signing + +Signing a JWT requires a role be configured and is easily done using the `sign` service, +providing the role name. + +Sign a JWT with default configured claims. +```bash +vault write -f jwt/sign/test-role +``` + +Additionally, when signing a JWT, any claims allowed by the `allowed_claims` configuration and +can be specified. + +```bash +echo claims '{"claims": {"groups":"test-group"}}' | vault write jwt/sign/test-role - +``` + +âš ī¸ If a claim value has been specified in the role's `claims` field, it cannot +be overridden during the sign request. + +# Implementation Notes + +## `keysutil` Usage + +The plugin uses the same mechanism as the builtin `Transit` secrets engine. Using `keysutil` +ensures the key management and rotation is built on a solid cryptographic engine. + +# Contributors + +The original plugin started life as a learning exercise for [Ian Fox](https://github.com/ian-fox) and +I'd like to thank him for his initial proof-of-concept. As we make improvements he has kindly allowed +us to take over the project and move it forward. + +We have taken the original proof-of-concept and rewrote it in hopes of providing a solid plugin that +can be used in production. + +# Quick Links + - Vault Website: https://www.vaultproject.io + - Main Project Github: https://www.github.com/hashicorp/vault + - JWT docs: https://jwt.io diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/cmd/vault-plugin-secrets-jwt/main.go b/infra/openbao/plugins/vault-plugin-secrets-jwt/cmd/vault-plugin-secrets-jwt/main.go new file mode 100644 index 000000000..dff6add0f --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/cmd/vault-plugin-secrets-jwt/main.go @@ -0,0 +1,50 @@ +// +// Copyright 2021 Outfox, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package main + +import ( + "os" + + "github.com/hashicorp/go-hclog" + "github.com/hashicorp/vault/api" + "github.com/hashicorp/vault/sdk/plugin" + jwtsecrets "github.com/NVIDIA/nvcf/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin" +) + +func main() { + logger := hclog.New(&hclog.LoggerOptions{}) + + apiClientMeta := &api.PluginAPIClientMeta{} + flags := apiClientMeta.FlagSet() + err := flags.Parse(os.Args[1:]) + if err != nil { + logger.Error("plugin shutting down", "invalid args", err) + os.Exit(1) + } + + tlsConfig := apiClientMeta.GetTLSConfig() + tlsProviderFunc := api.VaultPluginTLSProvider(tlsConfig) + + err = plugin.ServeMultiplex(&plugin.ServeOpts{ + BackendFactoryFunc: jwtsecrets.Factory, + TLSProviderFunc: tlsProviderFunc, + }) + if err != nil { + logger.Error("plugin shutting down", "serve error", err) + os.Exit(1) + } +} diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/go.mod b/infra/openbao/plugins/vault-plugin-secrets-jwt/go.mod new file mode 100644 index 000000000..2db74fa1c --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/go.mod @@ -0,0 +1,94 @@ +module github.com/NVIDIA/nvcf/infra/openbao/plugins/vault-plugin-secrets-jwt + +go 1.23.3 + +toolchain go1.23.8 + +require ( + github.com/go-test/deep v1.1.1 + github.com/google/uuid v1.6.0 + github.com/hashicorp/go-hclog v1.6.3 + github.com/hashicorp/vault/api v1.15.0 + github.com/hashicorp/vault/sdk v0.15.2 + gopkg.in/square/go-jose.v2 v2.6.0 +) + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/armon/go-metrics v0.4.1 // indirect + github.com/armon/go-radix v1.0.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v27.2.1+incompatible // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/evanphx/json-patch/v5 v5.6.0 // indirect + github.com/fatih/color v1.17.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-jose/go-jose/v4 v4.0.4 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/google/certificate-transparency-go v1.3.1 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-hmac-drbg v0.0.0-20210916214228-a6e5a68489f6 // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect + github.com/hashicorp/go-kms-wrapping/entropy/v2 v2.0.1 // indirect + github.com/hashicorp/go-kms-wrapping/v2 v2.0.18 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-plugin v1.6.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/cryptoutil v0.1.1 // indirect + github.com/hashicorp/go-secure-stdlib/mlock v0.1.3 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.1.9 // indirect + github.com/hashicorp/go-secure-stdlib/permitpool v1.0.0 // indirect + github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.1 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.6 // indirect + github.com/hashicorp/go-uuid v1.0.3 // indirect + github.com/hashicorp/go-version v1.7.0 // indirect + github.com/hashicorp/golang-lru v1.0.2 // indirect + github.com/hashicorp/hcl v1.0.1-vault-5 // indirect + github.com/hashicorp/yamux v0.1.1 // indirect + github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/go-testing-interface v1.14.1 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/oklog/run v1.1.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 // indirect + github.com/pierrec/lz4 v2.6.1+incompatible // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/sasha-s/go-deadlock v0.3.5 // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/tink-crypto/tink-go/v2 v2.2.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect + go.opentelemetry.io/otel v1.31.0 // indirect + go.opentelemetry.io/otel/metric v1.31.0 // indirect + go.opentelemetry.io/otel/trace v1.31.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/net v0.34.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.21.0 // indirect + golang.org/x/time v0.9.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 // indirect + google.golang.org/grpc v1.69.4 // indirect + google.golang.org/protobuf v1.36.3 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/go.sum b/infra/openbao/plugins/vault-plugin-secrets-jwt/go.sum new file mode 100644 index 000000000..c395c485a --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/go.sum @@ -0,0 +1,347 @@ +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= +github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= +github.com/armon/go-metrics v0.4.1 h1:hR91U9KYmb6bLBYLQjyM+3j+rcd/UhE+G78SFnF8gJA= +github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+3JqfkOG4= +github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= +github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= +github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bufbuild/protocompile v0.10.0 h1:+jW/wnLMLxaCEG8AX9lD0bQ5v9h1RUiMKOBOT5ll9dM= +github.com/bufbuild/protocompile v0.10.0/go.mod h1:G9qQIQo0xZ6Uyj6CMNz0saGmx2so+KONo8/KrELABiY= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag= +github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +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/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/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v27.2.1+incompatible h1:fQdiLfW7VLscyoeYEBz7/J8soYFDZV1u6VW6gJEjNMI= +github.com/docker/docker v27.2.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww= +github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4= +github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= +github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= +github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= +github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.0 h1:+cqqvzZV87b4adx/5ayVOaYZ2CrvM4ejQvUdBzPPUss= +github.com/frankban/quicktest v1.14.0/go.mod h1:NeW+ay9A/U67EYXNFA1nPE8e/tnQv/09mUdL/ijj8og= +github.com/go-jose/go-jose/v4 v4.0.4 h1:VsjPI33J0SB9vQM6PLmNjoHqMQNGPiZ0rHL7Ni7Q6/E= +github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc= +github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= +github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= +github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/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/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/certificate-transparency-go v1.3.1 h1:akbcTfQg0iZlANZLn0L9xOeWtyCIdeoYhKrqi5iH3Go= +github.com/google/certificate-transparency-go v1.3.1/go.mod h1:gg+UQlx6caKEDQ9EElFOujyxEQEfOiQzAt6782Bvi8k= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +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/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/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1 h1:/c3QmbOGMGTOumP2iT/rCwB7b0QDGLKzqOmktBjT+Is= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-hmac-drbg v0.0.0-20210916214228-a6e5a68489f6 h1:kBoJV4Xl5FLtBfnBjDvBxeNSy2IRITSGs73HQsFUEjY= +github.com/hashicorp/go-hmac-drbg v0.0.0-20210916214228-a6e5a68489f6/go.mod h1:y+HSOcOGB48PkUxNyLAiCiY6rEENu+E+Ss4LG8QHwf4= +github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-kms-wrapping/entropy/v2 v2.0.1 h1:KIge4FHZEDb2/xjaWgmBheCTgRL6HV4sgTfDsH876L8= +github.com/hashicorp/go-kms-wrapping/entropy/v2 v2.0.1/go.mod h1:aHO1EoFD0kBYLBedqxXgalfFT8lrWfP7kpuSoaqGjH0= +github.com/hashicorp/go-kms-wrapping/v2 v2.0.18 h1:DLfC677GfKEpSAFpEWvl1vXsGpEcSHmbhBaPLrdDQHc= +github.com/hashicorp/go-kms-wrapping/v2 v2.0.18/go.mod h1:t/eaR/mi2mw3klfl1WEAuiLKrlZ/Q8cosmsT+RIPLu0= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-plugin v1.6.1 h1:P7MR2UP6gNKGPp+y7EZw2kOiq4IR9WiqLvp0XOsVdwI= +github.com/hashicorp/go-plugin v1.6.1/go.mod h1:XPHFku2tFo3o3QKFgSYo+cghcUhw1NA1hZyMK0PWAw0= +github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/cryptoutil v0.1.1 h1:VaLXp47MqD1Y2K6QVrA9RooQiPyCgAbnfeJg44wKuJk= +github.com/hashicorp/go-secure-stdlib/cryptoutil v0.1.1/go.mod h1:hH8rgXHh9fPSDPerG6WzABHsHF+9ZpLhRI1LPk4JZ8c= +github.com/hashicorp/go-secure-stdlib/mlock v0.1.3 h1:kH3Rhiht36xhAfhuHyWJDgdXXEx9IIZhDGRk24CDhzg= +github.com/hashicorp/go-secure-stdlib/mlock v0.1.3/go.mod h1:ov1Q0oEDjC3+A4BwsG2YdKltrmEw8sf9Pau4V9JQ4Vo= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.9 h1:FW0YttEnUNDJ2WL9XcrrfteS1xW8u+sh4ggM8pN5isQ= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.9/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/permitpool v1.0.0 h1:U6y5MXGiDVOOtkWJ6o/tu1TxABnI0yKTQWJr7z6BpNk= +github.com/hashicorp/go-secure-stdlib/permitpool v1.0.0/go.mod h1:ecDb3o+8D4xtP0nTCufJaAVawHavy5M2eZ64Nq/8/LM= +github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.1 h1:JY+zGg8gOmslwif1fiCqT5Hu1SikLZQcHkmQhCoA9gY= +github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.1/go.mod h1:jW3KCTvdPyAdVecOUwiiO2XaYgUJ/isigt++ISkszkY= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.6 h1:RSG8rKU28VTUTvEKghe5gIhIQpv8evvNpnDEyqO4u9I= +github.com/hashicorp/go-sockaddr v1.0.6/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3lyjsj6+CCykpaxI= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= +github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= +github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= +github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/hcl v1.0.1-vault-5 h1:kI3hhbbyzr4dldA8UdTb7ZlVVlI2DACdCfz31RPDgJM= +github.com/hashicorp/hcl v1.0.1-vault-5/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.15.0 h1:O24FYQCWwhwKnF7CuSqP30S51rTV7vz1iACXE/pj5DA= +github.com/hashicorp/vault/api v1.15.0/go.mod h1:+5YTO09JGn0u+b6ySD/LLVf8WkJCPLAL2Vkmrn2+CM8= +github.com/hashicorp/vault/sdk v0.15.2 h1:Rp5Yp4lyBhlWgq24ZVb2n/YN47RKOAvmx/jlMfS9ku4= +github.com/hashicorp/vault/sdk v0.15.2/go.mod h1:2Wj2tHIgfz0gNWgEPWBbCXFIiPrq96E8FTjPNV9J1Bc= +github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= +github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/jhump/protoreflect v1.16.0 h1:54fZg+49widqXYQ0b+usAFHbMkBGR4PpXrsHc8+TBDg= +github.com/jhump/protoreflect v1.16.0/go.mod h1:oYPd7nPvcBw/5wlDfm/AVmU9zH9BgqGCI469pGxfj/8= +github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531 h1:hgVxRoDDPtQE68PT4LFvNlPz2nBKd3OMlGKIQ69OmR4= +github.com/joshlf/go-acl v0.0.0-20200411065538-eae00ae38531/go.mod h1:fqTUQpVYBvhCNIsMXGl2GE9q6z94DIP6NtFKXCSTVbg= +github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d h1:J8tJzRyiddAFF65YVgxli+TyWBi0f79Sld6rJP6CBcY= +github.com/joshlf/testutil v0.0.0-20170608050642-b5d8aa79d93d/go.mod h1:b+Q3v8Yrg5o15d71PSUraUzYb+jWl6wQMSBXSGS/hv0= +github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= +github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +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/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +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/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= +github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= +github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= +github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= +github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= +github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7 h1:Dx7Ovyv/SFnMFw3fD4oEoeorXc6saIiQ23LrGLth0Gw= +github.com/petermattis/goid v0.0.0-20240813172612-4fcff4a6cae7/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM= +github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= +github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= +github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= +github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU= +github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= +github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= +github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4= +github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= +github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= +github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/sasha-s/go-deadlock v0.3.5 h1:tNCOEEDG6tBqrNDOX35j/7hL5FcFViG6awUGROb2NsU= +github.com/sasha-s/go-deadlock v0.3.5/go.mod h1:bugP6EGbdGYObIlx7pUZtWqlvo8k9H6vCBBsiChJQ5U= +github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +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/tink-crypto/tink-go/v2 v2.2.0 h1:L2Da0F2Udh2agtKztdr69mV/KpnY3/lGTkMgLTVIXlA= +github.com/tink-crypto/tink-go/v2 v2.2.0/go.mod h1:JJ6PomeNPF3cJpfWC0lgyTES6zpJILkAX0cJNwlS3xU= +github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= +go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= +go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0 h1:umZgi92IyxfXd/l4kaDhnKgY8rnN/cZcF1LKc6I8OQ8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.30.0/go.mod h1:4lVs6obhSVRb1EW5FhOuBTyiQhtRtAnnva9vD3yRfq8= +go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= +go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= +go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= +go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= +go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= +go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= +go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= +go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= +go.opentelemetry.io/proto/otlp v1.1.0 h1:2Di21piLrCqJ3U3eXGCTPHE9R8Nh+0uglSnOyxikMeI= +go.opentelemetry.io/proto/otlp v1.1.0/go.mod h1:GpBHCBWiqvVLDqmHZsoMM3C5ySeKTC7ej/RNTae6MdY= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +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.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +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-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +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.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +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-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +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-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.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/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/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk= +google.golang.org/genproto/googleapis/api v0.0.0-20241113202542-65e8d215514f h1:M65LEviCfuZTfrfzwwEoxVtgvfkFkBUbFnRbxCXuXhU= +google.golang.org/genproto/googleapis/api v0.0.0-20241113202542-65e8d215514f/go.mod h1:Yo94eF2nj7igQt+TiJ49KxjIH8ndLYPZMIRSiRcEbg0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU= +google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= +google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= +google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= +google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI= +gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +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= +gotest.tools/v3 v3.5.0 h1:Ljk6PdHdOhAb5aDMWXjDLMMhph+BpztA4v1QdqEW2eY= +gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/backend.go b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/backend.go new file mode 100644 index 000000000..78d5b7593 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/backend.go @@ -0,0 +1,302 @@ +// +// Copyright 2021 Outfox, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package jwtsecrets + +import ( + "context" + "crypto/rand" + "fmt" + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/helper/errutil" + "github.com/hashicorp/vault/sdk/helper/keysutil" + "github.com/hashicorp/vault/sdk/logical" + "gopkg.in/square/go-jose.v2" + "strconv" + "strings" + "sync" + "time" +) + +const ( + configPath = "config" + mainKeyName = "main" + + // Minimum cache size for transit backend + minCacheSize = 10 +) + +type backend struct { + *framework.Backend + id string + lockManager *keysutil.LockManager + cachedConfig *Config + cachedConfigLock *sync.RWMutex + idGen uniqueIdGenerator +} + +// Factory returns a new backend as logical.Backend. +func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) { + b, err := createBackend(conf) + if err != nil { + return nil, err + } + if err := b.Setup(ctx, conf); err != nil { + return nil, err + } + return b, nil +} + +func createBackend(conf *logical.BackendConfig) (*backend, error) { + var b backend + + var err error + b.lockManager, err = keysutil.NewLockManager(true, minCacheSize) + if err != nil { + return nil, err + } + + b.id = conf.BackendUUID + b.cachedConfigLock = new(sync.RWMutex) + b.idGen = friendlyIdGenerator{} + + b.Backend = &framework.Backend{ + BackendType: logical.TypeLogical, + Help: strings.TrimSpace(backendHelp), + PathsSpecial: &logical.Paths{ + Unauthenticated: []string{"jwks"}, + }, + Paths: framework.PathAppend( + pathRole(&b), + []*framework.Path{ + pathConfig(&b), + pathJwks(&b), + pathSign(&b), + }, + ), + Secrets: []*framework.Secret{ + b.token(), + }, + InitializeFunc: b.initialize, + PeriodicFunc: b.periodic, + Invalidate: b.invalidate, + Clean: b.clean, + } + return &b, nil +} + +func (b *backend) initialize(ctx context.Context, req *logical.InitializationRequest) error { + + if _, err := b.getConfig(ctx, req.Storage); err != nil { + return err + } + + b.Logger().Debug("Initialized") + + return nil +} + +func (b *backend) periodic(ctx context.Context, req *logical.Request) error { + + config, err := b.getConfig(ctx, req.Storage) + if err != nil { + return err + } + + policy, err := b.getPolicy(ctx, req.Storage, config, req.MountPoint) + if err != nil { + return err + } + + return b.pruneKeyVersions(ctx, req.Storage, policy, config, req.MountPoint) +} + +func (b *backend) invalidate(_ context.Context, key string) { + if b.Logger().IsDebug() { + b.Logger().Debug("invalidating key", "key", key) + } + switch { + case strings.HasPrefix(key, "policy/"): + name := strings.TrimPrefix(key, "policy/") + b.lockManager.InvalidatePolicy(name) + case strings.HasPrefix(key, configPath): + b.cachedConfigLock.Lock() + defer b.cachedConfigLock.Unlock() + b.cachedConfig = nil + } +} + +func (b *backend) clean(_ context.Context) { + // Nothing to do +} + +func (b *backend) getPolicy(ctx context.Context, stg logical.Storage, config *Config, mount string) (*keysutil.Policy, error) { + + polReq := keysutil.PolicyRequest{ + Upsert: true, + Storage: stg, + Name: mainKeyName, + Derived: false, + Convergent: false, + Exportable: false, + AllowPlaintextBackup: false, + } + + var err error + + switch config.SignatureAlgorithm { + case jose.RS256, jose.RS384, jose.RS512: + switch config.RSAKeyBits { + case 2048: + polReq.KeyType = keysutil.KeyType_RSA2048 + case 3072: + polReq.KeyType = keysutil.KeyType_RSA3072 + case 4096: + polReq.KeyType = keysutil.KeyType_RSA4096 + default: + err = errutil.InternalError{Err: "unsupported RSA key size"} + } + case jose.ES256: + polReq.KeyType = keysutil.KeyType_ECDSA_P256 + case jose.ES384: + polReq.KeyType = keysutil.KeyType_ECDSA_P384 + case jose.ES512: + polReq.KeyType = keysutil.KeyType_ECDSA_P521 + default: + err = errutil.InternalError{Err: "unknown/unsupported signature algorithm"} + } + + if err != nil { + return nil, err + } + + policy, _, err := b.lockManager.GetPolicy(ctx, polReq, rand.Reader) + if err != nil { + return nil, err + } + + if err := b.rotateIfNecessary(ctx, stg, policy, config, mount); err != nil { + return nil, err + } + + return policy, nil +} + +func (b *backend) rotateIfNecessary(ctx context.Context, stg logical.Storage, policy *keysutil.Policy, config *Config, mount string) error { + policy.Lock(true) + defer policy.Unlock() + + latestKey, ok := policy.Keys[strconv.Itoa(policy.LatestVersion)] + if !ok { + return nil + } + + if latestKey.CreationTime.Add(config.KeyRotationPeriod).After(time.Now()) { + return nil + } + + err := policy.Rotate(ctx, stg, rand.Reader) + if err != nil { + return err + } + + b.lockManager.InvalidatePolicy(policy.Name) + + b.Logger().Info(fmt.Sprintf("Key Rotated: mount=%s", mount)) + + return nil +} + +func (b *backend) pruneKeyVersions(ctx context.Context, stg logical.Storage, policy *keysutil.Policy, config *Config, mount string) error { + + logger := b.Logger() + + if logger.IsDebug() { + logger.Debug(fmt.Sprintf("Pruning Keys: mount=%s", mount)) + } + + policy.Lock(false) + + unexpiredVersion := intMax(policy.MinAvailableVersion, 1) + for ; unexpiredVersion < policy.LatestVersion; unexpiredVersion += 1 { + + keyVersion, ok := policy.Keys[strconv.Itoa(unexpiredVersion)] + if !ok { + continue + } + + keyExpiresAt := keyVersion.CreationTime.Add(config.KeyRotationPeriod).Add(config.TokenTTL) + + if logger.IsDebug() { + logger.Debug( + fmt.Sprintf( + "Checking Key: mount=%s, version=%d created=%s, expires=%s", + mount, + unexpiredVersion, + keyVersion.CreationTime.Format(time.RFC3339), + keyExpiresAt.Format(time.RFC3339), + ), + ) + } + + if keyExpiresAt.After(time.Now()) { + break + } + } + + if unexpiredVersion == policy.MinAvailableVersion { + policy.Unlock() + return nil + } + + policy.Unlock() + policy.Lock(true) + defer policy.Unlock() + + // Recheck after exclusive lock + if unexpiredVersion == policy.MinAvailableVersion { + return nil + } + + // Ensure that cache doesn't get corrupted in error cases + previousMinAvailableVersion := policy.MinAvailableVersion + previousMinDecryptionVersion := policy.MinDecryptionVersion + + policy.MinAvailableVersion = unexpiredVersion + policy.MinDecryptionVersion = unexpiredVersion + + if err := policy.Persist(ctx, stg); err != nil { + policy.MinAvailableVersion = previousMinAvailableVersion + policy.MinDecryptionVersion = previousMinDecryptionVersion + return err + } + + logger.Info( + fmt.Sprintf( + "Key Trimmed: mount=%s, latest=%d, min-available=%d, min-decryption=%d", + mount, + policy.LatestVersion, + policy.MinAvailableVersion, + policy.MinDecryptionVersion, + ), + ) + + return nil +} + +const backendHelp = ` +The JWT secrets engine signs JWTs. +` diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/backend_test.go b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/backend_test.go new file mode 100644 index 000000000..f77a3c86b --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/backend_test.go @@ -0,0 +1,249 @@ +// +// Copyright 2021 Outfox, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package jwtsecrets + +import ( + "context" + "github.com/go-test/deep" + "github.com/google/uuid" + "github.com/hashicorp/vault/sdk/logical" + "testing" + "time" +) + +func getTestBackend(t *testing.T) (*backend, *logical.Storage) { + + config := logical.TestBackendConfig() + config.StorageView = new(logical.InmemStorage) + config.BackendUUID = uuid.New().String() + + b, err := createBackend(config) + if err != nil { + t.Fatalf("unable to create backend: %v", err) + } + if err := b.Setup(context.Background(), config); err != nil { + t.Fatalf("unable to create backend: %v", err) + } + + b.idGen = &fakeIDGenerator{0} + + _ = b.clearConfig(context.Background(), config.StorageView) + + return b, &config.StorageView +} + +func TestRotate(t *testing.T) { + b, storage := getTestBackend(t) + + _, err := writeConfig(b, storage, map[string]interface{}{ + keyRotationDuration: "2s", + keyTokenTTL: "1s", + }) + if err != nil { + t.Fatalf("%s\n", err) + } + + err = writeRole(b, storage, "tester", "tester.example.com", map[string]interface{}{}, map[string]interface{}{}) + if err != nil { + t.Fatalf("%s\n", err) + } + + config, err := b.getConfig(context.Background(), *storage) + if err != nil { + t.Fatalf("%s\n", err) + } + + policy, err := b.getPolicy(context.Background(), *storage, config, "test") + if err != nil { + t.Fatalf("%s\n", err) + } + + // Pre-rotate checks + if diff := deep.Equal(policy.LatestVersion, 1); diff != nil { + t.Error("policy latest version", diff) + } + if diff := deep.Equal(policy.MinAvailableVersion, 0); diff != nil { + t.Error("policy min-available version", diff) + } + if diff := deep.Equal(policy.MinDecryptionVersion, 1); diff != nil { + t.Error("policy min-decryption version", diff) + } + if diff := deep.Equal(policy.ArchiveVersion, 1); diff != nil { + t.Error("policy archive version", diff) + } + if diff := deep.Equal(policy.ArchiveMinVersion, 0); diff != nil { + t.Error("policy archive-min version", diff) + } + + time.Sleep(config.KeyRotationPeriod + 1) + + // Post-rotate #1 checks + policy, err = b.getPolicy(context.Background(), *storage, config, "test") + if err != nil { + t.Fatalf("%s\n", err) + } + + if diff := deep.Equal(policy.LatestVersion, 2); diff != nil { + t.Error("policy latest version", diff) + } + if diff := deep.Equal(policy.MinAvailableVersion, 0); diff != nil { + t.Error("policy min-available version", diff) + } + if diff := deep.Equal(policy.MinDecryptionVersion, 1); diff != nil { + t.Error("policy min-decryption version", diff) + } + if diff := deep.Equal(policy.ArchiveVersion, 2); diff != nil { + t.Error("policy archive version", diff) + } + if diff := deep.Equal(policy.ArchiveMinVersion, 0); diff != nil { + t.Error("policy archive-min version", diff) + } + + policy, err = b.getPolicy(context.Background(), *storage, config, "test") + if err != nil { + t.Fatalf("%s\n", err) + } + + // Should not have rotated yet + if diff := deep.Equal(policy.LatestVersion, 2); diff != nil { + t.Error("policy latest version", diff) + } + if diff := deep.Equal(policy.MinAvailableVersion, 0); diff != nil { + t.Error("policy min-available version", diff) + } + if diff := deep.Equal(policy.MinDecryptionVersion, 1); diff != nil { + t.Error("policy min-decryption version", diff) + } + if diff := deep.Equal(policy.ArchiveVersion, 2); diff != nil { + t.Error("policy archive version", diff) + } + if diff := deep.Equal(policy.ArchiveMinVersion, 0); diff != nil { + t.Error("policy archive-min version", diff) + } + + time.Sleep(config.KeyRotationPeriod + 1) + + policy, err = b.getPolicy(context.Background(), *storage, config, "test") + if err != nil { + t.Fatalf("%s\n", err) + } + + // Post-rotate #2 checks + if diff := deep.Equal(policy.LatestVersion, 3); diff != nil { + t.Error("policy latest version", diff) + } + if diff := deep.Equal(policy.MinAvailableVersion, 0); diff != nil { + t.Error("policy min-available version", diff) + } + if diff := deep.Equal(policy.MinDecryptionVersion, 1); diff != nil { + t.Error("policy min-decryption version", diff) + } + if diff := deep.Equal(policy.ArchiveVersion, 3); diff != nil { + t.Error("policy archive version", diff) + } + if diff := deep.Equal(policy.ArchiveMinVersion, 0); diff != nil { + t.Error("policy archive-min version", diff) + } +} + +func TestPrune(t *testing.T) { + b, storage := getTestBackend(t) + + _, err := writeConfig(b, storage, map[string]interface{}{ + keyRotationDuration: "2s", + keyTokenTTL: "1s", + }) + if err != nil { + t.Fatalf("%s\n", err) + } + + err = writeRole(b, storage, "tester", "tester.example.com", map[string]interface{}{}, map[string]interface{}{}) + if err != nil { + t.Fatalf("%s\n", err) + } + + config, err := b.getConfig(context.Background(), *storage) + if err != nil { + t.Fatalf("%s\n", err) + } + + policy, err := b.getPolicy(context.Background(), *storage, config, "test") + if err != nil { + t.Fatalf("%s\n", err) + } + if diff := deep.Equal(policy.LatestVersion, 1); diff != nil { + t.Error("policy latest version", diff) + } + + time.Sleep(config.KeyRotationPeriod + 1) + + policy, err = b.getPolicy(context.Background(), *storage, config, "test") + if err != nil { + t.Fatalf("%s\n", err) + } + if diff := deep.Equal(policy.LatestVersion, 2); diff != nil { + t.Error("policy latest version", diff) + } + + time.Sleep(config.KeyRotationPeriod + 1) + + policy, err = b.getPolicy(context.Background(), *storage, config, "test") + if err != nil { + t.Fatalf("%s\n", err) + } + if diff := deep.Equal(policy.LatestVersion, 3); diff != nil { + t.Error("policy latest version", diff) + } + + time.Sleep(config.KeyRotationPeriod + config.TokenTTL + 1) + + err = b.pruneKeyVersions(context.Background(), *storage, policy, config, "test") + if err != nil { + t.Fatalf("%s\n", err) + } + + // Post-prune checks + if diff := deep.Equal(policy.LatestVersion, 3); diff != nil { + t.Error("policy latest version", diff) + } + if diff := deep.Equal(policy.MinAvailableVersion, 3); diff != nil { + t.Error("policy min-available version", diff) + } + if diff := deep.Equal(policy.MinDecryptionVersion, 3); diff != nil { + t.Error("policy min-decryption version", diff) + } + if diff := deep.Equal(policy.ArchiveVersion, 3); diff != nil { + t.Error("policy archive version", diff) + } + if diff := deep.Equal(policy.ArchiveMinVersion, 3); diff != nil { + t.Error("policy archive-min version", diff) + } + + time.Sleep(config.KeyRotationPeriod) + + // Check that JWKS set contains the correct key versions. + // Should be 2 keys because pruning should have reduced it to 1 version + // and fetching will rotate again, leaving two keys. + jwks, err := FetchJWKS(b, storage) + if err != nil { + t.Fatalf("%s\n", err) + } + + if diff := deep.Equal(len(jwks.Keys), 2); diff != nil { + t.Error("jwks key count", diff) + } +} diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/config.go b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/config.go new file mode 100644 index 000000000..45ca604cc --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/config.go @@ -0,0 +1,272 @@ +// +// Copyright 2021 Outfox, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package jwtsecrets + +import ( + "context" + "crypto/rand" + "encoding/json" + "github.com/hashicorp/vault/sdk/helper/errutil" + "github.com/hashicorp/vault/sdk/helper/keysutil" + "github.com/hashicorp/vault/sdk/logical" + "gopkg.in/square/go-jose.v2" + "time" +) + +// Default values for configuration options. +const ( + DefaultSignatureAlgorithm = jose.ES256 + DefaultRSAKeyBits = 2048 + DefaultKeyRotationPeriod = "2h0m0s" + DefaultTokenTTL = "3m0s" + DefaultSetIAT = true + DefaultSetJTI = true + DefaultSetNBF = true + DefaultAudiencePattern = ".*" + DefaultSubjectPattern = ".*" + DefaultMaxAudiences = -1 +) + +// DefaultAllowedClaims is the default value for the AllowedClaims config option. +// By default, only the 'sub' and 'aud' claims can be set by the caller. +var DefaultAllowedClaims = []string{"sub", "aud"} + +var ReservedClaims = []string{"iss", "exp", "nbf", "iat", "jti"} +var ReservedHeaders = []string{"kid", "alg", "enc", "zip", "crit"} + +var AllowedSignatureAlgorithmNames = []string{string(jose.ES256), string(jose.ES384), string(jose.ES512), string(jose.RS256), string(jose.RS384), string(jose.RS512)} +var AllowedRSAKeyBits = []int{2048, 3072, 4096} + +// Config holds all configuration for the backend. +type Config struct { + // SignatureAlgorithm is the signing algorithm to use. + SignatureAlgorithm jose.SignatureAlgorithm + + // RSAKeyBits is size of generated RSA keys; only used when SignatureAlgorithm is one of the supported RSA algorithms. + RSAKeyBits int + + // KeyRotationPeriod is how frequently a new key is created. + KeyRotationPeriod time.Duration + + // TokenTTL defines how long a token is valid for after being signed. + TokenTTL time.Duration + + // SetIat defines if the backend sets the 'iat' claim or not. + SetIAT bool + + // SetJTI defines if the backend generates and sets the 'jti' claim or not. + SetJTI bool + + // SetNBF defines if the backend sets the 'nbf' claim. If true, the claim will be set to the same as the 'iat' claim. + SetNBF bool + + // AudiencePattern defines a regular expression (https://golang.org/pkg/regexp/) which must be matched by any incoming 'aud' claims. + // If the audience claim is an array, each element in the array must match the pattern. + AudiencePattern string + + // SubjectPattern defines a regular expression (https://golang.org/pkg/regexp/) which must be matched by any incoming 'sub' claims. + SubjectPattern string + + // MaxAudiences defines the maximum number of strings in the 'aud' claim. + MaxAudiences int + + // AllowedClaims defines which claims can be defined on the role or provided to the sign request to be set on the JWT. + AllowedClaims []string + + // allowedClaimsMap is used to easily check if a claim is in the allowed claim set. + allowedClaimsMap map[string]bool + + // AllowedHeaders defines which headers can be defined on the role or provided to the sign request to be set on the JWT. + AllowedHeaders []string + + // allowedHeadersMap is used to easily check if a header is in the allowed header set. + allowedHeadersMap map[string]bool +} + +func (b *backend) getConfig(ctx context.Context, stg logical.Storage) (*Config, error) { + b.cachedConfigLock.RLock() + if b.cachedConfig != nil { + defer b.cachedConfigLock.RUnlock() + return b.cachedConfig.copy(), nil + } + + b.cachedConfigLock.RUnlock() + b.cachedConfigLock.Lock() + defer b.cachedConfigLock.Unlock() + + // Double check somebody else didn't already cache it + if b.cachedConfig != nil { + return b.cachedConfig.copy(), nil + } + + // Attempt to load config from storage & cache + + rawConfig, err := stg.Get(ctx, configPath) + if err != nil { + return nil, err + } + + if rawConfig != nil { + // Found it, finish load from storage + conf := &Config{} + if err := json.Unmarshal(rawConfig.Value, conf); err == nil { + b.cachedConfig = conf.cache() + } else { + b.Logger().Warn("Failed to unmarshal config, resetting to default") + } + } + if b.cachedConfig == nil { + // Nothing found, initialize configuration to default and save + b.cachedConfig = DefaultConfig(b.System()) + if err := b.saveConfigUnlocked(ctx, stg, b.cachedConfig); err != nil { + return nil, err + } + + b.Logger().Debug("Config Initialized") + } + + return b.cachedConfig.copy(), nil +} + +func (c *Config) copy() *Config { + cc := *c + return &cc +} + +func (b *backend) saveConfig(ctx context.Context, stg logical.Storage, config *Config, mount string) error { + b.cachedConfigLock.Lock() + defer b.cachedConfigLock.Unlock() + + keyFormatChanged := + b.cachedConfig != nil && + (config.SignatureAlgorithm != b.cachedConfig.SignatureAlgorithm || + config.RSAKeyBits != b.cachedConfig.RSAKeyBits) + + if err := b.saveConfigUnlocked(ctx, stg, config); err != nil { + return err + } + + if !keyFormatChanged { + return nil + } + + b.Logger().Info("Key Format Rotation") + + policy, err := b.getPolicy(ctx, stg, config, mount) + if err != nil { + return err + } + + policy.Lock(true) + defer policy.Unlock() + + switch config.SignatureAlgorithm { + case jose.RS256, jose.RS384, jose.RS512: + switch config.RSAKeyBits { + case 2048: + policy.Type = keysutil.KeyType_RSA2048 + case 3072: + policy.Type = keysutil.KeyType_RSA3072 + case 4096: + policy.Type = keysutil.KeyType_RSA4096 + default: + err = errutil.InternalError{Err: "unsupported RSA key size"} + } + case jose.ES256: + policy.Type = keysutil.KeyType_ECDSA_P256 + case jose.ES384: + policy.Type = keysutil.KeyType_ECDSA_P384 + case jose.ES512: + policy.Type = keysutil.KeyType_ECDSA_P521 + default: + err = errutil.InternalError{Err: "unknown/unsupported signature algorithm"} + } + + if err != nil { + // Return the error rather than nil. Swallowing it reported success for + // an unsupported signature algorithm, so the key was never rotated and + // the caller had no way to find out. + return err + } + + defer b.lockManager.InvalidatePolicy(mainKeyName) + + return policy.Rotate(ctx, stg, rand.Reader) +} + +func (b *backend) saveConfigUnlocked(ctx context.Context, stg logical.Storage, config *Config) error { + + entry, err := logical.StorageEntryJSON(configPath, config) + if err != nil { + return err + } + if err := stg.Put(ctx, entry); err != nil { + return err + } + + b.cachedConfig = config.cache() + + return nil +} + +func (b *backend) clearConfig(ctx context.Context, stg logical.Storage) error { + b.cachedConfigLock.Lock() + defer b.cachedConfigLock.Unlock() + + if err := stg.Delete(ctx, configPath); err != nil { + return err + } + + b.cachedConfig = nil + + return nil +} + +// DefaultConfig returns a default configuration. +func DefaultConfig(sys logical.SystemView) *Config { + defaultKeyRotationPeriod, _ := time.ParseDuration(DefaultKeyRotationPeriod) + defaultTokenTTL, _ := time.ParseDuration(DefaultTokenTTL) + + c := &Config{} + c.SignatureAlgorithm = DefaultSignatureAlgorithm + c.RSAKeyBits = DefaultRSAKeyBits + c.KeyRotationPeriod = defaultKeyRotationPeriod + c.TokenTTL = durationMin(defaultTokenTTL, sys.DefaultLeaseTTL()) + c.SetIAT = DefaultSetIAT + c.SetJTI = DefaultSetJTI + c.SetNBF = DefaultSetNBF + c.AudiencePattern = DefaultAudiencePattern + c.SubjectPattern = DefaultSubjectPattern + c.MaxAudiences = DefaultMaxAudiences + c.AllowedClaims = DefaultAllowedClaims + return c +} + +func (c *Config) cache() *Config { + c.allowedClaimsMap = makeAllowedClaimsMap(c.AllowedClaims) + c.allowedHeadersMap = makeAllowedClaimsMap(c.AllowedHeaders) + return c +} + +// turn the slice of allowed claims into a map to easily check if a given claim is in the set +func makeAllowedClaimsMap(allowedClaims []string) map[string]bool { + newClaims := make(map[string]bool) + for _, claim := range allowedClaims { + newClaims[claim] = true + } + return newClaims +} diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/friendlyid.go b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/friendlyid.go new file mode 100644 index 000000000..ceb02f9b0 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/friendlyid.go @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package jwtsecrets + +import ( + "math/big" + + "github.com/google/uuid" +) + +// base62Alphabet is the conventional base62 digit set, ordered so that the +// digit value equals the index: 0-9, then A-Z, then a-z. friendly-id uses this +// same ordering, so ids produced here sort and compare like the ones the plugin +// produced before. +const base62Alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + +// encodedIDLen is the fixed width of an encoded id. 2^128 needs +// ceil(128 / log2(62)) = 22 base62 digits, so every id is padded to 22 to keep +// the width uniform; without padding a UUID with leading zero bytes would +// encode shorter than its peers and read as a different kind of identifier. +const encodedIDLen = 22 + +// encodeBase62UUID renders a UUID as a fixed-width base62 string. +// +// The UUID is treated as a single unsigned 128-bit big-endian integer and +// repeatedly divided by 62, most significant digit first. This is the standard +// friendly-id construction and is written here from that definition so the +// plugin carries no dependency on an unlicensed third-party implementation. +func encodeBase62UUID(id uuid.UUID) string { + n := new(big.Int).SetBytes(id[:]) + base := big.NewInt(int64(len(base62Alphabet))) + rem := new(big.Int) + + // Filled back to front: division yields the least significant digit first. + out := make([]byte, encodedIDLen) + for i := encodedIDLen - 1; i >= 0; i-- { + n.QuoRem(n, base, rem) + out[i] = base62Alphabet[rem.Int64()] + } + return string(out) +} diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/friendlyid_test.go b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/friendlyid_test.go new file mode 100644 index 000000000..7c5480da1 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/friendlyid_test.go @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package jwtsecrets + +import ( + "math/big" + "strings" + "testing" + + "github.com/google/uuid" +) + +// decodeBase62 is the test-side inverse. The production code only encodes, so +// this exists to assert the encoding is reversible rather than merely stable. +func decodeBase62(s string) (*big.Int, error) { + n := new(big.Int) + base := big.NewInt(int64(len(base62Alphabet))) + for _, r := range s { + i := strings.IndexRune(base62Alphabet, r) + if i < 0 { + return nil, errBadDigit + } + n.Mul(n, base) + n.Add(n, big.NewInt(int64(i))) + } + return n, nil +} + +var errBadDigit = &digitError{} + +type digitError struct{} + +func (*digitError) Error() string { return "digit outside the base62 alphabet" } + +func TestEncodeBase62UUIDIsFixedWidth(t *testing.T) { + // A UUID of all zero bytes is the case padding exists for: without it this + // encodes to "0" and looks nothing like a sibling id. + got := encodeBase62UUID(uuid.UUID{}) + if len(got) != encodedIDLen { + t.Fatalf("zero UUID encoded to %d chars, want %d: %q", len(got), encodedIDLen, got) + } + if got != strings.Repeat("0", encodedIDLen) { + t.Errorf("zero UUID = %q, want %q", got, strings.Repeat("0", encodedIDLen)) + } + + // The maximum UUID must still fit in the fixed width, which is what pins + // encodedIDLen at 22 rather than 21. + var max uuid.UUID + for i := range max { + max[i] = 0xff + } + if got := encodeBase62UUID(max); len(got) != encodedIDLen { + t.Errorf("max UUID encoded to %d chars, want %d: %q", len(got), encodedIDLen, got) + } +} + +func TestEncodeBase62UUIDRoundTrips(t *testing.T) { + for i := 0; i < 200; i++ { + id, err := uuid.NewRandom() + if err != nil { + t.Fatalf("generating uuid: %v", err) + } + enc := encodeBase62UUID(id) + if len(enc) != encodedIDLen { + t.Fatalf("%s encoded to %d chars, want %d", id, len(enc), encodedIDLen) + } + decoded, err := decodeBase62(enc) + if err != nil { + t.Fatalf("decoding %q: %v", enc, err) + } + if want := new(big.Int).SetBytes(id[:]); decoded.Cmp(want) != 0 { + t.Fatalf("%s round-tripped to %s via %q", id, decoded, enc) + } + } +} + +func TestEncodeBase62UUIDIsDistinct(t *testing.T) { + // Guards against an encoder that silently truncates: distinct UUIDs must + // not collapse onto the same id. + seen := make(map[string]uuid.UUID, 500) + for i := 0; i < 500; i++ { + id, err := uuid.NewRandom() + if err != nil { + t.Fatalf("generating uuid: %v", err) + } + enc := encodeBase62UUID(id) + if prev, dup := seen[enc]; dup { + t.Fatalf("collision: %s and %s both encode to %q", prev, id, enc) + } + seen[enc] = id + } +} + +func TestEncodeBase62UUIDUsesOnlyAlphabet(t *testing.T) { + id, err := uuid.NewRandom() + if err != nil { + t.Fatalf("generating uuid: %v", err) + } + for _, r := range encodeBase62UUID(id) { + if !strings.ContainsRune(base62Alphabet, r) { + t.Errorf("encoded id contains %q, outside the base62 alphabet", r) + } + } +} + +func TestFriendlyIDGeneratorProducesUsableIDs(t *testing.T) { + // The generator is what the plugin actually calls; assert the wiring, not + // just the helper. + var gen friendlyIdGenerator + id, err := gen.id() + if err != nil { + t.Fatalf("id(): %v", err) + } + if len(id) != encodedIDLen { + t.Errorf("generator produced %d chars, want %d: %q", len(id), encodedIDLen, id) + } +} + +func TestGeneratedIDsAreNotTimeOrMACDerived(t *testing.T) { + // Assert on what the generator actually returns, decoded back to a UUID. + // Checking uuid.NewRandom() directly would only test the uuid package; the + // property that matters is that ids reaching the token's jti are v4, since + // a v1 UUID encodes the host MAC address and the creation timestamp. + var gen friendlyIdGenerator + for i := 0; i < 50; i++ { + encoded, err := gen.id() + if err != nil { + t.Fatalf("id(): %v", err) + } + n, err := decodeBase62(encoded) + if err != nil { + t.Fatalf("decoding %q: %v", encoded, err) + } + var id uuid.UUID + n.FillBytes(id[:]) + if got := id.Version(); got != 4 { + t.Fatalf("id %q decoded to a version %d UUID, want version 4", encoded, got) + } + if got := id.Variant(); got != uuid.RFC4122 { + t.Fatalf("id %q decoded to variant %v, want RFC4122", encoded, got) + } + } +} diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_config.go b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_config.go new file mode 100644 index 000000000..c06bbd742 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_config.go @@ -0,0 +1,320 @@ +// +// Copyright 2021 Outfox, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package jwtsecrets + +import ( + "context" + "gopkg.in/square/go-jose.v2" + "regexp" + "time" + + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/logical" +) + +const ( + keySignatureAlgorithm = "sig_alg" + keyRSAKeyBits = "rsa_key_bits" + keyRotationDuration = "key_ttl" + keyTokenTTL = "jwt_ttl" + keySetIAT = "set_iat" + keySetJTI = "set_jti" + keySetNBF = "set_nbf" + keyAudiencePattern = "audience_pattern" + keySubjectPattern = "subject_pattern" + keyMaxAllowedAudiences = "max_audiences" + keyAllowedClaims = "allowed_claims" + keyAllowedHeaders = "allowed_headers" +) + +func pathConfig(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "config", + Fields: map[string]*framework.FieldSchema{ + keySignatureAlgorithm: { + Type: framework.TypeString, + Description: `Signature algorithm used to sign new tokens.`, + }, + keyRSAKeyBits: { + Type: framework.TypeInt, + Description: `Size of generated RSA keys, when signature algorithm is one of the allowed RSA signing algorithm.`, + }, + keyRotationDuration: { + Type: framework.TypeString, + Description: `Duration a specific key will be used to sign new tokens.`, + }, + keyTokenTTL: { + Type: framework.TypeString, + Description: `Duration a token is valid for (mapped to the 'exp' claim).`, + }, + keySetIAT: { + Type: framework.TypeBool, + Description: `Whether or not the backend should generate and set the 'iat' claim.`, + }, + keySetJTI: { + Type: framework.TypeBool, + Description: `Whether or not the backend should generate and set the 'jti' claim.`, + }, + keySetNBF: { + Type: framework.TypeBool, + Description: `Whether or not the backend should generate and set the 'nbf' claim.`, + }, + keyIssuer: { + Type: framework.TypeString, + Description: `Value to set as the 'iss' claim. Claim is omitted if empty.`, + }, + keyAudiencePattern: { + Type: framework.TypeString, + Description: `Regular expression which must match incoming 'aud' claims.`, + }, + keySubjectPattern: { + Type: framework.TypeString, + Description: `Regular expression which must match incoming 'sub' claims`, + }, + keyMaxAllowedAudiences: { + Type: framework.TypeInt, + Description: `Maximum number of allowed audiences, or -1 for no limit.`, + }, + keyAllowedClaims: { + Type: framework.TypeStringSlice, + Description: `Claims which are able to be set in addition to ones generated by the backend. +Note: 'aud' and 'sub' should be in this list if you would like to set them.`, + }, + keyAllowedHeaders: { + Type: framework.TypeStringSlice, + Description: `Headers which are able to be set in addition to ones generated by the backend.`, + }, + }, + + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ReadOperation: &framework.PathOperation{ + Callback: b.pathConfigRead, + }, + logical.CreateOperation: &framework.PathOperation{ + Callback: b.pathConfigWrite, + }, + logical.UpdateOperation: &framework.PathOperation{ + Callback: b.pathConfigWrite, + }, + logical.DeleteOperation: &framework.PathOperation{ + Callback: b.pathConfigDelete, + }, + }, + + ExistenceCheck: b.pathConfigExistenceCheck, + HelpSynopsis: pathConfigHelpSyn, + HelpDescription: pathConfigHelpDesc, + } +} + +func (b *backend) pathConfigExistenceCheck(ctx context.Context, req *logical.Request, _ *framework.FieldData) (bool, error) { + savedConfig, err := req.Storage.Get(ctx, configPath) + if err != nil { + return false, err + } + + return savedConfig != nil, nil +} + +func (b *backend) pathConfigWrite(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + config, err := b.getConfig(ctx, req.Storage) + if err != nil { + return nil, err + } + + if newRawSignatureAlgorithmName, ok := d.GetOk(keySignatureAlgorithm); ok { + newSignatureAlgorithmName, ok := newRawSignatureAlgorithmName.(string) + if !ok { + return logical.ErrorResponse("sig_alg must be a string"), logical.ErrInvalidRequest + } + if !stringInSlice(newSignatureAlgorithmName, AllowedSignatureAlgorithmNames) { + return logical.ErrorResponse("unknown/unsupported signature algorithm, must be one of %s", AllowedSignatureAlgorithmNames), logical.ErrInvalidRequest + } + config.SignatureAlgorithm = jose.SignatureAlgorithm(newSignatureAlgorithmName) + } + + if newRawRSAKeyBits, ok := d.GetOk(keyRSAKeyBits); ok { + newRSAKeyBits, ok := newRawRSAKeyBits.(int) + if !ok { + return logical.ErrorResponse("rsa_key_bits must be an integer"), logical.ErrInvalidRequest + } + if !intInSlice(newRSAKeyBits, AllowedRSAKeyBits) { + return logical.ErrorResponse("unsupported rsa_key_bits, must be one of %s", AllowedRSAKeyBits), logical.ErrInvalidRequest + } + config.RSAKeyBits = newRSAKeyBits + } + + if newRotationPeriod, ok := d.GetOk(keyRotationDuration); ok { + duration, err := time.ParseDuration(newRotationPeriod.(string)) + if err != nil { + return nil, err + } + config.KeyRotationPeriod = duration + } + + if newTTL, ok := d.GetOk(keyTokenTTL); ok { + duration, err := time.ParseDuration(newTTL.(string)) + if err != nil { + return nil, err + } + config.TokenTTL = duration + } + + if newSetIat, ok := d.GetOk(keySetIAT); ok { + config.SetIAT = newSetIat.(bool) + } + + if newSetJTI, ok := d.GetOk(keySetJTI); ok { + config.SetJTI = newSetJTI.(bool) + } + + if newSetNBF, ok := d.GetOk(keySetNBF); ok { + config.SetNBF = newSetNBF.(bool) + } + + if newAudiencePattern, ok := d.GetOk(keyAudiencePattern); ok { + config.AudiencePattern = newAudiencePattern.(string) + _, err := regexp.Compile(config.AudiencePattern) + if err != nil { + return logical.ErrorResponse("invalid audience pattern"), err + } + } + + if newSubjectPattern, ok := d.GetOk(keySubjectPattern); ok { + config.SubjectPattern = newSubjectPattern.(string) + _, err := regexp.Compile(config.SubjectPattern) + if err != nil { + return logical.ErrorResponse("invalid subject pattern"), err + } + } + + if newMaxAudiences, ok := d.GetOk(keyMaxAllowedAudiences); ok { + config.MaxAudiences = newMaxAudiences.(int) + } + + if newAllowedClaims, ok := d.GetOk(keyAllowedClaims); ok { + + // Check allowed claims doesn't contain reserved claims + for _, newAllowedClaim := range newAllowedClaims.([]string) { + if stringInSlice(newAllowedClaim, ReservedClaims) { + return logical.ErrorResponse("'%s' claim is reserved and not permitted in allowed_claims", newAllowedClaim), logical.ErrInvalidRequest + } + } + + config.AllowedClaims = newAllowedClaims.([]string) + } + + if newAllowedHeaders, ok := d.GetOk(keyAllowedHeaders); ok { + + // Check allowed headers doesn't contain reserved headers + for _, newAllowedHeader := range newAllowedHeaders.([]string) { + if stringInSlice(newAllowedHeader, ReservedHeaders) { + return logical.ErrorResponse("'%s' header is reserved and not permitted in allowed_headers", newAllowedHeader), logical.ErrInvalidRequest + } + } + + config.AllowedHeaders = newAllowedHeaders.([]string) + } + + if config.TokenTTL > b.System().MaxLeaseTTL() { + return logical.ErrorResponse("'%s' is greater that the max lease ttl", keyTokenTTL), logical.ErrInvalidRequest + } + + if err := b.saveConfig(ctx, req.Storage, config, req.MountPoint); err != nil { + return nil, err + } + + return configResponse(config) +} + +func (b *backend) pathConfigRead(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { + config, err := b.getConfig(ctx, req.Storage) + if err != nil { + return nil, err + } + + return configResponse(config) +} + +func (b *backend) pathConfigDelete(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { + err := b.clearConfig(ctx, req.Storage) + if err != nil { + return nil, err + } + + return nil, nil +} + +func configResponse(config *Config) (*logical.Response, error) { + return &logical.Response{ + Data: map[string]interface{}{ + keySignatureAlgorithm: config.SignatureAlgorithm, + keyRSAKeyBits: config.RSAKeyBits, + keyRotationDuration: config.KeyRotationPeriod.String(), + keyTokenTTL: config.TokenTTL.String(), + keySetIAT: config.SetIAT, + keySetJTI: config.SetJTI, + keySetNBF: config.SetNBF, + keyAudiencePattern: config.AudiencePattern, + keySubjectPattern: config.SubjectPattern, + keyMaxAllowedAudiences: config.MaxAudiences, + keyAllowedClaims: config.AllowedClaims, + keyAllowedHeaders: config.AllowedHeaders, + }, + }, nil +} + +func stringInSlice(a string, list []string) bool { + for _, b := range list { + if b == a { + return true + } + } + return false +} + +func intInSlice(a int, list []int) bool { + for _, b := range list { + if b == a { + return true + } + } + return false +} + +const pathConfigHelpSyn = ` +Configure the backend. +` + +const pathConfigHelpDesc = ` +Configure the backend. + +sig_alg: Signature algorithm used to sign new tokens. +rsa_key_bits: Size of generate RSA keys, when using RSA signature algorithms. +key_ttl: Duration before a key stops signing new tokens and a new one is generated. + After this period the public key will still be available to verify JWTs. +jwt_ttl: Duration before a token expires. +set_iat: Whether or not the backend should generate and set the 'iat' claim. +set_jti: Whether or not the backend should generate and set the 'jti' claim. +set_nbf: Whether or not the backend should generate and set the 'nbf' claim. +issuer: Value to set as the 'iss' claim. Claim omitted if empty. +audience_pattern: Regular expression which must match incoming 'aud' claims. +subject_pattern: Regular expression which must match incoming 'sub' claims. +max_audiences: Maximum number of allowed audiences, or -1 for no limit. +allowed_claims: Claims which are able to be set in addition to ones generated by the backend. + Note: 'aud' and 'sub' should be in this list if you would like to set them. +` diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_config_test.go b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_config_test.go new file mode 100644 index 000000000..768c69fb5 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_config_test.go @@ -0,0 +1,214 @@ +// +// Copyright 2021 Outfox, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package jwtsecrets + +import ( + "context" + "gopkg.in/square/go-jose.v2" + "testing" + + "github.com/go-test/deep" + "github.com/hashicorp/vault/sdk/logical" +) + +const ( + updateRSAKeyBits = 4096 + updatedRotationPeriod = "5m0s" + secondUpdatedRotationPeriod = "1h0m0s" + updatedTTL = "6m0s" +) + +func writeConfig(b *backend, storage *logical.Storage, config map[string]interface{}) (*logical.Response, error) { + + req := &logical.Request{ + Operation: logical.UpdateOperation, + Path: "config", + Storage: *storage, + Data: config, + MountPoint: "test", + } + + resp, err := b.HandleRequest(context.Background(), req) + if err != nil { + return nil, err + } + if resp != nil && resp.IsError() { + return nil, resp.Error() + } + return resp, nil +} + +func TestDefaultConfig(t *testing.T) { + b, storage := getTestBackend(t) + + req := &logical.Request{ + Operation: logical.ReadOperation, + Path: "config", + Storage: *storage, + MountPoint: "test", + } + + resp, err := b.HandleRequest(context.Background(), req) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%s resp:%#v\n", err, resp) + } + + rotationPeriod := resp.Data[keyRotationDuration].(string) + tokenTTL := resp.Data[keyTokenTTL].(string) + + if diff := deep.Equal(DefaultKeyRotationPeriod, rotationPeriod); diff != nil { + t.Error(diff) + } + + if diff := deep.Equal(DefaultTokenTTL, tokenTTL); diff != nil { + t.Error(diff) + } +} + +func TestWriteConfig(t *testing.T) { + b, storage := getTestBackend(t) + + resp, err := writeConfig(b, storage, map[string]interface{}{ + keyRotationDuration: updatedRotationPeriod, + }) + if err != nil { + t.Fatalf("err:%s resp:%#v\n", err, resp) + } + + sigAlg := resp.Data[keySignatureAlgorithm].(jose.SignatureAlgorithm) + rsaKeyBits := resp.Data[keyRSAKeyBits].(int) + rotationPeriod := resp.Data[keyRotationDuration].(string) + tokenTTL := resp.Data[keyTokenTTL].(string) + setIAT := resp.Data[keySetIAT].(bool) + setJTI := resp.Data[keySetJTI].(bool) + setNBF := resp.Data[keySetNBF].(bool) + + if diff := deep.Equal(DefaultSignatureAlgorithm, sigAlg); diff != nil { + t.Error("signature algorithm should be unchanged:", diff) + } + + if diff := deep.Equal(DefaultRSAKeyBits, rsaKeyBits); diff != nil { + t.Error("rsa key bits should be unchanged:", diff) + } + + if diff := deep.Equal(updatedRotationPeriod, rotationPeriod); diff != nil { + t.Error("failed to update rotation period:", diff) + } + + if diff := deep.Equal(DefaultTokenTTL, tokenTTL); diff != nil { + t.Error("expiry period should be unchanged:", diff) + } + + if diff := deep.Equal(DefaultSetIAT, setIAT); diff != nil { + t.Error("set_iat should be unchanged:", diff) + } + + if diff := deep.Equal(DefaultSetJTI, setJTI); diff != nil { + t.Error("set_jti should be unchanged:", diff) + } + + if diff := deep.Equal(DefaultSetNBF, setNBF); diff != nil { + t.Error("set_nbf should be unchanged:", diff) + } + + resp, err = writeConfig(b, storage, map[string]interface{}{ + keyRSAKeyBits: updateRSAKeyBits, + keyRotationDuration: secondUpdatedRotationPeriod, + keyTokenTTL: updatedTTL, + keySetIAT: false, + keySetJTI: false, + keySetNBF: false, + }) + if err != nil || (resp != nil && resp.IsError()) { + t.Fatalf("err:%s resp:%#v\n", err, resp) + } + + sigAlg = resp.Data[keySignatureAlgorithm].(jose.SignatureAlgorithm) + rsaKeyBits = resp.Data[keyRSAKeyBits].(int) + rotationPeriod = resp.Data[keyRotationDuration].(string) + tokenTTL = resp.Data[keyTokenTTL].(string) + setIAT = resp.Data[keySetIAT].(bool) + setJTI = resp.Data[keySetJTI].(bool) + setNBF = resp.Data[keySetNBF].(bool) + + if diff := deep.Equal(DefaultSignatureAlgorithm, sigAlg); diff != nil { + t.Error("signature algorithm should be unchanged:", diff) + } + + if diff := deep.Equal(updateRSAKeyBits, rsaKeyBits); diff != nil { + t.Error("failed to update rsa key bits:", diff) + } + + if diff := deep.Equal(secondUpdatedRotationPeriod, rotationPeriod); diff != nil { + t.Error("failed to update rotation period:", diff) + } + + if diff := deep.Equal(updatedTTL, tokenTTL); diff != nil { + t.Error("expiry period should be unchanged:", diff) + } + + if diff := deep.Equal(false, setIAT); diff != nil { + t.Error("expected set_iat to be false") + } + + if diff := deep.Equal(false, setJTI); diff != nil { + t.Error("expected set_jti to be false") + } + + if diff := deep.Equal(false, setNBF); diff != nil { + t.Error("expected set_nbf to be false") + } +} + +func TestWriteInvalidConfig(t *testing.T) { + b, storage := getTestBackend(t) + + resp, err := writeConfig(b, storage, map[string]interface{}{ + keyRotationDuration: "not a real duration", + }) + if err == nil { + t.Errorf("Should have errored but got response: %#v", resp) + } + + resp, err = writeConfig(b, storage, map[string]interface{}{ + keyAudiencePattern: "(", + }) + if err == nil { + t.Errorf("Should have errored but got response: %#v", resp) + } + + resp, err = writeConfig(b, storage, map[string]interface{}{ + keyAllowedClaims: []string{"iss"}, + }) + if err == nil { + t.Errorf("Should have errored but got response: %#v", resp) + } + + resp, err = writeConfig(b, storage, map[string]interface{}{ + keyAllowedHeaders: []string{"kid"}, + }) + if err == nil { + t.Errorf("Should have errored but got response: %#v", resp) + } + + resp, err = writeConfig(b, storage, map[string]interface{}{ + keySignatureAlgorithm: "HS256", + }) + if err == nil { + t.Errorf("Should have errored but got response: %#v", resp) + } +} diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_jwks.go b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_jwks.go new file mode 100644 index 000000000..879b21cd5 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_jwks.go @@ -0,0 +1,124 @@ +// +// Copyright 2021 Outfox, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package jwtsecrets + +import ( + "context" + "crypto/x509" + "encoding/json" + "encoding/pem" + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/logical" + "gopkg.in/square/go-jose.v2" + "strconv" +) + +func pathJwks(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "jwks", + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ReadOperation: &framework.PathOperation{ + Callback: b.pathJwksRead, + }, + }, + + HelpSynopsis: pathJwksHelpSyn, + HelpDescription: pathJwksHelpDesc, + } +} + +func (b *backend) pathJwksRead(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { + + jwkSet, err := b.getPublicKeys(ctx, req.Storage, req.MountPoint) + if err != nil { + return nil, err + } + + jwkSetJson, err := json.Marshal(map[string]interface{}{"keys": jwkSet.Keys}) + if err != nil { + return nil, err + } + + return &logical.Response{ + Data: map[string]interface{}{ + logical.HTTPStatusCode: 200, + logical.HTTPContentType: "application/jwk-set+json", + logical.HTTPRawBody: jwkSetJson, + }, + }, nil +} + +// GetPublicKeys returns a set of JSON Web Keys. +func (b *backend) getPublicKeys(ctx context.Context, stg logical.Storage, mount string) (*jose.JSONWebKeySet, error) { + + config, err := b.getConfig(ctx, stg) + if err != nil { + return nil, err + } + + policy, err := b.getPolicy(ctx, stg, config, mount) + if err != nil { + return nil, err + } + + policy.Lock(false) + defer policy.Unlock() + + keyCount := (policy.LatestVersion - policy.MinDecryptionVersion) + 1 + + jwkSet := jose.JSONWebKeySet{ + Keys: make([]jose.JSONWebKey, keyCount), + } + + keyIdx := 0 + for version := policy.MinDecryptionVersion; version <= policy.LatestVersion; version++ { + + key, ok := policy.Keys[strconv.Itoa(version)] + if !ok { + continue + } + + if key.FormattedPublicKey != "" { + block, _ := pem.Decode([]byte(key.FormattedPublicKey)) + if block == nil { + continue + } + + jwkSet.Keys[keyIdx].Key, err = x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + continue + } + } else if key.RSAKey != nil { + jwkSet.Keys[keyIdx].Key = &key.RSAKey.PublicKey + } + + jwkSet.Keys[keyIdx].KeyID = createKeyId(b.id, policy.Name, version) + jwkSet.Keys[keyIdx].Algorithm = string(config.SignatureAlgorithm) + jwkSet.Keys[keyIdx].Use = "sig" + keyIdx += 1 + } + + return &jwkSet, nil +} + +const pathJwksHelpSyn = ` +Get a JSON Web Key Set. +` + +const pathJwksHelpDesc = ` +Get a JSON Web Key Set. +` diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_jwks_test.go b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_jwks_test.go new file mode 100644 index 000000000..907ecbfa3 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_jwks_test.go @@ -0,0 +1,95 @@ +// +// Copyright 2021 Outfox, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package jwtsecrets + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/go-test/deep" + "github.com/hashicorp/vault/sdk/logical" + "gopkg.in/square/go-jose.v2" +) + +func FetchJWKS(b *backend, storage *logical.Storage) (*jose.JSONWebKeySet, error) { + + req := &logical.Request{ + Operation: logical.ReadOperation, + Path: "jwks", + Storage: *storage, + MountPoint: "test", + } + + resp, err := b.HandleRequest(context.Background(), req) + if err != nil { + return nil, err + } + if resp.IsError() { + return nil, resp.Error() + } + + rawBody, ok := resp.Data[logical.HTTPRawBody].([]byte) + if !ok { + return nil, errors.New("no raw body returned") + } + + jwkSet := jose.JSONWebKeySet{} + err = json.Unmarshal(rawBody, &jwkSet) + if err != nil { + return nil, errors.New("cannot unmarshal body to JSONWebKeySet") + } + + return &jwkSet, nil +} + +func TestJwks(t *testing.T) { + b, storage := getTestBackend(t) + + err := writeRole(b, storage, "tester", "tester.example.com", map[string]interface{}{}, map[string]interface{}{}) + if err != nil { + t.Fatalf("%s\n", err) + } + + jwkSet, err := FetchJWKS(b, storage) + if err != nil { + t.Fatalf("err:%s\n", err) + } + + expectedKeySet, err := b.getPublicKeys(context.Background(), *storage, "test") + if err != nil { + t.Fatalf("err: %#v", err) + } + + for i, ek := range expectedKeySet.Keys { + data, _ := json.Marshal(ek) + var nek jose.JSONWebKey + if json.Unmarshal(data, &nek) != nil { + t.Fatalf("Unable to transcode key") + } + expectedKeySet.Keys[i] = nek + } + + if len(expectedKeySet.Keys) == 0 { + t.Fatal("Expected at least one key to be present.") + } + + if diff := deep.Equal(expectedKeySet, jwkSet); diff != nil { + t.Error(diff) + } +} diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_roles.go b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_roles.go new file mode 100644 index 000000000..c962a33af --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_roles.go @@ -0,0 +1,358 @@ +// +// Copyright 2021 Outfox, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package jwtsecrets + +import ( + "context" + "fmt" + "path" + "regexp" + + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/logical" +) + +const ( + keyStorageRolePath = "role" + keyRoleName = "name" + keyIssuer = "issuer" +) + +type Role struct { + + // Issuer defines the 'iss' claim for the issued JWT. It is required for each role. + Issuer string + + // Claims defines claim values to be set on the issued JWT; each claim must be allowed by the plugin config. + Claims map[string]interface{} `json:"claims"` + + // SubjectPattern defines a regular expression (https://golang.org/pkg/regexp/) which must be matched by any + // incoming 'sub' claims. This restriction is in addition to that defined on the plugin config. + SubjectPattern string + + // AudiencePattern defines a regular expression (https://golang.org/pkg/regexp/) which must be matched by any + // incoming 'aud' claims. If the audience claim is an array, each element in the array must match the pattern. + // This restriction is in addition to that defined on the plugin config. + AudiencePattern string + + // Headers defines header values to be set on the issued JWT; each header must be allowed by the plugin config. + Headers map[string]interface{} `json:"headers"` +} + +// Return response data for a role +func (r *Role) toResponseData() map[string]interface{} { + respData := map[string]interface{}{ + keyIssuer: r.Issuer, + keyClaims: r.Claims, + keyHeaders: r.Headers, + keySubjectPattern: r.SubjectPattern, + keyAudiencePattern: r.AudiencePattern, + } + return respData +} + +func pathRole(b *backend) []*framework.Path { + return []*framework.Path{ + { + Pattern: "roles/" + framework.GenericNameRegex(keyRoleName), + Fields: map[string]*framework.FieldSchema{ + keyRoleName: { + Type: framework.TypeLowerCaseString, + Description: `Specifies the name of the role to create. This is part of the request URL.`, + Required: true, + }, + keyIssuer: { + Type: framework.TypeString, + Description: `Value to set as the 'iss' claim. Required on all roles.`, + }, + keyClaims: { + Type: framework.TypeMap, + Description: `Claims to be set on issued JWTs. Each claim must be allowed by the configuration.`, + }, + keySubjectPattern: { + Type: framework.TypeString, + Description: `Regular expression which must match 'sub' claims provided during sign requests. +This restriction is in addition to that defined in the config.`, + }, + keyAudiencePattern: { + Type: framework.TypeString, + Description: `Regular expression which must match 'aud' claims provided during sign requests. +This restriction is in addition to that defined in the config.`, + }, + keyMaxAllowedAudiences: { + Type: framework.TypeInt, + Description: `Maximum number of allowed audiences, or -1 for no limit. +Must be less than or equal to the maximum number of allowed audiences defined in the config`, + }, + keyAllowedClaims: { + Type: framework.TypeStringSlice, + Description: `Claims which are able to be set in addition to ones generated by the backend. +Note: 'aud' and 'sub' should be in this list if you would like to set them.`, + }, + keyHeaders: { + Type: framework.TypeMap, + Description: `Headers to be set on issued JWTs. Each header must be allowed by the configuration.`, + }, + }, + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ReadOperation: &framework.PathOperation{ + Callback: b.pathRolesRead, + }, + logical.CreateOperation: &framework.PathOperation{ + Callback: b.pathRolesWrite, + }, + logical.UpdateOperation: &framework.PathOperation{ + Callback: b.pathRolesWrite, + }, + logical.DeleteOperation: &framework.PathOperation{ + Callback: b.pathRolesDelete, + }, + }, + ExistenceCheck: b.pathRoleExistenceCheck, + HelpSynopsis: pathRoleHelpSyn, + HelpDescription: pathRoleHelpDesc, + }, + { + Pattern: "roles/?$", + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ListOperation: &framework.PathOperation{ + Callback: b.pathRolesList, + }, + }, + HelpSynopsis: pathRoleListHelpSyn, + HelpDescription: pathRoleListHelpDesc, + }, + } +} + +func (b *backend) pathRoleExistenceCheck(ctx context.Context, req *logical.Request, d *framework.FieldData) (bool, error) { + name := d.Get("name").(string) + + role, err := req.Storage.Get(ctx, path.Join(keyStorageRolePath, name)) + if err != nil { + return false, err + } + + return role != nil, nil +} + +// pathRolesList makes a request to Vault storage to retrieve a list of roles for the backend +func (b *backend) pathRolesList(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) { + entries, err := req.Storage.List(ctx, keyStorageRolePath+"/") + if err != nil { + return nil, err + } + + return logical.ListResponse(entries), nil +} + +// pathRolesRead makes a request to Vault storage to read a role and return response data +func (b *backend) pathRolesRead(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + role, err := b.getRole(ctx, req.Storage, d.Get(keyRoleName).(string)) + if err != nil { + return nil, err + } + + if role == nil { + return nil, nil + } + + return &logical.Response{ + Data: role.toResponseData(), + }, nil +} + +// pathRolesWrite makes a request to Vault storage to update a role based on the attributes passed to the role configuration +func (b *backend) pathRolesWrite(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + name, ok := d.GetOk(keyRoleName) + if !ok { + return logical.ErrorResponse("missing role name"), nil + } + + role, err := b.getRole(ctx, req.Storage, name.(string)) + if err != nil { + return nil, err + } + + if role == nil { + role = &Role{} + role.SubjectPattern = DefaultSubjectPattern + role.AudiencePattern = DefaultAudiencePattern + } + + config, err := b.getConfig(ctx, req.Storage) + if err != nil { + return nil, err + } + + createOperation := req.Operation == logical.CreateOperation + + if newIssuer, ok := d.GetOk(keyIssuer); ok { + role.Issuer = newIssuer.(string) + } else if !ok && createOperation { + return nil, fmt.Errorf("missing issuer in role") + } + + if newClaims, ok := d.GetOk(keyClaims); ok { + role.Claims = newClaims.(map[string]interface{}) + } + + if newHeaders, ok := d.GetOk(keyHeaders); ok { + role.Headers = newHeaders.(map[string]interface{}) + } + + if newAudiencePattern, ok := d.GetOk(keyAudiencePattern); ok { + role.AudiencePattern = newAudiencePattern.(string) + _, err := regexp.Compile(role.AudiencePattern) + if err != nil { + return logical.ErrorResponse("invalid audience pattern"), err + } + } + + if newSubjectPattern, ok := d.GetOk(keySubjectPattern); ok { + role.SubjectPattern = newSubjectPattern.(string) + _, err := regexp.Compile(role.SubjectPattern) + if err != nil { + return logical.ErrorResponse("invalid subject pattern"), err + } + } + + // Check any provided claims are allowed from the config. + for claim := range role.Claims { + if allowedClaim, ok := config.allowedClaimsMap[claim]; !ok || !allowedClaim { + return logical.ErrorResponse("claim %s not permitted", claim), logical.ErrInvalidRequest + } + } + + // Check that issuer claim isn't included in claims field. + if _, ok := role.Claims["iss"]; ok { + return logical.ErrorResponse("'iss' claim cannot be present in 'claims' field"), logical.ErrInvalidRequest + } + + // added for nv + // disabled to allow sub claim to be set in role's claims field + // Check that subject claim isn't included in claims field. + // if _, ok := role.Claims["sub"]; ok { + // return logical.ErrorResponse("'sub' claim cannot be present in 'claims' field"), logical.ErrInvalidRequest + // } + + // If any audience is set in the claims, validate it against the configured restrictions. + if rawAud, ok := role.Claims["aud"]; ok { + switch aud := rawAud.(type) { + case string: + if matched, _ := regexp.MatchString(config.AudiencePattern, aud); !matched { + return logical.ErrorResponse("validation of 'aud' claim failed"), logical.ErrInvalidRequest + } + case []interface{}: + if config.MaxAudiences > -1 && len(aud) > config.MaxAudiences { + return logical.ErrorResponse("too many audience claims: %d", len(aud)), logical.ErrInvalidRequest + } + for _, rawAudEntry := range aud { + audEntry, ok := rawAudEntry.(string) + if !ok { + return logical.ErrorResponse("'aud' claim was %T, not string", audEntry), logical.ErrInvalidRequest + } + if matched, _ := regexp.MatchString(config.AudiencePattern, audEntry); !matched { + return logical.ErrorResponse("validation of 'aud' claim failed"), logical.ErrInvalidRequest + } + } + default: + return logical.ErrorResponse("'aud' claim was %T, not string or []string", rawAud), logical.ErrInvalidRequest + } + } + + // Check any provided headers are allowed from the config. + for header := range role.Headers { + if allowedHeader, ok := config.allowedHeadersMap[header]; !ok || !allowedHeader { + return logical.ErrorResponse("header %s not permitted", header), logical.ErrInvalidRequest + } + } + + if err := b.setRole(ctx, req.Storage, name.(string), role); err != nil { + return nil, err + } + + return nil, nil +} + +// pathRolesDelete makes a request to Vault storage to delete a role +func (b *backend) pathRolesDelete(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + err := req.Storage.Delete(ctx, path.Join(keyStorageRolePath, d.Get(keyRoleName).(string))) + if err != nil { + return nil, fmt.Errorf("error deleting role: %w", err) + } + return nil, nil +} + +// getRole gets the role from the Vault storage API +func (b *backend) getRole(ctx context.Context, stg logical.Storage, name string) (*Role, error) { + if name == "" { + return nil, fmt.Errorf("missing role name") + } + + entry, err := stg.Get(ctx, path.Join(keyStorageRolePath, name)) + if err != nil { + return nil, err + } + + if entry == nil { + return nil, nil + } + + var role Role + + if err := entry.DecodeJSON(&role); err != nil { + return nil, err + } + return &role, nil +} + +// setRole adds the role to the Vault storage API +func (b *backend) setRole(ctx context.Context, stg logical.Storage, name string, role *Role) error { + entry, err := logical.StorageEntryJSON(path.Join(keyStorageRolePath, name), role) + if err != nil { + return err + } + + if entry == nil { + return fmt.Errorf("failed to create storage entry for role") + } + + if err := stg.Put(ctx, entry); err != nil { + return err + } + + return nil +} + +const pathRoleHelpSyn = ` +Manages Vault role for generating tokens. +` + +const pathRoleHelpDesc = ` +Manages Vault role for generating tokens. + +subject: Subject claim (sub) for tokens generated using this role. +` + +const pathRoleListHelpSyn = ` +This endpoint returns a list of available roles. +` + +const pathRoleListHelpDesc = ` +This endpoint returns a list of available roles. Only the role names are returned, not any values. +` diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_roles_test.go b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_roles_test.go new file mode 100644 index 000000000..156c3f81d --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_roles_test.go @@ -0,0 +1,276 @@ +// +// Copyright 2021 Outfox, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package jwtsecrets + +import ( + "context" + "fmt" + "testing" + + "github.com/go-test/deep" + + "github.com/hashicorp/vault/sdk/logical" +) + +func writeRole(b *backend, storage *logical.Storage, name string, issuer string, claims map[string]interface{}, headers map[string]interface{}) error { + data := map[string]interface{}{ + "issuer": issuer, + "claims": claims, + "headers": headers, + } + + req := &logical.Request{ + Operation: logical.CreateOperation, + Path: "roles/" + name, + Storage: *storage, + Data: data, + MountPoint: "test", + } + + resp, err := b.HandleRequest(context.Background(), req) + if err != nil || (resp != nil && resp.IsError()) { + return fmt.Errorf("err:%s resp:%#v", err, resp) + } + + return nil +} + +func readRole(b *backend, storage *logical.Storage, name string) (*logical.Response, error) { + + req := &logical.Request{ + Operation: logical.ReadOperation, + Path: "roles/" + name, + Storage: *storage, + MountPoint: "test", + } + + resp, err := b.HandleRequest(context.Background(), req) + if err != nil || (resp != nil && resp.IsError()) { + return nil, fmt.Errorf("err:%s resp:%#v", err, resp) + } + + return resp, nil +} + +func TestCreate(t *testing.T) { + b, storage := getTestBackend(t) + + role := "tester" + + err := writeRole(b, storage, role, role+".example.com", map[string]interface{}{}, map[string]interface{}{}) + if err != nil { + t.Fatalf("%v\n", err) + } + + resp, err := readRole(b, storage, role) + if err != nil { + t.Fatalf("%v\n", err) + } + + subject := resp.Data[keyIssuer].(string) + if diff := deep.Equal(role+".example.com", subject); diff != nil { + t.Error("failed to update subject:", diff) + } + +} + +func TestCreateRestrictedAudience(t *testing.T) { + b, storage := getTestBackend(t) + + role := "tester" + + resp, err := writeConfig(b, storage, map[string]interface{}{ + keyAudiencePattern: "[a-z]+\\.[a-z]+\\.[a-z]+", + }) + if err != nil { + t.Fatalf("err:%s resp:%#v\n", err, resp) + } + + err = writeRole(b, storage, role, role+".example.com", map[string]interface{}{"aud": "invalid audience"}, map[string]interface{}{}) + if err == nil { + t.Fatalf("create role with non-matching audience pattern succeeded") + } + + err = writeRole(b, storage, role, role+".example.com", map[string]interface{}{"aud": "audience.example.com"}, map[string]interface{}{}) + if err != nil { + t.Fatalf("%s\n", err) + } + + resp, err = readRole(b, storage, role) + if err != nil { + t.Fatalf("%v\n", err) + } + + claims, ok := resp.Data[keyClaims].(map[string]interface{}) + if !ok { + t.Error("failed to read response claims") + } + + audience, ok := claims["aud"] + if !ok { + t.Error("no audience claim found") + } + if diff := deep.Equal("audience.example.com", audience); diff != nil { + t.Error("failed to update audience:", diff) + } + +} + +func TestCreateDisallowedOtherClaim(t *testing.T) { + b, storage := getTestBackend(t) + + role := "tester" + + // added for nv + err := writeRole(b, storage, role, role+".example.com", map[string]interface{}{"sub": "allowed"}, map[string]interface{}{}) + + // added for nv + // sub claim is allowed in role's claims field + // if err == nil { + // t.Fatalf("Create role should have failed") + // } + + err = writeRole(b, storage, role, role+".example.com", map[string]interface{}{"foo": "bar"}, map[string]interface{}{}) + if err == nil { + t.Fatalf("Create role should have failed") + } + + resp, err := writeConfig(b, storage, map[string]interface{}{"allowed_claims": []string{"foo"}}) + if err != nil { + t.Fatalf("err:%s resp:%#v\n", err, resp) + } + + err = writeRole(b, storage, role, role+".example.com", map[string]interface{}{"foo": "bar"}, map[string]interface{}{}) + if err != nil { + t.Errorf("%s\n", err) + } + +} + +func TestCreateDisallowedOtherHeader(t *testing.T) { + b, storage := getTestBackend(t) + + role := "tester" + + err := writeRole(b, storage, role, role+".example.com", map[string]interface{}{}, map[string]interface{}{"tid": "not allowed"}) + if err == nil { + t.Fatalf("Create role should have failed") + } + + resp, err := writeConfig(b, storage, map[string]interface{}{"allowed_headers": []string{"tid"}}) + if err != nil { + t.Fatalf("err:%s resp:%#v\n", err, resp) + } + + err = writeRole(b, storage, role, role+".example.com", map[string]interface{}{}, map[string]interface{}{"tid": "12345"}) + if err != nil { + t.Errorf("%s\n", err) + } + +} + +func TestCreateAudienceAsArray(t *testing.T) { + b, storage := getTestBackend(t) + + role := "tester" + + claims := map[string]interface{}{ + "aud": []interface{}{"foo", "bar"}, + } + + if err := writeRole(b, storage, role, role+".example.com", claims, map[string]interface{}{}); err != nil { + t.Fatalf("%v\n", err) + } + + resp, err := readRole(b, storage, role) + if err != nil { + t.Fatalf("%v\n", err) + } + + claims, ok := resp.Data[keyClaims].(map[string]interface{}) + if !ok { + t.Error("failed to read response claims") + } + + audience, ok := claims["aud"] + if !ok { + t.Error("no audience claim found") + } + if diff := deep.Equal(claims["aud"], audience); diff != nil { + t.Error("failed to update audience:", diff) + } +} + +func TestList(t *testing.T) { + b, storage := getTestBackend(t) + + err := writeRole(b, storage, "tester1", "tester.example.com", map[string]interface{}{}, map[string]interface{}{}) + if err != nil { + t.Fatalf("%v\n", err) + } + + err = writeRole(b, storage, "tester2", "tester.example.com", map[string]interface{}{}, map[string]interface{}{}) + if err != nil { + t.Fatalf("%v\n", err) + } + + req := &logical.Request{ + Operation: logical.ListOperation, + Path: "roles", + Storage: *storage, + MountPoint: "test", + } + + resp, err := b.HandleRequest(context.Background(), req) + if err != nil { + t.Fatalf("%v\n", err) + } + + keys := resp.Data["keys"].([]string) + if keys == nil { + t.Fatalf("Missing keys in list response") + } + + if diff := deep.Equal(keys, []string{"tester1", "tester2"}); diff != nil { + t.Error("failed to list roles:", diff) + } +} + +func TestDelete(t *testing.T) { + b, storage := getTestBackend(t) + + role := "tester" + + if err := writeRole(b, storage, role, role+".example.com", map[string]interface{}{}, map[string]interface{}{}); err != nil { + t.Fatalf("%v\n", err) + } + + req := &logical.Request{ + Operation: logical.DeleteOperation, + Path: "roles/" + role, + Storage: *storage, + MountPoint: "test", + } + + if _, err := b.HandleRequest(context.Background(), req); err != nil { + t.Fatalf("%v\n", err) + } + + if resp, err := readRole(b, storage, role); err != nil || resp != nil { + t.Errorf("Should have received empty response but got response: %#v", resp) + } +} diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_sign.go b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_sign.go new file mode 100644 index 000000000..05e2b1e26 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_sign.go @@ -0,0 +1,209 @@ +// +// Copyright 2021 Outfox, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package jwtsecrets + +import ( + "context" + "regexp" + "time" + + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/logical" + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" +) + +const ( + keyClaims = "claims" + keyHeaders = "headers" +) + +func pathSign(b *backend) *framework.Path { + return &framework.Path{ + Pattern: "sign/" + framework.GenericNameRegex(keyRoleName), + Fields: map[string]*framework.FieldSchema{ + keyRoleName: { + Type: framework.TypeLowerCaseString, + Description: "Name of the role", + Required: true, + }, + keyClaims: { + Type: framework.TypeMap, + Description: `JSON claims set to sign.`, + Required: false, + }, + }, + Operations: map[logical.Operation]framework.OperationHandler{ + logical.UpdateOperation: &framework.PathOperation{ + Callback: b.pathSignWrite, + }, + logical.ReadOperation: &framework.PathOperation{ + Callback: b.pathSignWrite, + }, + }, + HelpSynopsis: pathSignHelpSyn, + HelpDescription: pathSignHelpDesc, + } +} + +func (b *backend) pathSignWrite(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + roleName := d.Get("name").(string) + + role, err := b.getRole(ctx, req.Storage, roleName) + if err != nil { + return nil, err + } + if role == nil { + return logical.ErrorResponse("unknown role"), logical.ErrInvalidRequest + } + + // Gather "freeform" claims + + rawClaims, ok := d.GetOk(keyClaims) + if !ok { + rawClaims = map[string]interface{}{} + } + + claims, ok := rawClaims.(map[string]any) + if !ok { + return logical.ErrorResponse("claims not a map"), logical.ErrInvalidRequest + } + + config, err := b.getConfig(ctx, req.Storage) + if err != nil { + return nil, err + } + + for claim := range claims { + if allowedClaim, ok := config.allowedClaimsMap[claim]; !ok || !allowedClaim { + return logical.ErrorResponse("claim %s not permitted", claim), logical.ErrInvalidRequest + } + if _, ok := role.Claims[claim]; ok { + return logical.ErrorResponse("claim %s not permitted, already provided by role", claim), logical.ErrInvalidRequest + } + } + + for roleClaim := range role.Claims { + claims[roleClaim] = role.Claims[roleClaim] + } + + claims["iss"] = role.Issuer + + now := time.Now() + + expiry := now.Add(config.TokenTTL) + claims["exp"] = jwt.NumericDate(expiry.Unix()) + + if config.SetIAT { + claims["iat"] = jwt.NumericDate(now.Unix()) + } + + if config.SetNBF { + claims["nbf"] = jwt.NumericDate(now.Unix()) + } + + if config.SetJTI { + jti, err := b.idGen.id() + if err != nil { + return logical.ErrorResponse("could not generate 'jti' claim: %v", err), err + } + claims["jti"] = jti + } + + if rawSub, ok := claims["sub"]; ok { + if sub, ok := rawSub.(string); ok { + if matched, _ := regexp.MatchString(role.SubjectPattern, sub); !matched { + return logical.ErrorResponse("validation of 'sub' claim failed (doesn't match role restriction)"), logical.ErrInvalidRequest + } + if matched, _ := regexp.MatchString(config.SubjectPattern, sub); !matched { + return logical.ErrorResponse("validation of 'sub' claim failed (doesn't match config restriction)"), logical.ErrInvalidRequest + } + } else { + return logical.ErrorResponse("'sub' claim was %T, not string"), logical.ErrInvalidRequest + } + } + + if rawAud, ok := claims["aud"]; ok { + switch aud := rawAud.(type) { + case string: + if matched, _ := regexp.MatchString(role.AudiencePattern, aud); !matched { + return logical.ErrorResponse("validation of 'aud' claim failed (doesn't match role restriction)"), logical.ErrInvalidRequest + } + if matched, _ := regexp.MatchString(config.AudiencePattern, aud); !matched { + return logical.ErrorResponse("validation of 'aud' claim failed (doesn't match config restriction)"), logical.ErrInvalidRequest + } + case []interface{}: + if config.MaxAudiences > -1 && len(aud) > config.MaxAudiences { + return logical.ErrorResponse("too many audience claims: %d", len(aud)), logical.ErrInvalidRequest + } + for _, rawAudEntry := range aud { + audEntry, ok := rawAudEntry.(string) + if !ok { + return logical.ErrorResponse("'aud' claim was %T, not string", audEntry), logical.ErrInvalidRequest + } + if matched, _ := regexp.MatchString(role.AudiencePattern, audEntry); !matched { + return logical.ErrorResponse("validation of 'aud' claim failed (doesn't match role restriction)"), logical.ErrInvalidRequest + } + if matched, _ := regexp.MatchString(config.AudiencePattern, audEntry); !matched { + return logical.ErrorResponse("validation of 'aud' claim failed (doesn't match config restriction)"), logical.ErrInvalidRequest + } + } + default: + return logical.ErrorResponse("'aud' claim was %T, not string or []string", rawAud), logical.ErrInvalidRequest + } + } + + policy, err := b.getPolicy(ctx, req.Storage, config, req.MountPoint) + if err != nil { + return logical.ErrorResponse("error getting key: %v", err), err + } + + signer := &PolicySigner{ + BackendId: b.id, + SignatureAlgorithm: config.SignatureAlgorithm, + Policy: policy, + SignerOptions: (&jose.SignerOptions{}).WithType("JWT"), + } + + for headerName := range role.Headers { + headerValue := role.Headers[headerName] + signer.SignerOptions = signer.SignerOptions.WithHeader(jose.HeaderKey(headerName), headerValue) + } + + token, err := jwt.Signed(signer).Claims(claims).CompactSerialize() + if err != nil { + return logical.ErrorResponse("error serializing jwt: %v", err), err + } + + resp := b.Secret(jwtSecretsTokenType).Response( + map[string]interface{}{ + "token": token, + }, + map[string]interface{}{}, + ) + resp.Secret.TTL = config.TokenTTL + + return resp, nil +} + +const pathSignHelpSyn = ` +Sign a set of claims. +` + +const pathSignHelpDesc = ` +Sign a set of claims. +` diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_sign_test.go b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_sign_test.go new file mode 100644 index 000000000..59f50ade2 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_sign_test.go @@ -0,0 +1,414 @@ +// +// Copyright 2021 Outfox, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package jwtsecrets + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/go-test/deep" + "github.com/hashicorp/vault/sdk/logical" + "gopkg.in/square/go-jose.v2/jwt" +) + +func getSignedTokenWithClaims(b *backend, storage *logical.Storage, role string, claims map[string]interface{}, headers map[string]interface{}, claimsDest interface{}, headersDest map[string]interface{}) error { + data := map[string]interface{}{ + "claims": claims, + "headers": headers, + } + + req := &logical.Request{ + Operation: logical.UpdateOperation, + Path: "sign/" + role, + Storage: *storage, + Data: data, + MountPoint: "test", + } + + resp, err := b.HandleRequest(context.Background(), req) + if err != nil || (resp != nil && resp.IsError()) { + return fmt.Errorf("err:%s resp:%#v", err, resp) + } + + rawToken, ok := resp.Data["token"] + if !ok { + return fmt.Errorf("no returned token") + } + + strToken, ok := rawToken.(string) + if !ok { + return fmt.Errorf("token was %T, not a string", rawToken) + } + + token, err := jwt.ParseSigned(strToken) + if err != nil { + return fmt.Errorf("error parsing jwt: %s", err) + } + + publicKeys, err := FetchJWKS(b, storage) + if err != nil { + return fmt.Errorf("error retrieving public keys: %s", err) + } + + matchingPublicKeys := publicKeys.Key(token.Headers[0].KeyID) + if len(matchingPublicKeys) != 1 { + return fmt.Errorf("error locating unique public keys: %s", err) + } + + if headersDest != nil { + for header := range token.Headers[0].ExtraHeaders { + headersDest[string(header)] = token.Headers[0].ExtraHeaders[header] + } + } + + var targetClaims interface{} + if claimsDest != nil { + targetClaims = claimsDest + } else { + targetClaims = &jwt.Claims{} + } + + if err = token.Claims(matchingPublicKeys[0], targetClaims); err != nil { + return fmt.Errorf("error decoding claims: %s", err) + } + + return nil +} + +func getSignedTokenWithoutClaims(b *backend, storage *logical.Storage, role string, claimsDest any, headersDest map[string]any) error { + + req := &logical.Request{ + Operation: logical.ReadOperation, + Path: "sign/" + role, + Storage: *storage, + MountPoint: "test", + } + + resp, err := b.HandleRequest(context.Background(), req) + if err != nil || (resp != nil && resp.IsError()) { + return fmt.Errorf("err:%s resp:%#v", err, resp) + } + + rawToken, ok := resp.Data["token"] + if !ok { + return fmt.Errorf("no returned token") + } + + strToken, ok := rawToken.(string) + if !ok { + return fmt.Errorf("token was %T, not a string", rawToken) + } + + token, err := jwt.ParseSigned(strToken) + if err != nil { + return fmt.Errorf("error parsing jwt: %s", err) + } + + publicKeys, err := FetchJWKS(b, storage) + if err != nil { + return fmt.Errorf("error retrieving public keys: %s", err) + } + + matchingPublicKeys := publicKeys.Key(token.Headers[0].KeyID) + if len(matchingPublicKeys) != 1 { + return fmt.Errorf("error locating unique public keys: %s", err) + } + + if headersDest != nil { + for header := range token.Headers[0].ExtraHeaders { + headersDest[string(header)] = token.Headers[0].ExtraHeaders[header] + } + } + + var targetClaims interface{} + if claimsDest != nil { + targetClaims = claimsDest + } else { + targetClaims = &jwt.Claims{} + } + + if err = token.Claims(matchingPublicKeys[0], targetClaims); err != nil { + return fmt.Errorf("error decoding claims: %s", err) + } + + return nil +} + +func TestSignWrite(t *testing.T) { + b, storage := getTestBackend(t) + + role := "tester" + + if err := writeRole(b, storage, role, role+".example.com", map[string]interface{}{}, map[string]interface{}{}); err != nil { + t.Fatalf("%v\n", err) + } + + claims := map[string]interface{}{ + "sub": "Kif Kroker", + "aud": "Zapp Brannigan", + } + + var decoded jwt.Claims + if err := getSignedTokenWithClaims(b, storage, role, claims, map[string]interface{}{}, &decoded, nil); err != nil { + t.Fatalf("%v\n", err) + } + + if decoded.Expiry.Time().After(time.Now().Add((3 * time.Minute) + (1 * time.Second))) { + t.Errorf("expiry is too far in the future") + } + if decoded.Expiry.Time().Before(time.Now().Add((3 * time.Minute) - (1 * time.Second))) { + t.Errorf("expiry is too far in the past") + } + decoded.Expiry = nil + + if decoded.IssuedAt.Time().After(time.Now().Add(1 * time.Second)) { + t.Errorf("issued at is too far in the future") + } + if decoded.IssuedAt.Time().Before(time.Now().Add(-1 * time.Second)) { + t.Errorf("issued at is too far in the past") + } + decoded.IssuedAt = nil + + if decoded.NotBefore.Time().After(time.Now().Add(1 * time.Second)) { + t.Errorf("not before is too far in the future") + } + if decoded.NotBefore.Time().Before(time.Now().Add(-1 * time.Second)) { + t.Errorf("not before is too far in the past") + } + decoded.NotBefore = nil + + expectedClaims := jwt.Claims{ + Subject: "Kif Kroker", + Audience: []string{"Zapp Brannigan"}, + ID: "1", + Issuer: role + ".example.com", + } + + if diff := deep.Equal(expectedClaims, decoded); diff != nil { + t.Error(diff) + } +} + +func TestSignRead(t *testing.T) { + b, storage := getTestBackend(t) + + role := "tester" + + claims := map[string]any{ + "sub": "Kif Kroker", + "aud": "Zapp Brannigan", + } + + if err := writeRole(b, storage, role, role+".example.com", claims, map[string]any{}); err != nil { + t.Fatalf("%v\n", err) + } + + var decoded jwt.Claims + if err := getSignedTokenWithoutClaims(b, storage, role, &decoded, nil); err != nil { + t.Fatalf("%v\n", err) + } + + if decoded.Expiry.Time().After(time.Now().Add((3 * time.Minute) + (1 * time.Second))) { + t.Errorf("expiry is too far in the future") + } + if decoded.Expiry.Time().Before(time.Now().Add((3 * time.Minute) - (1 * time.Second))) { + t.Errorf("expiry is too far in the past") + } + decoded.Expiry = nil + + if decoded.IssuedAt.Time().After(time.Now().Add(1 * time.Second)) { + t.Errorf("issued at is too far in the future") + } + if decoded.IssuedAt.Time().Before(time.Now().Add(-1 * time.Second)) { + t.Errorf("issued at is too far in the past") + } + decoded.IssuedAt = nil + + if decoded.NotBefore.Time().After(time.Now().Add(1 * time.Second)) { + t.Errorf("not before is too far in the future") + } + if decoded.NotBefore.Time().Before(time.Now().Add(-1 * time.Second)) { + t.Errorf("not before is too far in the past") + } + decoded.NotBefore = nil + + expectedClaims := jwt.Claims{ + Subject: "Kif Kroker", + Audience: []string{"Zapp Brannigan"}, + ID: "1", + Issuer: role + ".example.com", + } + + if diff := deep.Equal(expectedClaims, decoded); diff != nil { + t.Error(diff) + } +} + +type customToken struct { + Foo string `json:"foo"` +} + +func TestPrivateClaim(t *testing.T) { + b, storage := getTestBackend(t) + + if _, err := writeConfig(b, storage, map[string]interface{}{"allowed_claims": []string{"aud", "foo"}}); err != nil { + t.Fatalf("%v\n", err) + } + + role := "tester" + + if err := writeRole(b, storage, role, role+".example.com", map[string]interface{}{"aud": "an audience"}, map[string]interface{}{}); err != nil { + t.Fatalf("%v\n", err) + } + + claims := map[string]interface{}{ + "foo": "bar", + } + + var decoded customToken + if err := getSignedTokenWithClaims(b, storage, role, claims, map[string]interface{}{}, &decoded, nil); err != nil { + t.Fatalf("%v\n", err) + } + + expectedClaims := customToken{ + Foo: "bar", + } + + if diff := deep.Equal(expectedClaims, decoded); diff != nil { + t.Error(diff) + } +} + +func TestPrivateHeader(t *testing.T) { + b, storage := getTestBackend(t) + + if _, err := writeConfig(b, storage, map[string]interface{}{"allowed_headers": []string{"tid"}}); err != nil { + t.Fatalf("%v\n", err) + } + + role := "tester" + + if err := writeRole(b, storage, role, role+".example.com", map[string]interface{}{}, map[string]interface{}{"tid": "12345"}); err != nil { + t.Fatalf("%v\n", err) + } + + headers := map[string]interface{}{ + "tid": "12345", + } + + decoded := map[string]interface{}{} + if err := getSignedTokenWithClaims(b, storage, role, map[string]interface{}{}, headers, nil, decoded); err != nil { + t.Fatalf("%v\n", err) + } + + expectedHeaders := map[string]interface{}{ + "typ": "JWT", + "tid": "12345", + } + + if diff := deep.Equal(expectedHeaders, decoded); diff != nil { + t.Error(diff) + } +} + +func TestAudienceAsArray(t *testing.T) { + b, storage := getTestBackend(t) + + role := "tester" + + if err := writeRole(b, storage, role, role+".example.com", map[string]interface{}{}, map[string]interface{}{}); err != nil { + t.Fatalf("%v\n", err) + } + + claims := map[string]interface{}{ + "aud": []interface{}{"foo", "bar"}, + } + + var decoded map[string]interface{} + if err := getSignedTokenWithClaims(b, storage, role, claims, map[string]interface{}{}, &decoded, nil); err != nil { + t.Fatalf("%v\n", err) + } + + aud, ok := decoded["aud"].([]interface{}) + if !ok { + t.Fatalf("audience is not a string array") + } + + if diff := deep.Equal(aud, []interface{}{"foo", "bar"}); diff != nil { + t.Error(diff) + } +} + +func TestRejectReservedClaims(t *testing.T) { + b, storage := getTestBackend(t) + + role := "tester" + + if err := writeRole(b, storage, role, role+".example.com", map[string]interface{}{}, map[string]interface{}{}); err != nil { + t.Fatalf("%v\n", err) + } + + data := map[string]interface{}{ + "claims": map[string]interface{}{ + "exp": 1234, + }, + } + + req := &logical.Request{ + Operation: logical.UpdateOperation, + Path: "sign/" + role, + Storage: *storage, + Data: data, + MountPoint: "test", + } + + resp, err := b.HandleRequest(context.Background(), req) + if err == nil || resp != nil && !resp.IsError() { + t.Fatalf("expected to get an error from sign. got:%v\n", resp) + } +} + +func TestRejectOverwriteRoleOtherClaim(t *testing.T) { + b, storage := getTestBackend(t) + + role := "tester" + + if err := writeRole(b, storage, role, role+".example.com", map[string]interface{}{"aud": "an audience"}, map[string]interface{}{}); err != nil { + t.Fatalf("%v\n", err) + } + + data := map[string]interface{}{ + "claims": map[string]interface{}{ + "aud": 1234, + }, + } + + req := &logical.Request{ + Operation: logical.UpdateOperation, + Path: "sign/" + role, + Storage: *storage, + Data: data, + MountPoint: "test", + } + + resp, err := b.HandleRequest(context.Background(), req) + if err == nil || resp != nil && !resp.IsError() { + t.Fatalf("expected to get an error from sign. got:%v\n", resp) + } +} diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/policy_signer.go b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/policy_signer.go new file mode 100644 index 000000000..177660df7 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/policy_signer.go @@ -0,0 +1,146 @@ +// +// Copyright 2021 Outfox, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package jwtsecrets + +import ( + "bytes" + "crypto" + "encoding/base64" + "encoding/json" + "fmt" + "github.com/hashicorp/vault/sdk/helper/errutil" + "github.com/hashicorp/vault/sdk/helper/keysutil" + "gopkg.in/square/go-jose.v2" + "strings" +) + +type PolicySigner struct { + BackendId string + SignatureAlgorithm jose.SignatureAlgorithm + Policy *keysutil.Policy + SignerOptions *jose.SignerOptions +} + +func (ps *PolicySigner) Sign(payload []byte) (*jose.JSONWebSignature, error) { + + // Lock for entire sign operation to ensure no changes to versions happens + ps.Policy.Lock(false) + defer ps.Policy.Unlock() + + kid := createKeyId(ps.BackendId, ps.Policy.Name, ps.Policy.LatestVersion) + + protected := map[jose.HeaderKey]string{ + "kid": kid, + "alg": string(ps.SignatureAlgorithm), + } + for k, v := range ps.SignerOptions.ExtraHeaders { + protected[k] = fmt.Sprintf("%s", v) + } + + serializedProtected, err := json.Marshal(protected) + if err != nil { + return nil, err + } + + var input bytes.Buffer + + input.WriteString(base64.RawURLEncoding.EncodeToString(serializedProtected)) + input.WriteByte('.') + input.WriteString(base64.RawURLEncoding.EncodeToString(payload)) + + signature, err := ps.sign(input.Bytes()) + if err != nil { + return nil, err + } + + encodedSignature, err := json.Marshal(map[string]interface{}{ + "payload": base64.RawURLEncoding.EncodeToString(payload), + "protected": base64.RawURLEncoding.EncodeToString(serializedProtected), + "signatures": []map[string]interface{}{ + { + "protected": base64.RawURLEncoding.EncodeToString(serializedProtected), + "signature": base64.RawURLEncoding.EncodeToString(signature), + }, + }, + }) + if err != nil { + return nil, err + } + + return jose.ParseSigned(bytes.NewBuffer(encodedSignature).String()) +} + +func (ps *PolicySigner) sign(input []byte) ([]byte, error) { + + var hash crypto.Hash + var hashType keysutil.HashType + var sigAlg string + switch ps.SignatureAlgorithm { + case jose.RS256: + hashType = keysutil.HashTypeSHA2256 + hash = crypto.SHA256 + sigAlg = "pkcs1v15" + case jose.RS384: + hashType = keysutil.HashTypeSHA2384 + hash = crypto.SHA384 + sigAlg = "pkcs1v15" + case jose.RS512: + hashType = keysutil.HashTypeSHA2512 + hash = crypto.SHA512 + sigAlg = "pkcs1v15" + case jose.ES256: + hashType = keysutil.HashTypeSHA2256 + hash = crypto.SHA256 + sigAlg = "" + case jose.ES384: + hashType = keysutil.HashTypeSHA2384 + hash = crypto.SHA384 + sigAlg = "" + case jose.ES512: + hashType = keysutil.HashTypeSHA2512 + hash = crypto.SHA512 + sigAlg = "" + default: + return nil, errutil.InternalError{Err: fmt.Sprintf("unsupported signature algorithm: %s", ps.SignatureAlgorithm)} + } + + keyVersion := ps.Policy.LatestVersion + + hasher := hash.New() + + // According to documentation, Write() on hash never fails + _, _ = hasher.Write(input) + hashedInput := hasher.Sum(nil) + + result, err := ps.Policy.Sign(keyVersion, nil, hashedInput, hashType, sigAlg, keysutil.MarshalingTypeJWS) + if err != nil { + return nil, err + } + + encodedSignature := strings.TrimPrefix(result.Signature, fmt.Sprintf("vault:v%d:", keyVersion)) + + signature, err := base64.RawURLEncoding.DecodeString(encodedSignature) + if err != nil { + return nil, err + } + + return signature, nil +} + +func (ps *PolicySigner) Options() jose.SignerOptions { + return *ps.SignerOptions +} diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/token.go b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/token.go new file mode 100644 index 000000000..be534b2bc --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/token.go @@ -0,0 +1,45 @@ +// +// Copyright 2021 Outfox, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package jwtsecrets + +import ( + "context" + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/logical" +) + +const ( + jwtSecretsTokenType = "jwt_token" +) + +func (b *backend) token() *framework.Secret { + return &framework.Secret{ + Type: jwtSecretsTokenType, + Fields: map[string]*framework.FieldSchema{ + "token": { + Type: framework.TypeString, + Description: "Signed JWT", + }, + }, + Revoke: tokenRevoke, + } +} + +func tokenRevoke(_ context.Context, _ *logical.Request, _ *framework.FieldData) (*logical.Response, error) { + // Nothing to do! + return nil, nil +} diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/util.go b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/util.go new file mode 100644 index 000000000..d8cadf1c7 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/util.go @@ -0,0 +1,82 @@ +// +// Copyright 2021 Outfox, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package jwtsecrets + +import ( + "crypto" + "encoding/base64" + "path" + "strconv" + "time" + + "github.com/google/uuid" +) + +// uniqueIdGenerator is an interface for generating unique ids. +type uniqueIdGenerator interface { + id() (string, error) +} + +// friendlyIdGenerator generates friendly-id formatted UUIDs. +type friendlyIdGenerator struct{} + +func (fid friendlyIdGenerator) id() (string, error) { + // NewRandom (v4), not NewUUID (v1). A v1 UUID encodes the host MAC address + // and the creation timestamp, and this id is published as the token's jti, + // so v1 would leak the signer's hardware address and issue time to every + // holder of a token. + generatedUUID, err := uuid.NewRandom() + if err != nil { + return "", err + } + + return encodeBase62UUID(generatedUUID), nil +} + +// fakeIDGenerator generates a predictable sequence of numeric ids for testing. +type fakeIDGenerator struct { + Counter int +} + +func (f *fakeIDGenerator) id() (string, error) { + f.Counter++ + return strconv.Itoa(f.Counter), nil +} + +func intMax(x int, y int) int { + if x > y { + return x + } + return y +} + +func durationMin(x time.Duration, y time.Duration) time.Duration { + if x < y { + return x + } + return y +} + +func createKeyId(backendId string, policyName string, version int) string { + + rawId := path.Join(backendId, policyName, strconv.Itoa(version)) + + hasher := crypto.SHA1.New() + hasher.Write([]byte(rawId)) + + return base64.RawURLEncoding.EncodeToString(hasher.Sum(nil)) +} diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/test/Dockerfile b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/Dockerfile new file mode 100644 index 000000000..36c77b96a --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/Dockerfile @@ -0,0 +1,28 @@ +# Install vault +FROM alpine as vault-installer +WORKDIR /vault +RUN wget https://releases.hashicorp.com/vault/1.15.2/vault_1.15.2_linux_amd64.zip -O vault.zip +RUN unzip vault.zip && chmod +x vault + +# Build the addon and the test helper +FROM golang:1.19-alpine as plugin-builder +COPY go.mod go.sum ${GOPATH}/src/github.com/outfoxx/vault-plugin-secrets-jwt/ +COPY cmd/vault-plugin-secrets-jwt/main.go ${GOPATH}/src/github.com/outfoxx/vault-plugin-secrets-jwt/cmd/vault-plugin-secrets-jwt/ +COPY plugin/ ${GOPATH}/src/github.com/outfoxx/vault-plugin-secrets-jwt/plugin/ +COPY test/jwtverify/jwtverify.go ${GOPATH}/src/github.com/outfoxx/vault-plugin-secrets-jwt/test/ +WORKDIR ${GOPATH}/src/github.com/outfoxx/vault-plugin-secrets-jwt +RUN go build -o /vault/plugins/vault-plugin-secrets-jwt cmd/vault-plugin-secrets-jwt/main.go +RUN go install test/jwtverify.go + +# Test environment +FROM alpine +RUN apk add bash jq +COPY --from=vault-installer /vault /usr/local/bin/ +COPY test/config.hcl /vault/ +COPY test/testdata/* test/test.sh /test/ +COPY --from=plugin-builder /vault/plugins /vault/plugins/ +COPY --from=plugin-builder /go/bin/jwtverify /usr/local/bin/ + +WORKDIR /test +RUN chmod +x /test/test.sh +RUN /test/test.sh diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/test/Stress-Dockerfile b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/Stress-Dockerfile new file mode 100644 index 000000000..f1de019f5 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/Stress-Dockerfile @@ -0,0 +1,27 @@ +# Install vault +FROM alpine as vault-installer +WORKDIR /vault +RUN wget https://releases.hashicorp.com/vault/1.15.2/vault_1.15.2_linux_amd64.zip -O vault.zip +RUN unzip vault.zip && chmod +x vault + +# Build the addon and the test helper +FROM golang:1.19-alpine as plugin-builder +COPY go.mod go.sum ${GOPATH}/src/github.com/outfoxx/vault-plugin-secrets-jwt/ +COPY cmd/vault-plugin-secrets-jwt/main.go ${GOPATH}/src/github.com/outfoxx/vault-plugin-secrets-jwt/cmd/vault-plugin-secrets-jwt/ +COPY plugin/ ${GOPATH}/src/github.com/outfoxx/vault-plugin-secrets-jwt/plugin/ +COPY test/jwtverify/jwtverify.go ${GOPATH}/src/github.com/outfoxx/vault-plugin-secrets-jwt/test/ +WORKDIR ${GOPATH}/src/github.com/outfoxx/vault-plugin-secrets-jwt +RUN go build -o /vault/plugins/vault-plugin-secrets-jwt cmd/vault-plugin-secrets-jwt/main.go +RUN go install test/jwtverify.go + +# Test environment +FROM alpine +RUN apk add bash jq +COPY --from=vault-installer /vault /usr/local/bin/ +COPY test/config.hcl /vault/ +COPY test/testdata/* test/stress-test.sh /test/ +COPY --from=plugin-builder /vault/plugins /vault/plugins/ +COPY --from=plugin-builder /go/bin/jwtverify /usr/local/bin/ + +WORKDIR /test +RUN chmod +x /test/stress-test.sh diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/test/config.hcl b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/config.hcl new file mode 100644 index 000000000..fc5a2096b --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/config.hcl @@ -0,0 +1 @@ +plugin_directory = "/vault/plugins" \ No newline at end of file diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/test/godoc.go b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/godoc.go new file mode 100644 index 000000000..a6649aa40 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/godoc.go @@ -0,0 +1,17 @@ +// +// Copyright 2021 Outfox, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package test diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/test/jwtverify/jwtverify.go b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/jwtverify/jwtverify.go new file mode 100644 index 000000000..9eb021824 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/jwtverify/jwtverify.go @@ -0,0 +1,103 @@ +// +// Copyright 2021 Outfox, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + + "gopkg.in/square/go-jose.v2" + "gopkg.in/square/go-jose.v2/jwt" +) + +func main() { + if len(os.Args) < 3 { + fmt.Fprintln(os.Stderr, "Usage: jwtverify {JWT} {JWKS Endpoint}") + os.Exit(1) + } + + if err := validateToken(os.Args[1], os.Args[2]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +type customToken struct { + jwt.Claims + Foo string `json:"foo"` +} + +func validateToken(rawToken, jwksEndpoint string) error { + tok, err := jwt.ParseSigned(rawToken) + if err != nil { + return err + } + + resp, err := http.Get(jwksEndpoint) + if err != nil { + return err + } + + defer resp.Body.Close() + jwksBody, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + var jwks jose.JSONWebKeySet + if err = json.Unmarshal(jwksBody, &jwks); err != nil { + return err + } + + var kid string + for _, header := range tok.Headers { + if header.KeyID != "" { + kid = header.KeyID + break + } + } + + if kid == "" { + return errors.New("no kid header set") + } + + matchingKeys := jwks.Key(kid) + if len(matchingKeys) == 0 { + return fmt.Errorf("no matching keys for kid %s", kid) + } + if len(matchingKeys) > 1 { + return fmt.Errorf("multiple matching keys for kid %s\n%s", kid, jwksBody) + } + + cl := customToken{} + if err = tok.Claims(matchingKeys[0].Key, &cl); err != nil { + return fmt.Errorf("%s\n%s\n%s", err, rawToken, jwksBody) + } + + jsonClaims, err := json.Marshal(cl) + if err != nil { + return fmt.Errorf("error serializing decoded claims: %v", err) + } + + fmt.Printf("%s\n", jsonClaims) + + return nil +} diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/test/stress-test.sh b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/stress-test.sh new file mode 100755 index 000000000..1e5c5d661 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/stress-test.sh @@ -0,0 +1,106 @@ +#!/bin/bash + +# Configure vault +vault server -dev -dev-root-token-id="root" -config=/vault/config.hcl & +VAULT_PROC=$! + +export VAULT_ADDR='http://127.0.0.1:8200' + +pid=$$ + +fail() { + pkill -P $pid +} + +expect_equal() { + # Usage: expect_equal op1 op2 message + if [[ ! "$1" = "$2" ]]; then + echo "$3: $1 != $2" + fail + fi +} + +expect_match() { + # Usage: expect_match str pattern message + if [[ ! $1 =~ $2 ]]; then + echo "$3: $1 does not match $2" + fail + fi +} + +SHASUM=$(sha256sum "/vault/plugins/vault-plugin-secrets-jwt" | cut -d " " -f1) + +vault login root + +set -e + +echo -e "\n### Register plugin" +vault plugin register -sha256 $SHASUM vault-plugin-secrets-jwt + +echo -e "\n### Enable JWT engine at /jwt1 path" +vault secrets enable -path=jwt1 vault-plugin-secrets-jwt + +echo -e "\n### Change the expiry time and make a pattern to check subjects against" +vault write jwt1/config "sig_alg=RS256" "key_ttl=3s" "jwt_ttl=40s" + +echo -e "\n### Enable JWT engine at /jwt2 path" +vault secrets enable -path=jwt2 vault-plugin-secrets-jwt + +echo -e "\n### Change the expiry time and make a pattern to check subjects against" +vault write jwt2/config "sig_alg=RS256" "key_ttl=3s" "jwt_ttl=40s" + +stress() { + + echo -e "### [${1}] Adding role test${1}" + if ! vault write jwt${2}/roles/test${1} issuer="DOOP"; then + echo "Failed to add role" + fail + fi + + expected_sub=$(cat claims${3}.json | jq -r '.claims.sub') + + for i in {1..1000}; do + echo -e "### [${1}] <${i}> Generating a token" + if ! vault write -field=token jwt${2}/sign/test${1} @claims${3}.json > jwt-${1}-${i}.txt; then + echo -e "##############################################" + echo -e "### [${1}] <${i}> Failed to generate token ###" + echo -e "##############################################" + fail + fi + + START_TIME="$(date -u +%s)" + echo -e "### [${1}] <${i}> Validating 100 times" + for j in {1..100}; do +# echo -e "### [${1}] <${i}:${j}> Verify that the token is formatted as expected" + if ! jwtverify "$(cat jwt-${1}-${i}.txt)" $VAULT_ADDR/v1/jwt${2}/jwks > decoded-${1}-${i}-${j}.txt; then + echo -e "### [${1}] <${i}:${j}> Failed to verify token" + fail + fi + + expect_equal "$(cat decoded-${1}-${i}-${j}.txt | jq -r '.sub')" "${expected_sub}" "Wrong subject" + expect_match "$(cat decoded-${1}-${i}-${j}.txt | jq '.exp')" "[0-9]+" "Invalid 'exp' claim" + expect_match "$(cat decoded-${1}-${i}-${j}.txt | jq '.iat')" "[0-9]+" "Invalid 'iat' claim" + expect_match "$(cat decoded-${1}-${i}-${j}.txt | jq '.nbf')" "[0-9]+" "Invalid 'nbf' claim" + done + END_TIME="$(date -u +%s)" + + ELAPSED_TIME="$(($END_TIME-$START_TIME))" + if [[ $ELAPSED_TIME -gt 30 ]]; then + echo -e "############################################################" + echo -e "### [${1}] <${i}> Elapsed time: ${ELAPSED_TIME} seconds" + echo -e "############################################################" + fail + fi + + done +} + +for i in {1..10}; do + stress $i "1" $i & + sleep 1 +done + +for i in {1..10}; do + stress $((i+10)) "2" $i & + sleep 1 +done diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/test/test.sh b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/test.sh new file mode 100644 index 000000000..fa7754c97 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/test.sh @@ -0,0 +1,131 @@ +#!/bin/bash + +# Configure vault +vault server -dev -dev-root-token-id="root" -config=/vault/config.hcl & +VAULT_PROC=$! + +export VAULT_ADDR='http://127.0.0.1:8200' + +expect_equal() { + # Usage: expect_equal op1 op2 message + if [[ ! "$1" = "$2" ]]; then + echo "$3: $1 != $2" + exit 1 + fi +} + +expect_not_equal() { + # Usage: expect_equal op1 op2 message + if [[ $1 = $2 ]]; then + echo "$3: $1 = $2" + exit 1 + fi +} + +expect_match() { + # Usage: expect_match str pattern message + if [[ ! $1 =~ $2 ]]; then + echo "$3: $1 does not match $2" + exit 1 + fi +} + +expect_no_match() { + # Usage: expect_no_match str pattern message + if [[ $1 =~ $2 ]]; then + echo "$3: $1 matches $2" + exit 1 + fi +} + +SHASUM=$(sha256sum "/vault/plugins/vault-plugin-secrets-jwt" | cut -d " " -f1) + +vault login root + +set -e + +echo -e "\n\n### Register plugin" +vault plugin register -sha256 $SHASUM vault-plugin-secrets-jwt + +echo -e "\n\n### Enable JWT engine at /jwt path" +vault secrets enable -path=jwt vault-plugin-secrets-jwt + +echo -e "\n\n### Change the expiry time and make a pattern to check subjects against" +vault write jwt/config "key_ttl=3s" "jwt_ttl=3s" "subject_pattern=^[A-Z][a-z]+ [A-Z][a-z]+$" + +echo -e "\n\n### Attempt to create a token before role is created" +if vault write -field=token jwt/sign/test @claims.json; then + echo "Signing with unknown role incorrectly succeeded." + exit 1 +fi + +echo -e "\n\n### Attempt to create a role with a disallowed claim value" +if vault write jwt/roles/test @claims_foo.json; then + echo "Creating a role with a disallowed claim value incorrectly succeeded." + exit 1 +fi + +echo -e "\n\n### Adding role test" +vault write jwt/roles/test issuer="DOOP" + +echo -e "\n\n### Reading role test" +vault read jwt/roles/test + +echo -e "\n\n### Create a token with test role" +vault write -field=token jwt/sign/test @claims.json > jwt1.txt + +echo -e "\n\n### Verify that the token is formatted as expected" +jwtverify "$(cat jwt1.txt)" $VAULT_ADDR/v1/jwt/jwks | tee decoded.txt +expect_equal "$(cat decoded.txt | jq '.sub')" '"Zapp Brannigan"' "Wrong subject" +expect_match "$(cat decoded.txt | jq '.exp')" "[0-9]+" "Invalid 'exp' claim" +expect_match "$(cat decoded.txt | jq '.iat')" "[0-9]+" "Invalid 'iat' claim" +expect_match "$(cat decoded.txt | jq '.nbf')" "[0-9]+" "Invalid 'nbf' claim" + +EXP_TIME=$(cat decoded.txt | jq '.exp') +IAT_TIME=$(cat decoded.txt | jq '.iat') +if [[ "(( EXP_TIME - IAT_TIME ))" -ne 3 ]]; then + echo "times don't match" + exit 1 +fi + +echo -e "\n\n### Switch to RSA 256 algorithm" +vault write jwt/config "sig_alg=RS256" + +echo -e "\n\n### Wait and generate a second jwt" +sleep 3 +vault write jwt/config "set_iat=false" +vault write -field=token jwt/sign/test @claims.json > jwt2.txt + +echo -e "\n\n### Verify that the second token is formatted as expected" +jwtverify "$(cat jwt2.txt)" $VAULT_ADDR/v1/jwt/jwks | tee decoded2.txt + +echo -e "\n\n### Verify that key rotation happened" +expect_not_equal "$(wget -qO- $VAULT_ADDR/v1/jwt/jwks | jq '.keys | length')" "1" "Key Not Rotated" + +echo -e "\n\n### Verify second token does not have an iat claim" +expect_no_match "$(cat decoded2.txt)" "iat" "should not have 'iat' claim" + +echo -e "\n\n### Verify that tokens have different unique ids" +expect_not_equal "$(cat decoded.txt | jq '.jti')" "$(cat decoded2.txt | jq '.jti')" "JTI claims should differ" + +echo -e "\n\n### Attempt to sign with a claim that has an invalid value" +if vault write -field=token jwt/sign/test @invalid_claims.json; then + echo "Writing an invalid subject claim incorrectly succeeded." + exit 1 +fi + +echo -e "\n\n### Attempt to sign with a claim that has a disallowed claim" +if vault write -field=token jwt/sign/test @claims_foo.json; then + echo "Writing a set of claims which contains a disallowed claim." + exit 1 +fi + +echo -e "\n\n### Allow 'foo' claim" +vault write -field=allowed_claims jwt/config @allowed_claims.json + +echo -e "\n\n### Verify signing now allows 'foo' claim" +vault write -field=token jwt/sign/test @claims_foo.json > jwt3.txt + +echo -e "\n\n### Verify third token is formatted as expected" +jwtverify "$(cat jwt3.txt)" $VAULT_ADDR/v1/jwt/jwks | tee decoded3.txt +expect_equal "$(cat decoded3.txt | jq '.foo')" '"bar"' "jwt should have 'foo' field set" diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/allowed_claims.json b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/allowed_claims.json new file mode 100644 index 000000000..dce9bc100 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/allowed_claims.json @@ -0,0 +1,3 @@ +{ + "allowed_claims": ["foo", "aud"] +} diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims.json b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims.json new file mode 100644 index 000000000..a22a117e7 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims.json @@ -0,0 +1,5 @@ +{ + "claims": { + "sub": "Zapp Brannigan" + } +} \ No newline at end of file diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims1.json b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims1.json new file mode 100644 index 000000000..a22a117e7 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims1.json @@ -0,0 +1,5 @@ +{ + "claims": { + "sub": "Zapp Brannigan" + } +} \ No newline at end of file diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims10.json b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims10.json new file mode 100644 index 000000000..c1676a2ab --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims10.json @@ -0,0 +1,5 @@ +{ + "claims": { + "sub": "Scruffy Scruffington" + } +} diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims2.json b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims2.json new file mode 100644 index 000000000..7d7839493 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims2.json @@ -0,0 +1,5 @@ +{ + "claims": { + "sub": "Kif Kroker" + } +} diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims3.json b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims3.json new file mode 100644 index 000000000..69f8e486a --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims3.json @@ -0,0 +1,5 @@ +{ + "claims": { + "sub": "Philip J. Fry" + } +} diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims4.json b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims4.json new file mode 100644 index 000000000..c345fb25d --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims4.json @@ -0,0 +1,5 @@ +{ + "claims": { + "sub": "Turanga Leela" + } +} diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims5.json b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims5.json new file mode 100644 index 000000000..c7197bfc8 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims5.json @@ -0,0 +1,5 @@ +{ + "claims": { + "sub": "Bender Bending Rodriguez" + } +} diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims6.json b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims6.json new file mode 100644 index 000000000..f137fbe94 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims6.json @@ -0,0 +1,5 @@ +{ + "claims": { + "sub": "Professor Farnsworth" + } +} diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims7.json b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims7.json new file mode 100644 index 000000000..f88a55358 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims7.json @@ -0,0 +1,5 @@ +{ + "claims": { + "sub": "Amy Wong" + } +} diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims8.json b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims8.json new file mode 100644 index 000000000..bf7ea9183 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims8.json @@ -0,0 +1,5 @@ +{ + "claims": { + "sub": "Hermes Conrad" + } +} diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims9.json b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims9.json new file mode 100644 index 000000000..14d0293c4 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims9.json @@ -0,0 +1,5 @@ +{ + "claims": { + "sub": "Doctor Zoidberg" + } +} diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims_foo.json b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims_foo.json new file mode 100644 index 000000000..66ac4effb --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims_foo.json @@ -0,0 +1,5 @@ +{ + "claims": { + "foo": "bar" + } +} \ No newline at end of file diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/invalid_claims.json b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/invalid_claims.json new file mode 100644 index 000000000..97fe84814 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/invalid_claims.json @@ -0,0 +1,5 @@ +{ + "claims": { + "sub": "This name should be invalid because it has more than one space and the regex doesn't allow that" + } +} \ No newline at end of file diff --git a/infra/openbao/scripts/apply-license-header.sh b/infra/openbao/scripts/apply-license-header.sh new file mode 100755 index 000000000..5d27f2944 --- /dev/null +++ b/infra/openbao/scripts/apply-license-header.sh @@ -0,0 +1,67 @@ +#!/bin/sh +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -eu + +header_file=$1 +shift + +strip_existing_header() { + file=$1 + start_line=$2 + + if [ "$(sed -n "${start_line}p" "$file")" != "# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved." ]; then + cat "$file" + return + fi + + awk -v start="$start_line" ' + NR < start { print; next } + NR == start && $0 !~ /^# SPDX-FileCopyrightText:/ { print; next } + NR >= start && skipping { + if ($0 == "# limitations under the License.") { + skipping = 0 + skip_blank = 1 + } + next + } + NR == start { skipping = 1; next } + skip_blank && $0 == "" { skip_blank = 0; next } + { print } + ' "$file" +} + +for file in "$@"; do + tmp=$(mktemp) + cleaned=$(mktemp) + + if sed -n '1p' "$file" | grep -q '^#!'; then + sed -n '1p' "$file" > "$tmp" + cat "$header_file" >> "$tmp" + strip_existing_header "$file" 2 > "$cleaned" + sed '1d' "$cleaned" | cat >> "$tmp" + else + strip_existing_header "$file" 1 > "$cleaned" + cat "$header_file" > "$tmp" + cat "$cleaned" >> "$tmp" + fi + + # Use cat-redirect rather than `mv` so the destination's mode (and any + # other file metadata) is preserved. mktemp creates files at 0600; an + # `mv` would silently clobber the executable bit on tracked scripts. + cat "$tmp" > "$file" + rm -f "$tmp" "$cleaned" +done diff --git a/infra/openbao/scripts/build-jwt-plugin.sh b/infra/openbao/scripts/build-jwt-plugin.sh new file mode 100755 index 000000000..f5bf7a0c5 --- /dev/null +++ b/infra/openbao/scripts/build-jwt-plugin.sh @@ -0,0 +1,82 @@ +#!/bin/sh +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repo_root=$(CDPATH= cd -- "$script_dir/.." && pwd) + +# The plugin source lives in this repository, at ../plugins. It used to be +# cloned from a fork hosted elsewhere at a pinned revision, which meant a +# public build could not reproduce the image without access to that fork. +plugin_src=${PLUGIN_SRC:-"$repo_root/plugins/vault-plugin-secrets-jwt"} +vault_api_version=${VAULT_API_VERSION:-v1.15.0} +vault_sdk_version=${VAULT_SDK_VERSION:-v0.15.2} +x_net_version=${X_NET_VERSION:-v0.55.0} +output_dir=${OUTPUT_DIR:-"$repo_root/files/plugins"} + +# Only a work dir this script created is ours to remove. Deleting a +# caller-supplied WORK_DIR would destroy a directory the caller still owns. +if [ -n "${WORK_DIR:-}" ]; then + work_dir=$WORK_DIR + work_dir_is_ours=0 +else + work_dir=$(mktemp -d "${TMPDIR:-/tmp}/nvcf-openbao-jwt-plugin.XXXXXX") + work_dir_is_ours=1 +fi +src_dir="$work_dir/source" +build_dir="$work_dir/build" + +cleanup() { + if [ -z "${KEEP_WORK_DIR:-}" ] && [ "$work_dir_is_ours" = "1" ]; then + rm -rf "$work_dir" + else + echo "Keeping work dir: $work_dir" + fi +} +trap cleanup EXIT INT TERM + +mkdir -p "$build_dir" "$output_dir" + +# Copy rather than build in place: the steps below run `go get` and +# `go mod tidy`, which would otherwise rewrite the committed go.mod and go.sum +# of a source tree under version control. +mkdir -p "$src_dir" +cp -R "$plugin_src/." "$src_dir/" + +( + cd "$src_dir" + go get \ + "github.com/hashicorp/vault/api@${vault_api_version}" \ + "github.com/hashicorp/vault/sdk@${vault_sdk_version}" \ + "golang.org/x/net@${x_net_version}" + go mod tidy + go test ./... + + for arch in amd64 arm64; do + binary="vault-plugin-secrets-jwt-linux-${arch}" + GOOS=linux GOARCH="$arch" CGO_ENABLED=0 go build \ + -o "$build_dir/$binary" \ + ./cmd/vault-plugin-secrets-jwt + chmod 775 "$build_dir/$binary" + done +) + +for arch in amd64 arm64; do + install -m 775 "$build_dir/vault-plugin-secrets-jwt-linux-${arch}" "$output_dir/" +done + +PLUGIN_DIR="$output_dir" "$script_dir/verify-jwt-plugin.sh" diff --git a/infra/openbao/scripts/check-license-header.sh b/infra/openbao/scripts/check-license-header.sh new file mode 100755 index 000000000..9ee0d16f0 --- /dev/null +++ b/infra/openbao/scripts/check-license-header.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -eu + +failed=0 + +for file in "$@"; do + if sed -n '1p' "$file" | grep -q '^#!'; then + copyright_line=$(sed -n '2p' "$file") + license_line=$(sed -n '3p' "$file") + else + copyright_line=$(sed -n '1p' "$file") + license_line=$(sed -n '2p' "$file") + fi + + if [ "$copyright_line" != "# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved." ] || \ + [ "$license_line" != "# SPDX-License-Identifier: Apache-2.0" ]; then + echo "[license-header-check] missing or malformed header: $file" >&2 + failed=1 + fi +done + +exit "$failed" diff --git a/infra/openbao/scripts/smoke-jwt-plugin-runtime.sh b/infra/openbao/scripts/smoke-jwt-plugin-runtime.sh new file mode 100755 index 000000000..3084fc222 --- /dev/null +++ b/infra/openbao/scripts/smoke-jwt-plugin-runtime.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env sh +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -eu + +# A private directory rather than fixed /tmp paths: these files carry a dev +# root token and server logs, and predictable names in a shared /tmp are both +# a disclosure risk and a collision between concurrent runs. +smoke_tmp=$(mktemp -d "${TMPDIR:-/tmp}/nvcf-openbao-smoke.XXXXXX") +chmod 700 "$smoke_tmp" +trap 'rm -rf "$smoke_tmp"' EXIT INT TERM + +export BAO_ADDR="${BAO_ADDR:-http://127.0.0.1:8200}" +export BAO_TOKEN="${BAO_TOKEN:-root}" +PLUGIN_PATH="${PLUGIN_PATH:-/openbao/plugins/vault-plugin-secrets-jwt}" +EXPECTED_PLUGIN_SHA="${EXPECTED_PLUGIN_SHA:-}" +JWTVERIFY="${JWTVERIFY:-}" + +decode_jwt_payload() { + token="$1" + payload="$(printf "%s" "${token}" | cut -d. -f2 | tr "_-" "/+")" + case $((${#payload} % 4)) in + 2) payload="${payload}==" ;; + 3) payload="${payload}=" ;; + esac + printf "%s" "${payload}" | base64 -d +} + +decode_or_verify_jwt() { + token="$1" + output="$2" + + if [ -n "${JWTVERIFY}" ] && [ -x "${JWTVERIFY}" ]; then + "${JWTVERIFY}" "${token}" "${BAO_ADDR}/v1/jwt/jwks" > "${output}" + else + decode_jwt_payload "${token}" > "${output}" + fi +} + +printf "%s\n" "plugin_directory = \"/openbao/plugins\"" > $smoke_tmp/openbao-dev.hcl +bao server -dev -dev-root-token-id="${BAO_TOKEN}" -dev-listen-address=127.0.0.1:8200 -config=$smoke_tmp/openbao-dev.hcl >$smoke_tmp/openbao.log 2>&1 & +server_pid=$! +trap 'kill "${server_pid}" >/dev/null 2>&1 || true' EXIT + +ready=0 +for _ in $(seq 1 30); do + if bao status >$smoke_tmp/bao-status.txt 2>&1; then + ready=1 + break + fi + sleep 1 +done + +if [ "${ready}" != "1" ]; then + cat $smoke_tmp/openbao.log + cat $smoke_tmp/bao-status.txt 2>/dev/null || true + exit 1 +fi + +actual_sha="$(sha256sum "${PLUGIN_PATH}" | awk "{print \$1}")" +if [ -n "${EXPECTED_PLUGIN_SHA}" ] && [ "${actual_sha}" != "${EXPECTED_PLUGIN_SHA}" ]; then + echo "unexpected plugin sha: ${actual_sha}" + exit 1 +fi + +bao plugin register -sha256="${actual_sha}" secret vault-plugin-secrets-jwt +bao secrets enable -path=jwt vault-plugin-secrets-jwt +bao write jwt/config key_ttl=3s jwt_ttl=30s "subject_pattern=^[A-Z][a-z]+ [A-Z][a-z]+$" + +printf "%s\n" "{\"claims\":{\"sub\":\"Zapp Brannigan\"}}" > /tmp/claims.json +printf "%s\n" "{\"claims\":{\"sub\":\"This name should be invalid because it has more than one space\"}}" > /tmp/invalid-claims.json +printf "%s\n" "{\"claims\":{\"foo\":\"bar\"}}" > /tmp/foo-claims.json + +if bao write -field=token jwt/sign/test @/tmp/claims.json >/tmp/missing-role.jwt 2>/tmp/missing-role.err; then + echo "signing with missing role unexpectedly succeeded" + exit 1 +fi +grep -q "unknown role" /tmp/missing-role.err || { cat /tmp/missing-role.err; exit 1; } + +bao write jwt/roles/test issuer=test.example.com +bao read -field=issuer jwt/roles/test | grep -qx "test.example.com" + +bao write -field=token jwt/sign/test @/tmp/claims.json > /tmp/jwt1.txt +decode_or_verify_jwt "$(cat /tmp/jwt1.txt)" /tmp/decoded1.json +jq -e ".iss == \"test.example.com\" and .sub == \"Zapp Brannigan\" and (.exp | type == \"number\") and (.iat | type == \"number\") and (.nbf | type == \"number\") and (.jti | type == \"string\")" /tmp/decoded1.json >/dev/null + +if bao write -field=token jwt/sign/test @/tmp/invalid-claims.json >/tmp/invalid.jwt 2>/tmp/invalid.err; then + echo "signing invalid subject unexpectedly succeeded" + exit 1 +fi +grep -q "validation of .sub. claim failed" /tmp/invalid.err || { cat /tmp/invalid.err; exit 1; } + +if bao write -field=token jwt/sign/test @/tmp/foo-claims.json >/tmp/disallowed.jwt 2>/tmp/disallowed.err; then + echo "signing disallowed foo claim unexpectedly succeeded" + exit 1 +fi +grep -q "claim foo not permitted" /tmp/disallowed.err || { cat /tmp/disallowed.err; exit 1; } + +bao write jwt/config allowed_claims=foo allowed_claims=aud +bao write -field=token jwt/sign/test @/tmp/foo-claims.json > /tmp/jwt2.txt +decode_or_verify_jwt "$(cat /tmp/jwt2.txt)" /tmp/decoded2.json +jq -e ".iss == \"test.example.com\" and .foo == \"bar\"" /tmp/decoded2.json >/dev/null + +bao write jwt/config sig_alg=RS256 set_iat=false +sleep 3 +bao write -field=token jwt/sign/test @/tmp/foo-claims.json > /tmp/jwt3.txt +decode_or_verify_jwt "$(cat /tmp/jwt3.txt)" /tmp/decoded3.json +jq -e "(.foo == \"bar\") and (has(\"iat\") | not)" /tmp/decoded3.json >/dev/null + +jwks_count="$(wget -qO- "${BAO_ADDR}/v1/jwt/jwks" | jq ".keys | length")" +if [ "${jwks_count}" -lt 2 ]; then + echo "expected at least two JWKS keys after RSA switch/rotation, got ${jwks_count}" + exit 1 +fi + +printf "JWT plugin runtime smoke passed: plugin_sha=%s jwks_keys=%s\n" "${actual_sha}" "${jwks_count}" diff --git a/infra/openbao/scripts/verify-jwt-plugin.sh b/infra/openbao/scripts/verify-jwt-plugin.sh new file mode 100755 index 000000000..df1b3a13a --- /dev/null +++ b/infra/openbao/scripts/verify-jwt-plugin.sh @@ -0,0 +1,161 @@ +#!/bin/sh +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +repo_root=$(CDPATH= cd -- "$script_dir/.." && pwd) + +go_bin=${GO:-go} +plugin_dir=${PLUGIN_DIR:-"$repo_root/files/plugins"} +required_go_version=${REQUIRED_GO_VERSION:-v1.25.0} +required_x_net_version=${REQUIRED_X_NET_VERSION:-v0.55.0} +required_vault_api_version=${REQUIRED_VAULT_API_VERSION:-v1.15.0} +required_vault_sdk_version=${REQUIRED_VAULT_SDK_VERSION:-v0.15.2} + +metadata_files= +cleanup_metadata_files() { + # shellcheck disable=SC2086 + rm -f $metadata_files +} +trap cleanup_metadata_files EXIT + +version_ge() { + current=${1#v} + required=${2#v} + awk -v current="$current" -v required="$required" ' + BEGIN { + split(current, a, ".") + split(required, b, ".") + for (i = 1; i <= 3; i++) { + av = a[i] + 0 + bv = b[i] + 0 + if (av > bv) exit 0 + if (av < bv) exit 1 + } + exit 0 + } + ' +} + +dep_version() { + module=$1 + metadata=$2 + awk -v module="$module" '$1 == "dep" && $2 == module { print $3 }' "$metadata" +} + +build_value() { + key=$1 + metadata=$2 + awk -v key="$key" '$1 == "build" && $2 ~ ("^" key "=") { sub("^" key "=", "", $2); print $2 }' "$metadata" +} + +sha256() { + file=$1 + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$file" | awk '{ print $1 }' + else + shasum -a 256 "$file" | awk '{ print $1 }' + fi +} + +log_hash() { + arch=$1 + hash=$2 + printf 'vault-plugin-secrets-jwt-linux-%s sha256=%s\n' "$arch" "$hash" +} + +verify_binary() { + arch=$1 + binary="$plugin_dir/vault-plugin-secrets-jwt-linux-${arch}" + metadata=$(mktemp) + metadata_files="$metadata_files $metadata" + + if [ ! -x "$binary" ]; then + echo "missing executable JWT plugin binary: $binary" >&2 + exit 1 + fi + + "$go_bin" version -m "$binary" > "$metadata" + + toolchain=$(sed -n '1p' "$metadata" | awk -F': ' '{ print $2 }') + toolchain_version="v${toolchain#go}" + if ! version_ge "$toolchain_version" "$required_go_version"; then + echo "$binary was built with $toolchain; need Go ${required_go_version#v} or newer" >&2 + exit 1 + fi + + path=$(awk '$1 == "path" { print $2 }' "$metadata") + if [ "$path" != "github.com/NVIDIA/nvcf/infra/openbao/plugins/vault-plugin-secrets-jwt/cmd/vault-plugin-secrets-jwt" ]; then + echo "$binary has unexpected module path: $path" >&2 + exit 1 + fi + + goos=$(build_value GOOS "$metadata") + goarch=$(build_value GOARCH "$metadata") + cgo_enabled=$(build_value CGO_ENABLED "$metadata") + if [ "$goos" != "linux" ] || [ "$goarch" != "$arch" ] || [ "$cgo_enabled" != "0" ]; then + echo "$binary has unexpected target metadata: GOOS=$goos GOARCH=$goarch CGO_ENABLED=$cgo_enabled" >&2 + exit 1 + fi + + # No vcs.revision assertion. It used to pin the external fork this plugin was + # cloned from, which git could stamp because the build ran inside a clone. + # Neither build path has git metadata now: this script builds from a copy in + # a temp dir, and the image build COPYs the source into a layer. Requiring + # the stamp fails both, and requiring a specific value pins a revision that + # no longer exists. Provenance is instead carried by the assertions above - + # module path, target triple, toolchain floor - plus the dependency versions + # checked below, all of which are stamped without git. + + # Hashes are recorded, not asserted. They were pinned when the binary came + # from a frozen external revision and could therefore be reproduced exactly. + # Now any source edit in this repository legitimately changes them, so an + # equality check would fail on every real change and teach people to update + # the constant without reading it. The provenance that still holds is + # asserted above: module path, target, toolchain, dependency versions. + actual_hash=$(sha256 "$binary") + log_hash "$arch" "$actual_hash" + + x_net_version=$(dep_version golang.org/x/net "$metadata") + vault_api_version=$(dep_version github.com/hashicorp/vault/api "$metadata") + vault_sdk_version=$(dep_version github.com/hashicorp/vault/sdk "$metadata") + + if ! version_ge "$x_net_version" "$required_x_net_version"; then + echo "$binary embeds golang.org/x/net $x_net_version; need $required_x_net_version or newer" >&2 + exit 1 + fi + if [ "$vault_api_version" != "$required_vault_api_version" ]; then + echo "$binary embeds github.com/hashicorp/vault/api $vault_api_version; expected $required_vault_api_version" >&2 + exit 1 + fi + if [ "$vault_sdk_version" != "$required_vault_sdk_version" ]; then + echo "$binary embeds github.com/hashicorp/vault/sdk $vault_sdk_version; expected $required_vault_sdk_version" >&2 + exit 1 + fi + + echo "verified $binary" + echo " go: $toolchain" + echo " x/net: $x_net_version" + echo " vault/api: $vault_api_version" + echo " vault/sdk: $vault_sdk_version" + echo " sha256: $actual_hash" + + rm -f "$metadata" +} + +verify_binary amd64 +verify_binary arm64 diff --git a/infra/openbao/upgrade/Dockerfile.upgrade b/infra/openbao/upgrade/Dockerfile.upgrade new file mode 100644 index 000000000..e546090cf --- /dev/null +++ b/infra/openbao/upgrade/Dockerfile.upgrade @@ -0,0 +1,13 @@ +# Dockerfile for OpenBao upgrade testing +# Usage: docker build -f Dockerfile.upgrade --build-arg OPENBAO_VERSION=2.4.04-t nvcr.io/0651155215864979/ncp-dev/nvcf-openbao:2.4.0-upgrade . + +ARG OPENBAO_VERSION=2.4.4 + +FROM openbao/openbao:${OPENBAO_VERSION} + +ARG TARGETARCH + +RUN apk add --no-cache curl jq bash && \ + mkdir -p /openbao/plugins + +COPY --chmod=775 files/plugins/vault-plugin-secrets-jwt-linux-${TARGETARCH} /openbao/plugins/vault-plugin-secrets-jwt