diff --git a/docs/Configuring.md b/docs/Configuring.md index 3984da4dd5..0242f4ee20 100644 --- a/docs/Configuring.md +++ b/docs/Configuring.md @@ -240,6 +240,16 @@ For OTLP provider: - `trace.provider.otlp.insecure`: Whether to use an insecure connection - `trace.provider.otlp.headers`: Headers to include in OTLP requests +## Security Configuration + +Root level key `security` + +| Field | Description | Default | +|-----------------------------|-------------------------------------------------------------------------------------------------|---------| +| `unsafe.clock_skew` | Platform-wide maximum tolerated clock skew for token verification (Go duration, use cautiously) | `1m` | + +> **Warning:** Increasing `unsafe.clock_skew` weakens token freshness guarantees. Only raise this value temporarily while you correct clock drift. + ## Services Configuration Root level key `services` @@ -262,6 +272,11 @@ Environment Variable: `OPENTDF_SERVICES_KAS_KEYRING='[{"kid":"k1","alg":"rsa:204 Example: ```yaml +security: + unsafe: + # Increase only when diagnosing clock drift issues + # clock_skew: 90s + services: kas: keyring: diff --git a/opentdf-kas-mode.yaml b/opentdf-kas-mode.yaml index b6431b297e..508c53ed04 100644 --- a/opentdf-kas-mode.yaml +++ b/opentdf-kas-mode.yaml @@ -11,6 +11,10 @@ logger: level: debug type: text output: stdout +security: + unsafe: + # Increase only when diagnosing clock drift issues; default is 1m + # clock_skew: 90s services: kas: registered_kas_uri: http://localhost:8080 # Should match what you have registered for *this* KAS in the policy db. diff --git a/service/kas/access/provider.go b/service/kas/access/provider.go index fc6235abb1..fb0981fdb2 100644 --- a/service/kas/access/provider.go +++ b/service/kas/access/provider.go @@ -2,6 +2,7 @@ package access import ( "context" + "log/slog" "net/url" "time" @@ -29,6 +30,7 @@ type Provider struct { Logger *logger.Logger Config *config.ServiceConfig KASConfig + securityConfig *config.SecurityConfig trace.Tracer } @@ -73,6 +75,36 @@ func (p *Provider) IsReady(ctx context.Context) error { return nil } +// ApplyConfig stores the latest KAS configuration, tracks the associated security +// overrides, and emits a warning when the configured clock skew exceeds the default. +func (p *Provider) ApplyConfig(cfg KASConfig, securityCfg *config.SecurityConfig) { + p.KASConfig = cfg + p.securityConfig = securityCfg + + if p.Logger != nil { + if skew := p.acceptableSkew(); skew > config.DefaultUnsafeClockSkew { + p.Logger.Warn("configured SRT acceptable skew exceeds default", + slog.Duration("configured_skew", skew), + slog.Duration("default_skew", config.DefaultUnsafeClockSkew), + ) + } + } +} + +// SecurityConfig exposes the most recent security configuration captured via ApplyConfig. +func (p *Provider) SecurityConfig() *config.SecurityConfig { + return p.securityConfig +} + +// acceptableSkew returns the tolerated clock skew for SRT validation, falling back to the +// global unsafe default when no override is present. +func (p *Provider) acceptableSkew() time.Duration { + if p.securityConfig == nil { + return config.DefaultUnsafeClockSkew + } + return p.securityConfig.ClockSkew() +} + func (kasCfg *KASConfig) UpgradeMapToKeyring(c *security.StandardCrypto) { switch { case kasCfg.ECCertID != "" && len(kasCfg.Keyring) > 0: diff --git a/service/kas/access/rewrap.go b/service/kas/access/rewrap.go index 31c784fb4c..03df2ccdbe 100644 --- a/service/kas/access/rewrap.go +++ b/service/kas/access/rewrap.go @@ -3,6 +3,7 @@ package access import ( "bytes" "context" + "crypto" "crypto/ecdsa" "crypto/hmac" "crypto/rsa" @@ -16,6 +17,7 @@ import ( "fmt" "log/slog" "net/http" + "sort" "strings" "time" @@ -134,29 +136,149 @@ func generateHMACDigest(ctx context.Context, msg, key []byte, logger logger.Logg return mac.Sum(nil), nil } -var acceptableSkew = 30 * time.Second +func jwkThumbprintAttr(key jwk.Key) slog.Attr { + if key == nil { + return slog.String("jwk_thumbprint", "none") + } + thumbprint, err := key.Thumbprint(crypto.SHA256) + if err != nil { + return slog.String("jwk_thumbprint_error", err.Error()) + } + return slog.String("jwk_thumbprint", base64.RawURLEncoding.EncodeToString(thumbprint)) +} + +// logSRTParseFailure emits contextual details when an SRT cannot be parsed, adjusting +// verbosity depending on whether signature verification was required. +func (p *Provider) logSRTParseFailure(ctx context.Context, srt string, requireVerification bool, err error) { + attrs := []any{slog.Any("error", err)} + if requireVerification { + attrs = append(attrs, slog.String("srt", srt)) + p.Logger.WarnContext(ctx, "unable to verify request token", attrs...) + return + } + + p.Logger.WarnContext(ctx, "unable to validate or parse token", attrs...) +} -func verifySRT(ctx context.Context, srt string, dpopJWK jwk.Key, logger logger.Logger) (string, error) { - token, err := jwt.Parse([]byte(srt), jwt.WithKey(jwa.RS256, dpopJWK), jwt.WithAcceptableSkew(acceptableSkew)) +// parseSRT parses the JWT payload without validation, returning the token and embedded +// request body string while translating parse errors into client-facing status codes. +func (p *Provider) parseSRT(ctx context.Context, srt string, requireVerification bool) (jwt.Token, string, error) { + token, err := jwt.Parse([]byte(srt), jwt.WithVerify(false), jwt.WithValidate(false)) if err != nil { - logger.WarnContext(ctx, - "unable to verify request token", - slog.String("srt", srt), - slog.Any("jwk", dpopJWK), - slog.Any("err", err), + p.logSRTParseFailure(ctx, srt, requireVerification, err) + if requireVerification { + return nil, "", err401("unable to verify request token") + } + return nil, "", err401("could not parse token") + } + + rbString, err := justRequestBody(ctx, token, *p.Logger) + if err != nil { + return nil, "", err + } + + return token, rbString, nil +} + +// logSRTValidationFailure collects claim timestamps and skew details to aid debugging when +// validation fails after parsing the SRT. +func (p *Provider) logSRTValidationFailure(ctx context.Context, token jwt.Token, message string, err error) { + now := time.Now().UTC() + + fields := []any{ + slog.Any("error", err), + slog.Time("server_time", now), + slog.Duration("acceptable_skew", p.acceptableSkew()), + } + + failureClaims := map[string]struct{}{} + + issuedAt := token.IssuedAt() + if !issuedAt.IsZero() { + fields = append(fields, + slog.Time("iat", issuedAt), + slog.Duration("iat_delta", issuedAt.Sub(now)), ) - return "", err401("unable to verify request token") + if errors.Is(err, jwt.ErrInvalidIssuedAt()) { + failureClaims["iat"] = struct{}{} + } + } + + expires := token.Expiration() + if !expires.IsZero() { + fields = append(fields, + slog.Time("exp", expires), + slog.Duration("exp_delta", now.Sub(expires)), + ) + if errors.Is(err, jwt.ErrTokenExpired()) { + failureClaims["exp"] = struct{}{} + } + } + + notBefore := token.NotBefore() + if !notBefore.IsZero() { + fields = append(fields, + slog.Time("nbf", notBefore), + slog.Duration("nbf_delta", notBefore.Sub(now)), + ) + } + + if errors.Is(err, jwt.ErrTokenNotYetValid()) { + failureClaims["nbf"] = struct{}{} } - return justRequestBody(ctx, token, logger) + + if len(failureClaims) > 0 { + names := make([]string, 0, len(failureClaims)) + for claim := range failureClaims { + names = append(names, claim) + } + sort.Strings(names) + fields = append(fields, slog.Any("validation_failure_claims", names)) + } + + fields = append(fields, slog.String("failure_reason", message)) + p.Logger.WarnContext(ctx, "srt validation failure", fields...) } -func noverify(ctx context.Context, srt string, logger logger.Logger) (string, error) { - token, err := jwt.Parse([]byte(srt), jwt.WithVerify(false), jwt.WithAcceptableSkew(acceptableSkew)) +// validateSRTClaims enforces temporal constraints on the parsed SRT, incorporating the +// configured acceptable skew and translating failures into user-friendly errors. +func (p *Provider) validateSRTClaims(ctx context.Context, token jwt.Token, requireVerification bool) error { + err := jwt.Validate(token, jwt.WithAcceptableSkew(p.acceptableSkew())) + if err == nil { + return nil + } + + message := "unable to validate or parse token" + userErr := err401("could not parse token") + if requireVerification { + message = "unable to verify request token" + userErr = err401("unable to verify request token") + } + + p.logSRTValidationFailure(ctx, token, message, err) + return userErr +} + +// verifySRTSignature validates the SRT signature against the supplied DPoP key when +// verification is required. +func (p *Provider) verifySRTSignature(ctx context.Context, srt string, dpopJWK jwk.Key) error { + _, err := jwt.Parse( + []byte(srt), + jwt.WithKey(jwa.RS256, dpopJWK), + jwt.WithValidate(false), + ) if err != nil { - logger.WarnContext(ctx, "unable to validate or parse token", slog.Any("error", err)) - return "", err401("could not parse token") + if p.Logger != nil { + p.Logger.WarnContext(ctx, + "unable to verify request token", + slog.String("srt", srt), + jwkThumbprintAttr(dpopJWK), + slog.Any("error", err), + ) + } + return err401("unable to verify request token") } - return justRequestBody(ctx, token, logger) + return nil } func justRequestBody(ctx context.Context, token jwt.Token, logger logger.Logger) (string, error) { @@ -217,44 +339,52 @@ func extractAndConvertV1SRTBody(body []byte) (kaspb.UnsignedRewrapRequest, error }, nil } -func extractSRTBody(ctx context.Context, headers http.Header, in *kaspb.RewrapRequest, logger logger.Logger) (*kaspb.UnsignedRewrapRequest, bool, error) { +func (p *Provider) extractSRTBody(ctx context.Context, headers http.Header, in *kaspb.RewrapRequest) (*kaspb.UnsignedRewrapRequest, bool, error) { isV1 := false // First load legacy method for verifying SRT if vpk, ok := headers["X-Virtrupubkey"]; ok && len(vpk) == 1 { - logger.InfoContext(ctx, "legacy Client: Processing X-Virtrupubkey") + p.Logger.InfoContext(ctx, "legacy Client: Processing X-Virtrupubkey") } // get dpop public key from context - dpopJWK := ctxAuth.GetJWKFromContext(ctx, &logger) + dpopJWK := ctxAuth.GetJWKFromContext(ctx, p.Logger) - var err error - var rbString string srt := in.GetSignedRequestToken() - if dpopJWK == nil { - logger.InfoContext(ctx, "no DPoP key provided") + requireVerification := dpopJWK != nil + if !requireVerification { + p.Logger.InfoContext(ctx, "no DPoP key provided") // if we have no DPoP key it's for one of two reasons: // 1. auth is disabled so we can't get a DPoP JWK // 2. auth is enabled _but_ we aren't requiring DPoP // in either case letting the request through makes sense - rbString, err = noverify(ctx, srt, logger) - if err != nil { - logger.ErrorContext(ctx, "unable to load RSA verifier", slog.Any("error", err)) - return nil, false, err + } + + token, rbString, parseErr := p.parseSRT(ctx, srt, requireVerification) + if parseErr != nil { + if !requireVerification { + p.Logger.ErrorContext(ctx, "srt parsing failed (signature verification skipped)", slog.Any("error", parseErr)) } - } else { - // verify and validate the request token - var err error - rbString, err = verifySRT(ctx, srt, dpopJWK, logger) - if err != nil { + return nil, false, parseErr + } + + if validateErr := p.validateSRTClaims(ctx, token, requireVerification); validateErr != nil { + if !requireVerification { + p.Logger.ErrorContext(ctx, "srt validation failed (signature verification skipped)", slog.Any("error", validateErr)) + } + return nil, false, validateErr + } + + if requireVerification { + if err := p.verifySRTSignature(ctx, srt, dpopJWK); err != nil { return nil, false, err } } var requestBody kaspb.UnsignedRewrapRequest - err = protojson.UnmarshalOptions{DiscardUnknown: true}.Unmarshal([]byte(rbString), &requestBody) + err := protojson.UnmarshalOptions{DiscardUnknown: true}.Unmarshal([]byte(rbString), &requestBody) // if there are no requests then it could be a v1 request if err != nil { - logger.WarnContext(ctx, + p.Logger.WarnContext(ctx, "invalid SRT", slog.Any("err_v2", err), slog.String("srt", rbString), @@ -262,11 +392,11 @@ func extractSRTBody(ctx context.Context, headers http.Header, in *kaspb.RewrapRe return nil, false, err400("invalid request body") } if len(requestBody.GetRequests()) == 0 { - logger.DebugContext(ctx, "legacy v1 SRT") + p.Logger.DebugContext(ctx, "legacy v1 SRT") var errv1 error if requestBody, errv1 = extractAndConvertV1SRTBody([]byte(rbString)); errv1 != nil { - logger.WarnContext(ctx, + p.Logger.WarnContext(ctx, "invalid SRT", slog.Any("err_v1", errv1), slog.String("srt", rbString), @@ -277,7 +407,7 @@ func extractSRTBody(ctx context.Context, headers http.Header, in *kaspb.RewrapRe isV1 = true } // TODO: this log is too big and should be reconsidered or removed - logger.DebugContext(ctx, + p.Logger.DebugContext(ctx, "extracted request body", slog.String("rewrap_body", requestBody.String()), slog.String("rewrap_srt", rbString), @@ -285,14 +415,14 @@ func extractSRTBody(ctx context.Context, headers http.Header, in *kaspb.RewrapRe block, _ := pem.Decode([]byte(requestBody.GetClientPublicKey())) if block == nil { - logger.WarnContext(ctx, "missing clientPublicKey") + p.Logger.WarnContext(ctx, "missing clientPublicKey") return nil, isV1, err400("clientPublicKey failure") } // Try to parse the clientPublicKey clientPublicKey, err := x509.ParsePKIXPublicKey(block.Bytes) if err != nil { - logger.WarnContext(ctx, "failure to parse clientPublicKey", slog.Any("error", err)) + p.Logger.WarnContext(ctx, "failure to parse clientPublicKey", slog.Any("error", err)) return nil, isV1, err400("clientPublicKey parse failure") } // Check to make sure the clientPublicKey is a supported key type @@ -302,7 +432,7 @@ func extractSRTBody(ctx context.Context, headers http.Header, in *kaspb.RewrapRe case *ecdsa.PublicKey: return &requestBody, isV1, nil default: - logger.WarnContext(ctx, "unsupported clientPublicKey type", slog.String("type", fmt.Sprintf("%T", clientPublicKey))) + p.Logger.WarnContext(ctx, "unsupported clientPublicKey type", slog.String("type", fmt.Sprintf("%T", clientPublicKey))) return nil, isV1, err400("clientPublicKey unsupported type") } } @@ -433,7 +563,7 @@ func (p *Provider) Rewrap(ctx context.Context, req *connect.Request[kaspb.Rewrap in := req.Msg p.Logger.DebugContext(ctx, "REWRAP") - body, isV1, err := extractSRTBody(ctx, req.Header(), in, *p.Logger) + body, isV1, err := p.extractSRTBody(ctx, req.Header(), in) if err != nil { p.Logger.DebugContext(ctx, "unverifiable srt", slog.Any("error", err)) return nil, err diff --git a/service/kas/access/rewrap_test.go b/service/kas/access/rewrap_test.go index 813e6c096f..bba322405e 100644 --- a/service/kas/access/rewrap_test.go +++ b/service/kas/access/rewrap_test.go @@ -1,6 +1,7 @@ package access import ( + "bytes" "context" "crypto/rsa" "crypto/x509" @@ -11,7 +12,9 @@ import ( "errors" "log/slog" "net/http" + "strings" "testing" + "time" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/types/known/structpb" @@ -24,6 +27,7 @@ import ( "github.com/opentdf/platform/lib/ocrypto" "github.com/opentdf/platform/service/logger" ctxAuth "github.com/opentdf/platform/service/pkg/auth" + "github.com/opentdf/platform/service/pkg/config" "github.com/opentdf/platform/service/trust" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -88,6 +92,42 @@ func (f *fakeKeyIndex) ListKeysWith(_ context.Context, opts trust.ListKeyOptions return f.keys, f.err } +func newBufferLogger() (*logger.Logger, *bytes.Buffer) { + buf := &bytes.Buffer{} + handler := slog.NewJSONHandler(buf, &slog.HandlerOptions{ + Level: slog.LevelDebug, + }) + return &logger.Logger{ + Logger: slog.New(handler), + }, buf +} + +func extractLastLogRecord(t *testing.T, buf *bytes.Buffer) map[string]any { + t.Helper() + data := strings.TrimSpace(buf.String()) + require.NotEmpty(t, data) + lines := strings.Split(data, "\n") + var record map[string]any + require.NoError(t, json.Unmarshal([]byte(lines[len(lines)-1]), &record)) + return record +} + +func toStringSlice(t *testing.T, raw any) []string { + t.Helper() + if raw == nil { + return nil + } + interfaceSlice, ok := raw.([]any) + require.True(t, ok) + result := make([]string, 0, len(interfaceSlice)) + for _, v := range interfaceSlice { + str, strOK := v.(string) + require.True(t, strOK) + result = append(result, str) + } + return result +} + func TestListLegacyKeys_KeyringPopulated(t *testing.T) { testLogger := logger.CreateTestLogger() // Simulate a Provider with Keyring containing legacy RSA keys @@ -150,6 +190,26 @@ func TestListLegacyKeys_KeyIndexError(t *testing.T) { assert.Empty(t, kids) } +func TestProviderApplyConfig_DefaultAndWarning(t *testing.T) { + log, buf := newBufferLogger() + p := &Provider{ + Logger: log, + } + + security := &config.SecurityConfig{} + p.ApplyConfig(KASConfig{}, security) + require.Equal(t, config.DefaultUnsafeClockSkew, p.acceptableSkew()) + require.Empty(t, strings.TrimSpace(buf.String())) + + buf.Reset() + + custom := 2 * time.Minute + security.Unsafe.ClockSkew = custom + p.ApplyConfig(KASConfig{}, security) + require.Equal(t, custom, p.acceptableSkew()) + require.Contains(t, buf.String(), "configured SRT acceptable skew exceeds default") +} + const ( ecCert = `-----BEGIN CERTIFICATE----- MIIB5DCBzQIUZsQqf2nfB0JuxsKBwrVjfCVjjmUwDQYJKoZIhvcNAQELBQAwGzEZ @@ -475,15 +535,17 @@ func TestParseAndVerifyRequest(t *testing.T) { md := metadata.New(map[string]string{"token": bearer}) ctx = metadata.NewIncomingContext(ctx, md) - logger := logger.CreateTestLogger() + testLogger := logger.CreateTestLogger() + p := &Provider{ + Logger: testLogger, + } - verified, _, err := extractSRTBody( + verified, _, err := p.extractSRTBody( ctx, http.Header{}, &kaspb.RewrapRequest{ SignedRequestToken: string(tt.body), }, - *logger, ) if tt.goodDPoP { require.NoError(t, err, "failed to parse srt=[%s], tok=[%s]", tt.body, bearer) @@ -491,7 +553,7 @@ func TestParseAndVerifyRequest(t *testing.T) { require.NotNil(t, verified.GetClientPublicKey(), "unable to load public key") for _, req := range verified.GetRequests() { - err := verifyPolicyBinding(t.Context(), []byte(req.GetPolicy().GetBody()), req.GetKeyAccessObjects()[0], []byte(plainKey), *logger) + err := verifyPolicyBinding(t.Context(), []byte(req.GetPolicy().GetBody()), req.GetKeyAccessObjects()[0], []byte(plainKey), *testLogger) if !tt.shouldError { require.NoError(t, err, "failed to verify policy body=[%v]", tt.body) } else { @@ -517,18 +579,79 @@ func Test_SignedRequestBody_When_Bad_Signature_Expect_Failure(t *testing.T) { md := metadata.New(map[string]string{"token": string(jwtWrongKey(t))}) ctx = metadata.NewIncomingContext(ctx, md) - verified, _, err := extractSRTBody( + badLogger := logger.CreateTestLogger() + p := &Provider{ + Logger: badLogger, + } + verified, _, err := p.extractSRTBody( ctx, http.Header{}, &kaspb.RewrapRequest{ SignedRequestToken: string(makeRewrapBody(t, fauxPolicyBytes(t), false)), }, - *logger.CreateTestLogger(), ) require.Error(t, err) require.Nil(t, verified) } +func TestValidateSRTClaims_LogsFutureIAT(t *testing.T) { + log, buf := newBufferLogger() + p := &Provider{Logger: log} + + token := jwt.New() + future := time.Now().Add(2 * time.Minute) + require.NoError(t, token.Set(jwt.IssuedAtKey, future)) + + err := p.validateSRTClaims(t.Context(), token, false) + require.Error(t, err) + + record := extractLastLogRecord(t, buf) + require.Equal(t, "srt validation failure", record["msg"]) + require.NotNil(t, record["iat"]) + require.NotNil(t, record["iat_delta"]) + require.Equal(t, "unable to validate or parse token", record["failure_reason"]) + + claims := toStringSlice(t, record["validation_failure_claims"]) + assert.Contains(t, claims, "iat") +} + +func TestValidateSRTClaims_LogsExpired(t *testing.T) { + log, buf := newBufferLogger() + p := &Provider{Logger: log} + + token := jwt.New() + past := time.Now().Add(-2 * time.Minute) + require.NoError(t, token.Set(jwt.ExpirationKey, past)) + + err := p.validateSRTClaims(t.Context(), token, false) + require.Error(t, err) + + record := extractLastLogRecord(t, buf) + require.Equal(t, "srt validation failure", record["msg"]) + require.NotNil(t, record["exp"]) + require.NotNil(t, record["exp_delta"]) + require.Equal(t, "unable to validate or parse token", record["failure_reason"]) + + claims := toStringSlice(t, record["validation_failure_claims"]) + assert.Contains(t, claims, "exp") +} + +func TestValidateSRTClaims_CustomSkewAllowsFutureIAT(t *testing.T) { + log, _ := newBufferLogger() + p := &Provider{Logger: log} + custom := 3 * time.Minute + security := &config.SecurityConfig{} + security.Unsafe.ClockSkew = custom + p.ApplyConfig(KASConfig{}, security) + + token := jwt.New() + future := time.Now().Add(2 * time.Minute) + require.NoError(t, token.Set(jwt.IssuedAtKey, future)) + + err := p.validateSRTClaims(t.Context(), token, false) + require.NoError(t, err) +} + func Test_GetEntityInfo_When_Missing_MD_Expect_Error(t *testing.T) { ctx := t.Context() _, err := getEntityInfo(ctx, logger.CreateTestLogger()) diff --git a/service/kas/kas.go b/service/kas/kas.go index 71eede23bb..8957e74c8f 100644 --- a/service/kas/kas.go +++ b/service/kas/kas.go @@ -27,7 +27,7 @@ func OnConfigUpdate(p *access.Provider) serviceregistry.OnConfigUpdateHook { return fmt.Errorf("invalid kas cfg [%v] %w", cfg, err) } - p.KASConfig = kasCfg + p.ApplyConfig(kasCfg, p.SecurityConfig()) p.Logger.Info("kas config reloaded") return nil @@ -114,7 +114,7 @@ func NewRegistration() *serviceregistry.Service[kasconnect.AccessServiceHandler] p.SDK = srp.SDK p.Logger = srp.Logger - p.KASConfig = kasCfg + p.ApplyConfig(kasCfg, srp.Security) p.Tracer = srp.Tracer srp.Logger.Debug("kas config", slog.Any("config", kasCfg)) diff --git a/service/pkg/config/config.go b/service/pkg/config/config.go index a8528e4f4a..ef945e3683 100644 --- a/service/pkg/config/config.go +++ b/service/pkg/config/config.go @@ -42,6 +42,9 @@ type Config struct { // By default, it runs all services. Mode []string `mapstructure:"mode" json:"mode" default:"[\"all\"]"` + // Security holds platform-wide security overrides. + Security SecurityConfig `mapstructure:"security" json:"security"` + // SDKConfig represents the configuration settings for the SDK. SDKConfig SDKConfig `mapstructure:"sdk_config" json:"sdk_config"` @@ -101,6 +104,7 @@ func (c *Config) LogValue() slog.Value { slog.Any("db", c.DB), slog.Any("logger", c.Logger), slog.Any("mode", c.Mode), + slog.Any("security", c.Security), slog.Any("sdk_config", c.SDKConfig), slog.Any("server", c.Server), ) @@ -250,6 +254,15 @@ func (c *Config) Reload(ctx context.Context) error { if err := validator.New().Struct(c); err != nil { return errors.Join(err, ErrUnmarshallingConfig) } + + if skew := c.Security.ClockSkew(); skew > DefaultUnsafeClockSkew { + slog.WarnContext(ctx, + "unsafe clock skew override active", + slog.Duration("clock_skew", skew), + slog.Duration("default_clock_skew", DefaultUnsafeClockSkew), + ) + } + return nil } diff --git a/service/pkg/config/security.go b/service/pkg/config/security.go new file mode 100644 index 0000000000..11e44dbdd8 --- /dev/null +++ b/service/pkg/config/security.go @@ -0,0 +1,32 @@ +package config + +import ( + "time" +) + +const ( + // DefaultUnsafeClockSkew is the default tolerated clock skew used when an unsafe override is not provided. + DefaultUnsafeClockSkew = time.Minute +) + +// SecurityConfig collects platform-wide security toggles and overrides. +type SecurityConfig struct { + Unsafe UnsafeSecurityConfig `mapstructure:"unsafe" json:"unsafe"` +} + +// UnsafeSecurityConfig exposes overrides that may weaken standard security guarantees. +type UnsafeSecurityConfig struct { + // ClockSkew increases the tolerated clock skew for token validation. Defaults to 1 minute. + ClockSkew time.Duration `mapstructure:"clock_skew" json:"clock_skew"` +} + +// ClockSkew returns the configured clock skew, defaulting to DefaultUnsafeClockSkew when unset. +func (s *SecurityConfig) ClockSkew() time.Duration { + if s == nil { + return DefaultUnsafeClockSkew + } + if s.Unsafe.ClockSkew <= 0 { + return DefaultUnsafeClockSkew + } + return s.Unsafe.ClockSkew +} diff --git a/service/pkg/server/services.go b/service/pkg/server/services.go index 5d073daea2..beca3f4825 100644 --- a/service/pkg/server/services.go +++ b/service/pkg/server/services.go @@ -204,6 +204,7 @@ func startServices(ctx context.Context, params startServicesParams) (func(), err err = svc.Start(ctx, serviceregistry.RegistrationParams{ Config: cfg.Services[svc.GetNamespace()], + Security: &cfg.Security, Logger: svcLogger, DBClient: svcDBClient, SDK: client, diff --git a/service/pkg/serviceregistry/serviceregistry.go b/service/pkg/serviceregistry/serviceregistry.go index 990d28384c..95fff64632 100644 --- a/service/pkg/serviceregistry/serviceregistry.go +++ b/service/pkg/serviceregistry/serviceregistry.go @@ -32,6 +32,8 @@ type RegistrationParams struct { // Config scoped to the service config. Since the main config contains all the service configs, // which could have need-to-know information we don't want to expose it to all services. Config config.ServiceConfig + // Security exposes platform-wide security overrides. + Security *config.SecurityConfig // OTDF is the OpenTDF server that can be used to interact with the OpenTDFServer instance. OTDF *server.OpenTDFServer // DBClient is the database client that can be used to interact with the database. This client