Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions vault/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ require (
github.com/gaucho-racing/ulid-go v1.1.0
github.com/gin-contrib/cors v1.7.6
github.com/gin-gonic/gin v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/makiuchi-d/gozxing v0.1.1
github.com/pquerna/otp v1.5.0
go.uber.org/zap v1.27.1
Expand Down
2 changes: 2 additions & 0 deletions vault/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
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=
Expand Down
4 changes: 4 additions & 0 deletions vault/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"github.com/gaucho-racing/vault/vault/config"
"github.com/gaucho-racing/vault/vault/database"
"github.com/gaucho-racing/vault/vault/pkg/logger"
"github.com/gaucho-racing/vault/vault/pkg/sentinel"
"github.com/gaucho-racing/vault/vault/service"
)

Expand All @@ -14,6 +15,9 @@ func main() {

config.Verify()
config.PrintStartupBanner()
if err := sentinel.InitializeSigningKeys(); err != nil {
logger.SugarLogger.Warnf("initialize Sentinel signing keys: %v", err)
}
database.Init()
service.InitializeVaultKeys()

Expand Down
171 changes: 171 additions & 0 deletions vault/pkg/sentinel/jwks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
package sentinel

import (
"context"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"net/http"
"strings"
"sync"
"time"

"github.com/gaucho-racing/vault/vault/config"
"github.com/golang-jwt/jwt/v5"
)

const signingKeyRefetchFloor = time.Minute

var signingKeys = struct {
sync.RWMutex
keys map[string]*rsa.PublicKey
lastFetch time.Time
lastError error
refreshMutex sync.Mutex
}{}

func InitializeSigningKeys() error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return refreshSigningKeys(ctx, true)
}

func ValidateToken(token string) (map[string]interface{}, error) {
claims := jwt.MapClaims{}
parsed, err := jwt.ParseWithClaims(
token,
claims,
func(token *jwt.Token) (any, error) {
kid, _ := token.Header["kid"].(string)
return signingKey(kid)
},
jwt.WithValidMethods([]string{"RS256"}),
jwt.WithExpirationRequired(),
jwt.WithAudience(config.SentinelClientID),
)
if err != nil {
return nil, err
}
if !parsed.Valid {
return nil, errors.New("token is invalid")
}
return map[string]interface{}(claims), nil
}

func signingKey(kid string) (*rsa.PublicKey, error) {
if kid == "" {
return nil, errors.New("token key id is missing")
}
if key := cachedSigningKey(kid); key != nil {
return key, nil
}
Comment on lines +63 to +65

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refresh cached keys periodically to honor key revocation

When Sentinel removes a compromised or retired key while continuing to use already-cached key IDs, this early return prevents Vault from ever fetching the updated JWKS; refreshes occur only for an unknown kid. Tokens signed with the removed private key therefore remain accepted until the process restarts or an unrelated new key ID triggers a refresh, and an attacker holding that key can choose a far-future expiration. Add a bounded cache lifetime or background refresh while retaining stale keys only when a refresh actually fails.

Useful? React with 👍 / 👎.

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := refreshSigningKeys(ctx, false); err != nil {
return nil, err
}
if key := cachedSigningKey(kid); key != nil {
return key, nil
}
return nil, fmt.Errorf("no signing key matches kid %q", kid)
}

func cachedSigningKey(kid string) *rsa.PublicKey {
signingKeys.RLock()
defer signingKeys.RUnlock()
return signingKeys.keys[kid]
}

func refreshSigningKeys(ctx context.Context, force bool) error {
signingKeys.refreshMutex.Lock()
defer signingKeys.refreshMutex.Unlock()

signingKeys.RLock()
lastFetch := signingKeys.lastFetch
lastError := signingKeys.lastError
signingKeys.RUnlock()
if !force && !lastFetch.IsZero() && time.Since(lastFetch) < signingKeyRefetchFloor {
return lastError
}

keys, err := fetchSigningKeys(ctx)
signingKeys.Lock()
signingKeys.lastFetch = time.Now()
signingKeys.lastError = err
if err == nil {
signingKeys.keys = keys
}
signingKeys.Unlock()
return err
}

func fetchSigningKeys(ctx context.Context) (map[string]*rsa.PublicKey, error) {
if strings.TrimSpace(config.SentinelURL) == "" {
return nil, errors.New("SENTINEL_URL is not configured")
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(config.SentinelURL, "/")+"/api/core/keys", nil)
if err != nil {
return nil, err
}
request.Header.Set("Accept", "application/json")
response, err := httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("fetch Sentinel JWKS: %w", err)
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return nil, fmt.Errorf("read Sentinel JWKS: %w", err)
}
if response.StatusCode != http.StatusOK {
return nil, fmt.Errorf("fetch Sentinel JWKS: HTTP %d", response.StatusCode)
}
var document struct {
Keys []struct {
KeyType string `json:"kty"`
Use string `json:"use"`
Algorithm string `json:"alg"`
ID string `json:"kid"`
Modulus string `json:"n"`
Exponent string `json:"e"`
} `json:"keys"`
}
if err := json.Unmarshal(body, &document); err != nil {
return nil, fmt.Errorf("decode Sentinel JWKS: %w", err)
}
keys := make(map[string]*rsa.PublicKey, len(document.Keys))
for _, encoded := range document.Keys {
if encoded.KeyType != "RSA" || encoded.Algorithm != "RS256" || encoded.Use != "sig" || encoded.ID == "" {
continue
}
key, err := decodeRSAKey(encoded.Modulus, encoded.Exponent)
if err != nil {
return nil, fmt.Errorf("decode Sentinel signing key %q: %w", encoded.ID, err)
}
keys[encoded.ID] = key
}
if len(keys) == 0 {
return nil, errors.New("Sentinel JWKS contains no RS256 signing keys")
}
return keys, nil
}

func decodeRSAKey(modulus string, exponent string) (*rsa.PublicKey, error) {
n, err := base64.RawURLEncoding.DecodeString(modulus)
if err != nil || len(n) == 0 {
return nil, errors.New("invalid RSA modulus")
}
e, err := base64.RawURLEncoding.DecodeString(exponent)
if err != nil || len(e) == 0 || len(e) > 4 {
return nil, errors.New("invalid RSA exponent")
}
exponentValue := new(big.Int).SetBytes(e)
if !exponentValue.IsInt64() || exponentValue.Int64() < 2 {
return nil, errors.New("invalid RSA exponent")
}
return &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(exponentValue.Int64())}, nil
}
41 changes: 0 additions & 41 deletions vault/pkg/sentinel/sentinel.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,47 +90,6 @@ type Group struct {

var httpClient = &http.Client{Timeout: 5 * time.Second}

func ValidateToken(token string) (map[string]interface{}, error) {
if strings.TrimSpace(config.SentinelURL) == "" {
return nil, fmt.Errorf("SENTINEL_URL is not configured")
}

body, err := json.Marshal(map[string]string{"token": token})
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, strings.TrimRight(config.SentinelURL, "/")+"/api/core/token/validate", bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")

resp, err := httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()

respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
var sentinelErr Error
if err := json.Unmarshal(respBody, &sentinelErr); err != nil {
return nil, err
}
sentinelErr.Code = resp.StatusCode
return nil, fmt.Errorf("sentinel error: [%d] %s", sentinelErr.Code, sentinelErr.Message)
}

var claims map[string]interface{}
if err := json.Unmarshal(respBody, &claims); err != nil {
return nil, err
}
return claims, nil
}

func ExchangeAuthorizationCode(code string, redirectURI string) (TokenResponse, error) {
form := url.Values{}
form.Set("grant_type", "authorization_code")
Expand Down
Loading