Add app-token JWT secrets engine
Implement a generic Vault/OpenBao secrets engine that issues short-lived signed JWTs for self-made services, replacing per-app static bearer Secrets. Per-app roles set the audience, TTLs, subject allowlist and custom claims; creds/<role> mints an EdDSA (or RS256) token. Apps validate offline against the unauthenticated JWKS + OIDC-metadata paths, so there is no Vault round-trip per request. Signing keys are seal-wrapped in the barrier and rotate with a configurable JWKS grace window. Mirrors the other vault-plugin-secrets-* engines: cmd ServeMultiplex, Makefile with patch|minor|major tags, .woodpecker CI (k8s resources + SA), dual-flavour nfpm RPM to artifactapi rpm-internal. Tests (-race) cover issuance+JWKS validation for both algorithms, rotation grace/trim, role isolation, subject allowlist and the unauthenticated/seal-wrap wiring. Claude-Session: https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
/dist/
|
||||
/vault-plugin-secrets-apptoken
|
||||
*.out
|
||||
*.test
|
||||
.env
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -0,0 +1,62 @@
|
||||
.PHONY: build install test lint fmt clean tidy rpm rpm-package patch minor major check-go
|
||||
|
||||
BINARY := vault-plugin-secrets-apptoken
|
||||
PKG := ./cmd/vault-plugin-secrets-apptoken
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "0.0.0-dev")
|
||||
OS ?= $(shell go env GOOS)
|
||||
ARCH ?= $(shell go env GOARCH)
|
||||
PLUGIN_DIR ?= ./dist
|
||||
|
||||
GO_VERSION_REQUIRED := 1.25
|
||||
GO_VERSION_ACTUAL := $(shell go version | sed 's/go version go\([0-9]*\.[0-9]*\).*/\1/')
|
||||
|
||||
check-go:
|
||||
@if [ "$$(printf '%s\n%s' "$(GO_VERSION_REQUIRED)" "$(GO_VERSION_ACTUAL)" | sort -V | head -1)" != "$(GO_VERSION_REQUIRED)" ]; then \
|
||||
echo "ERROR: Go >= $(GO_VERSION_REQUIRED) required, found $(GO_VERSION_ACTUAL)"; exit 1; \
|
||||
fi
|
||||
|
||||
build: check-go tidy
|
||||
CGO_ENABLED=0 GOOS=$(OS) GOARCH=$(ARCH) go build -ldflags="-s -w -X main.version=$(VERSION)" -o $(PLUGIN_DIR)/$(BINARY) $(PKG)
|
||||
|
||||
install: build
|
||||
@echo "Built $(PLUGIN_DIR)/$(BINARY) (register it with: vault plugin register -sha256=<sha> secret $(BINARY))"
|
||||
|
||||
test: check-go
|
||||
go test -race -count=1 ./...
|
||||
|
||||
lint: check-go
|
||||
go vet ./...
|
||||
|
||||
fmt: check-go
|
||||
gofmt -w .
|
||||
|
||||
tidy:
|
||||
go mod tidy
|
||||
|
||||
clean:
|
||||
rm -rf $(PLUGIN_DIR)
|
||||
|
||||
# Build the plugin binary then package it into an RPM with nfpm.
|
||||
rpm: build rpm-package
|
||||
|
||||
# Package an already-built binary into an RPM (used by CI after the build step).
|
||||
rpm-package:
|
||||
./scripts/build-rpm.sh $(VERSION)
|
||||
|
||||
_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
|
||||
_BASE := $(if $(_LATEST),$(_LATEST),v0.0.0)
|
||||
_MAJ := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f1)
|
||||
_MIN := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f2)
|
||||
_PAT := $(shell echo $(_BASE) | sed 's/^v//' | cut -d. -f3)
|
||||
|
||||
patch:
|
||||
@NEW=v$(_MAJ).$(_MIN).$(shell expr $(_PAT) + 1); \
|
||||
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
|
||||
|
||||
minor:
|
||||
@NEW=v$(_MAJ).$(shell expr $(_MIN) + 1).0; \
|
||||
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
|
||||
|
||||
major:
|
||||
@NEW=v$(shell expr $(_MAJ) + 1).0.0; \
|
||||
git tag $$NEW && echo "Tagged $$NEW" && git push origin $$NEW
|
||||
@@ -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
|
||||
A HashiCorp Vault / OpenBao secrets engine that issues **short-lived, signed
|
||||
JWT "app tokens"** for self-made services (keaapi, encapi, artifactapi,
|
||||
tomswallapi, bootapi, ...). It replaces per-app static bearer `Secret`s with one
|
||||
mechanism: define a per-app role, read `creds/<app>` to mint a token, and let
|
||||
the app validate it **offline** against the engine's public JWKS — no Vault
|
||||
round-trip per request.
|
||||
|
||||
Any principal allowed to read `creds/<role>` (for example the agents approle)
|
||||
can mint a token for that app. Signing keys are generated inside Vault's barrier
|
||||
(seal-wrapped) and never leave it; only the public JWKS is exposed.
|
||||
|
||||
```
|
||||
issuer (this engine) consumer app (keaapi)
|
||||
├── creds/keaapi ──▶ signed JWT ──────▶ Authorization: Bearer <jwt>
|
||||
│ (aud=keaapi, exp, sub=caller) │
|
||||
└── .well-known/jwks.json ◀── fetch ───────┘ verify signature + aud, offline
|
||||
```
|
||||
|
||||
## Signing
|
||||
|
||||
Tokens are signed with **EdDSA (Ed25519)** by default — compact signatures,
|
||||
fast verification, first-class support in Go's stdlib and `go-jose`, which the
|
||||
whole ecosystem (and Vault itself) uses. `RS256` is available via
|
||||
`config algorithm=RS256` for consumers that need RSA interop. The `kid` header
|
||||
is the RFC 7638 JWK thumbprint of the signing key, so it is stable and matches
|
||||
the JWKS entry.
|
||||
|
||||
## Paths
|
||||
|
||||
| Path | Op | Purpose |
|
||||
|------|----|---------|
|
||||
| `config` | R/W | `issuer` (external mount URL, becomes `iss` + base of `jwks_uri`), `algorithm` (`EdDSA`\|`RS256`), `retained_keys` |
|
||||
| `config/keys` | R | current signing `kid` + all retained kids (keys are generated lazily on first use) |
|
||||
| `config/keys/rotate` | W | generate a new current signing key; up to `retained_keys` previous keys stay in the JWKS |
|
||||
| `roles/<name>` | R/W/D, list `roles/` | per-app role: `audience` (defaults to role name), `ttl`, `max_ttl`, `allowed_subjects`, `claims` |
|
||||
| `creds/<name>` | R | mint a signed JWT for the role |
|
||||
| `.well-known/jwks.json` | R, **unauthenticated** | public JSON Web Key Set for offline validation |
|
||||
| `.well-known/openid-configuration` | R, **unauthenticated** | OIDC issuer metadata (`issuer`, `jwks_uri`) |
|
||||
|
||||
Token claims: `iss` (issuer), `aud` (role audience), `sub` (requesting Vault
|
||||
entity id or token display name), `exp`/`nbf`/`iat`, `jti`, plus any role
|
||||
`claims`. Registered claims cannot be overridden by a role.
|
||||
|
||||
`allowed_subjects`, when set, restricts which callers (matched on Vault entity
|
||||
id or token display name) may mint from a role. `retained_keys` keeps N previous
|
||||
signing keys published after a rotation so tokens signed just before it keep
|
||||
validating during the grace window.
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
# register + enable (the plugin_directory must contain the binary)
|
||||
sha=$(sha256sum /opt/vault-plugins/vault-plugin-secrets-apptoken | cut -d' ' -f1)
|
||||
vault plugin register -sha256=$sha secret vault-plugin-secrets-apptoken
|
||||
vault secrets enable -path=apptoken vault-plugin-secrets-apptoken
|
||||
|
||||
# configure the issuer to this mount's external URL
|
||||
vault write apptoken/config issuer="https://vault.k8s.syd1.au.unkin.net/v1/apptoken"
|
||||
|
||||
# define a per-app role and mint a token
|
||||
vault write apptoken/roles/keaapi ttl=15m max_ttl=1h claims="scope=leases"
|
||||
vault read apptoken/creds/keaapi # -> token, aud=keaapi, exp, ...
|
||||
|
||||
# public validation material (no token required)
|
||||
curl -s "$VAULT_ADDR/v1/apptoken/.well-known/jwks.json"
|
||||
curl -s "$VAULT_ADDR/v1/apptoken/.well-known/openid-configuration"
|
||||
|
||||
# rotate signing keys; tokens signed before rotation still validate during grace
|
||||
vault write -f apptoken/config/keys/rotate
|
||||
```
|
||||
|
||||
## Validating tokens in a service (Go)
|
||||
|
||||
Consumers only need the JWKS URL and their expected audience. Fetch and cache
|
||||
the JWKS (honouring cache headers / periodic refresh), then verify offline:
|
||||
|
||||
```go
|
||||
// keaapi middleware sketch — do NOT hit Vault per request.
|
||||
import (
|
||||
"github.com/coreos/go-oidc/v3/oidc" // or MicahParks/keyfunc + golang-jwt
|
||||
)
|
||||
|
||||
// jwks_uri = "https://vault.../v1/apptoken/.well-known/jwks.json"
|
||||
keySet := oidc.NewRemoteKeySet(ctx, jwksURL)
|
||||
verifier := func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||
payload, err := keySet.VerifySignature(r.Context(), raw)
|
||||
// then decode payload, check: iss == expected, aud contains "keaapi",
|
||||
// exp in the future. Reject otherwise.
|
||||
...
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
`kea-operator`'s `internal/keaapi` is the first intended consumer — see
|
||||
Follow-ups.
|
||||
|
||||
## Why not the built-in OIDC identity-token provider?
|
||||
|
||||
Vault ships an identity-token provider (`identity/oidc`) that also issues signed
|
||||
JWTs with a JWKS. This engine is deliberately parallel to it because:
|
||||
|
||||
- **Roles are issuable by other principals.** `creds/<role>` is a normal secret
|
||||
path, so the agents approle (or CI) can mint an app's token without being the
|
||||
token's own identity. The identity provider ties issuance to the caller's
|
||||
entity and its role/client model is heavier.
|
||||
- **Simpler audience model.** One role = one app = one audience, no separate
|
||||
OIDC client/assignment objects to wire up.
|
||||
- **OpenBao parity + house pattern.** Same plugin/Makefile/`.woodpecker`/RPM
|
||||
shape as the other `vault-plugin-secrets-*` engines, and the identical binary
|
||||
runs on OpenBao.
|
||||
|
||||
If you only need tokens whose subject is always the calling entity, the built-in
|
||||
provider may suffice; for per-app service tokens minted by shared automation,
|
||||
this engine is the better fit.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
make build # -> dist/vault-plugin-secrets-apptoken
|
||||
make test lint # go test -race / go vet
|
||||
make rpm # vault + openbao RPM flavours
|
||||
```
|
||||
|
||||
CI (Woodpecker) runs pre-commit/build/lint/test on PRs and, on a `v*` tag,
|
||||
builds the vault + openbao RPM flavours and uploads them to the artifactapi
|
||||
`rpm-internal` repo. Cut a release with `make patch|minor|major` (tags + pushes).
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
package apptoken
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/vault/sdk/logical"
|
||||
)
|
||||
|
||||
func TestConfig_Validation(t *testing.T) {
|
||||
b, s := getTestBackend(t)
|
||||
|
||||
// Bad issuer is rejected.
|
||||
resp, err := doReq(b, s, &logical.Request{
|
||||
Operation: logical.CreateOperation,
|
||||
Path: "config",
|
||||
Data: map[string]interface{}{"issuer": "not-a-url"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp == nil || !resp.IsError() {
|
||||
t.Fatalf("expected error for bad issuer, got %#v", resp)
|
||||
}
|
||||
|
||||
// Bad algorithm is rejected.
|
||||
resp, err = doReq(b, s, &logical.Request{
|
||||
Operation: logical.CreateOperation,
|
||||
Path: "config",
|
||||
Data: map[string]interface{}{"issuer": testIssuer, "algorithm": "HS256"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp == nil || !resp.IsError() {
|
||||
t.Fatalf("expected error for bad algorithm, got %#v", resp)
|
||||
}
|
||||
|
||||
// Good config round-trips, trailing slash trimmed.
|
||||
do(t, b, s, logical.CreateOperation, "config", map[string]interface{}{
|
||||
"issuer": testIssuer + "/",
|
||||
"algorithm": algRS256,
|
||||
"retained_keys": 3,
|
||||
})
|
||||
read := do(t, b, s, logical.ReadOperation, "config", nil)
|
||||
if read.Data["issuer"] != testIssuer {
|
||||
t.Fatalf("issuer not trimmed/stored: %v", read.Data["issuer"])
|
||||
}
|
||||
if read.Data["algorithm"] != algRS256 {
|
||||
t.Fatalf("algorithm not stored: %v", read.Data["algorithm"])
|
||||
}
|
||||
if read.Data["retained_keys"] != 3 {
|
||||
t.Fatalf("retained_keys not stored: %v", read.Data["retained_keys"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRole_CRUDAndReservedClaims(t *testing.T) {
|
||||
b, s := getTestBackend(t)
|
||||
|
||||
do(t, b, s, logical.CreateOperation, "roles/keaapi", map[string]interface{}{
|
||||
"ttl": "5m",
|
||||
"max_ttl": "30m",
|
||||
"claims": []string{"team=dns", "tier=prod"},
|
||||
})
|
||||
|
||||
read := do(t, b, s, logical.ReadOperation, "roles/keaapi", nil)
|
||||
if read.Data["ttl"].(int64) != 300 {
|
||||
t.Fatalf("ttl = %v, want 300", read.Data["ttl"])
|
||||
}
|
||||
if read.Data["audience"] != "" {
|
||||
t.Fatalf("audience should default to empty (role name at issue time)")
|
||||
}
|
||||
claims := read.Data["claims"].(map[string]string)
|
||||
if claims["team"] != "dns" || claims["tier"] != "prod" {
|
||||
t.Fatalf("claims not stored: %#v", claims)
|
||||
}
|
||||
|
||||
// List.
|
||||
list := do(t, b, s, logical.ListOperation, "roles/", nil)
|
||||
if got := list.Data["keys"].([]string); len(got) != 1 || got[0] != "keaapi" {
|
||||
t.Fatalf("list = %#v", got)
|
||||
}
|
||||
|
||||
// Reserved claim rejected.
|
||||
resp, err := doReq(b, s, &logical.Request{
|
||||
Operation: logical.CreateOperation,
|
||||
Path: "roles/bad",
|
||||
Data: map[string]interface{}{"claims": "iss=evil"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp == nil || !resp.IsError() {
|
||||
t.Fatalf("expected reserved-claim error, got %#v", resp)
|
||||
}
|
||||
|
||||
// ttl > max_ttl rejected.
|
||||
resp, err = doReq(b, s, &logical.Request{
|
||||
Operation: logical.CreateOperation,
|
||||
Path: "roles/bad2",
|
||||
Data: map[string]interface{}{"ttl": "1h", "max_ttl": "1m"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp == nil || !resp.IsError() {
|
||||
t.Fatalf("expected ttl>max_ttl error, got %#v", resp)
|
||||
}
|
||||
|
||||
// Delete.
|
||||
do(t, b, s, logical.DeleteOperation, "roles/keaapi", nil)
|
||||
if read := do(t, b, s, logical.ReadOperation, "roles/keaapi", nil); read != nil {
|
||||
t.Fatalf("role should be gone, got %#v", read)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreds_IssueAndValidateAgainstJWKS(t *testing.T) {
|
||||
for _, alg := range []string{algEdDSA, algRS256} {
|
||||
t.Run(alg, func(t *testing.T) {
|
||||
b, s := getTestBackend(t)
|
||||
configureIssuer(t, b, s, alg)
|
||||
do(t, b, s, logical.CreateOperation, "roles/keaapi", map[string]interface{}{
|
||||
"ttl": "10m",
|
||||
"claims": "scope=leases",
|
||||
})
|
||||
|
||||
resp := mintToken(t, b, s, "keaapi")
|
||||
token := resp.Data["token"].(string)
|
||||
if resp.Data["audience"] != "keaapi" {
|
||||
t.Fatalf("audience = %v, want keaapi", resp.Data["audience"])
|
||||
}
|
||||
if resp.Data["algorithm"] != alg {
|
||||
t.Fatalf("algorithm = %v, want %v", resp.Data["algorithm"], alg)
|
||||
}
|
||||
|
||||
jwks := readJWKS(t, b, s)
|
||||
claims := validateToken(t, jwks, token, testIssuer, "keaapi")
|
||||
|
||||
if claims.Subject != "test-caller" {
|
||||
t.Fatalf("subject = %q, want test-caller", claims.Subject)
|
||||
}
|
||||
if claims.ID == "" {
|
||||
t.Fatalf("jti should be set")
|
||||
}
|
||||
if claims.Expiry == nil || claims.IssuedAt == nil {
|
||||
t.Fatalf("exp/iat must be set")
|
||||
}
|
||||
if d := claims.Expiry.Time().Sub(claims.IssuedAt.Time()); d != 10*time.Minute {
|
||||
t.Fatalf("ttl = %v, want 10m", d)
|
||||
}
|
||||
|
||||
// Wrong audience must fail validation.
|
||||
if err := verifyWrongAudienceFails(jwks, token); err == nil {
|
||||
t.Fatalf("validation should fail for wrong audience")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreds_RequiresIssuer(t *testing.T) {
|
||||
b, s := getTestBackend(t)
|
||||
do(t, b, s, logical.CreateOperation, "roles/keaapi", nil)
|
||||
|
||||
resp, err := doReq(b, s, &logical.Request{Operation: logical.ReadOperation, Path: "creds/keaapi", DisplayName: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp == nil || !resp.IsError() {
|
||||
t.Fatalf("expected issuer-not-configured error, got %#v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreds_UnknownRole(t *testing.T) {
|
||||
b, s := getTestBackend(t)
|
||||
configureIssuer(t, b, s, "")
|
||||
resp, err := doReq(b, s, &logical.Request{Operation: logical.ReadOperation, Path: "creds/nope", DisplayName: "x"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp == nil || !resp.IsError() {
|
||||
t.Fatalf("expected unknown-role error, got %#v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRoles_Isolation proves a token minted for one role cannot masquerade as
|
||||
// another: the audience is bound to the issuing role.
|
||||
func TestRoles_Isolation(t *testing.T) {
|
||||
b, s := getTestBackend(t)
|
||||
configureIssuer(t, b, s, "")
|
||||
do(t, b, s, logical.CreateOperation, "roles/keaapi", nil)
|
||||
do(t, b, s, logical.CreateOperation, "roles/encapi", map[string]interface{}{"audience": "encapi.internal"})
|
||||
|
||||
kea := mintToken(t, b, s, "keaapi").Data["token"].(string)
|
||||
enc := mintToken(t, b, s, "encapi").Data["token"].(string)
|
||||
|
||||
// JWKS is read after issuance so the lazily-generated key is present.
|
||||
jwks := readJWKS(t, b, s)
|
||||
|
||||
validateToken(t, jwks, kea, testIssuer, "keaapi")
|
||||
validateToken(t, jwks, enc, testIssuer, "encapi.internal")
|
||||
|
||||
// A keaapi token must not validate for the encapi audience.
|
||||
if err := verifyAudience(jwks, kea, "encapi.internal"); err == nil {
|
||||
t.Fatalf("keaapi token validated for encapi audience")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRole_SubjectAllowlist(t *testing.T) {
|
||||
b, s := getTestBackend(t)
|
||||
configureIssuer(t, b, s, "")
|
||||
do(t, b, s, logical.CreateOperation, "roles/keaapi", map[string]interface{}{
|
||||
"allowed_subjects": "agents,ci",
|
||||
})
|
||||
|
||||
// Caller display name not in allowlist -> denied (returned as a
|
||||
// permission-denied error and/or an error response).
|
||||
resp, err := doReq(b, s, &logical.Request{Operation: logical.ReadOperation, Path: "creds/keaapi", DisplayName: "intruder"})
|
||||
if err == nil && (resp == nil || !resp.IsError()) {
|
||||
t.Fatalf("expected permission denied, got resp=%#v err=%v", resp, err)
|
||||
}
|
||||
|
||||
// Allowed caller -> issued, subject is the matched identity.
|
||||
resp, err = doReq(b, s, &logical.Request{Operation: logical.ReadOperation, Path: "creds/keaapi", DisplayName: "agents"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.IsError() {
|
||||
t.Fatalf("allowed caller denied: %v", resp.Error())
|
||||
}
|
||||
if resp.Data["subject"] != "agents" {
|
||||
t.Fatalf("subject = %v, want agents", resp.Data["subject"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestRotation_GraceAndTrim proves that after rotation an older token still
|
||||
// validates (its key stays in the JWKS) up to retained_keys, and that keys
|
||||
// beyond the retention window are dropped so their tokens no longer validate.
|
||||
func TestRotation_GraceAndTrim(t *testing.T) {
|
||||
b, s := getTestBackend(t)
|
||||
// retained_keys=1: keep current + 1 previous.
|
||||
do(t, b, s, logical.CreateOperation, "config", map[string]interface{}{
|
||||
"issuer": testIssuer,
|
||||
"retained_keys": 1,
|
||||
})
|
||||
do(t, b, s, logical.CreateOperation, "roles/keaapi", nil)
|
||||
|
||||
tokenV1 := mintToken(t, b, s, "keaapi").Data["token"].(string)
|
||||
kidV1 := currentKID(t, b, s)
|
||||
|
||||
// Rotate once: v1 key retained, v1 token still validates.
|
||||
do(t, b, s, logical.UpdateOperation, "config/keys/rotate", nil)
|
||||
kidV2 := currentKID(t, b, s)
|
||||
if kidV1 == kidV2 {
|
||||
t.Fatalf("rotation did not change current kid")
|
||||
}
|
||||
tokenV2 := mintToken(t, b, s, "keaapi").Data["token"].(string)
|
||||
|
||||
jwks := readJWKS(t, b, s)
|
||||
if len(jwks.Keys) != 2 {
|
||||
t.Fatalf("expected 2 keys in JWKS after 1 rotation, got %d", len(jwks.Keys))
|
||||
}
|
||||
validateToken(t, jwks, tokenV1, testIssuer, "keaapi") // grace: still valid
|
||||
validateToken(t, jwks, tokenV2, testIssuer, "keaapi")
|
||||
|
||||
// Rotate again: v1 key now beyond retention and dropped.
|
||||
do(t, b, s, logical.UpdateOperation, "config/keys/rotate", nil)
|
||||
jwks = readJWKS(t, b, s)
|
||||
if len(jwks.Keys) != 2 {
|
||||
t.Fatalf("expected 2 keys after 2nd rotation (trim), got %d", len(jwks.Keys))
|
||||
}
|
||||
if len(jwks.Key(kidV1)) != 0 {
|
||||
t.Fatalf("v1 kid should have been trimmed from JWKS")
|
||||
}
|
||||
// v1 token can no longer be validated (its key is gone).
|
||||
if err := findAndVerify(jwks, tokenV1); err == nil {
|
||||
t.Fatalf("v1 token should no longer validate after key trimmed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenIDConfiguration(t *testing.T) {
|
||||
b, s := getTestBackend(t)
|
||||
configureIssuer(t, b, s, "")
|
||||
|
||||
resp := do(t, b, s, logical.ReadOperation, openidConfigPath, nil)
|
||||
raw := resp.Data[logical.HTTPRawBody].([]byte)
|
||||
meta := decodeJSON(t, raw)
|
||||
if meta["issuer"] != testIssuer {
|
||||
t.Fatalf("issuer = %v", meta["issuer"])
|
||||
}
|
||||
if meta["jwks_uri"] != testIssuer+"/"+jwksPath {
|
||||
t.Fatalf("jwks_uri = %v", meta["jwks_uri"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnauthenticatedPaths(t *testing.T) {
|
||||
b := backend()
|
||||
special := b.SpecialPaths()
|
||||
want := map[string]bool{jwksPath: false, openidConfigPath: false}
|
||||
for _, p := range special.Unauthenticated {
|
||||
if _, ok := want[p]; ok {
|
||||
want[p] = true
|
||||
}
|
||||
}
|
||||
for p, found := range want {
|
||||
if !found {
|
||||
t.Fatalf("path %q not marked unauthenticated", p)
|
||||
}
|
||||
}
|
||||
// The keyset must be seal-wrapped.
|
||||
if len(special.SealWrapStorage) == 0 || special.SealWrapStorage[0] != keysetStoragePath {
|
||||
t.Fatalf("keyset not seal-wrapped: %#v", special.SealWrapStorage)
|
||||
}
|
||||
}
|
||||
|
||||
// currentKID reads config/keys and returns the current key id.
|
||||
func currentKID(t *testing.T, b *appTokenBackend, s logical.Storage) string {
|
||||
t.Helper()
|
||||
resp := do(t, b, s, logical.ReadOperation, "config/keys", nil)
|
||||
return resp.Data["current_key_id"].(string)
|
||||
}
|
||||
|
||||
var _ = context.Background
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
// Package apptoken implements a Vault / OpenBao secrets engine that issues
|
||||
// short-lived, signed JWT "app tokens" for self-made services. Each per-app
|
||||
// role mints a token whose audience is the app; services validate tokens
|
||||
// offline against the engine's unauthenticated JWKS endpoint, so no Vault
|
||||
// round-trip is needed per request.
|
||||
//
|
||||
// The engine replaces per-app static bearer Secrets with a single mechanism:
|
||||
// any principal allowed to read creds/<role> (for example the agents approle)
|
||||
// can obtain a token for that app, while signing keys stay inside Vault's
|
||||
// barrier and only the public JWKS ever leaves it.
|
||||
package apptoken
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/hashicorp/vault/sdk/framework"
|
||||
"github.com/hashicorp/vault/sdk/logical"
|
||||
)
|
||||
|
||||
const (
|
||||
configStoragePath = "config"
|
||||
keysetStoragePath = "keyset"
|
||||
roleStoragePrefix = "role/"
|
||||
)
|
||||
|
||||
type appTokenBackend struct {
|
||||
*framework.Backend
|
||||
|
||||
// keyLock serializes read-modify-write cycles on the signing keyset
|
||||
// (generation and rotation).
|
||||
keyLock sync.Mutex
|
||||
}
|
||||
|
||||
// Factory returns a configured app-token secrets backend.
|
||||
func Factory(ctx context.Context, conf *logical.BackendConfig) (logical.Backend, error) {
|
||||
b := backend()
|
||||
if err := b.Setup(ctx, conf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func backend() *appTokenBackend {
|
||||
b := &appTokenBackend{}
|
||||
|
||||
b.Backend = &framework.Backend{
|
||||
Help: strings.TrimSpace(backendHelp),
|
||||
BackendType: logical.TypeLogical,
|
||||
PathsSpecial: &logical.Paths{
|
||||
// The signing keyset holds private keys; keep it seal-wrapped.
|
||||
SealWrapStorage: []string{
|
||||
keysetStoragePath,
|
||||
},
|
||||
// JWKS and issuer metadata are public by design: apps fetch them
|
||||
// to validate tokens offline, with no Vault token.
|
||||
Unauthenticated: []string{
|
||||
jwksPath,
|
||||
openidConfigPath,
|
||||
},
|
||||
},
|
||||
Paths: framework.PathAppend(
|
||||
[]*framework.Path{
|
||||
pathConfig(b),
|
||||
pathConfigKeys(b),
|
||||
pathConfigKeysRotate(b),
|
||||
pathRole(b),
|
||||
pathRolesList(b),
|
||||
pathCredentials(b),
|
||||
pathJWKS(b),
|
||||
pathOpenIDConfig(b),
|
||||
},
|
||||
),
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
const backendHelp = `
|
||||
The apptoken secrets engine issues short-lived signed JWTs that self-made
|
||||
services accept in place of static bearer tokens.
|
||||
|
||||
Configure the issuer URL and signing algorithm on "config", define a per-app
|
||||
role under "roles/<app>", then read "creds/<app>" to mint a token whose
|
||||
audience is the app. Services validate tokens offline using the public keys at
|
||||
".well-known/jwks.json" and the metadata at ".well-known/openid-configuration",
|
||||
both of which are unauthenticated. Rotate signing keys with
|
||||
"config/keys/rotate"; previous keys stay published in the JWKS during the
|
||||
configured grace so tokens signed before the rotation keep validating.
|
||||
`
|
||||
+178
@@ -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" }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
# Ensure the plugin directory exists before the binary is laid down.
|
||||
# Rendered per flavour via envsubst (see scripts/build-rpm.sh).
|
||||
mkdir -p ${PACKAGE_PLUGIN_DIR}
|
||||
+160
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package apptoken
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/vault/sdk/framework"
|
||||
"github.com/hashicorp/vault/sdk/logical"
|
||||
)
|
||||
|
||||
func pathConfigKeys(b *appTokenBackend) *framework.Path {
|
||||
return &framework.Path{
|
||||
Pattern: "config/keys$",
|
||||
DisplayAttrs: &framework.DisplayAttributes{
|
||||
OperationPrefix: "apptoken",
|
||||
OperationSuffix: "keys",
|
||||
},
|
||||
Operations: map[logical.Operation]framework.OperationHandler{
|
||||
logical.ReadOperation: &framework.PathOperation{Callback: b.pathConfigKeysRead},
|
||||
},
|
||||
HelpSynopsis: "Inspect the signing keyset.",
|
||||
HelpDescription: "Read the current signing key id plus every key id retained in the JWKS. Signing keys are generated lazily on first use.",
|
||||
}
|
||||
}
|
||||
|
||||
func pathConfigKeysRotate(b *appTokenBackend) *framework.Path {
|
||||
return &framework.Path{
|
||||
Pattern: "config/keys/rotate$",
|
||||
DisplayAttrs: &framework.DisplayAttributes{
|
||||
OperationPrefix: "apptoken",
|
||||
OperationSuffix: "keys-rotate",
|
||||
},
|
||||
Operations: map[logical.Operation]framework.OperationHandler{
|
||||
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathConfigKeysRotate},
|
||||
},
|
||||
HelpSynopsis: "Rotate the signing key.",
|
||||
HelpDescription: "Generate a new current signing key. Up to retained_keys previous keys stay published in the JWKS so tokens signed before the rotation keep validating.",
|
||||
}
|
||||
}
|
||||
|
||||
func (b *appTokenBackend) pathConfigKeysRead(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
|
||||
cfg, err := b.getConfigOrDefault(ctx, req.Storage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b.keyLock.Lock()
|
||||
defer b.keyLock.Unlock()
|
||||
|
||||
ks, err := b.getOrCreateKeyset(ctx, req.Storage, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keys := make([]map[string]interface{}, 0, len(ks.Keys))
|
||||
for i := range ks.Keys {
|
||||
keys = append(keys, map[string]interface{}{
|
||||
"key_id": ks.Keys[i].KeyID,
|
||||
"algorithm": ks.Keys[i].Algorithm,
|
||||
"created": ks.Keys[i].Created.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return &logical.Response{Data: map[string]interface{}{
|
||||
"current_key_id": ks.current().KeyID,
|
||||
"keys": keys,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (b *appTokenBackend) pathConfigKeysRotate(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
|
||||
cfg, err := b.getConfigOrDefault(ctx, req.Storage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b.keyLock.Lock()
|
||||
defer b.keyLock.Unlock()
|
||||
|
||||
ks, err := b.getOrCreateKeyset(ctx, req.Storage, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ks.rotate(cfg.Algorithm, cfg.RetainedKeys, time.Now()); err != nil {
|
||||
return logical.ErrorResponse(err.Error()), nil
|
||||
}
|
||||
if err := b.putKeyset(ctx, req.Storage, ks); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &logical.Response{Data: map[string]interface{}{
|
||||
"current_key_id": ks.current().KeyID,
|
||||
}}, nil
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
package apptoken
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/vault/sdk/framework"
|
||||
"github.com/hashicorp/vault/sdk/logical"
|
||||
)
|
||||
|
||||
func pathCredentials(b *appTokenBackend) *framework.Path {
|
||||
return &framework.Path{
|
||||
Pattern: "creds/" + framework.GenericNameRegex("name"),
|
||||
DisplayAttrs: &framework.DisplayAttributes{
|
||||
OperationPrefix: "apptoken",
|
||||
OperationSuffix: "credentials",
|
||||
},
|
||||
Fields: map[string]*framework.FieldSchema{
|
||||
"name": {
|
||||
Type: framework.TypeLowerCaseString,
|
||||
Description: "Name of the role to mint a token for.",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
Operations: map[logical.Operation]framework.OperationHandler{
|
||||
logical.ReadOperation: &framework.PathOperation{Callback: b.pathCredentialsRead},
|
||||
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathCredentialsRead},
|
||||
},
|
||||
HelpSynopsis: "Mint a signed JWT app token from a role.",
|
||||
HelpDescription: "Reading this path issues a short-lived JWT whose audience is the role's app. Services validate it offline against the engine's JWKS.",
|
||||
}
|
||||
}
|
||||
|
||||
func (b *appTokenBackend) pathCredentialsRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||
name := data.Get("name").(string)
|
||||
|
||||
role, err := b.getRole(ctx, req.Storage, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if role == nil {
|
||||
return logical.ErrorResponse("role %q does not exist", name), nil
|
||||
}
|
||||
|
||||
cfg, err := b.getConfigOrDefault(ctx, req.Storage)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.Issuer == "" {
|
||||
return logical.ErrorResponse("issuer is not configured; set it via the config path"), nil
|
||||
}
|
||||
|
||||
subject, ok := b.resolveSubject(req, role)
|
||||
if !ok {
|
||||
return logical.ErrorResponse("caller is not permitted to mint tokens from role %q", name), logical.ErrPermissionDenied
|
||||
}
|
||||
|
||||
b.keyLock.Lock()
|
||||
ks, err := b.getOrCreateKeyset(ctx, req.Storage, cfg)
|
||||
b.keyLock.Unlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ttl := b.resolveTTL(role)
|
||||
tok, err := signToken(ks.current(), cfg.Issuer, subject, role.audience(name), role.Claims, ttl, time.Now())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &logical.Response{Data: map[string]interface{}{
|
||||
"token": tok.Token,
|
||||
"token_type": "Bearer",
|
||||
"issuer": cfg.Issuer,
|
||||
"audience": tok.Audience,
|
||||
"subject": tok.Subject,
|
||||
"key_id": tok.KeyID,
|
||||
"algorithm": tok.Algorithm,
|
||||
"jti": tok.JTI,
|
||||
"issued_at": tok.IssuedAt.Format(time.RFC3339),
|
||||
"expires_at": tok.ExpiresAt.Format(time.RFC3339),
|
||||
"ttl_seconds": int64(ttl.Seconds()),
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// resolveSubject determines the JWT subject from the caller's Vault identity
|
||||
// and enforces the role's subject allowlist. It returns (subject, allowed).
|
||||
func (b *appTokenBackend) resolveSubject(req *logical.Request, role *appRole) (string, bool) {
|
||||
var candidates []string
|
||||
if req.EntityID != "" {
|
||||
candidates = append(candidates, req.EntityID)
|
||||
}
|
||||
if req.DisplayName != "" {
|
||||
candidates = append(candidates, req.DisplayName)
|
||||
}
|
||||
|
||||
subject := ""
|
||||
if len(candidates) > 0 {
|
||||
subject = candidates[0]
|
||||
}
|
||||
|
||||
if len(role.AllowedSubjects) == 0 {
|
||||
return subject, true
|
||||
}
|
||||
allowed := make(map[string]bool, len(role.AllowedSubjects))
|
||||
for _, s := range role.AllowedSubjects {
|
||||
allowed[s] = true
|
||||
}
|
||||
for _, c := range candidates {
|
||||
if allowed[c] {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return subject, false
|
||||
}
|
||||
|
||||
// resolveTTL clamps the role TTL against the role max and the mount/system
|
||||
// lease limits.
|
||||
func (b *appTokenBackend) resolveTTL(role *appRole) time.Duration {
|
||||
sysMax := b.System().MaxLeaseTTL()
|
||||
|
||||
maxTTL := role.MaxTTL
|
||||
if maxTTL <= 0 || maxTTL > sysMax {
|
||||
maxTTL = sysMax
|
||||
}
|
||||
|
||||
ttl := role.TTL
|
||||
if ttl <= 0 {
|
||||
ttl = b.System().DefaultLeaseTTL()
|
||||
}
|
||||
if ttl > maxTTL {
|
||||
ttl = maxTTL
|
||||
}
|
||||
return ttl
|
||||
}
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
package apptoken
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/vault/sdk/framework"
|
||||
"github.com/hashicorp/vault/sdk/logical"
|
||||
)
|
||||
|
||||
// appRole describes the tokens issued from creds/<name>.
|
||||
type appRole struct {
|
||||
// Audience is the JWT `aud` claim; it defaults to the role name and is the
|
||||
// value the consuming app checks. Empty means "use the role name".
|
||||
Audience string `json:"audience"`
|
||||
// TTL / MaxTTL bound the token lifetime.
|
||||
TTL time.Duration `json:"ttl"`
|
||||
MaxTTL time.Duration `json:"max_ttl"`
|
||||
// AllowedSubjects, when non-empty, restricts which requesting identities
|
||||
// (Vault entity id or token display name) may mint a token from this role.
|
||||
AllowedSubjects []string `json:"allowed_subjects"`
|
||||
// Claims are extra string claims merged into every issued token. They may
|
||||
// not override registered JWT claims (iss, sub, aud, exp, ...).
|
||||
Claims map[string]string `json:"claims"`
|
||||
}
|
||||
|
||||
func (r *appRole) audience(name string) string {
|
||||
if r.Audience != "" {
|
||||
return r.Audience
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func (r *appRole) toResponseData() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"audience": r.Audience,
|
||||
"ttl": int64(r.TTL.Seconds()),
|
||||
"max_ttl": int64(r.MaxTTL.Seconds()),
|
||||
"allowed_subjects": r.AllowedSubjects,
|
||||
"claims": r.Claims,
|
||||
}
|
||||
}
|
||||
|
||||
func pathRole(b *appTokenBackend) *framework.Path {
|
||||
return &framework.Path{
|
||||
Pattern: "roles/" + framework.GenericNameRegex("name"),
|
||||
DisplayAttrs: &framework.DisplayAttributes{
|
||||
OperationPrefix: "apptoken",
|
||||
OperationSuffix: "role",
|
||||
},
|
||||
Fields: map[string]*framework.FieldSchema{
|
||||
"name": {
|
||||
Type: framework.TypeLowerCaseString,
|
||||
Description: "Name of the role (typically the app name).",
|
||||
Required: true,
|
||||
},
|
||||
"audience": {
|
||||
Type: framework.TypeString,
|
||||
Description: "JWT audience (aud) claim. Defaults to the role name.",
|
||||
},
|
||||
"ttl": {
|
||||
Type: framework.TypeDurationSecond,
|
||||
Description: "Default token TTL for this role.",
|
||||
},
|
||||
"max_ttl": {
|
||||
Type: framework.TypeDurationSecond,
|
||||
Description: "Maximum token TTL for this role.",
|
||||
},
|
||||
"allowed_subjects": {
|
||||
Type: framework.TypeCommaStringSlice,
|
||||
Description: "Optional allowlist of requesting identities (Vault entity id or token display name) permitted to mint tokens from this role. Empty means any authorized caller.",
|
||||
},
|
||||
"claims": {
|
||||
Type: framework.TypeKVPairs,
|
||||
Description: "Extra key=value claims merged into each issued token. Registered JWT claims cannot be overridden.",
|
||||
},
|
||||
},
|
||||
Operations: map[logical.Operation]framework.OperationHandler{
|
||||
logical.ReadOperation: &framework.PathOperation{Callback: b.pathRoleRead},
|
||||
logical.CreateOperation: &framework.PathOperation{Callback: b.pathRoleWrite},
|
||||
logical.UpdateOperation: &framework.PathOperation{Callback: b.pathRoleWrite},
|
||||
logical.DeleteOperation: &framework.PathOperation{Callback: b.pathRoleDelete},
|
||||
},
|
||||
ExistenceCheck: b.pathRoleExistenceCheck,
|
||||
HelpSynopsis: "Manage per-app token roles.",
|
||||
HelpDescription: "A role defines the audience, TTLs, subject allowlist and custom claims of the JWTs issued from creds/<name>.",
|
||||
}
|
||||
}
|
||||
|
||||
func pathRolesList(b *appTokenBackend) *framework.Path {
|
||||
return &framework.Path{
|
||||
Pattern: "roles/?$",
|
||||
DisplayAttrs: &framework.DisplayAttributes{
|
||||
OperationPrefix: "apptoken",
|
||||
OperationSuffix: "roles",
|
||||
},
|
||||
Operations: map[logical.Operation]framework.OperationHandler{
|
||||
logical.ListOperation: &framework.PathOperation{Callback: b.pathRolesList},
|
||||
},
|
||||
HelpSynopsis: "List the configured roles.",
|
||||
}
|
||||
}
|
||||
|
||||
func (b *appTokenBackend) pathRoleExistenceCheck(ctx context.Context, req *logical.Request, data *framework.FieldData) (bool, error) {
|
||||
role, err := b.getRole(ctx, req.Storage, data.Get("name").(string))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return role != nil, nil
|
||||
}
|
||||
|
||||
func (b *appTokenBackend) pathRolesList(ctx context.Context, req *logical.Request, _ *framework.FieldData) (*logical.Response, error) {
|
||||
entries, err := req.Storage.List(ctx, roleStoragePrefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return logical.ListResponse(entries), nil
|
||||
}
|
||||
|
||||
func (b *appTokenBackend) pathRoleRead(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||
role, err := b.getRole(ctx, req.Storage, data.Get("name").(string))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if role == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &logical.Response{Data: role.toResponseData()}, nil
|
||||
}
|
||||
|
||||
func (b *appTokenBackend) pathRoleWrite(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||
name := data.Get("name").(string)
|
||||
if name == "" {
|
||||
return logical.ErrorResponse("role name is required"), nil
|
||||
}
|
||||
|
||||
role, err := b.getRole(ctx, req.Storage, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if role == nil {
|
||||
role = &appRole{}
|
||||
}
|
||||
|
||||
if v, ok := data.GetOk("audience"); ok {
|
||||
role.Audience = v.(string)
|
||||
}
|
||||
if v, ok := data.GetOk("ttl"); ok {
|
||||
role.TTL = time.Duration(v.(int)) * time.Second
|
||||
}
|
||||
if v, ok := data.GetOk("max_ttl"); ok {
|
||||
role.MaxTTL = time.Duration(v.(int)) * time.Second
|
||||
}
|
||||
if v, ok := data.GetOk("allowed_subjects"); ok {
|
||||
role.AllowedSubjects = v.([]string)
|
||||
}
|
||||
if v, ok := data.GetOk("claims"); ok {
|
||||
role.Claims = v.(map[string]string)
|
||||
}
|
||||
|
||||
if role.TTL < 0 || role.MaxTTL < 0 {
|
||||
return logical.ErrorResponse("ttl and max_ttl must not be negative"), nil
|
||||
}
|
||||
if role.MaxTTL != 0 && role.TTL > role.MaxTTL {
|
||||
return logical.ErrorResponse("ttl must not be greater than max_ttl"), nil
|
||||
}
|
||||
if err := validateClaims(role.Claims); err != nil {
|
||||
return logical.ErrorResponse(err.Error()), nil
|
||||
}
|
||||
|
||||
if err := b.setRole(ctx, req.Storage, name, role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (b *appTokenBackend) pathRoleDelete(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) {
|
||||
if err := req.Storage.Delete(ctx, roleStoragePrefix+data.Get("name").(string)); err != nil {
|
||||
return nil, fmt.Errorf("deleting role: %w", err)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (b *appTokenBackend) getRole(ctx context.Context, s logical.Storage, name string) (*appRole, error) {
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("missing role name")
|
||||
}
|
||||
entry, err := s.Get(ctx, roleStoragePrefix+name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if entry == nil {
|
||||
return nil, nil
|
||||
}
|
||||
role := &appRole{}
|
||||
if err := entry.DecodeJSON(role); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
|
||||
func (b *appTokenBackend) setRole(ctx context.Context, s logical.Storage, name string, role *appRole) error {
|
||||
entry, err := logical.StorageEntryJSON(roleStoragePrefix+name, role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Put(ctx, entry)
|
||||
}
|
||||
|
||||
// reservedClaims are registered JWT claims the engine sets itself; roles may
|
||||
// not override them via custom claims.
|
||||
var reservedClaims = map[string]bool{
|
||||
"iss": true, "sub": true, "aud": true, "exp": true,
|
||||
"nbf": true, "iat": true, "jti": true,
|
||||
}
|
||||
|
||||
func validateClaims(claims map[string]string) error {
|
||||
for k := range claims {
|
||||
if reservedClaims[k] {
|
||||
return fmt.Errorf("claim %q is reserved and cannot be set on a role", k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Package the (already built) plugin binary into RPMs with nfpm. Builds one RPM
|
||||
# per target server: Vault (/opt/vault-plugins) and OpenBao (/opt/openbao-plugins).
|
||||
# Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}"
|
||||
VERSION="${VERSION#v}"
|
||||
BINARY="vault-plugin-secrets-apptoken"
|
||||
DIST="dist"
|
||||
|
||||
if [ ! -f "${DIST}/${BINARY}" ]; then
|
||||
echo "ERROR: ${DIST}/${BINARY} not found; run 'make build' first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export PACKAGE_VERSION="${VERSION}"
|
||||
export PACKAGE_RELEASE="1"
|
||||
export PACKAGE_ARCH="amd64"
|
||||
export PACKAGE_PLATFORM="linux"
|
||||
export PACKAGE_DESCRIPTION="Vault/OpenBao secrets engine issuing short-lived signed JWT app tokens for self-made services"
|
||||
export PACKAGE_MAINTAINER="Ben Vincent <ben@unkin.net>"
|
||||
export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/vault-plugin-secrets-apptoken"
|
||||
export PACKAGE_LICENSE="MIT"
|
||||
|
||||
build_flavor() {
|
||||
export PACKAGE_NAME="$1"
|
||||
export PACKAGE_PLUGIN_DIR="$2"
|
||||
export PACKAGE_PREINSTALL="${DIST}/preinstall-${PACKAGE_NAME}.sh"
|
||||
envsubst '${PACKAGE_PLUGIN_DIR}' < packaging/scripts/preinstall.sh.tmpl > "${PACKAGE_PREINSTALL}"
|
||||
envsubst < packaging/nfpm.yaml > "${DIST}/nfpm-${PACKAGE_NAME}.yaml"
|
||||
nfpm pkg --config "${DIST}/nfpm-${PACKAGE_NAME}.yaml" --target "${DIST}" --packager rpm
|
||||
}
|
||||
|
||||
build_flavor "vault-plugin-secrets-apptoken" "/opt/vault-plugins"
|
||||
build_flavor "openbao-plugin-secrets-apptoken" "/opt/openbao-plugins"
|
||||
|
||||
echo "Built:"
|
||||
ls -1 "${DIST}"/*.rpm
|
||||
Reference in New Issue
Block a user