Files
unkinben ec12dfb84f
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
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
2026-08-02 23:42:42 +10:00

208 lines
5.4 KiB
Go

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
}