From 48e85e9674a79dd64e397c4b7654854c8196aba9 Mon Sep 17 00:00:00 2001 From: balaji Date: Mon, 3 Aug 2026 16:48:46 -0700 Subject: [PATCH 1/8] feat(openbao): import the JWT secrets plugin under infra/openbao OpenBao is approved for GitHub, and its image bakes in a JWT secrets plugin built from a fork that lives only on GitLab. A reproducible public build needs that source here. The plugin is third-party: outfoxx/vault-plugin-secrets-jwt, Apache-2.0, Copyright 2021 Outfox, Inc. 73 of its 74 commits are upstream work. It is placed under infra/ rather than src/ because src/ is first-party NVCF code and this is not; nesting it under infra/openbao keeps it with the Dockerfile and build script that pin it, the same shape as infra/cassandra. Attribution is preserved rather than overwritten: the Apache-2.0 LICENSE and the upstream HEADER stay, all 17 upstream files keep their Outfox header, and NOTICE enumerates every NVIDIA modification as section 4(b) requires. Only the two files NVIDIA authored carry an NVIDIA header. AGENTS.md tells the copyright stamper to stay out. The friendlyid-go dependency is gone. Upstream mariuszs/friendlyid-go carries no license at all, so it is not redistributable, and the repository's own OSRB report already required its removal with no source copied. plugin/friendlyid.go is an independent base62 implementation written from the definition, with tests covering fixed width, round-trip, collisions and alphabet containment. The module path is renamed to the in-repo path. We do not track upstream, so resolving inside the monorepo is worth more than keeping diffs readable against Outfox. Upstream project machinery is deliberately not imported: GitHub Actions workflows above all, which would otherwise become live workflows in this repository, plus goreleaser, the upstream Dockerfile, install script, Makefile and linter config. Co-authored-by: Balaji Ganesan --- .../vault-plugin-secrets-jwt/AGENTS.md | 30 ++ .../vault-plugin-secrets-jwt/CLAUDE.md | 1 + .../plugins/vault-plugin-secrets-jwt/HEADER | 14 + .../plugins/vault-plugin-secrets-jwt/LICENSE | 203 +++++++++ .../plugins/vault-plugin-secrets-jwt/NOTICE | 51 +++ .../vault-plugin-secrets-jwt/README.md | 315 +++++++++++++ .../cmd/vault-plugin-secrets-jwt/main.go | 50 +++ .../plugins/vault-plugin-secrets-jwt/go.mod | 94 ++++ .../plugins/vault-plugin-secrets-jwt/go.sum | 347 +++++++++++++++ .../plugin/backend.go | 302 +++++++++++++ .../plugin/backend_test.go | 249 +++++++++++ .../vault-plugin-secrets-jwt/plugin/config.go | 269 ++++++++++++ .../plugin/friendlyid.go | 54 +++ .../plugin/friendlyid_test.go | 130 ++++++ .../plugin/path_config.go | 320 ++++++++++++++ .../plugin/path_config_test.go | 214 +++++++++ .../plugin/path_jwks.go | 124 ++++++ .../plugin/path_jwks_test.go | 95 ++++ .../plugin/path_roles.go | 358 +++++++++++++++ .../plugin/path_roles_test.go | 276 ++++++++++++ .../plugin/path_sign.go | 209 +++++++++ .../plugin/path_sign_test.go | 414 ++++++++++++++++++ .../plugin/policy_signer.go | 146 ++++++ .../vault-plugin-secrets-jwt/plugin/token.go | 45 ++ .../vault-plugin-secrets-jwt/plugin/util.go | 78 ++++ .../vault-plugin-secrets-jwt/test/Dockerfile | 28 ++ .../test/Stress-Dockerfile | 27 ++ .../vault-plugin-secrets-jwt/test/config.hcl | 1 + .../vault-plugin-secrets-jwt/test/godoc.go | 17 + .../test/jwtverify/jwtverify.go | 103 +++++ .../test/stress-test.sh | 106 +++++ .../vault-plugin-secrets-jwt/test/test.sh | 131 ++++++ .../test/testdata/allowed_claims.json | 3 + .../test/testdata/claims.json | 5 + .../test/testdata/claims1.json | 5 + .../test/testdata/claims10.json | 5 + .../test/testdata/claims2.json | 5 + .../test/testdata/claims3.json | 5 + .../test/testdata/claims4.json | 5 + .../test/testdata/claims5.json | 5 + .../test/testdata/claims6.json | 5 + .../test/testdata/claims7.json | 5 + .../test/testdata/claims8.json | 5 + .../test/testdata/claims9.json | 5 + .../test/testdata/claims_foo.json | 5 + .../test/testdata/invalid_claims.json | 5 + 46 files changed, 4869 insertions(+) create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/AGENTS.md create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/CLAUDE.md create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/HEADER create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/LICENSE create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/NOTICE create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/README.md create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/cmd/vault-plugin-secrets-jwt/main.go create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/go.mod create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/go.sum create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/backend.go create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/backend_test.go create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/config.go create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/friendlyid.go create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/friendlyid_test.go create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_config.go create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_config_test.go create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_jwks.go create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_jwks_test.go create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_roles.go create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_roles_test.go create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_sign.go create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/path_sign_test.go create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/policy_signer.go create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/token.go create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/util.go create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/test/Dockerfile create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/test/Stress-Dockerfile create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/test/config.hcl create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/test/godoc.go create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/test/jwtverify/jwtverify.go create mode 100755 infra/openbao/plugins/vault-plugin-secrets-jwt/test/stress-test.sh create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/test/test.sh create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/allowed_claims.json create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims.json create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims1.json create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims10.json create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims2.json create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims3.json create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims4.json create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims5.json create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims6.json create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims7.json create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims8.json create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims9.json create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/claims_foo.json create mode 100644 infra/openbao/plugins/vault-plugin-secrets-jwt/test/testdata/invalid_claims.json 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..c9b3ed028 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/NOTICE @@ -0,0 +1,51 @@ +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. +Files not listed below are unmodified from the upstream project and remain +under Outfox, Inc. copyright. + +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. 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. All other files +retain the upstream Outfox, Inc. header. 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..9a3b89a54 --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/config.go @@ -0,0 +1,269 @@ +// +// 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 nil + } + + 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..3673f8bad --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/friendlyid_test.go @@ -0,0 +1,130 @@ +// 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) + } +} 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..568c37a6a --- /dev/null +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/util.go @@ -0,0 +1,78 @@ +// +// 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) { + generatedUUID, err := uuid.NewUUID() + 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 From b6aad6355ad6fccb2489fc79b1029563443e4eb2 Mon Sep 17 00:00:00 2001 From: balaji Date: Mon, 3 Aug 2026 17:15:09 -0700 Subject: [PATCH 2/8] ci(openbao): build and test the JWT plugin outside the Bazel graph The imported plugin is a standalone Go module with no BUILD.bazel and no entry in go.work.bazel, so the Bazel matrix never sees it and nothing would compile or test it. NVIDIA owns modifications to this code now, so that gap matters. It is kept out of the root graph on purpose. Its module graph is 278 modules including hashicorp/vault/api and hashicorp/vault/sdk, and the root module uses no hashicorp/vault at all today. Joining go.work.bazel would pull all of it into minimal version selection for every service in the repository in order to build one plugin binary that ships inside a single image. This job is plain go scoped to that one directory, path-filtered so it only runs when the plugin changes. It also asserts friendlyid-go stays out of the module graph. That project has no license and is not redistributable, so its return would be a licensing regression rather than a build failure, and nothing else in CI would notice. Co-authored-by: Balaji Ganesan --- .github/workflows/openbao-jwt-plugin.yml | 77 ++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 .github/workflows/openbao-jwt-plugin.yml diff --git a/.github/workflows/openbao-jwt-plugin.yml b/.github/workflows/openbao-jwt-plugin.yml new file mode 100644 index 000000000..fae330923 --- /dev/null +++ b/.github/workflows/openbao-jwt-plugin.yml @@ -0,0 +1,77 @@ +# 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: | + if go list -m all | grep -q 'mariuszs/friendlyid-go'; 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" From f33c1cf5f4022c27d5b529cb417e29f25e121834 Mon Sep 17 00:00:00 2001 From: balaji Date: Mon, 3 Aug 2026 19:16:27 -0700 Subject: [PATCH 3/8] feat(openbao): import the image source and build the plugin in-image Brings across the rest of nvcf-openbao, mirroring infra/cassandra: Dockerfile, scripts, files/plugins, README, license-header tooling, upgrade/. Not imported: Dockerfile.internal, which bases on an internal mirror; .gitlab-ci.yml, which stays as-is in nvcf-internal; renovate, releaserc, CODEOWNERS and the OSRB report. The plugin is now compiled in a Dockerfile build stage rather than copied in prebuilt. Previously build-jwt-plugin.sh cloned a fork hosted outside this repository at a pinned revision, so the image could not be built from public source and the binaries had to arrive some other way: committed to git, or injected as a private overlay the way cassandra takes its exporter jar. Neither is needed. buildah is invoked with infra/openbao as the build context and the plugin source now sits at plugins/ inside it, so the image compiles the plugin from the same commit that produces it. That is a stronger position than cassandra, whose exporter jar is a third-party release artifact we cannot build and must therefore vendor. files/plugins/.gitignore keeps built binaries out of git; the directory still ships only .gitkeep. build-jwt-plugin.sh is kept and repointed at the in-repo source, since it is also the local developer path and what verify/smoke run against. Co-authored-by: Balaji Ganesan --- infra/openbao/.license-header.txt | 15 ++ infra/openbao/Dockerfile | 35 ++++ infra/openbao/README.md | 68 ++++++++ infra/openbao/files/plugins/.gitignore | 1 + infra/openbao/files/plugins/.gitkeep | 0 infra/openbao/files/plugins/PROVENANCE.md | 29 ++++ infra/openbao/scripts/apply-license-header.sh | 67 +++++++ infra/openbao/scripts/build-jwt-plugin.sh | 74 ++++++++ infra/openbao/scripts/check-license-header.sh | 37 ++++ .../scripts/smoke-jwt-plugin-runtime.sh | 122 +++++++++++++ infra/openbao/scripts/verify-jwt-plugin.sh | 164 ++++++++++++++++++ infra/openbao/upgrade/Dockerfile.upgrade | 13 ++ 12 files changed, 625 insertions(+) create mode 100644 infra/openbao/.license-header.txt create mode 100644 infra/openbao/Dockerfile create mode 100644 infra/openbao/README.md create mode 100644 infra/openbao/files/plugins/.gitignore create mode 100644 infra/openbao/files/plugins/.gitkeep create mode 100644 infra/openbao/files/plugins/PROVENANCE.md create mode 100755 infra/openbao/scripts/apply-license-header.sh create mode 100755 infra/openbao/scripts/build-jwt-plugin.sh create mode 100755 infra/openbao/scripts/check-license-header.sh create mode 100755 infra/openbao/scripts/smoke-jwt-plugin-runtime.sh create mode 100755 infra/openbao/scripts/verify-jwt-plugin.sh create mode 100644 infra/openbao/upgrade/Dockerfile.upgrade 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..65f002f21 --- /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=775 /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..043e4e06e --- /dev/null +++ b/infra/openbao/README.md @@ -0,0 +1,68 @@ +# 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`) + +Build a compatible `vault-plugin-secrets-jwt` plugin for each target architecture, place the resulting binary at the path above, and ensure it is executable. For example: + +```bash +git clone https://github.com/outfoxx/vault-plugin-secrets-jwt +cd vault-plugin-secrets-jwt + +# amd64 +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build \ + -o ../files/plugins/vault-plugin-secrets-jwt-linux-amd64 ./cmd/vault-plugin-secrets-jwt +chmod +x ../files/plugins/vault-plugin-secrets-jwt-linux-amd64 + +# arm64 +GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build \ + -o ../files/plugins/vault-plugin-secrets-jwt-linux-arm64 ./cmd/vault-plugin-secrets-jwt +chmod +x ../files/plugins/vault-plugin-secrets-jwt-linux-arm64 +``` + +## 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..7d75bc295 --- /dev/null +++ b/infra/openbao/files/plugins/PROVENANCE.md @@ -0,0 +1,29 @@ +# JWT Plugin Provenance + +The committed `vault-plugin-secrets-jwt` binaries are rebuilt from NVIDIA's +internal fork: + +- Source: https://gitlab-master.nvidia.com/kaizen-data/forks/vault-plugin-secrets-jwt +- Commit: `183b3159512f6fcfe766c8a3d738f47a751bad5c` +- Build script: `scripts/build-jwt-plugin.sh` +- Verification script: `scripts/verify-jwt-plugin.sh` + +Pinned dependency floor for `NVCF-10946`: + +- `golang.org/x/net v0.55.0` + +Compatibility pins retained from the previously shipped NVCF plugin binary: + +- `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 OpenBao producer image copies one binary per target platform into +`/openbao/plugins/vault-plugin-secrets-jwt`. Run +`scripts/verify-jwt-plugin.sh` before publishing the image. + +Current committed binary hashes: + +- `vault-plugin-secrets-jwt-linux-amd64`: `be2a2bcea1e028c6a6be43877facafd12509c07aa09ce2da982fa9117135d006` +- `vault-plugin-secrets-jwt-linux-arm64`: `88a14ef10d3fc1a6290ffc78de3367de92de7cb56e9d45a097c7c945f13ec77d` 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..0414f0b37 --- /dev/null +++ b/infra/openbao/scripts/build-jwt-plugin.sh @@ -0,0 +1,74 @@ +#!/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"} + +work_dir=${WORK_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/nvcf-openbao-jwt-plugin.XXXXXX")} +src_dir="$work_dir/source" +build_dir="$work_dir/build" + +cleanup() { + if [ -z "${KEEP_WORK_DIR:-}" ]; 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..690066e73 --- /dev/null +++ b/infra/openbao/scripts/smoke-jwt-plugin-runtime.sh @@ -0,0 +1,122 @@ +#!/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 + +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\"" > /tmp/openbao-dev.hcl +bao server -dev -dev-root-token-id="${BAO_TOKEN}" -dev-listen-address=127.0.0.1:8200 -config=/tmp/openbao-dev.hcl >/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 >/tmp/bao-status.txt 2>&1; then + ready=1 + break + fi + sleep 1 +done + +if [ "${ready}" != "1" ]; then + cat /tmp/openbao.log + cat /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..df00cb170 --- /dev/null +++ b/infra/openbao/scripts/verify-jwt-plugin.sh @@ -0,0 +1,164 @@ +#!/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} +required_plugin_revision=${REQUIRED_PLUGIN_REVISION:-183b3159512f6fcfe766c8a3d738f47a751bad5c} + +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 +} + +expected_sha256() { + arch=$1 + case "$arch" in + amd64) echo "be2a2bcea1e028c6a6be43877facafd12509c07aa09ce2da982fa9117135d006" ;; + arm64) echo "88a14ef10d3fc1a6290ffc78de3367de92de7cb56e9d45a097c7c945f13ec77d" ;; + *) + echo "unknown architecture: $arch" >&2 + exit 1 + ;; + esac +} + +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/outfoxx/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 + + plugin_revision=$(build_value vcs.revision "$metadata") + if [ "$plugin_revision" != "$required_plugin_revision" ]; then + echo "$binary was built from revision $plugin_revision; expected $required_plugin_revision" >&2 + exit 1 + fi + + expected_hash=$(expected_sha256 "$arch") + actual_hash=$(sha256 "$binary") + if [ "$actual_hash" != "$expected_hash" ]; then + echo "$binary has sha256 $actual_hash; expected $expected_hash" >&2 + exit 1 + fi + + 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 " vcs.revision: $plugin_revision" + 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 From 85fb845aa5b11438b35f16a4d933ed7ec40c992a Mon Sep 17 00:00:00 2001 From: balaji Date: Mon, 3 Aug 2026 19:19:42 -0700 Subject: [PATCH 4/8] chore(license): regenerate NOTICE for the imported plugin check-license enforces that NOTICE lists every per-directory notice file, and the import added one it did not know about: ERROR: NOTICE is out of sync with repo notice / third-party license paths. + infra/openbao/plugins/vault-plugin-secrets-jwt/NOTICE Regenerated with ./tools/scripts/update-license. check-license now passes: 291 files with valid headers, MPL audit in sync. Co-authored-by: Balaji Ganesan --- NOTICE | 1 + 1 file changed, 1 insertion(+) 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 From 4525c79b4c8f3b83228c21568f5a5ca65c63ca1e Mon Sep 17 00:00:00 2001 From: balaji Date: Mon, 3 Aug 2026 19:29:27 -0700 Subject: [PATCH 5/8] fix(openbao): address review and make the verifier match the build CodeRabbit, both valid: The workflow's licensing gate failed open. Inside `if`, a failing `go list` read as "dependency absent", and under pipefail `grep -q` can close the pipe and leave `go list` killed by SIGPIPE. It now captures the module list and fails the step if enumeration fails. NOTICE claimed unlisted files were unmodified upstream and that all other files retain the Outfox header. AGENTS.md and CLAUDE.md are NVIDIA-added, so both statements were false. The scope is now limited to upstream-originated files and the guidance files are inventoried. The remaining eight findings are pre-existing upstream code, including the two on NVIDIA-modified files: git blame puts path_roles.go:105 and path_roles_test.go:215 on upstream commits. Fixing upstream defects inside an import would blur which lines are Outfox's and which are ours. verify-jwt-plugin.sh asserted three things that only held when the plugin came from a frozen external revision: - the module path, updated for the rename - a required vcs.revision, dropped. Neither build path has git metadata: this script builds from a copy in a temp dir and the image build COPYs source into a layer. Requiring the stamp fails both; requiring a value pins a revision that no longer exists. - pinned sha256s, now recorded rather than asserted. Any source edit in this repository legitimately changes them, so equality would fail on every real change and teach people to update the constant without reading it. What still carries provenance is asserted: module path, target triple, toolchain floor, and the x/net, vault/api and vault/sdk versions. Verified end to end: build-jwt-plugin.sh produces both arches from the in-repo source and verify-jwt-plugin.sh passes on both. Co-authored-by: Balaji Ganesan --- .github/workflows/openbao-jwt-plugin.yml | 10 ++++- .../plugins/vault-plugin-secrets-jwt/NOTICE | 17 +++++--- infra/openbao/scripts/verify-jwt-plugin.sh | 43 +++++++++---------- 3 files changed, 41 insertions(+), 29 deletions(-) diff --git a/.github/workflows/openbao-jwt-plugin.yml b/.github/workflows/openbao-jwt-plugin.yml index fae330923..c00394c96 100644 --- a/.github/workflows/openbao-jwt-plugin.yml +++ b/.github/workflows/openbao-jwt-plugin.yml @@ -70,7 +70,15 @@ jobs: # would catch it. - name: Assert no unlicensed dependency run: | - if go list -m all | grep -q 'mariuszs/friendlyid-go'; then + # 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 diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/NOTICE b/infra/openbao/plugins/vault-plugin-secrets-jwt/NOTICE index c9b3ed028..ccdcb8e81 100644 --- a/infra/openbao/plugins/vault-plugin-secrets-jwt/NOTICE +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/NOTICE @@ -10,8 +10,9 @@ This directory contains a modified copy of: 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. -Files not listed below are unmodified from the upstream project and remain -under Outfox, Inc. copyright. +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 ------------------------------------------------ @@ -42,10 +43,16 @@ changes were made to the original work: module resolves inside this repository. The friendlyid-go requirement was removed. Go, Vault, and security-sensitive dependency versions were updated. -7. Upstream project machinery not applicable to this repository was omitted: +7. 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. + +8. 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. All other files -retain the upstream Outfox, Inc. header. +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/scripts/verify-jwt-plugin.sh b/infra/openbao/scripts/verify-jwt-plugin.sh index df00cb170..df1b3a13a 100755 --- a/infra/openbao/scripts/verify-jwt-plugin.sh +++ b/infra/openbao/scripts/verify-jwt-plugin.sh @@ -25,7 +25,6 @@ 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} -required_plugin_revision=${REQUIRED_PLUGIN_REVISION:-183b3159512f6fcfe766c8a3d738f47a751bad5c} metadata_files= cleanup_metadata_files() { @@ -73,16 +72,10 @@ sha256() { fi } -expected_sha256() { +log_hash() { arch=$1 - case "$arch" in - amd64) echo "be2a2bcea1e028c6a6be43877facafd12509c07aa09ce2da982fa9117135d006" ;; - arm64) echo "88a14ef10d3fc1a6290ffc78de3367de92de7cb56e9d45a097c7c945f13ec77d" ;; - *) - echo "unknown architecture: $arch" >&2 - exit 1 - ;; - esac + hash=$2 + printf 'vault-plugin-secrets-jwt-linux-%s sha256=%s\n' "$arch" "$hash" } verify_binary() { @@ -106,7 +99,7 @@ verify_binary() { fi path=$(awk '$1 == "path" { print $2 }' "$metadata") - if [ "$path" != "github.com/outfoxx/vault-plugin-secrets-jwt/cmd/vault-plugin-secrets-jwt" ]; then + 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 @@ -119,18 +112,23 @@ verify_binary() { exit 1 fi - plugin_revision=$(build_value vcs.revision "$metadata") - if [ "$plugin_revision" != "$required_plugin_revision" ]; then - echo "$binary was built from revision $plugin_revision; expected $required_plugin_revision" >&2 - exit 1 - fi - - expected_hash=$(expected_sha256 "$arch") + # 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") - if [ "$actual_hash" != "$expected_hash" ]; then - echo "$binary has sha256 $actual_hash; expected $expected_hash" >&2 - exit 1 - fi + 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") @@ -154,7 +152,6 @@ verify_binary() { echo " x/net: $x_net_version" echo " vault/api: $vault_api_version" echo " vault/sdk: $vault_sdk_version" - echo " vcs.revision: $plugin_revision" echo " sha256: $actual_hash" rm -f "$metadata" From ed923b5e6ca5c07ce7dc7e90735f66917b5be74e Mon Sep 17 00:00:00 2001 From: balaji Date: Mon, 3 Aug 2026 19:36:27 -0700 Subject: [PATCH 6/8] fix(openbao): return the unsupported-algorithm error and stop leaking the host MAC Two upstream defects CodeRabbit raised. I had declined all eight upstream findings as out of scope for an import, which was wrong for these two: we do not track upstream, so this code is ours to maintain, and both are security relevant in a plugin that signs tokens. plugin/config.go discarded the "unknown/unsupported signature algorithm" error and returned nil, so an unsupported algorithm reported success while the key was never rotated and the caller had no way to detect it. plugin/util.go generated token ids from uuid.NewUUID, which is a version 1 UUID: it encodes the host MAC address and the creation timestamp. That id is published as the token's jti, so every token holder received the signer's hardware address and issue time. Now uuid.NewRandom (v4), with a test that asserts the version and that consecutive ids do not share a trailing segment, which is what a node-derived id would show. Both recorded in NOTICE as required by Apache-2.0 section 4(b). The remaining six findings are upstream style and test-harness issues with no security or correctness impact, and are left to a follow-up so they can be reviewed as NVIDIA changes rather than buried in an import. Co-authored-by: Balaji Ganesan --- .../plugins/vault-plugin-secrets-jwt/NOTICE | 12 ++++++-- .../vault-plugin-secrets-jwt/plugin/config.go | 5 +++- .../plugin/friendlyid_test.go | 29 +++++++++++++++++++ .../vault-plugin-secrets-jwt/plugin/util.go | 6 +++- 4 files changed, 48 insertions(+), 4 deletions(-) diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/NOTICE b/infra/openbao/plugins/vault-plugin-secrets-jwt/NOTICE index ccdcb8e81..0a85bdd20 100644 --- a/infra/openbao/plugins/vault-plugin-secrets-jwt/NOTICE +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/NOTICE @@ -43,11 +43,19 @@ changes were made to the original work: module resolves inside this repository. The friendlyid-go requirement was removed. Go, Vault, and security-sensitive dependency versions were updated. -7. AGENTS.md and CLAUDE.md (added by NVIDIA): repository guidance describing +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. -8. Upstream project machinery not applicable to this repository was omitted: +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. diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/config.go b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/config.go index 9a3b89a54..45ca604cc 100644 --- a/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/config.go +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/config.go @@ -197,7 +197,10 @@ func (b *backend) saveConfig(ctx context.Context, stg logical.Storage, config *C } if err != nil { - return 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) 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 index 3673f8bad..decb0bbcb 100644 --- a/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/friendlyid_test.go +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/friendlyid_test.go @@ -128,3 +128,32 @@ func TestFriendlyIDGeneratorProducesUsableIDs(t *testing.T) { t.Errorf("generator produced %d chars, want %d: %q", len(id), encodedIDLen, id) } } + +func TestGeneratedIDsAreNotTimeOrMACDerived(t *testing.T) { + // A v1 UUID encodes the host MAC and a timestamp, and this id becomes the + // token's jti. Assert version 4 at the source rather than inspecting the + // encoded string, which is opaque by design. + for i := 0; i < 50; i++ { + id, err := uuid.NewRandom() + if err != nil { + t.Fatalf("generating uuid: %v", err) + } + if got := id.Version(); got != 4 { + t.Fatalf("expected a version 4 UUID, got version %d", got) + } + } + // Two ids in a row must not share a suffix: v1 UUIDs end in the node id, + // which is constant per host. + var gen friendlyIdGenerator + a, err := gen.id() + if err != nil { + t.Fatalf("id(): %v", err) + } + b, err := gen.id() + if err != nil { + t.Fatalf("id(): %v", err) + } + if a[len(a)-6:] == b[len(b)-6:] { + t.Errorf("consecutive ids share a trailing segment (%q, %q); ids may be node-derived", a, b) + } +} diff --git a/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/util.go b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/util.go index 568c37a6a..d8cadf1c7 100644 --- a/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/util.go +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/util.go @@ -35,7 +35,11 @@ type uniqueIdGenerator interface { type friendlyIdGenerator struct{} func (fid friendlyIdGenerator) id() (string, error) { - generatedUUID, err := uuid.NewUUID() + // 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 } From be90a697f7f46be713c5bc6a10f80ae306ce824d Mon Sep 17 00:00:00 2001 From: balaji Date: Mon, 3 Aug 2026 20:18:43 -0700 Subject: [PATCH 7/8] fix(openbao): address review on the imported image source All six findings were on files this PR adds, and all were valid. files/plugins/PROVENANCE.md carried an internal GitLab fork URL and a private tracker id. I imported it wholesale without scanning it, which is exactly what the OSS hygiene rule exists to prevent. It was also stale: it described committed binaries and pinned hashes that no longer apply now that the plugin is compiled during the image build. Rewritten around the in-tree source. Dockerfile: the plugin is installed 0555 rather than 775. Group write on an executable the server exec's is not needed by anything. README.md still told readers to clone the upstream project and place binaries by hand. It now documents the in-tree build. build-jwt-plugin.sh deleted a caller-supplied WORK_DIR on exit. It now removes only a directory it created itself. smoke-jwt-plugin-runtime.sh wrote a dev root token, server logs and status output to fixed /tmp paths. It now uses a private 0700 directory removed on exit; predictable names in a shared /tmp are both a disclosure risk and a collision between concurrent runs. The UUID version test asserted on uuid.NewRandom() directly, which tested the uuid package rather than this code. It now decodes what friendlyIdGenerator.id() actually returns and asserts version 4 and the RFC4122 variant. Confirmed the test fails when the v1 call is reintroduced and passes when it is restored. Co-authored-by: Balaji Ganesan --- infra/openbao/Dockerfile | 2 +- infra/openbao/README.md | 31 ++++++++------- infra/openbao/files/plugins/PROVENANCE.md | 38 +++++++++++-------- .../plugin/friendlyid_test.go | 37 +++++++++--------- infra/openbao/scripts/build-jwt-plugin.sh | 12 +++++- .../scripts/smoke-jwt-plugin-runtime.sh | 17 ++++++--- 6 files changed, 80 insertions(+), 57 deletions(-) diff --git a/infra/openbao/Dockerfile b/infra/openbao/Dockerfile index 65f002f21..1a558980b 100644 --- a/infra/openbao/Dockerfile +++ b/infra/openbao/Dockerfile @@ -32,4 +32,4 @@ FROM openbao/openbao:${BAO_VERSION} RUN apk add --no-cache curl jq bash && \ mkdir -p /openbao/plugins -COPY --from=plugin-build --chmod=775 /out/vault-plugin-secrets-jwt /openbao/plugins/vault-plugin-secrets-jwt +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 index 043e4e06e..424cc6597 100644 --- a/infra/openbao/README.md +++ b/infra/openbao/README.md @@ -16,23 +16,28 @@ 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`) -Build a compatible `vault-plugin-secrets-jwt` plugin for each target architecture, place the resulting binary at the path above, and ensure it is executable. For example: +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 -git clone https://github.com/outfoxx/vault-plugin-secrets-jwt -cd vault-plugin-secrets-jwt - -# amd64 -GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build \ - -o ../files/plugins/vault-plugin-secrets-jwt-linux-amd64 ./cmd/vault-plugin-secrets-jwt -chmod +x ../files/plugins/vault-plugin-secrets-jwt-linux-amd64 - -# arm64 -GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build \ - -o ../files/plugins/vault-plugin-secrets-jwt-linux-arm64 ./cmd/vault-plugin-secrets-jwt -chmod +x ../files/plugins/vault-plugin-secrets-jwt-linux-arm64 +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) diff --git a/infra/openbao/files/plugins/PROVENANCE.md b/infra/openbao/files/plugins/PROVENANCE.md index 7d75bc295..1cf3fdb3a 100644 --- a/infra/openbao/files/plugins/PROVENANCE.md +++ b/infra/openbao/files/plugins/PROVENANCE.md @@ -1,29 +1,35 @@ -# JWT Plugin Provenance +# JWT plugin provenance -The committed `vault-plugin-secrets-jwt` binaries are rebuilt from NVIDIA's -internal fork: +The `vault-plugin-secrets-jwt` binaries in this directory are build output. +They are not committed; `.gitignore` excludes them and only `.gitkeep` ships. -- Source: https://gitlab-master.nvidia.com/kaizen-data/forks/vault-plugin-secrets-jwt -- Commit: `183b3159512f6fcfe766c8a3d738f47a751bad5c` -- Build script: `scripts/build-jwt-plugin.sh` -- Verification script: `scripts/verify-jwt-plugin.sh` +## Source -Pinned dependency floor for `NVCF-10946`: +`../../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. -- `golang.org/x/net v0.55.0` +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. -Compatibility pins retained from the previously shipped NVCF plugin binary: +## 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 OpenBao producer image copies one binary per target platform into -`/openbao/plugins/vault-plugin-secrets-jwt`. Run -`scripts/verify-jwt-plugin.sh` before publishing the image. +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 -Current committed binary hashes: + scripts/build-jwt-plugin.sh # writes both arch binaries here + scripts/verify-jwt-plugin.sh # asserts module path, target, toolchain, deps -- `vault-plugin-secrets-jwt-linux-amd64`: `be2a2bcea1e028c6a6be43877facafd12509c07aa09ce2da982fa9117135d006` -- `vault-plugin-secrets-jwt-linux-arm64`: `88a14ef10d3fc1a6290ffc78de3367de92de7cb56e9d45a097c7c945f13ec77d` +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/plugin/friendlyid_test.go b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/friendlyid_test.go index decb0bbcb..7c5480da1 100644 --- a/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/friendlyid_test.go +++ b/infra/openbao/plugins/vault-plugin-secrets-jwt/plugin/friendlyid_test.go @@ -130,30 +130,27 @@ func TestFriendlyIDGeneratorProducesUsableIDs(t *testing.T) { } func TestGeneratedIDsAreNotTimeOrMACDerived(t *testing.T) { - // A v1 UUID encodes the host MAC and a timestamp, and this id becomes the - // token's jti. Assert version 4 at the source rather than inspecting the - // encoded string, which is opaque by design. + // 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++ { - id, err := uuid.NewRandom() + encoded, err := gen.id() if err != nil { - t.Fatalf("generating uuid: %v", err) + 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("expected a version 4 UUID, got version %d", got) + 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) } - } - // Two ids in a row must not share a suffix: v1 UUIDs end in the node id, - // which is constant per host. - var gen friendlyIdGenerator - a, err := gen.id() - if err != nil { - t.Fatalf("id(): %v", err) - } - b, err := gen.id() - if err != nil { - t.Fatalf("id(): %v", err) - } - if a[len(a)-6:] == b[len(b)-6:] { - t.Errorf("consecutive ids share a trailing segment (%q, %q); ids may be node-derived", a, b) } } diff --git a/infra/openbao/scripts/build-jwt-plugin.sh b/infra/openbao/scripts/build-jwt-plugin.sh index 0414f0b37..f5bf7a0c5 100755 --- a/infra/openbao/scripts/build-jwt-plugin.sh +++ b/infra/openbao/scripts/build-jwt-plugin.sh @@ -28,12 +28,20 @@ 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"} -work_dir=${WORK_DIR:-$(mktemp -d "${TMPDIR:-/tmp}/nvcf-openbao-jwt-plugin.XXXXXX")} +# 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:-}" ]; then + if [ -z "${KEEP_WORK_DIR:-}" ] && [ "$work_dir_is_ours" = "1" ]; then rm -rf "$work_dir" else echo "Keeping work dir: $work_dir" diff --git a/infra/openbao/scripts/smoke-jwt-plugin-runtime.sh b/infra/openbao/scripts/smoke-jwt-plugin-runtime.sh index 690066e73..3084fc222 100755 --- a/infra/openbao/scripts/smoke-jwt-plugin-runtime.sh +++ b/infra/openbao/scripts/smoke-jwt-plugin-runtime.sh @@ -16,6 +16,13 @@ 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}" @@ -43,14 +50,14 @@ decode_or_verify_jwt() { fi } -printf "%s\n" "plugin_directory = \"/openbao/plugins\"" > /tmp/openbao-dev.hcl -bao server -dev -dev-root-token-id="${BAO_TOKEN}" -dev-listen-address=127.0.0.1:8200 -config=/tmp/openbao-dev.hcl >/tmp/openbao.log 2>&1 & +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 >/tmp/bao-status.txt 2>&1; then + if bao status >$smoke_tmp/bao-status.txt 2>&1; then ready=1 break fi @@ -58,8 +65,8 @@ for _ in $(seq 1 30); do done if [ "${ready}" != "1" ]; then - cat /tmp/openbao.log - cat /tmp/bao-status.txt 2>/dev/null || true + cat $smoke_tmp/openbao.log + cat $smoke_tmp/bao-status.txt 2>/dev/null || true exit 1 fi From 27687ba5bc1a6dd8a2c2fee8063a6bca40760920 Mon Sep 17 00:00:00 2001 From: balaji Date: Mon, 3 Aug 2026 20:56:34 -0700 Subject: [PATCH 8/8] chore(deps): regenerate dependencies.md for the imported plugin check-dependency-docs regenerates dependencies.md and fails if the committed copy differs. The imported JWT plugin adds eleven Go modules to the repository-wide inventory, mostly hashicorp/go-secure-stdlib and crypto transitives pulled in by the vault SDK. Regenerated with `GOWORK=off go run -C ./tools/collect-dependencies .`, which is what CI runs. Also satisfies the OSRB report's requirement that dependency counts be regenerated once friendlyid-go is removed, which this branch does. Co-authored-by: Balaji Ganesan --- dependencies.md | 11 +++++++++++ 1 file changed, 11 insertions(+) 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`