ec12dfb84f
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
208 lines
5.4 KiB
Go
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
|
|
}
|