413 lines
13 KiB
Go
413 lines
13 KiB
Go
package securityevents
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math/big"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/golang-jwt/jwt/v5"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
const (
|
|
SessionRevokedEventType = "https://schemas.openid.net/secevent/caep/event-type/session-revoked"
|
|
VerificationEventType = "https://schemas.openid.net/secevent/ssf/event-type/verification"
|
|
StreamUpdatedEventType = "https://schemas.openid.net/secevent/ssf/event-type/stream-updated"
|
|
)
|
|
|
|
type Event struct {
|
|
Issuer string
|
|
Audience string
|
|
JTI string
|
|
TransactionID string
|
|
IssuedAt time.Time
|
|
EventType string
|
|
EventTimestamp time.Time
|
|
SubjectIssuer string
|
|
Subject string
|
|
TenantID string
|
|
InitiatingEntity string
|
|
StreamID string
|
|
State string
|
|
StreamStatus string
|
|
Reason string
|
|
}
|
|
|
|
type VerifierConfig struct {
|
|
TransmitterIssuer string
|
|
Audience string
|
|
SubjectIssuer string
|
|
TenantID string
|
|
StreamID string
|
|
ClockSkew time.Duration
|
|
HTTPClient *http.Client
|
|
}
|
|
|
|
type Verifier struct {
|
|
config VerifierConfig
|
|
client *http.Client
|
|
mu sync.Mutex
|
|
keys map[string]*ecdsa.PublicKey
|
|
expiresAt time.Time
|
|
metrics *Metrics
|
|
clock func() time.Time
|
|
}
|
|
|
|
func (v *Verifier) SetMetrics(metrics *Metrics) { v.metrics = metrics }
|
|
|
|
type transmitterMetadata struct {
|
|
Issuer string `json:"issuer"`
|
|
JWKSURI string `json:"jwks_uri"`
|
|
}
|
|
|
|
type jwkSet struct {
|
|
Keys []struct {
|
|
KID string `json:"kid"`
|
|
KTY string `json:"kty"`
|
|
Use string `json:"use"`
|
|
Alg string `json:"alg"`
|
|
Crv string `json:"crv"`
|
|
X string `json:"x"`
|
|
Y string `json:"y"`
|
|
} `json:"keys"`
|
|
}
|
|
|
|
type protocolError struct {
|
|
Code string
|
|
}
|
|
|
|
func (e *protocolError) Error() string { return e.Code }
|
|
|
|
func NewVerifier(config VerifierConfig) (*Verifier, error) {
|
|
config.TransmitterIssuer = strings.TrimRight(strings.TrimSpace(config.TransmitterIssuer), "/")
|
|
config.SubjectIssuer = strings.TrimRight(strings.TrimSpace(config.SubjectIssuer), "/")
|
|
if !absoluteHTTPURL(config.TransmitterIssuer) || !absoluteHTTPURL(config.SubjectIssuer) || config.Audience == "" || config.TenantID == "" {
|
|
return nil, errors.New("SSF transmitter, audience, subject issuer, and tenant are required")
|
|
}
|
|
if _, err := uuid.Parse(config.StreamID); err != nil {
|
|
return nil, errors.New("SSF stream id must be a UUID")
|
|
}
|
|
if config.ClockSkew < 0 || config.ClockSkew > 5*time.Minute {
|
|
return nil, errors.New("SSF clock skew is invalid")
|
|
}
|
|
client := config.HTTPClient
|
|
if client == nil {
|
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
|
transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
|
|
transport.DisableCompression = true
|
|
client = &http.Client{Timeout: 10 * time.Second,
|
|
Transport: transport,
|
|
CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}
|
|
}
|
|
return &Verifier{config: config, client: client, keys: make(map[string]*ecdsa.PublicKey), clock: time.Now}, nil
|
|
}
|
|
|
|
func (v *Verifier) Verify(ctx context.Context, raw string) (Event, error) {
|
|
parser := jwt.NewParser(jwt.WithValidMethods([]string{"ES256"}))
|
|
unverified, _, err := parser.ParseUnverified(raw, jwt.MapClaims{})
|
|
if err != nil || unverified == nil || unverified.Header["typ"] != "secevent+jwt" || unverified.Header["alg"] != "ES256" {
|
|
return Event{}, &protocolError{Code: "invalid_request"}
|
|
}
|
|
unverifiedClaims, ok := unverified.Claims.(jwt.MapClaims)
|
|
if !ok {
|
|
return Event{}, &protocolError{Code: "invalid_request"}
|
|
}
|
|
if stringClaim(unverifiedClaims, "iss") != v.config.TransmitterIssuer {
|
|
return Event{}, &protocolError{Code: "invalid_issuer"}
|
|
}
|
|
if !claimHasAudience(unverifiedClaims["aud"], v.config.Audience) {
|
|
return Event{}, &protocolError{Code: "invalid_audience"}
|
|
}
|
|
kid, _ := unverified.Header["kid"].(string)
|
|
if kid == "" {
|
|
return Event{}, &protocolError{Code: "invalid_key"}
|
|
}
|
|
key, err := v.key(ctx, kid)
|
|
if err != nil {
|
|
return Event{}, &protocolError{Code: "invalid_key"}
|
|
}
|
|
token, err := jwt.Parse(raw, func(token *jwt.Token) (any, error) { return key, nil },
|
|
jwt.WithValidMethods([]string{"ES256"}), jwt.WithIssuer(v.config.TransmitterIssuer),
|
|
jwt.WithAudience(v.config.Audience), jwt.WithIssuedAt(), jwt.WithLeeway(v.config.ClockSkew))
|
|
if err != nil || !token.Valid {
|
|
if errors.Is(err, jwt.ErrTokenSignatureInvalid) {
|
|
return Event{}, &protocolError{Code: "invalid_key"}
|
|
}
|
|
return Event{}, &protocolError{Code: "invalid_request"}
|
|
}
|
|
claims, ok := token.Claims.(jwt.MapClaims)
|
|
if !ok {
|
|
return Event{}, &protocolError{Code: "invalid_request"}
|
|
}
|
|
if _, present := claims["sub"]; present {
|
|
return Event{}, &protocolError{Code: "invalid_request"}
|
|
}
|
|
if _, present := claims["exp"]; present {
|
|
return Event{}, &protocolError{Code: "invalid_request"}
|
|
}
|
|
jti := stringClaim(claims, "jti")
|
|
if _, err := uuid.Parse(jti); err != nil {
|
|
return Event{}, &protocolError{Code: "invalid_request"}
|
|
}
|
|
issuedAt, err := claims.GetIssuedAt()
|
|
if err != nil || issuedAt == nil {
|
|
return Event{}, &protocolError{Code: "invalid_request"}
|
|
}
|
|
events, ok := claims["events"].(map[string]any)
|
|
if !ok || len(events) != 1 {
|
|
return Event{}, &protocolError{Code: "invalid_request"}
|
|
}
|
|
event := Event{Issuer: v.config.TransmitterIssuer, Audience: v.config.Audience, JTI: jti, TransactionID: stringClaim(claims, "txn"), IssuedAt: issuedAt.Time}
|
|
if payload, present := events[SessionRevokedEventType]; present {
|
|
value, ok := payload.(map[string]any)
|
|
if !ok || event.TransactionID == "" {
|
|
return Event{}, &protocolError{Code: "invalid_request"}
|
|
}
|
|
if _, err := uuid.Parse(event.TransactionID); err != nil {
|
|
return Event{}, &protocolError{Code: "invalid_request"}
|
|
}
|
|
event.EventType, event.EventTimestamp, event.InitiatingEntity = SessionRevokedEventType, unixClaim(value["event_timestamp"]), stringValue(value["initiating_entity"])
|
|
if event.EventTimestamp.IsZero() || event.EventTimestamp.After(v.now().Add(v.config.ClockSkew)) ||
|
|
!validInitiator(event.InitiatingEntity) || !v.readSessionSubject(claims["sub_id"], &event) {
|
|
return Event{}, &protocolError{Code: "invalid_request"}
|
|
}
|
|
return event, nil
|
|
}
|
|
if payload, present := events[VerificationEventType]; present {
|
|
value, ok := payload.(map[string]any)
|
|
if !ok || !v.readStreamSubject(claims["sub_id"], &event) {
|
|
return Event{}, &protocolError{Code: "invalid_request"}
|
|
}
|
|
event.EventType = VerificationEventType
|
|
if rawState, present := value["state"]; present {
|
|
var valid bool
|
|
event.State, valid = rawState.(string)
|
|
if !valid || len(event.State) > 1024 {
|
|
return Event{}, &protocolError{Code: "invalid_request"}
|
|
}
|
|
}
|
|
if len(event.State) > 1024 {
|
|
return Event{}, &protocolError{Code: "invalid_request"}
|
|
}
|
|
return event, nil
|
|
}
|
|
if payload, present := events[StreamUpdatedEventType]; present {
|
|
value, ok := payload.(map[string]any)
|
|
if !ok || !v.readStreamSubject(claims["sub_id"], &event) {
|
|
return Event{}, &protocolError{Code: "invalid_request"}
|
|
}
|
|
event.EventType = StreamUpdatedEventType
|
|
event.StreamStatus, event.Reason = stringValue(value["status"]), stringValue(value["reason"])
|
|
if event.StreamStatus != "enabled" && event.StreamStatus != "paused" && event.StreamStatus != "disabled" {
|
|
return Event{}, &protocolError{Code: "invalid_request"}
|
|
}
|
|
if len(event.Reason) > 1024 {
|
|
return Event{}, &protocolError{Code: "invalid_request"}
|
|
}
|
|
return event, nil
|
|
}
|
|
return Event{}, &protocolError{Code: "invalid_request"}
|
|
}
|
|
|
|
func claimHasAudience(raw any, expected string) bool {
|
|
switch value := raw.(type) {
|
|
case string:
|
|
return value == expected
|
|
case []string:
|
|
for _, audience := range value {
|
|
if audience == expected {
|
|
return true
|
|
}
|
|
}
|
|
case []any:
|
|
for _, item := range value {
|
|
if audience, ok := item.(string); ok && audience == expected {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (v *Verifier) now() time.Time {
|
|
if v.clock != nil {
|
|
return v.clock().UTC()
|
|
}
|
|
return time.Now().UTC()
|
|
}
|
|
|
|
func (v *Verifier) readSessionSubject(raw any, event *Event) bool {
|
|
subject, ok := raw.(map[string]any)
|
|
if !ok || stringValue(subject["format"]) != "complex" {
|
|
return false
|
|
}
|
|
user, userOK := subject["user"].(map[string]any)
|
|
tenant, tenantOK := subject["tenant"].(map[string]any)
|
|
if !userOK || !tenantOK || stringValue(user["format"]) != "iss_sub" || stringValue(tenant["format"]) != "opaque" {
|
|
return false
|
|
}
|
|
event.SubjectIssuer, event.Subject, event.TenantID = strings.TrimRight(stringValue(user["iss"]), "/"), stringValue(user["sub"]), stringValue(tenant["id"])
|
|
if event.SubjectIssuer != v.config.SubjectIssuer || event.TenantID != v.config.TenantID {
|
|
return false
|
|
}
|
|
_, err := uuid.Parse(event.Subject)
|
|
return err == nil
|
|
}
|
|
|
|
func (v *Verifier) readStreamSubject(raw any, event *Event) bool {
|
|
subject, ok := raw.(map[string]any)
|
|
if !ok || stringValue(subject["format"]) != "opaque" || stringValue(subject["id"]) != v.config.StreamID {
|
|
return false
|
|
}
|
|
event.StreamID = v.config.StreamID
|
|
return true
|
|
}
|
|
|
|
func (v *Verifier) key(ctx context.Context, kid string) (*ecdsa.PublicKey, error) {
|
|
v.mu.Lock()
|
|
if key := v.keys[kid]; key != nil && time.Now().Before(v.expiresAt) {
|
|
v.mu.Unlock()
|
|
return key, nil
|
|
}
|
|
v.mu.Unlock()
|
|
if err := v.refresh(ctx); err != nil {
|
|
if v.metrics != nil {
|
|
v.metrics.ObserveJWKSRefreshFailure("ssf")
|
|
}
|
|
return nil, err
|
|
}
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
key := v.keys[kid]
|
|
if key == nil {
|
|
return nil, errors.New("SSF signing key is unavailable")
|
|
}
|
|
return key, nil
|
|
}
|
|
|
|
func (v *Verifier) refresh(ctx context.Context) error {
|
|
v.mu.Lock()
|
|
defer v.mu.Unlock()
|
|
configurationURL, err := ssfConfigurationURL(v.config.TransmitterIssuer)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var metadata transmitterMetadata
|
|
if err := v.fetchJSON(ctx, configurationURL, &metadata); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimRight(metadata.Issuer, "/") != v.config.TransmitterIssuer || !sameOrigin(metadata.JWKSURI, v.config.TransmitterIssuer) {
|
|
return errors.New("SSF metadata is not bound to configured issuer")
|
|
}
|
|
var set jwkSet
|
|
if err := v.fetchJSON(ctx, metadata.JWKSURI, &set); err != nil {
|
|
return err
|
|
}
|
|
keys := make(map[string]*ecdsa.PublicKey)
|
|
for _, item := range set.Keys {
|
|
if item.KID == "" || item.KTY != "EC" || item.Alg != "ES256" || item.Crv != "P-256" || item.Use != "sig" {
|
|
continue
|
|
}
|
|
x, xErr := decodeCoordinate(item.X)
|
|
y, yErr := decodeCoordinate(item.Y)
|
|
if xErr == nil && yErr == nil && elliptic.P256().IsOnCurve(x, y) {
|
|
keys[item.KID] = &ecdsa.PublicKey{Curve: elliptic.P256(), X: x, Y: y}
|
|
}
|
|
}
|
|
if len(keys) == 0 {
|
|
return errors.New("SSF JWKS contains no ES256 keys")
|
|
}
|
|
v.keys, v.expiresAt = keys, time.Now().Add(5*time.Minute)
|
|
return nil
|
|
}
|
|
|
|
func (v *Verifier) fetchJSON(ctx context.Context, endpoint string, target any) error {
|
|
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
request.Header.Set("Accept", "application/json")
|
|
response, err := v.client.Do(request)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer response.Body.Close()
|
|
if response.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("SSF endpoint returned HTTP %d", response.StatusCode)
|
|
}
|
|
payload, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1))
|
|
if err != nil || len(payload) > 1<<20 {
|
|
return errors.New("SSF endpoint response is too large")
|
|
}
|
|
if err := json.Unmarshal(payload, target); err != nil {
|
|
return errors.New("SSF endpoint returned invalid JSON")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ssfConfigurationURL(issuer string) (string, error) {
|
|
parsed, err := url.Parse(issuer)
|
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
|
return "", errors.New("SSF issuer is invalid")
|
|
}
|
|
path := strings.TrimSuffix(parsed.EscapedPath(), "/")
|
|
parsed.Path, parsed.RawPath = "/.well-known/ssf-configuration"+path, ""
|
|
parsed.RawQuery, parsed.Fragment = "", ""
|
|
return parsed.String(), nil
|
|
}
|
|
|
|
func sameOrigin(candidate, issuer string) bool {
|
|
candidateURL, candidateErr := url.Parse(candidate)
|
|
issuerURL, issuerErr := url.Parse(issuer)
|
|
return candidateErr == nil && issuerErr == nil && candidateURL.Scheme == issuerURL.Scheme && strings.EqualFold(candidateURL.Host, issuerURL.Host) && candidateURL.User == nil && candidateURL.Fragment == ""
|
|
}
|
|
|
|
func absoluteHTTPURL(raw string) bool {
|
|
parsed, err := url.Parse(raw)
|
|
return err == nil && parsed.IsAbs() && parsed.Hostname() != "" && (parsed.Scheme == "https" || parsed.Scheme == "http") && parsed.User == nil && parsed.Fragment == ""
|
|
}
|
|
|
|
func decodeCoordinate(raw string) (*big.Int, error) {
|
|
value, err := base64.RawURLEncoding.DecodeString(raw)
|
|
if err != nil || len(value) != 32 {
|
|
return nil, errors.New("invalid EC coordinate")
|
|
}
|
|
return new(big.Int).SetBytes(value), nil
|
|
}
|
|
|
|
func stringClaim(claims jwt.MapClaims, name string) string { return stringValue(claims[name]) }
|
|
|
|
func stringValue(value any) string {
|
|
result, _ := value.(string)
|
|
return result
|
|
}
|
|
|
|
func unixClaim(value any) time.Time {
|
|
switch typed := value.(type) {
|
|
case float64:
|
|
return time.Unix(int64(typed), 0).UTC()
|
|
case json.Number:
|
|
integer, err := typed.Int64()
|
|
if err == nil {
|
|
return time.Unix(integer, 0).UTC()
|
|
}
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
func validInitiator(value string) bool {
|
|
return value == "admin" || value == "user" || value == "policy" || value == "system"
|
|
}
|