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,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
|
||||
}
|
||||
Reference in New Issue
Block a user