diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d43b16d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/dist/ +/vault-plugin-secrets-apptoken +*.out +*.test +.env diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..5b65ffe --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 diff --git a/.woodpecker/build.yml b/.woodpecker/build.yml new file mode 100644 index 0000000..74123b5 --- /dev/null +++ b/.woodpecker/build.yml @@ -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 diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..d57b508 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -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 diff --git a/.woodpecker/release.yml b/.woodpecker/release.yml new file mode 100644 index 0000000..f1a4b5c --- /dev/null +++ b/.woodpecker/release.yml @@ -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} diff --git a/.woodpecker/test.yml b/.woodpecker/test.yml new file mode 100644 index 0000000..8e81e45 --- /dev/null +++ b/.woodpecker/test.yml @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f6821dd --- /dev/null +++ b/Makefile @@ -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= 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 diff --git a/README.md b/README.md index 63ed273..e5bb15f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,130 @@ # 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 \ No newline at end of file +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/` 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/` (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 + │ (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/` | R/W/D, list `roles/` | per-app role: `audience` (defaults to role name), `ttl`, `max_ttl`, `allowed_subjects`, `claims` | +| `creds/` | 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/` 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). diff --git a/apptoken_test.go b/apptoken_test.go new file mode 100644 index 0000000..0fa26f0 --- /dev/null +++ b/apptoken_test.go @@ -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 diff --git a/backend.go b/backend.go new file mode 100644 index 0000000..dbd9b17 --- /dev/null +++ b/backend.go @@ -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/ (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/", then read "creds/" 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. +` diff --git a/backend_test.go b/backend_test.go new file mode 100644 index 0000000..ff7f6d7 --- /dev/null +++ b/backend_test.go @@ -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" } diff --git a/cmd/vault-plugin-secrets-apptoken/main.go b/cmd/vault-plugin-secrets-apptoken/main.go new file mode 100644 index 0000000..5b007d2 --- /dev/null +++ b/cmd/vault-plugin-secrets-apptoken/main.go @@ -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) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..44efb18 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..8ddefdb --- /dev/null +++ b/go.sum @@ -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= diff --git a/jwt.go b/jwt.go new file mode 100644 index 0000000..c4c1a71 --- /dev/null +++ b/jwt.go @@ -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 +} diff --git a/keyset.go b/keyset.go new file mode 100644 index 0000000..4058bb8 --- /dev/null +++ b/keyset.go @@ -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 +} diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml new file mode 100644 index 0000000..5300942 --- /dev/null +++ b/packaging/nfpm.yaml @@ -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} diff --git a/packaging/scripts/preinstall.sh.tmpl b/packaging/scripts/preinstall.sh.tmpl new file mode 100644 index 0000000..e129bf4 --- /dev/null +++ b/packaging/scripts/preinstall.sh.tmpl @@ -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} diff --git a/path_config.go b/path_config.go new file mode 100644 index 0000000..c4f4325 --- /dev/null +++ b/path_config.go @@ -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) +} diff --git a/path_config_keys.go b/path_config_keys.go new file mode 100644 index 0000000..8d017ab --- /dev/null +++ b/path_config_keys.go @@ -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 +} diff --git a/path_creds.go b/path_creds.go new file mode 100644 index 0000000..c68ed3d --- /dev/null +++ b/path_creds.go @@ -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 +} diff --git a/path_roles.go b/path_roles.go new file mode 100644 index 0000000..e797966 --- /dev/null +++ b/path_roles.go @@ -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/. +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/.", + } +} + +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 +} diff --git a/path_wellknown.go b/path_wellknown.go new file mode 100644 index 0000000..76f5457 --- /dev/null +++ b/path_wellknown.go @@ -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) +} diff --git a/scripts/build-rpm.sh b/scripts/build-rpm.sh new file mode 100755 index 0000000..5504689 --- /dev/null +++ b/scripts/build-rpm.sh @@ -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 " +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