Add app-token JWT secrets engine
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

Implement a generic Vault/OpenBao secrets engine that issues short-lived
signed JWTs for self-made services, replacing per-app static bearer
Secrets. Per-app roles set the audience, TTLs, subject allowlist and
custom claims; creds/<role> mints an EdDSA (or RS256) token. Apps
validate offline against the unauthenticated JWKS + OIDC-metadata paths,
so there is no Vault round-trip per request. Signing keys are seal-wrapped
in the barrier and rotate with a configurable JWKS grace window.

Mirrors the other vault-plugin-secrets-* engines: cmd ServeMultiplex,
Makefile with patch|minor|major tags, .woodpecker CI (k8s resources + SA),
dual-flavour nfpm RPM to artifactapi rpm-internal. Tests (-race) cover
issuance+JWKS validation for both algorithms, rotation grace/trim, role
isolation, subject allowlist and the unauthenticated/seal-wrap wiring.

Claude-Session: https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
This commit is contained in:
2026-08-02 23:42:42 +10:00
parent 048ab5721b
commit ec12dfb84f
24 changed files with 2461 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
/dist/
/vault-plugin-secrets-apptoken
*.out
*.test
.env
+15
View File
@@ -0,0 +1,15 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- repo: https://github.com/dnephin/pre-commit-golang
rev: v0.5.1
hooks:
- id: go-fmt
- id: go-vet
- id: go-mod-tidy
+18
View File
@@ -0,0 +1,18 @@
when:
- event: pull_request
steps:
- name: build
image: golang:1.25
commands:
- make build
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+18
View File
@@ -0,0 +1,18 @@
when:
- event: pull_request
steps:
- name: pre-commit
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
commands:
- uvx pre-commit run --all-files
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+47
View File
@@ -0,0 +1,47 @@
when:
- event: tag
steps:
- name: build
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
commands:
- make build VERSION=${CI_COMMIT_TAG}
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests: {memory: 512Mi, cpu: 1}
limits: {memory: 2Gi, cpu: 2}
- name: package
image: git.unkin.net/unkin/almalinux9-rpmbuilder:latest
commands:
- ./scripts/build-rpm.sh ${CI_COMMIT_TAG}
depends_on: [build]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests: {memory: 512Mi, cpu: 1}
limits: {memory: 2Gi, cpu: 2}
- name: upload
image: git.unkin.net/unkin/almalinux9-base:20260606
commands:
- |
HOST="https://artifactapi.k8s.syd1.au.unkin.net"
REPO="rpm-internal"
for rpm in dist/*.rpm; do
FILE=$$(basename "$$rpm")
code=$$(curl -s -o /dev/null -w '%{http_code}' "$$HOST/api/v2/remotes/$$REPO/files/Packages/$$FILE" || true)
if [ "$$code" = "200" ]; then echo "$$FILE exists; skipping"; continue; fi
echo "Uploading $$FILE (probe $$code)"
curl -f -X PUT "$$HOST/api/v2/remotes/$$REPO/files/$$FILE" -H "Content-Type: application/x-rpm" --data-binary @"$$rpm"
done
depends_on: [package]
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests: {memory: 128Mi, cpu: 100m}
limits: {memory: 512Mi, cpu: 500m}
+33
View File
@@ -0,0 +1,33 @@
when:
- event: pull_request
steps:
- name: lint
image: golang:1.25
commands:
- make lint
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
- name: test
image: golang:1.25
commands:
- make test
backend_options:
kubernetes:
serviceAccountName: default
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+62
View File
@@ -0,0 +1,62 @@
.PHONY: build install test lint fmt clean tidy rpm rpm-package patch minor major check-go
BINARY := vault-plugin-secrets-apptoken
PKG := ./cmd/vault-plugin-secrets-apptoken
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev")
OS ?= $(shell go env GOOS)
ARCH ?= $(shell go env GOARCH)
PLUGIN_DIR ?= ./dist
GO_VERSION_REQUIRED := 1.25
GO_VERSION_ACTUAL := $(shell go version | sed 's/go version go\([0-9]*\.[0-9]*\).*/\1/')
check-go:
@if [ "$$(printf '%s\n%s' "$(GO_VERSION_REQUIRED)" "$(GO_VERSION_ACTUAL)" | sort -V | head -1)" != "$(GO_VERSION_REQUIRED)" ]; then \
echo "ERROR: Go >= $(GO_VERSION_REQUIRED) required, found $(GO_VERSION_ACTUAL)"; exit 1; \
fi
build: check-go tidy
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build -ldflags="-s -w -X main.version=$(VERSION)" -o $(PLUGIN_DIR)/$(BINARY) $(PKG)
install: build
@echo "Built $(PLUGIN_DIR)/$(BINARY) (register it with: vault plugin register -sha256=<sha> secret $(BINARY))"
test: check-go
go test -race -count=1 ./...
lint: check-go
go vet ./...
fmt: check-go
gofmt -w .
tidy:
go mod tidy
clean:
rm -rf $(PLUGIN_DIR)
# Build the plugin binary then package it into an RPM with nfpm.
rpm: build rpm-package
# Package an already-built binary into an RPM (used by CI after the build step).
rpm-package:
./scripts/build-rpm.sh $(VERSION)
_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0)
_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1)
_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2)
_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3)
patch:
@NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
minor:
@NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
major:
@NEW=v$(shell expr $(_MAJ) + 1).0.0; \
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
+128 -1
View File
@@ -1,3 +1,130 @@
# vault-plugin-secrets-apptoken # vault-plugin-secrets-apptoken
HashiCorp Vault / OpenBao secrets engine issuing short-lived signed JWT app tokens (per-app roles, offline JWKS validation) for self-made services A HashiCorp Vault / OpenBao secrets engine that issues **short-lived, signed
JWT "app tokens"** for self-made services (keaapi, encapi, artifactapi,
tomswallapi, bootapi, ...). It replaces per-app static bearer `Secret`s with one
mechanism: define a per-app role, read `creds/<app>` to mint a token, and let
the app validate it **offline** against the engine's public JWKS — no Vault
round-trip per request.
Any principal allowed to read `creds/<role>` (for example the agents approle)
can mint a token for that app. Signing keys are generated inside Vault's barrier
(seal-wrapped) and never leave it; only the public JWKS is exposed.
```
issuer (this engine) consumer app (keaapi)
├── creds/keaapi ──▶ signed JWT ──────▶ Authorization: Bearer <jwt>
│ (aud=keaapi, exp, sub=caller) │
└── .well-known/jwks.json ◀── fetch ───────┘ verify signature + aud, offline
```
## Signing
Tokens are signed with **EdDSA (Ed25519)** by default — compact signatures,
fast verification, first-class support in Go's stdlib and `go-jose`, which the
whole ecosystem (and Vault itself) uses. `RS256` is available via
`config algorithm=RS256` for consumers that need RSA interop. The `kid` header
is the RFC 7638 JWK thumbprint of the signing key, so it is stable and matches
the JWKS entry.
## Paths
| Path | Op | Purpose |
|------|----|---------|
| `config` | R/W | `issuer` (external mount URL, becomes `iss` + base of `jwks_uri`), `algorithm` (`EdDSA`\|`RS256`), `retained_keys` |
| `config/keys` | R | current signing `kid` + all retained kids (keys are generated lazily on first use) |
| `config/keys/rotate` | W | generate a new current signing key; up to `retained_keys` previous keys stay in the JWKS |
| `roles/<name>` | R/W/D, list `roles/` | per-app role: `audience` (defaults to role name), `ttl`, `max_ttl`, `allowed_subjects`, `claims` |
| `creds/<name>` | R | mint a signed JWT for the role |
| `.well-known/jwks.json` | R, **unauthenticated** | public JSON Web Key Set for offline validation |
| `.well-known/openid-configuration` | R, **unauthenticated** | OIDC issuer metadata (`issuer`, `jwks_uri`) |
Token claims: `iss` (issuer), `aud` (role audience), `sub` (requesting Vault
entity id or token display name), `exp`/`nbf`/`iat`, `jti`, plus any role
`claims`. Registered claims cannot be overridden by a role.
`allowed_subjects`, when set, restricts which callers (matched on Vault entity
id or token display name) may mint from a role. `retained_keys` keeps N previous
signing keys published after a rotation so tokens signed just before it keep
validating during the grace window.
## Usage
```sh
# register + enable (the plugin_directory must contain the binary)
sha=$(sha256sum /opt/vault-plugins/vault-plugin-secrets-apptoken | cut -d' ' -f1)
vault plugin register -sha256=$sha secret vault-plugin-secrets-apptoken
vault secrets enable -path=apptoken vault-plugin-secrets-apptoken
# configure the issuer to this mount's external URL
vault write apptoken/config issuer="https://vault.k8s.syd1.au.unkin.net/v1/apptoken"
# define a per-app role and mint a token
vault write apptoken/roles/keaapi ttl=15m max_ttl=1h claims="scope=leases"
vault read apptoken/creds/keaapi # -> token, aud=keaapi, exp, ...
# public validation material (no token required)
curl -s "$VAULT_ADDR/v1/apptoken/.well-known/jwks.json"
curl -s "$VAULT_ADDR/v1/apptoken/.well-known/openid-configuration"
# rotate signing keys; tokens signed before rotation still validate during grace
vault write -f apptoken/config/keys/rotate
```
## Validating tokens in a service (Go)
Consumers only need the JWKS URL and their expected audience. Fetch and cache
the JWKS (honouring cache headers / periodic refresh), then verify offline:
```go
// keaapi middleware sketch — do NOT hit Vault per request.
import (
"github.com/coreos/go-oidc/v3/oidc" // or MicahParks/keyfunc + golang-jwt
)
// jwks_uri = "https://vault.../v1/apptoken/.well-known/jwks.json"
keySet := oidc.NewRemoteKeySet(ctx, jwksURL)
verifier := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
payload, err := keySet.VerifySignature(r.Context(), raw)
// then decode payload, check: iss == expected, aud contains "keaapi",
// exp in the future. Reject otherwise.
...
})
}
```
`kea-operator`'s `internal/keaapi` is the first intended consumer — see
Follow-ups.
## Why not the built-in OIDC identity-token provider?
Vault ships an identity-token provider (`identity/oidc`) that also issues signed
JWTs with a JWKS. This engine is deliberately parallel to it because:
- **Roles are issuable by other principals.** `creds/<role>` is a normal secret
path, so the agents approle (or CI) can mint an app's token without being the
token's own identity. The identity provider ties issuance to the caller's
entity and its role/client model is heavier.
- **Simpler audience model.** One role = one app = one audience, no separate
OIDC client/assignment objects to wire up.
- **OpenBao parity + house pattern.** Same plugin/Makefile/`.woodpecker`/RPM
shape as the other `vault-plugin-secrets-*` engines, and the identical binary
runs on OpenBao.
If you only need tokens whose subject is always the calling entity, the built-in
provider may suffice; for per-app service tokens minted by shared automation,
this engine is the better fit.
## Build
```sh
make build # -> dist/vault-plugin-secrets-apptoken
make test lint # go test -race / go vet
make rpm # vault + openbao RPM flavours
```
CI (Woodpecker) runs pre-commit/build/lint/test on PRs and, on a `v*` tag,
builds the vault + openbao RPM flavours and uploads them to the artifactapi
`rpm-internal` repo. Cut a release with `make patch|minor|major` (tags + pushes).
+323
View File
@@ -0,0 +1,323 @@
package apptoken
import (
"context"
"testing"
"time"
"github.com/hashicorp/vault/sdk/logical"
)
func TestConfig_Validation(t *testing.T) {
b, s := getTestBackend(t)
// Bad issuer is rejected.
resp, err := doReq(b, s, &logical.Request{
Operation: logical.CreateOperation,
Path: "config",
Data: map[string]interface{}{"issuer": "not-a-url"},
})
if err != nil {
t.Fatal(err)
}
if resp == nil || !resp.IsError() {
t.Fatalf("expected error for bad issuer, got %#v", resp)
}
// Bad algorithm is rejected.
resp, err = doReq(b, s, &logical.Request{
Operation: logical.CreateOperation,
Path: "config",
Data: map[string]interface{}{"issuer": testIssuer, "algorithm": "HS256"},
})
if err != nil {
t.Fatal(err)
}
if resp == nil || !resp.IsError() {
t.Fatalf("expected error for bad algorithm, got %#v", resp)
}
// Good config round-trips, trailing slash trimmed.
do(t, b, s, logical.CreateOperation, "config", map[string]interface{}{
"issuer": testIssuer + "/",
"algorithm": algRS256,
"retained_keys": 3,
})
read := do(t, b, s, logical.ReadOperation, "config", nil)
if read.Data["issuer"] != testIssuer {
t.Fatalf("issuer not trimmed/stored: %v", read.Data["issuer"])
}
if read.Data["algorithm"] != algRS256 {
t.Fatalf("algorithm not stored: %v", read.Data["algorithm"])
}
if read.Data["retained_keys"] != 3 {
t.Fatalf("retained_keys not stored: %v", read.Data["retained_keys"])
}
}
func TestRole_CRUDAndReservedClaims(t *testing.T) {
b, s := getTestBackend(t)
do(t, b, s, logical.CreateOperation, "roles/keaapi", map[string]interface{}{
"ttl": "5m",
"max_ttl": "30m",
"claims": []string{"team=dns", "tier=prod"},
})
read := do(t, b, s, logical.ReadOperation, "roles/keaapi", nil)
if read.Data["ttl"].(int64) != 300 {
t.Fatalf("ttl = %v, want 300", read.Data["ttl"])
}
if read.Data["audience"] != "" {
t.Fatalf("audience should default to empty (role name at issue time)")
}
claims := read.Data["claims"].(map[string]string)
if claims["team"] != "dns" || claims["tier"] != "prod" {
t.Fatalf("claims not stored: %#v", claims)
}
// List.
list := do(t, b, s, logical.ListOperation, "roles/", nil)
if got := list.Data["keys"].([]string); len(got) != 1 || got[0] != "keaapi" {
t.Fatalf("list = %#v", got)
}
// Reserved claim rejected.
resp, err := doReq(b, s, &logical.Request{
Operation: logical.CreateOperation,
Path: "roles/bad",
Data: map[string]interface{}{"claims": "iss=evil"},
})
if err != nil {
t.Fatal(err)
}
if resp == nil || !resp.IsError() {
t.Fatalf("expected reserved-claim error, got %#v", resp)
}
// ttl > max_ttl rejected.
resp, err = doReq(b, s, &logical.Request{
Operation: logical.CreateOperation,
Path: "roles/bad2",
Data: map[string]interface{}{"ttl": "1h", "max_ttl": "1m"},
})
if err != nil {
t.Fatal(err)
}
if resp == nil || !resp.IsError() {
t.Fatalf("expected ttl>max_ttl error, got %#v", resp)
}
// Delete.
do(t, b, s, logical.DeleteOperation, "roles/keaapi", nil)
if read := do(t, b, s, logical.ReadOperation, "roles/keaapi", nil); read != nil {
t.Fatalf("role should be gone, got %#v", read)
}
}
func TestCreds_IssueAndValidateAgainstJWKS(t *testing.T) {
for _, alg := range []string{algEdDSA, algRS256} {
t.Run(alg, func(t *testing.T) {
b, s := getTestBackend(t)
configureIssuer(t, b, s, alg)
do(t, b, s, logical.CreateOperation, "roles/keaapi", map[string]interface{}{
"ttl": "10m",
"claims": "scope=leases",
})
resp := mintToken(t, b, s, "keaapi")
token := resp.Data["token"].(string)
if resp.Data["audience"] != "keaapi" {
t.Fatalf("audience = %v, want keaapi", resp.Data["audience"])
}
if resp.Data["algorithm"] != alg {
t.Fatalf("algorithm = %v, want %v", resp.Data["algorithm"], alg)
}
jwks := readJWKS(t, b, s)
claims := validateToken(t, jwks, token, testIssuer, "keaapi")
if claims.Subject != "test-caller" {
t.Fatalf("subject = %q, want test-caller", claims.Subject)
}
if claims.ID == "" {
t.Fatalf("jti should be set")
}
if claims.Expiry == nil || claims.IssuedAt == nil {
t.Fatalf("exp/iat must be set")
}
if d := claims.Expiry.Time().Sub(claims.IssuedAt.Time()); d != 10*time.Minute {
t.Fatalf("ttl = %v, want 10m", d)
}
// Wrong audience must fail validation.
if err := verifyWrongAudienceFails(jwks, token); err == nil {
t.Fatalf("validation should fail for wrong audience")
}
})
}
}
func TestCreds_RequiresIssuer(t *testing.T) {
b, s := getTestBackend(t)
do(t, b, s, logical.CreateOperation, "roles/keaapi", nil)
resp, err := doReq(b, s, &logical.Request{Operation: logical.ReadOperation, Path: "creds/keaapi", DisplayName: "x"})
if err != nil {
t.Fatal(err)
}
if resp == nil || !resp.IsError() {
t.Fatalf("expected issuer-not-configured error, got %#v", resp)
}
}
func TestCreds_UnknownRole(t *testing.T) {
b, s := getTestBackend(t)
configureIssuer(t, b, s, "")
resp, err := doReq(b, s, &logical.Request{Operation: logical.ReadOperation, Path: "creds/nope", DisplayName: "x"})
if err != nil {
t.Fatal(err)
}
if resp == nil || !resp.IsError() {
t.Fatalf("expected unknown-role error, got %#v", resp)
}
}
// TestRoles_Isolation proves a token minted for one role cannot masquerade as
// another: the audience is bound to the issuing role.
func TestRoles_Isolation(t *testing.T) {
b, s := getTestBackend(t)
configureIssuer(t, b, s, "")
do(t, b, s, logical.CreateOperation, "roles/keaapi", nil)
do(t, b, s, logical.CreateOperation, "roles/encapi", map[string]interface{}{"audience": "encapi.internal"})
kea := mintToken(t, b, s, "keaapi").Data["token"].(string)
enc := mintToken(t, b, s, "encapi").Data["token"].(string)
// JWKS is read after issuance so the lazily-generated key is present.
jwks := readJWKS(t, b, s)
validateToken(t, jwks, kea, testIssuer, "keaapi")
validateToken(t, jwks, enc, testIssuer, "encapi.internal")
// A keaapi token must not validate for the encapi audience.
if err := verifyAudience(jwks, kea, "encapi.internal"); err == nil {
t.Fatalf("keaapi token validated for encapi audience")
}
}
func TestRole_SubjectAllowlist(t *testing.T) {
b, s := getTestBackend(t)
configureIssuer(t, b, s, "")
do(t, b, s, logical.CreateOperation, "roles/keaapi", map[string]interface{}{
"allowed_subjects": "agents,ci",
})
// Caller display name not in allowlist -> denied (returned as a
// permission-denied error and/or an error response).
resp, err := doReq(b, s, &logical.Request{Operation: logical.ReadOperation, Path: "creds/keaapi", DisplayName: "intruder"})
if err == nil && (resp == nil || !resp.IsError()) {
t.Fatalf("expected permission denied, got resp=%#v err=%v", resp, err)
}
// Allowed caller -> issued, subject is the matched identity.
resp, err = doReq(b, s, &logical.Request{Operation: logical.ReadOperation, Path: "creds/keaapi", DisplayName: "agents"})
if err != nil {
t.Fatal(err)
}
if resp.IsError() {
t.Fatalf("allowed caller denied: %v", resp.Error())
}
if resp.Data["subject"] != "agents" {
t.Fatalf("subject = %v, want agents", resp.Data["subject"])
}
}
// TestRotation_GraceAndTrim proves that after rotation an older token still
// validates (its key stays in the JWKS) up to retained_keys, and that keys
// beyond the retention window are dropped so their tokens no longer validate.
func TestRotation_GraceAndTrim(t *testing.T) {
b, s := getTestBackend(t)
// retained_keys=1: keep current + 1 previous.
do(t, b, s, logical.CreateOperation, "config", map[string]interface{}{
"issuer": testIssuer,
"retained_keys": 1,
})
do(t, b, s, logical.CreateOperation, "roles/keaapi", nil)
tokenV1 := mintToken(t, b, s, "keaapi").Data["token"].(string)
kidV1 := currentKID(t, b, s)
// Rotate once: v1 key retained, v1 token still validates.
do(t, b, s, logical.UpdateOperation, "config/keys/rotate", nil)
kidV2 := currentKID(t, b, s)
if kidV1 == kidV2 {
t.Fatalf("rotation did not change current kid")
}
tokenV2 := mintToken(t, b, s, "keaapi").Data["token"].(string)
jwks := readJWKS(t, b, s)
if len(jwks.Keys) != 2 {
t.Fatalf("expected 2 keys in JWKS after 1 rotation, got %d", len(jwks.Keys))
}
validateToken(t, jwks, tokenV1, testIssuer, "keaapi") // grace: still valid
validateToken(t, jwks, tokenV2, testIssuer, "keaapi")
// Rotate again: v1 key now beyond retention and dropped.
do(t, b, s, logical.UpdateOperation, "config/keys/rotate", nil)
jwks = readJWKS(t, b, s)
if len(jwks.Keys) != 2 {
t.Fatalf("expected 2 keys after 2nd rotation (trim), got %d", len(jwks.Keys))
}
if len(jwks.Key(kidV1)) != 0 {
t.Fatalf("v1 kid should have been trimmed from JWKS")
}
// v1 token can no longer be validated (its key is gone).
if err := findAndVerify(jwks, tokenV1); err == nil {
t.Fatalf("v1 token should no longer validate after key trimmed")
}
}
func TestOpenIDConfiguration(t *testing.T) {
b, s := getTestBackend(t)
configureIssuer(t, b, s, "")
resp := do(t, b, s, logical.ReadOperation, openidConfigPath, nil)
raw := resp.Data[logical.HTTPRawBody].([]byte)
meta := decodeJSON(t, raw)
if meta["issuer"] != testIssuer {
t.Fatalf("issuer = %v", meta["issuer"])
}
if meta["jwks_uri"] != testIssuer+"/"+jwksPath {
t.Fatalf("jwks_uri = %v", meta["jwks_uri"])
}
}
func TestUnauthenticatedPaths(t *testing.T) {
b := backend()
special := b.SpecialPaths()
want := map[string]bool{jwksPath: false, openidConfigPath: false}
for _, p := range special.Unauthenticated {
if _, ok := want[p]; ok {
want[p] = true
}
}
for p, found := range want {
if !found {
t.Fatalf("path %q not marked unauthenticated", p)
}
}
// The keyset must be seal-wrapped.
if len(special.SealWrapStorage) == 0 || special.SealWrapStorage[0] != keysetStoragePath {
t.Fatalf("keyset not seal-wrapped: %#v", special.SealWrapStorage)
}
}
// currentKID reads config/keys and returns the current key id.
func currentKID(t *testing.T, b *appTokenBackend, s logical.Storage) string {
t.Helper()
resp := do(t, b, s, logical.ReadOperation, "config/keys", nil)
return resp.Data["current_key_id"].(string)
}
var _ = context.Background
+91
View File
@@ -0,0 +1,91 @@
// Package apptoken implements a Vault / OpenBao secrets engine that issues
// short-lived, signed JWT "app tokens" for self-made services. Each per-app
// role mints a token whose audience is the app; services validate tokens
// offline against the engine's unauthenticated JWKS endpoint, so no Vault
// round-trip is needed per request.
//
// The engine replaces per-app static bearer Secrets with a single mechanism:
// any principal allowed to read creds/<role> (for example the agents approle)
// can obtain a token for that app, while signing keys stay inside Vault's
// barrier and only the public JWKS ever leaves it.
package apptoken
import (
"context"
"strings"
"sync"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
const (
configStoragePath = "config"
keysetStoragePath = "keyset"
roleStoragePrefix = "role/"
)
type appTokenBackend struct {
*framework.Backend
// keyLock serializes read-modify-write cycles on the signing keyset
// (generation and rotation).
keyLock sync.Mutex
}
// Factory returns a configured app-token secrets backend.
func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) {
b := backend()
if err := b.Setup(ctx, conf); err != nil {
return nil, err
}
return b, nil
}
func backend() *appTokenBackend {
b := &appTokenBackend{}
b.Backend = &framework.Backend{
Help: strings.TrimSpace(backendHelp),
BackendType: logical.TypeLogical,
PathsSpecial: &logical.Paths{
// The signing keyset holds private keys; keep it seal-wrapped.
SealWrapStorage: []string{
keysetStoragePath,
},
// JWKS and issuer metadata are public by design: apps fetch them
// to validate tokens offline, with no Vault token.
Unauthenticated: []string{
jwksPath,
openidConfigPath,
},
},
Paths: framework.PathAppend(
[]*framework.Path{
pathConfig(b),
pathConfigKeys(b),
pathConfigKeysRotate(b),
pathRole(b),
pathRolesList(b),
pathCredentials(b),
pathJWKS(b),
pathOpenIDConfig(b),
},
),
}
return b
}
const backendHelp = `
The apptoken secrets engine issues short-lived signed JWTs that self-made
services accept in place of static bearer tokens.
Configure the issuer URL and signing algorithm on "config", define a per-app
role under "roles/<app>", then read "creds/<app>" to mint a token whose
audience is the app. Services validate tokens offline using the public keys at
".well-known/jwks.json" and the metadata at ".well-known/openid-configuration",
both of which are unauthenticated. Rotate signing keys with
"config/keys/rotate"; previous keys stay published in the JWKS during the
configured grace so tokens signed before the rotation keep validating.
`
+178
View File
@@ -0,0 +1,178 @@
package apptoken
import (
"context"
"encoding/json"
"testing"
"time"
jose "github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/hashicorp/vault/sdk/logical"
)
const testIssuer = "https://vault.example/v1/apptoken"
// getTestBackend returns a backend backed by in-memory storage with sane
// default/max lease TTLs so issued tokens have a non-zero lifetime.
func getTestBackend(t *testing.T) (*appTokenBackend, logical.Storage) {
t.Helper()
sysView := logical.TestSystemView()
sysView.DefaultLeaseTTLVal = time.Hour
sysView.MaxLeaseTTLVal = 24 * time.Hour
config := logical.TestBackendConfig()
config.StorageView = &logical.InmemStorage{}
config.System = sysView
b, err := Factory(context.Background(), config)
if err != nil {
t.Fatalf("creating backend: %v", err)
}
return b.(*appTokenBackend), config.StorageView
}
// do runs a request against the backend and fails on transport or logical error.
func do(t *testing.T, b *appTokenBackend, s logical.Storage, op logical.Operation, path string, data map[string]interface{}) *logical.Response {
t.Helper()
resp, err := b.HandleRequest(context.Background(), &logical.Request{
Operation: op,
Path: path,
Storage: s,
Data: data,
DisplayName: "test-caller",
})
if err != nil {
t.Fatalf("%s %s: transport error: %v", op, path, err)
}
if resp != nil && resp.IsError() {
t.Fatalf("%s %s: logical error: %v", op, path, resp.Error())
}
return resp
}
// doReq runs a request and returns both resp and err without failing, for
// negative-path assertions.
func doReq(b *appTokenBackend, s logical.Storage, req *logical.Request) (*logical.Response, error) {
req.Storage = s
return b.HandleRequest(context.Background(), req)
}
// configureIssuer writes the engine config with the test issuer.
func configureIssuer(t *testing.T, b *appTokenBackend, s logical.Storage, algorithm string) {
t.Helper()
data := map[string]interface{}{"issuer": testIssuer}
if algorithm != "" {
data["algorithm"] = algorithm
}
do(t, b, s, logical.CreateOperation, "config", data)
}
// readJWKS fetches the unauthenticated JWKS endpoint and parses it.
func readJWKS(t *testing.T, b *appTokenBackend, s logical.Storage) jose.JSONWebKeySet {
t.Helper()
resp := do(t, b, s, logical.ReadOperation, jwksPath, nil)
raw, ok := resp.Data[logical.HTTPRawBody].([]byte)
if !ok {
t.Fatalf("jwks response missing raw body: %#v", resp.Data)
}
var jwks jose.JSONWebKeySet
if err := json.Unmarshal(raw, &jwks); err != nil {
t.Fatalf("parsing jwks: %v", err)
}
return jwks
}
// validateToken verifies a compact JWT against the given JWKS the way a
// consuming service would: find the key by kid, check the signature, then the
// registered claims. It returns the validated claims.
func validateToken(t *testing.T, jwks jose.JSONWebKeySet, token, expectedIssuer, expectedAudience string) jwt.Claims {
t.Helper()
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.EdDSA, jose.RS256})
if err != nil {
t.Fatalf("parsing token: %v", err)
}
if len(parsed.Headers) == 0 || parsed.Headers[0].KeyID == "" {
t.Fatalf("token has no kid header")
}
keys := jwks.Key(parsed.Headers[0].KeyID)
if len(keys) == 0 {
t.Fatalf("kid %q not present in JWKS", parsed.Headers[0].KeyID)
}
var claims jwt.Claims
if err := parsed.Claims(keys[0].Key, &claims); err != nil {
t.Fatalf("verifying token signature: %v", err)
}
if err := claims.Validate(jwt.Expected{
Issuer: expectedIssuer,
AnyAudience: jwt.Audience{expectedAudience},
Time: time.Now(),
}); err != nil {
t.Fatalf("validating claims: %v", err)
}
return claims
}
// mintToken creates a role (if data provided) and reads creds, returning the token string.
func mintToken(t *testing.T, b *appTokenBackend, s logical.Storage, role string) *logical.Response {
t.Helper()
return do(t, b, s, logical.ReadOperation, "creds/"+role, nil)
}
// findAndVerify looks a token's kid up in the JWKS and checks its signature
// only (no claim validation). Returns an error when the kid is absent or the
// signature does not verify.
func findAndVerify(jwks jose.JSONWebKeySet, token string) error {
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.EdDSA, jose.RS256})
if err != nil {
return err
}
if len(parsed.Headers) == 0 {
return errNoKID
}
keys := jwks.Key(parsed.Headers[0].KeyID)
if len(keys) == 0 {
return errNoKID
}
var claims jwt.Claims
return parsed.Claims(keys[0].Key, &claims)
}
// verifyAudience verifies signature and requires the given audience.
func verifyAudience(jwks jose.JSONWebKeySet, token, audience string) error {
parsed, err := jwt.ParseSigned(token, []jose.SignatureAlgorithm{jose.EdDSA, jose.RS256})
if err != nil {
return err
}
keys := jwks.Key(parsed.Headers[0].KeyID)
if len(keys) == 0 {
return errNoKID
}
var claims jwt.Claims
if err := parsed.Claims(keys[0].Key, &claims); err != nil {
return err
}
return claims.Validate(jwt.Expected{AnyAudience: jwt.Audience{audience}, Time: time.Now()})
}
func verifyWrongAudienceFails(jwks jose.JSONWebKeySet, token string) error {
return verifyAudience(jwks, token, "some-other-app")
}
func decodeJSON(t *testing.T, raw []byte) map[string]interface{} {
t.Helper()
var m map[string]interface{}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("decoding json: %v", err)
}
return m
}
var errNoKID = &kidError{}
type kidError struct{}
func (*kidError) Error() string { return "kid not found in JWKS" }
+34
View File
@@ -0,0 +1,34 @@
package main
import (
"os"
hclog "github.com/hashicorp/go-hclog"
"github.com/hashicorp/vault/api"
"github.com/hashicorp/vault/sdk/plugin"
apptoken "git.unkin.net/unkin/vault-plugin-secrets-apptoken"
)
func main() {
apiClientMeta := &api.PluginAPIClientMeta{}
flags := apiClientMeta.FlagSet()
if err := flags.Parse(os.Args[1:]); err != nil {
logger := hclog.New(&hclog.LoggerOptions{})
logger.Error("failed to parse flags", "error", err)
os.Exit(1)
}
tlsConfig := apiClientMeta.GetTLSConfig()
tlsProviderFunc := api.VaultPluginTLSProvider(tlsConfig)
err := plugin.ServeMultiplex(&plugin.ServeOpts{
BackendFactoryFunc: apptoken.Factory,
TLSProviderFunc: tlsProviderFunc,
})
if err != nil {
logger := hclog.New(&hclog.LoggerOptions{})
logger.Error("plugin shutting down", "error", err)
os.Exit(1)
}
}
+90
View File
@@ -0,0 +1,90 @@
module git.unkin.net/unkin/vault-plugin-secrets-apptoken
go 1.25.0
require (
github.com/go-jose/go-jose/v4 v4.1.4
github.com/hashicorp/go-hclog v1.6.3
github.com/hashicorp/go-uuid v1.0.3
github.com/hashicorp/vault/api v1.15.0
github.com/hashicorp/vault/sdk v0.14.0
)
require (
github.com/Microsoft/go-winio v0.6.1 // 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/cespare/xxhash/v2 v2.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 v26.1.5+incompatible // indirect
github.com/docker/go-connections v0.4.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.16.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // 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/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-kms-wrapping/entropy/v2 v2.0.0 // indirect
github.com/hashicorp/go-kms-wrapping/v2 v2.0.8 // 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/mlock v0.1.2 // indirect
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 // indirect
github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.0 // 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-version v1.6.0 // indirect
github.com/hashicorp/golang-lru v0.5.4 // 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/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-rc2.0.20221005185240-3a7f492d3f1b // indirect
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5 // 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/ryanuber/go-glob v1.0.0 // indirect
github.com/sasha-s/go-deadlock v0.2.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
golang.org/x/crypto v0.54.0 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.57.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.47.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a // indirect
google.golang.org/grpc v1.82.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+340
View File
@@ -0,0 +1,340 @@
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/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.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
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.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA=
github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8=
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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/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/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 v26.1.5+incompatible h1:NEAxTwEjxV6VbBMBoGG3zPqbiJosIApZjxlbrG9q3/g=
github.com/docker/docker v26.1.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ=
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
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.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
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.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
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.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/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.0 h1:WOcxcdHcvdgThNXjw0t76K42FXTU7HpNQWHpA2HHNlg=
github.com/go-test/deep v1.1.0/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/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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
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/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
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-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.0 h1:pSjQfW3vPtrOTcasTUKgCTQT7OGPPTTMVRrOfU6FJD8=
github.com/hashicorp/go-kms-wrapping/entropy/v2 v2.0.0/go.mod h1:xvb32K2keAc+R8DSFG2IwDcydK9DBQE+fGA5fsw6hSk=
github.com/hashicorp/go-kms-wrapping/v2 v2.0.8 h1:9Q2lu1YbbmiAgvYZ7Pr31RdlVonUpX+mmDL7Z7qTA2U=
github.com/hashicorp/go-kms-wrapping/v2 v2.0.8/go.mod h1:qTCjxGig/kjuj3hk1z8pOUrzbse/GxB1tGfbrq8tGJg=
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/mlock v0.1.2 h1:p4AKXPPS24tO8Wc8i1gLvSKdmkiSY5xuju57czJ/IJQ=
github.com/hashicorp/go-secure-stdlib/mlock v0.1.2/go.mod h1:zq93CJChV6L9QTfGKtfBxKqD7BqqXx5O04A/ns2p5+I=
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8 h1:iBt4Ew4XEGLfh6/bPk4rSYmuZJGizr6/x/AEizP0CQc=
github.com/hashicorp/go-secure-stdlib/parseutil v0.1.8/go.mod h1:aiJI+PIApBRQG7FZTEBx5GiiX+HbOHilUdNxUZi4eV0=
github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.0 h1:7Yran48kl6X7jfUg3sfYDrFot1gD3LvzdC3oPu5l/qo=
github.com/hashicorp/go-secure-stdlib/plugincontainer v0.4.0/go.mod h1:9WJFu7L3d+Z4ViZmwUf+6/73/Uy7YMY1NXrB9wdElYE=
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.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
github.com/hashicorp/go-version v1.6.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 v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru v0.5.4/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.14.0 h1:8vagjlpLurkFTnKT9aFSGs4U1XnK2IFytnWSxgFrDo0=
github.com/hashicorp/vault/sdk v0.14.0/go.mod h1:3hnGK5yjx3CW2hFyk+Dw1jDgKxdBvUvjyxMHhq0oUFc=
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.15.1 h1:HUMERORf3I3ZdX05WaQ6MIpd/NJ434hTp5YiKgfCL6c=
github.com/jhump/protoreflect v1.15.1/go.mod h1:jD/2GMKKE6OqX8qTjhADU1e6DShO+gavG9e0Q693nKo=
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.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/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-rc2.0.20221005185240-3a7f492d3f1b h1:YWuSjZCQAPM8UUBLkYUk1e+rZcvWHJmFb6i6rM44Xs8=
github.com/opencontainers/image-spec v1.1.0-rc2.0.20221005185240-3a7f492d3f1b/go.mod h1:3OVijpioIKYWTqjiG0zfF6wvoJ4fAXGbjdZuI2NgsRQ=
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-20180202154549-b0b1615b78e5 h1:q2e307iGHPdTGp0hoxKjt1H5pDo6utceo3dQVK3I5XQ=
github.com/petermattis/goid v0.0.0-20180202154549-b0b1615b78e5/go.mod h1:jvVRKCrJTQWu0XVbaOlby/2lO20uSCHEMzzplHXte1o=
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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
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.2.0 h1:lMqc+fUb7RrFS3gQLtoQsJ7/6TV/pAIFvBsqX73DK8Y=
github.com/sasha-s/go-deadlock v0.2.0/go.mod h1:StQn567HiB1fF2yJ44N9au7wOhrPS3iZqiDbRupzT10=
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.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/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/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 h1:Xs2Ncz0gNihqu9iosIZ5SkBbWo5T8JhhLJFMQL1qmLI=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0/go.mod h1:vy+2G/6NvVMpwGX/NyLqcC41fxepnuKHk16E6IZUcJc=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0 h1:j9+03ymgYhPKmeXGk5Zu+cIZOlVzd9Zv7QIiyItjFBU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.28.0/go.mod h1:Y5+XiUG4Emn1hTfciPzGPJaSI+RpDts6BnCIir0SLqk=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
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.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
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/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
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.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
golang.org/x/sync v0.0.0-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/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
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.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
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=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260720211330-0afa2a65878a h1:97PfJ4tCxY5C7NzzgGqQEMZmXbISdvSArNNEOoUGKBg=
google.golang.org/genproto/googleapis/api v0.0.0-20260720211330-0afa2a65878a/go.mod h1:1brfde68Npq6+WA75c1EHWPijZEG1kMus61ygPZfn4A=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a h1:qI/YMH1ep2qQtqcp00gMQyoU7mjvbhg88GJKCvfoLj0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/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.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=
+86
View File
@@ -0,0 +1,86 @@
package apptoken
import (
"encoding/base64"
"fmt"
"time"
jose "github.com/go-jose/go-jose/v4"
"github.com/go-jose/go-jose/v4/jwt"
"github.com/hashicorp/go-uuid"
)
func base64URL(b []byte) string {
return base64.RawURLEncoding.EncodeToString(b)
}
// issuedToken is the result of minting a token: the compact JWT plus the
// claims that let a caller reason about it without decoding.
type issuedToken struct {
Token string
KeyID string
Algorithm string
Subject string
Audience string
IssuedAt time.Time
ExpiresAt time.Time
JTI string
}
// signToken builds and signs a JWT for the given role using the current
// signing key. now/ttl are supplied by the caller so tests are deterministic.
func signToken(key *signingKey, issuer, subject, audience string, extra map[string]string, ttl time.Duration, now time.Time) (*issuedToken, error) {
signer, err := key.signer()
if err != nil {
return nil, err
}
jti, err := uuid.GenerateUUID()
if err != nil {
return nil, fmt.Errorf("generating jti: %w", err)
}
joseSigner, err := jose.NewSigner(
jose.SigningKey{Algorithm: key.joseAlgorithm(), Key: signer},
(&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", key.KeyID),
)
if err != nil {
return nil, fmt.Errorf("building signer: %w", err)
}
expiry := now.Add(ttl)
registered := jwt.Claims{
Issuer: issuer,
Subject: subject,
Audience: jwt.Audience{audience},
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
Expiry: jwt.NewNumericDate(expiry),
ID: jti,
}
builder := jwt.Signed(joseSigner).Claims(registered)
if len(extra) > 0 {
claims := make(map[string]interface{}, len(extra))
for k, v := range extra {
claims[k] = v
}
builder = builder.Claims(claims)
}
token, err := builder.Serialize()
if err != nil {
return nil, fmt.Errorf("serializing token: %w", err)
}
return &issuedToken{
Token: token,
KeyID: key.KeyID,
Algorithm: key.Algorithm,
Subject: subject,
Audience: audience,
IssuedAt: now,
ExpiresAt: expiry,
JTI: jti,
}, nil
}
+207
View File
@@ -0,0 +1,207 @@
package apptoken
import (
"context"
"crypto"
"crypto/ed25519"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"errors"
"fmt"
"time"
jose "github.com/go-jose/go-jose/v4"
"github.com/hashicorp/vault/sdk/logical"
)
const (
algEdDSA = "EdDSA"
algRS256 = "RS256"
rsaBits = 2048
)
// signingKey is one generation of the engine's signing key. The private
// material is stored (seal-wrapped) inside the barrier; the kid is the RFC 7638
// JWK thumbprint of the public key so it is stable and collision-resistant.
type signingKey struct {
KeyID string `json:"key_id"`
Algorithm string `json:"algorithm"`
Created time.Time `json:"created"`
// PrivateKey is PKCS#8 DER for both Ed25519 and RSA keys.
PrivateKey []byte `json:"private_key"`
}
// keyset is the ordered set of signing keys: index 0 is the current signer,
// the remainder are retired keys kept in the JWKS so tokens signed before a
// rotation still validate.
type keyset struct {
Keys []signingKey `json:"keys"`
}
func (k *signingKey) signer() (crypto.Signer, error) {
priv, err := x509.ParsePKCS8PrivateKey(k.PrivateKey)
if err != nil {
return nil, fmt.Errorf("parsing stored private key: %w", err)
}
s, ok := priv.(crypto.Signer)
if !ok {
return nil, errors.New("stored key is not a crypto.Signer")
}
return s, nil
}
func (k *signingKey) joseAlgorithm() jose.SignatureAlgorithm {
return jose.SignatureAlgorithm(k.Algorithm)
}
// publicJWK returns the public half of the key as a JWK, tagged with its kid,
// algorithm and signing use — the exact form published in the JWKS.
func (k *signingKey) publicJWK() (jose.JSONWebKey, error) {
signer, err := k.signer()
if err != nil {
return jose.JSONWebKey{}, err
}
return jose.JSONWebKey{
Key: signer.Public(),
KeyID: k.KeyID,
Algorithm: k.Algorithm,
Use: "sig",
}, nil
}
// generateSigningKey creates a fresh key for the given algorithm and derives
// its kid from the JWK thumbprint of the public key.
func generateSigningKey(algorithm string, now time.Time) (signingKey, error) {
var pub crypto.PublicKey
var priv crypto.PrivateKey
switch algorithm {
case "", algEdDSA:
algorithm = algEdDSA
pk, sk, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return signingKey{}, fmt.Errorf("generating ed25519 key: %w", err)
}
pub, priv = pk, sk
case algRS256:
sk, err := rsa.GenerateKey(rand.Reader, rsaBits)
if err != nil {
return signingKey{}, fmt.Errorf("generating rsa key: %w", err)
}
pub, priv = &sk.PublicKey, sk
default:
return signingKey{}, fmt.Errorf("unsupported algorithm %q (supported: EdDSA, RS256)", algorithm)
}
der, err := x509.MarshalPKCS8PrivateKey(priv)
if err != nil {
return signingKey{}, fmt.Errorf("marshaling private key: %w", err)
}
kid, err := thumbprint(pub, algorithm)
if err != nil {
return signingKey{}, err
}
return signingKey{
KeyID: kid,
Algorithm: algorithm,
Created: now,
PrivateKey: der,
}, nil
}
// thumbprint returns the base64url-encoded RFC 7638 JWK thumbprint of a public
// key, used as its stable kid.
func thumbprint(pub crypto.PublicKey, algorithm string) (string, error) {
jwk := jose.JSONWebKey{Key: pub, Algorithm: algorithm, Use: "sig"}
tp, err := jwk.Thumbprint(crypto.SHA256)
if err != nil {
return "", fmt.Errorf("computing key thumbprint: %w", err)
}
return base64URL(tp), nil
}
// current returns the active signing key (index 0).
func (ks *keyset) current() *signingKey {
if ks == nil || len(ks.Keys) == 0 {
return nil
}
return &ks.Keys[0]
}
// rotate makes a freshly generated key the current signer and trims retired
// keys so that at most retained previous keys remain published.
func (ks *keyset) rotate(algorithm string, retained int, now time.Time) error {
nk, err := generateSigningKey(algorithm, now)
if err != nil {
return err
}
ks.Keys = append([]signingKey{nk}, ks.Keys...)
if max := retained + 1; len(ks.Keys) > max {
ks.Keys = ks.Keys[:max]
}
return nil
}
// jwks assembles the public JSON Web Key Set from every retained key.
func (ks *keyset) jwks() (jose.JSONWebKeySet, error) {
out := jose.JSONWebKeySet{}
if ks == nil {
return out, nil
}
for i := range ks.Keys {
jwk, err := ks.Keys[i].publicJWK()
if err != nil {
return out, err
}
out.Keys = append(out.Keys, jwk)
}
return out, nil
}
func (b *appTokenBackend) getKeyset(ctx context.Context, s logical.Storage) (*keyset, error) {
entry, err := s.Get(ctx, keysetStoragePath)
if err != nil {
return nil, err
}
if entry == nil {
return nil, nil
}
ks := &keyset{}
if err := entry.DecodeJSON(ks); err != nil {
return nil, err
}
return ks, nil
}
func (b *appTokenBackend) putKeyset(ctx context.Context, s logical.Storage, ks *keyset) error {
entry, err := logical.StorageEntryJSON(keysetStoragePath, ks)
if err != nil {
return err
}
return s.Put(ctx, entry)
}
// getOrCreateKeyset returns the signing keyset, lazily generating an initial
// key with the configured algorithm on first use. Callers that mutate the
// keyset must hold b.keyLock.
func (b *appTokenBackend) getOrCreateKeyset(ctx context.Context, s logical.Storage, cfg *config) (*keyset, error) {
ks, err := b.getKeyset(ctx, s)
if err != nil {
return nil, err
}
if ks != nil && len(ks.Keys) > 0 {
return ks, nil
}
ks = &keyset{}
if err := ks.rotate(cfg.Algorithm, cfg.RetainedKeys, time.Now()); err != nil {
return nil, err
}
if err := b.putKeyset(ctx, s, ks); err != nil {
return nil, err
}
return ks, nil
}
+35
View File
@@ -0,0 +1,35 @@
---
# nfpm config for the vault-plugin-secrets-apptoken RPM. Rendered through
# envsubst (see scripts/build-rpm.sh) then fed to `nfpm pkg`. Built once per
# target server (Vault, OpenBao); PACKAGE_NAME and PACKAGE_PLUGIN_DIR vary.
name: ${PACKAGE_NAME}
version: ${PACKAGE_VERSION}
release: ${PACKAGE_RELEASE}
arch: ${PACKAGE_ARCH}
platform: ${PACKAGE_PLATFORM}
section: default
priority: extra
description: "${PACKAGE_DESCRIPTION}"
maintainer: ${PACKAGE_MAINTAINER}
homepage: ${PACKAGE_HOMEPAGE}
license: ${PACKAGE_LICENSE}
disable_globbing: false
replaces:
- ${PACKAGE_NAME}
provides:
- ${PACKAGE_NAME}
contents:
- src: dist/vault-plugin-secrets-apptoken
dst: ${PACKAGE_PLUGIN_DIR}/vault-plugin-secrets-apptoken
file_info:
mode: 0755
owner: root
group: root
scripts:
preinstall: ${PACKAGE_PREINSTALL}
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
# Ensure the plugin directory exists before the binary is laid down.
# Rendered per flavour via envsubst (see scripts/build-rpm.sh).
mkdir -p ${PACKAGE_PLUGIN_DIR}
+160
View File
@@ -0,0 +1,160 @@
package apptoken
import (
"context"
"fmt"
"net/url"
"strings"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
// config holds engine-wide settings: the JWT issuer URL, the signing algorithm
// used for new keys, and how many retired keys stay published in the JWKS.
type config struct {
Issuer string `json:"issuer"`
Algorithm string `json:"algorithm"`
RetainedKeys int `json:"retained_keys"`
}
func defaultConfig() *config {
return &config{Algorithm: algEdDSA, RetainedKeys: 1}
}
func pathConfig(b *appTokenBackend) *framework.Path {
return &framework.Path{
Pattern: "config$",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "apptoken",
OperationSuffix: "config",
},
Fields: map[string]*framework.FieldSchema{
"issuer": {
Type: framework.TypeString,
Description: "External URL of this mount, used as the JWT `iss` claim and as the " +
"base for the published jwks_uri (e.g. https://vault.example/v1/apptoken).",
},
"algorithm": {
Type: framework.TypeString,
Description: "Signing algorithm for newly generated keys: EdDSA (default) or RS256.",
Default: algEdDSA,
},
"retained_keys": {
Type: framework.TypeInt,
Description: "Number of previous signing keys kept in the JWKS after a rotation, so tokens signed before the rotation still validate.",
Default: 1,
},
},
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},
},
ExistenceCheck: b.pathConfigExistenceCheck,
HelpSynopsis: "Configure the issuer, signing algorithm and key retention.",
HelpDescription: "Engine-wide settings shared by every role: the JWT issuer URL, the algorithm for new signing keys, and how many retired keys remain in the JWKS.",
}
}
func (b *appTokenBackend) pathConfigExistenceCheck(ctx context.Context, req *logical.Request, _ *framework.FieldData) (bool, error) {
cfg, err := b.getConfig(ctx, req.Storage)
if err != nil {
return false, err
}
return cfg != nil, nil
}
func (b *appTokenBackend) pathConfigRead(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
cfg, err := b.getConfig(ctx, req.Storage)
if err != nil {
return nil, err
}
if cfg == nil {
return nil, nil
}
return &logical.Response{Data: map[string]interface{}{
"issuer": cfg.Issuer,
"algorithm": cfg.Algorithm,
"retained_keys": cfg.RetainedKeys,
}}, nil
}
func (b *appTokenBackend) pathConfigWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
cfg, err := b.getConfig(ctx, req.Storage)
if err != nil {
return nil, err
}
if cfg == nil {
cfg = defaultConfig()
}
if v, ok := data.GetOk("issuer"); ok {
issuer := strings.TrimRight(v.(string), "/")
if issuer != "" {
u, err := url.Parse(issuer)
if err != nil || u.Scheme == "" || u.Host == "" {
return logical.ErrorResponse("issuer must be an absolute URL"), nil
}
}
cfg.Issuer = issuer
}
if v, ok := data.GetOk("algorithm"); ok {
alg := v.(string)
if alg != algEdDSA && alg != algRS256 {
return logical.ErrorResponse("algorithm must be EdDSA or RS256"), nil
}
cfg.Algorithm = alg
}
if v, ok := data.GetOk("retained_keys"); ok {
n := v.(int)
if n < 0 {
return logical.ErrorResponse("retained_keys must not be negative"), nil
}
cfg.RetainedKeys = n
}
if err := b.putConfig(ctx, req.Storage, cfg); err != nil {
return nil, err
}
return nil, nil
}
func (b *appTokenBackend) getConfig(ctx context.Context, s logical.Storage) (*config, error) {
entry, err := s.Get(ctx, configStoragePath)
if err != nil {
return nil, err
}
if entry == nil {
return nil, nil
}
cfg := &config{}
if err := entry.DecodeJSON(cfg); err != nil {
return nil, err
}
if cfg.Algorithm == "" {
cfg.Algorithm = algEdDSA
}
return cfg, nil
}
// getConfigOrDefault returns the stored config or a default one, so issuance
// and key generation work before "config" is explicitly written.
func (b *appTokenBackend) getConfigOrDefault(ctx context.Context, s logical.Storage) (*config, error) {
cfg, err := b.getConfig(ctx, s)
if err != nil {
return nil, err
}
if cfg == nil {
return defaultConfig(), nil
}
return cfg, nil
}
func (b *appTokenBackend) putConfig(ctx context.Context, s logical.Storage, cfg *config) error {
entry, err := logical.StorageEntryJSON(configStoragePath, cfg)
if err != nil {
return fmt.Errorf("encoding config: %w", err)
}
return s.Put(ctx, entry)
}
+91
View File
@@ -0,0 +1,91 @@
package apptoken
import (
"context"
"time"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
func pathConfigKeys(b *appTokenBackend) *framework.Path {
return &framework.Path{
Pattern: "config/keys$",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "apptoken",
OperationSuffix: "keys",
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ReadOperation: &framework.PathOperation{Callback: b.pathConfigKeysRead},
},
HelpSynopsis: "Inspect the signing keyset.",
HelpDescription: "Read the current signing key id plus every key id retained in the JWKS. Signing keys are generated lazily on first use.",
}
}
func pathConfigKeysRotate(b *appTokenBackend) *framework.Path {
return &framework.Path{
Pattern: "config/keys/rotate$",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "apptoken",
OperationSuffix: "keys-rotate",
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathConfigKeysRotate},
},
HelpSynopsis: "Rotate the signing key.",
HelpDescription: "Generate a new current signing key. Up to retained_keys previous keys stay published in the JWKS so tokens signed before the rotation keep validating.",
}
}
func (b *appTokenBackend) pathConfigKeysRead(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
cfg, err := b.getConfigOrDefault(ctx, req.Storage)
if err != nil {
return nil, err
}
b.keyLock.Lock()
defer b.keyLock.Unlock()
ks, err := b.getOrCreateKeyset(ctx, req.Storage, cfg)
if err != nil {
return nil, err
}
keys := make([]map[string]interface{}, 0, len(ks.Keys))
for i := range ks.Keys {
keys = append(keys, map[string]interface{}{
"key_id": ks.Keys[i].KeyID,
"algorithm": ks.Keys[i].Algorithm,
"created": ks.Keys[i].Created.Format(time.RFC3339),
})
}
return &logical.Response{Data: map[string]interface{}{
"current_key_id": ks.current().KeyID,
"keys": keys,
}}, nil
}
func (b *appTokenBackend) pathConfigKeysRotate(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
cfg, err := b.getConfigOrDefault(ctx, req.Storage)
if err != nil {
return nil, err
}
b.keyLock.Lock()
defer b.keyLock.Unlock()
ks, err := b.getOrCreateKeyset(ctx, req.Storage, cfg)
if err != nil {
return nil, err
}
if err := ks.rotate(cfg.Algorithm, cfg.RetainedKeys, time.Now()); err != nil {
return logical.ErrorResponse(err.Error()), nil
}
if err := b.putKeyset(ctx, req.Storage, ks); err != nil {
return nil, err
}
return &logical.Response{Data: map[string]interface{}{
"current_key_id": ks.current().KeyID,
}}, nil
}
+135
View File
@@ -0,0 +1,135 @@
package apptoken
import (
"context"
"time"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
func pathCredentials(b *appTokenBackend) *framework.Path {
return &framework.Path{
Pattern: "creds/" + framework.GenericNameRegex("name"),
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "apptoken",
OperationSuffix: "credentials",
},
Fields: map[string]*framework.FieldSchema{
"name": {
Type: framework.TypeLowerCaseString,
Description: "Name of the role to mint a token for.",
Required: true,
},
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ReadOperation: &framework.PathOperation{Callback: b.pathCredentialsRead},
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathCredentialsRead},
},
HelpSynopsis: "Mint a signed JWT app token from a role.",
HelpDescription: "Reading this path issues a short-lived JWT whose audience is the role's app. Services validate it offline against the engine's JWKS.",
}
}
func (b *appTokenBackend) pathCredentialsRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
name := data.Get("name").(string)
role, err := b.getRole(ctx, req.Storage, name)
if err != nil {
return nil, err
}
if role == nil {
return logical.ErrorResponse("role %q does not exist", name), nil
}
cfg, err := b.getConfigOrDefault(ctx, req.Storage)
if err != nil {
return nil, err
}
if cfg.Issuer == "" {
return logical.ErrorResponse("issuer is not configured; set it via the config path"), nil
}
subject, ok := b.resolveSubject(req, role)
if !ok {
return logical.ErrorResponse("caller is not permitted to mint tokens from role %q", name), logical.ErrPermissionDenied
}
b.keyLock.Lock()
ks, err := b.getOrCreateKeyset(ctx, req.Storage, cfg)
b.keyLock.Unlock()
if err != nil {
return nil, err
}
ttl := b.resolveTTL(role)
tok, err := signToken(ks.current(), cfg.Issuer, subject, role.audience(name), role.Claims, ttl, time.Now())
if err != nil {
return nil, err
}
return &logical.Response{Data: map[string]interface{}{
"token": tok.Token,
"token_type": "Bearer",
"issuer": cfg.Issuer,
"audience": tok.Audience,
"subject": tok.Subject,
"key_id": tok.KeyID,
"algorithm": tok.Algorithm,
"jti": tok.JTI,
"issued_at": tok.IssuedAt.Format(time.RFC3339),
"expires_at": tok.ExpiresAt.Format(time.RFC3339),
"ttl_seconds": int64(ttl.Seconds()),
}}, nil
}
// resolveSubject determines the JWT subject from the caller's Vault identity
// and enforces the role's subject allowlist. It returns (subject, allowed).
func (b *appTokenBackend) resolveSubject(req *logical.Request, role *appRole) (string, bool) {
var candidates []string
if req.EntityID != "" {
candidates = append(candidates, req.EntityID)
}
if req.DisplayName != "" {
candidates = append(candidates, req.DisplayName)
}
subject := ""
if len(candidates) > 0 {
subject = candidates[0]
}
if len(role.AllowedSubjects) == 0 {
return subject, true
}
allowed := make(map[string]bool, len(role.AllowedSubjects))
for _, s := range role.AllowedSubjects {
allowed[s] = true
}
for _, c := range candidates {
if allowed[c] {
return c, true
}
}
return subject, false
}
// resolveTTL clamps the role TTL against the role max and the mount/system
// lease limits.
func (b *appTokenBackend) resolveTTL(role *appRole) time.Duration {
sysMax := b.System().MaxLeaseTTL()
maxTTL := role.MaxTTL
if maxTTL <= 0 || maxTTL > sysMax {
maxTTL = sysMax
}
ttl := role.TTL
if ttl <= 0 {
ttl = b.System().DefaultLeaseTTL()
}
if ttl > maxTTL {
ttl = maxTTL
}
return ttl
}
+225
View File
@@ -0,0 +1,225 @@
package apptoken
import (
"context"
"fmt"
"time"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
// appRole describes the tokens issued from creds/<name>.
type appRole struct {
// Audience is the JWT `aud` claim; it defaults to the role name and is the
// value the consuming app checks. Empty means "use the role name".
Audience string `json:"audience"`
// TTL / MaxTTL bound the token lifetime.
TTL time.Duration `json:"ttl"`
MaxTTL time.Duration `json:"max_ttl"`
// AllowedSubjects, when non-empty, restricts which requesting identities
// (Vault entity id or token display name) may mint a token from this role.
AllowedSubjects []string `json:"allowed_subjects"`
// Claims are extra string claims merged into every issued token. They may
// not override registered JWT claims (iss, sub, aud, exp, ...).
Claims map[string]string `json:"claims"`
}
func (r *appRole) audience(name string) string {
if r.Audience != "" {
return r.Audience
}
return name
}
func (r *appRole) toResponseData() map[string]interface{} {
return map[string]interface{}{
"audience": r.Audience,
"ttl": int64(r.TTL.Seconds()),
"max_ttl": int64(r.MaxTTL.Seconds()),
"allowed_subjects": r.AllowedSubjects,
"claims": r.Claims,
}
}
func pathRole(b *appTokenBackend) *framework.Path {
return &framework.Path{
Pattern: "roles/" + framework.GenericNameRegex("name"),
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "apptoken",
OperationSuffix: "role",
},
Fields: map[string]*framework.FieldSchema{
"name": {
Type: framework.TypeLowerCaseString,
Description: "Name of the role (typically the app name).",
Required: true,
},
"audience": {
Type: framework.TypeString,
Description: "JWT audience (aud) claim. Defaults to the role name.",
},
"ttl": {
Type: framework.TypeDurationSecond,
Description: "Default token TTL for this role.",
},
"max_ttl": {
Type: framework.TypeDurationSecond,
Description: "Maximum token TTL for this role.",
},
"allowed_subjects": {
Type: framework.TypeCommaStringSlice,
Description: "Optional allowlist of requesting identities (Vault entity id or token display name) permitted to mint tokens from this role. Empty means any authorized caller.",
},
"claims": {
Type: framework.TypeKVPairs,
Description: "Extra key=value claims merged into each issued token. Registered JWT claims cannot be overridden.",
},
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ReadOperation: &framework.PathOperation{Callback: b.pathRoleRead},
logical.CreateOperation: &framework.PathOperation{Callback: b.pathRoleWrite},
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathRoleWrite},
logical.DeleteOperation: &framework.PathOperation{Callback: b.pathRoleDelete},
},
ExistenceCheck: b.pathRoleExistenceCheck,
HelpSynopsis: "Manage per-app token roles.",
HelpDescription: "A role defines the audience, TTLs, subject allowlist and custom claims of the JWTs issued from creds/<name>.",
}
}
func pathRolesList(b *appTokenBackend) *framework.Path {
return &framework.Path{
Pattern: "roles/?$",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "apptoken",
OperationSuffix: "roles",
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ListOperation: &framework.PathOperation{Callback: b.pathRolesList},
},
HelpSynopsis: "List the configured roles.",
}
}
func (b *appTokenBackend) pathRoleExistenceCheck(ctx context.Context, req *logical.Request, data *framework.FieldData) (bool, error) {
role, err := b.getRole(ctx, req.Storage, data.Get("name").(string))
if err != nil {
return false, err
}
return role != nil, nil
}
func (b *appTokenBackend) pathRolesList(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
entries, err := req.Storage.List(ctx, roleStoragePrefix)
if err != nil {
return nil, err
}
return logical.ListResponse(entries), nil
}
func (b *appTokenBackend) pathRoleRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
role, err := b.getRole(ctx, req.Storage, data.Get("name").(string))
if err != nil {
return nil, err
}
if role == nil {
return nil, nil
}
return &logical.Response{Data: role.toResponseData()}, nil
}
func (b *appTokenBackend) pathRoleWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
name := data.Get("name").(string)
if name == "" {
return logical.ErrorResponse("role name is required"), nil
}
role, err := b.getRole(ctx, req.Storage, name)
if err != nil {
return nil, err
}
if role == nil {
role = &appRole{}
}
if v, ok := data.GetOk("audience"); ok {
role.Audience = v.(string)
}
if v, ok := data.GetOk("ttl"); ok {
role.TTL = time.Duration(v.(int)) * time.Second
}
if v, ok := data.GetOk("max_ttl"); ok {
role.MaxTTL = time.Duration(v.(int)) * time.Second
}
if v, ok := data.GetOk("allowed_subjects"); ok {
role.AllowedSubjects = v.([]string)
}
if v, ok := data.GetOk("claims"); ok {
role.Claims = v.(map[string]string)
}
if role.TTL < 0 || role.MaxTTL < 0 {
return logical.ErrorResponse("ttl and max_ttl must not be negative"), nil
}
if role.MaxTTL != 0 && role.TTL > role.MaxTTL {
return logical.ErrorResponse("ttl must not be greater than max_ttl"), nil
}
if err := validateClaims(role.Claims); err != nil {
return logical.ErrorResponse(err.Error()), nil
}
if err := b.setRole(ctx, req.Storage, name, role); err != nil {
return nil, err
}
return nil, nil
}
func (b *appTokenBackend) pathRoleDelete(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
if err := req.Storage.Delete(ctx, roleStoragePrefix+data.Get("name").(string)); err != nil {
return nil, fmt.Errorf("deleting role: %w", err)
}
return nil, nil
}
func (b *appTokenBackend) getRole(ctx context.Context, s logical.Storage, name string) (*appRole, error) {
if name == "" {
return nil, fmt.Errorf("missing role name")
}
entry, err := s.Get(ctx, roleStoragePrefix+name)
if err != nil {
return nil, err
}
if entry == nil {
return nil, nil
}
role := &appRole{}
if err := entry.DecodeJSON(role); err != nil {
return nil, err
}
return role, nil
}
func (b *appTokenBackend) setRole(ctx context.Context, s logical.Storage, name string, role *appRole) error {
entry, err := logical.StorageEntryJSON(roleStoragePrefix+name, role)
if err != nil {
return err
}
return s.Put(ctx, entry)
}
// reservedClaims are registered JWT claims the engine sets itself; roles may
// not override them via custom claims.
var reservedClaims = map[string]bool{
"iss": true, "sub": true, "aud": true, "exp": true,
"nbf": true, "iat": true, "jti": true,
}
func validateClaims(claims map[string]string) error {
for k := range claims {
if reservedClaims[k] {
return fmt.Errorf("claim %q is reserved and cannot be set on a role", k)
}
}
return nil
}
+92
View File
@@ -0,0 +1,92 @@
package apptoken
import (
"context"
"encoding/json"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/logical"
)
const (
jwksPath = ".well-known/jwks.json"
openidConfigPath = ".well-known/openid-configuration"
)
func pathJWKS(b *appTokenBackend) *framework.Path {
return &framework.Path{
Pattern: jwksPath + "$",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "apptoken",
OperationSuffix: "jwks",
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ReadOperation: &framework.PathOperation{Callback: b.pathJWKSRead},
},
HelpSynopsis: "Public JSON Web Key Set for offline token validation.",
HelpDescription: "Unauthenticated. Returns the public signing keys (current plus retained) so services can verify app tokens without contacting Vault per request.",
}
}
func pathOpenIDConfig(b *appTokenBackend) *framework.Path {
return &framework.Path{
Pattern: openidConfigPath + "$",
DisplayAttrs: &framework.DisplayAttributes{
OperationPrefix: "apptoken",
OperationSuffix: "openid-configuration",
},
Operations: map[logical.Operation]framework.OperationHandler{
logical.ReadOperation: &framework.PathOperation{Callback: b.pathOpenIDConfigRead},
},
HelpSynopsis: "OIDC issuer metadata.",
HelpDescription: "Unauthenticated. Advertises the issuer and jwks_uri so standard OIDC/JWT clients can discover the validation keys.",
}
}
// rawJSONResponse renders a body as an unauthenticated raw JSON HTTP response,
// the form OIDC/JWKS clients expect (rather than Vault's data-wrapped JSON).
func rawJSONResponse(body interface{}) (*logical.Response, error) {
data, err := json.Marshal(body)
if err != nil {
return nil, err
}
return &logical.Response{
Data: map[string]interface{}{
logical.HTTPContentType: "application/json",
logical.HTTPRawBody: data,
logical.HTTPStatusCode: 200,
},
}, nil
}
func (b *appTokenBackend) pathJWKSRead(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
ks, err := b.getKeyset(ctx, req.Storage)
if err != nil {
return nil, err
}
jwks, err := ks.jwks()
if err != nil {
return nil, err
}
return rawJSONResponse(jwks)
}
func (b *appTokenBackend) pathOpenIDConfigRead(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
cfg, err := b.getConfig(ctx, req.Storage)
if err != nil {
return nil, err
}
issuer := ""
if cfg != nil {
issuer = cfg.Issuer
}
meta := map[string]interface{}{
"issuer": issuer,
"jwks_uri": issuer + "/" + jwksPath,
"response_types_supported": []string{"token"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{algEdDSA, algRS256},
}
return rawJSONResponse(meta)
}
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
#
# Package the (already built) plugin binary into RPMs with nfpm. Builds one RPM
# per target server: Vault (/opt/vault-plugins) and OpenBao (/opt/openbao-plugins).
# Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG)
#
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${ROOT_DIR}"
VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}"
VERSION="${VERSION#v}"
BINARY="vault-plugin-secrets-apptoken"
DIST="dist"
if [ ! -f "${DIST}/${BINARY}" ]; then
echo "ERROR: ${DIST}/${BINARY} not found; run 'make build' first" >&2
exit 1
fi
export PACKAGE_VERSION="${VERSION}"
export PACKAGE_RELEASE="1"
export PACKAGE_ARCH="amd64"
export PACKAGE_PLATFORM="linux"
export PACKAGE_DESCRIPTION="Vault/OpenBao secrets engine issuing short-lived signed JWT app tokens for self-made services"
export PACKAGE_MAINTAINER="Ben Vincent <ben@unkin.net>"
export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/vault-plugin-secrets-apptoken"
export PACKAGE_LICENSE="MIT"
build_flavor() {
export PACKAGE_NAME="$1"
export PACKAGE_PLUGIN_DIR="$2"
export PACKAGE_PREINSTALL="${DIST}/preinstall-${PACKAGE_NAME}.sh"
envsubst '${PACKAGE_PLUGIN_DIR}' < packaging/scripts/preinstall.sh.tmpl > "${PACKAGE_PREINSTALL}"
envsubst < packaging/nfpm.yaml > "${DIST}/nfpm-${PACKAGE_NAME}.yaml"
nfpm pkg --config "${DIST}/nfpm-${PACKAGE_NAME}.yaml" --target "${DIST}" --packager rpm
}
build_flavor "vault-plugin-secrets-apptoken" "/opt/vault-plugins"
build_flavor "openbao-plugin-secrets-apptoken" "/opt/openbao-plugins"
echo "Built:"
ls -1 "${DIST}"/*.rpm