diff --git a/Makefile b/Makefile index eb8a2757188..bd94d475925 100644 --- a/Makefile +++ b/Makefile @@ -320,6 +320,23 @@ cscli: ## Build cscli crowdsec: ## Build crowdsec @$(MAKE) -C $(CROWDSEC_FOLDER) build $(MAKE_FLAGS) +# Regenerate the WAF challenge JS artifacts: the bundled fpscanner source +# (fpscanner/bundle.js), the obfuscator runtime (obfuscate/index.wasm.gz), +# and the build-time pre-obfuscated initial bundle (initial_bundle.js.gz). +# These are committed to the tree so a normal `make build` does NOT rebuild +# them — only run this target after intentionally changing challenge.js, +# fpscanner sources, the obfuscator wrapper, or the dynamic-module template. +# +# Requires `javy` (https://github.com/bytecodealliance/javy/releases) on +# PATH. The full obfuscation pass is slow (~1 minute) because it runs +# javascript-obfuscator's "high-obfuscation" preset over the full bundle; +# this is the cost we are paying once at generate time so the runtime +# doesn't pay it on every startup. +.PHONY: generate-challenge-js +generate-challenge-js: ## Regenerate WAF challenge JS bundles (requires javy on PATH) + @command -v javy >/dev/null 2>&1 || (echo "Error: javy is not installed. Download it from https://github.com/bytecodealliance/javy/releases and put it on PATH." && exit 1) + $(GO) generate ./pkg/appsec/challenge/js/... + testenv: ifeq ($(TEST_LOCAL_ONLY),) @echo 'NOTE: You need to run "make localstack" in a separate shell, "make localstack-stop" to terminate it; or define the envvar TEST_LOCAL_ONLY to some value.' diff --git a/pkg/acquisition/modules/appsec/config.go b/pkg/acquisition/modules/appsec/config.go index ac3b20a327e..76dffc57547 100644 --- a/pkg/acquisition/modules/appsec/config.go +++ b/pkg/acquisition/modules/appsec/config.go @@ -43,6 +43,31 @@ type Configuration struct { AppsecConfigs []string `yaml:"appsec_configs"` AppsecConfigPath string `yaml:"appsec_config_path"` AuthCacheDuration *time.Duration `yaml:"auth_cache_duration"` + // ChallengeMasterSecret is the long-lived secret used by the WAF + // challenge runtime to sign tickets / PoW MACs and seal challenge + // cookies. In a distributed (multi-WAF) deployment, all instances MUST + // share the same value so that a challenge issued by one instance + // validates against another. May be a hex-encoded byte string or a raw + // passphrase; minimum 32 bytes / characters. If unset, an ephemeral + // random secret is generated at startup (suitable for single-instance + // deployments only — restarts invalidate outstanding challenge cookies). + ChallengeMasterSecret string `yaml:"challenge_master_secret"` + + // ChallengeKeyRotationInterval controls how often the per-epoch + // challenge key advances. All instances in a distributed setup MUST + // agree on this value to derive identical per-epoch keys. + ChallengeKeyRotationInterval *time.Duration `yaml:"challenge_key_rotation_interval"` + + // ChallengeMaxLiveEpochs is how many past epochs (in addition to the + // current one) the keyring continues to accept. Sized so any submission + // within the freshness window has a non-evicted epoch. + ChallengeMaxLiveEpochs int `yaml:"challenge_max_live_epochs"` + + // ChallengeCookieTTL controls how long a successful-challenge cookie + // stays valid. Decoupled from the keyring rotation window so cookies + // can be long-lived (e.g. 24h) without widening the per-epoch ticket- + // forgery exposure. Defaults to 12h when unset. + ChallengeCookieTTL *time.Duration `yaml:"challenge_cookie_ttl"` configuration.DataSourceCommonCfg `yaml:",inline"` } @@ -191,7 +216,26 @@ func (w *Source) Configure(ctx context.Context, yamlConfig []byte, logger *log.E if appsecRuntime.NeedWASMVM { logger.Info("Initializing WASM runtime for challenge obfuscation") - challengeRuntime, err := challenge.NewChallengeRuntime(ctx) + + var challengeOpts []challenge.Option + if w.config.ChallengeMasterSecret != "" { + secret, err := challenge.ParseConfiguredSecret(w.config.ChallengeMasterSecret) + if err != nil { + return fmt.Errorf("invalid challenge_master_secret: %w", err) + } + challengeOpts = append(challengeOpts, challenge.WithMasterSecret(secret)) + } + if w.config.ChallengeKeyRotationInterval != nil { + challengeOpts = append(challengeOpts, challenge.WithRotationInterval(*w.config.ChallengeKeyRotationInterval)) + } + if w.config.ChallengeMaxLiveEpochs > 0 { + challengeOpts = append(challengeOpts, challenge.WithMaxLiveEpochs(w.config.ChallengeMaxLiveEpochs)) + } + if w.config.ChallengeCookieTTL != nil { + challengeOpts = append(challengeOpts, challenge.WithCookieTTL(*w.config.ChallengeCookieTTL)) + } + + challengeRuntime, err := challenge.NewChallengeRuntime(ctx, challengeOpts...) if err != nil { return fmt.Errorf("unable to create challenge runtime: %w", err) } diff --git a/pkg/appsec/challenge/challenge.go b/pkg/appsec/challenge/challenge.go index e6853c20081..b744906bffa 100644 --- a/pkg/appsec/challenge/challenge.go +++ b/pkg/appsec/challenge/challenge.go @@ -5,32 +5,24 @@ import ( "compress/gzip" "context" "crypto/hmac" - crand "crypto/rand" "crypto/sha256" - "encoding/base64" - "encoding/hex" + _ "embed" "encoding/json" "fmt" "io" - "math/rand/v2" "net/http" "net/url" - "strconv" "strings" "sync" - "time" - - _ "embed" - "text/template" + "time" - challengejs "github.com/crowdsecurity/crowdsec/pkg/appsec/challenge/js" "github.com/crowdsecurity/crowdsec/pkg/appsec/challenge/pb" "github.com/crowdsecurity/crowdsec/pkg/appsec/cookie" - "github.com/google/uuid" log "github.com/sirupsen/logrus" "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1" + "golang.org/x/sync/singleflight" ) const ChallengeJSPath = "/crowdsec-internal/challenge/challenge.js" @@ -40,44 +32,119 @@ const ChallengeCookieName = "__crowdsec_challenge" const challengeJSCacheSize = 10 const challengeJSRefreshInterval = 10 * time.Minute -// PoW difficulty levels in leading zero bits. Pure JS SHA-256 through the -// obfuscator runs ~500-5000 ops/sec, so keep these conservative. -const ( - PowDifficultyDisabled = 0 // no PoW required, nonce "0" always valid - PowDifficultyLow = 10 // ~1024 avg iterations ≈ 0.2-2s - PowDifficultyMedium = 12 // ~4096 avg iterations ≈ 1-8s - PowDifficultyHigh = 15 // ~32768 avg iterations ≈ 7-60s - PowDifficultyImpossible = 256 // full SHA-256 width: clients cannot solve, server always rejects - - defaultPowDifficulty = PowDifficultyMedium -) +// defaultCookieTTL is how long a successful challenge cookie is valid by +// default. Decoupled from the keyring rotation window: an operator can +// configure long cookies (e.g. 24h) without widening the ticket-forgery +// exposure window (keyring live window, default 15m). +const defaultCookieTTL = 12 * time.Hour const DefaultChallengeCSP = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; worker-src 'self' blob:;" -// FIXME -const masterSecret = "SUPER_SECRET_KEY" - //go:embed challenge.html.tmpl var htmlTemplate string //go:embed pow-worker.js var PowWorkerJS string -//go:embed js/obfuscate/index.wasm.gz -var obfuscatorWasmGz []byte - -var ( - obfuscatorWasm []byte - obfuscatorWasmOnce sync.Once -) - type ChallengeRuntime struct { - r wazero.Runtime + r wazero.Runtime + obfuscatorMod wazero.CompiledModule obfuscatedJSCache []obfuscatedScript cacheMutex sync.RWMutex powDifficulty int + + // keys derives per-epoch sign keys (HMAC for tickets / PoW MACs) and the + // long-lived master cookie key from the configured master secret. All + // HMAC and AEAD operations route through this so that ticket rotation + // is a server-side bookkeeping change with no protocol impact. In + // distributed setups, every WAF instance with the same master_secret + // and rotation_interval derives bit-identical keys. + keys *KeyRing + + // dynamicModuleCache memoizes the obfuscated per-epoch key module so we + // only pay the wazero / javascript-obfuscator cost once per epoch. The + // dynamic module is small (~30 lines), so each obfuscation call costs a + // few seconds on first call and is then served from memory until the + // epoch advances. Stale entries are pruned on every Get. + // + // dynamicModuleSF de-duplicates concurrent obfuscation requests for the + // same epoch: if N requests hit a freshly-rotated epoch simultaneously, + // only one runs the obfuscator and the others wait for its result. The + // cache mutex is only held for the fast read/write of the map, never + // across the multi-second obfuscation pass, so concurrent requests at a + // rotation boundary observe at most one obfuscation latency rather than + // N serialized ones. + dynamicModuleCache map[int64]string + dynamicModuleCacheMu sync.RWMutex + dynamicModuleSF singleflight.Group + + // cookieTTL controls how long a successful-challenge cookie remains + // valid. Enforced by an explicit not_after timestamp inside the + // sealed envelope (see crypto.go), NOT by keyring eviction, so it + // can be much larger than the ticket-signing live window. + cookieTTL time.Duration +} + +// Option configures a ChallengeRuntime at construction time. +type Option func(*runtimeOptions) + +type runtimeOptions struct { + // masterSecret, if non-nil, overrides the secret-resolution path. The + // caller is responsible for ensuring it is at least minSecretBytes long. + masterSecret []byte + + // rotationInterval is the wall-clock period after which the per-epoch + // derived keys advance. Zero falls back to keyringDefaultRotation. + rotationInterval time.Duration + + // maxLiveEpochs is how many past epochs (plus the current one) the + // keyring keeps acceptable. Zero falls back to keyringDefaultMaxLive. + maxLiveEpochs int + + // cookieTTL controls how long an issued challenge cookie remains + // valid. Zero falls back to defaultCookieTTL. + cookieTTL time.Duration +} + +// WithMasterSecret sets the long-lived shared secret. In distributed +// deployments this MUST be the same across all WAF instances. If not set, +// NewChallengeRuntime generates a random secret at startup (suitable only for +// single-instance deployments — restarts invalidate outstanding cookies). +func WithMasterSecret(secret []byte) Option { + return func(o *runtimeOptions) { + o.masterSecret = secret + } +} + +// WithRotationInterval sets the per-epoch key rotation period. All instances +// in a distributed setup MUST agree on this value to derive identical keys. +func WithRotationInterval(d time.Duration) Option { + return func(o *runtimeOptions) { + o.rotationInterval = d + } +} + +// WithMaxLiveEpochs sets how many past epochs (in addition to the current +// one) the keyring continues to accept. Sized so any submission within the +// freshness window has a non-evicted epoch. +func WithMaxLiveEpochs(n int) Option { + return func(o *runtimeOptions) { + o.maxLiveEpochs = n + } +} + +// WithCookieTTL sets how long an issued challenge cookie remains valid. +// Decoupled from the per-epoch keyring window so cookies can be long-lived +// (e.g. 24h) while ticket-signing keys still rotate on a tight schedule. +// Zero or negative values are ignored (defaultCookieTTL is used). +func WithCookieTTL(ttl time.Duration) Option { + return func(o *runtimeOptions) { + if ttl > 0 { + o.cookieTTL = ttl + } + } } // DifficultyFromLevel resolves a named level ("low", "medium", "high") to @@ -116,17 +183,45 @@ func (c *ChallengeRuntime) SetDifficulty(level string) error { return nil } -type obfuscatedScript struct { - Code string // the obfuscated JS code - uuid uuid.UUID // unique ID to track the script -} +func NewChallengeRuntime(ctx context.Context, opts ...Option) (*ChallengeRuntime, error) { + resolvedOpts := runtimeOptions{} + for _, opt := range opts { + opt(&resolvedOpts) + } + + secret := resolvedOpts.masterSecret + if secret == nil { + var err error + secret, err = generateRandomSecret() + if err != nil { + return nil, err + } + log.Warn("no master secret configured for the WAF challenge runtime; generated an ephemeral random secret. " + + "Distributed (multi-WAF) deployments MUST configure a shared master_secret in the appsec config; " + + "single-instance deployments will see outstanding challenge cookies invalidated on restart.") + } else if len(secret) < minSecretBytes { + return nil, fmt.Errorf("master secret is %d bytes; minimum is %d", len(secret), minSecretBytes) + } + + rotationInterval := resolvedOpts.rotationInterval + if rotationInterval == 0 { + rotationInterval = keyringDefaultRotation + } + + keys, err := NewKeyRing(secret, rotationInterval, resolvedOpts.maxLiveEpochs) + if err != nil { + return nil, fmt.Errorf("build challenge keyring: %w", err) + } + + cookieTTL := resolvedOpts.cookieTTL + if cookieTTL <= 0 { + cookieTTL = defaultCookieTTL + } -func NewChallengeRuntime(ctx context.Context) (*ChallengeRuntime, error) { r := wazero.NewRuntime(ctx) // No need to keep the closer around, we can just close the runtime itself when stopping - _, err := wasi_snapshot_preview1.Instantiate(ctx, r) - if err != nil { + if _, err := wasi_snapshot_preview1.Instantiate(ctx, r); err != nil { return nil, fmt.Errorf("failed to instantiate WASI: %w", err) } @@ -151,169 +246,51 @@ func NewChallengeRuntime(ctx context.Context) (*ChallengeRuntime, error) { return nil, obfuscatorWasmErr } - challengeRuntime := &ChallengeRuntime{ - r: r, - obfuscatedJSCache: make([]obfuscatedScript, 0, challengeJSCacheSize), - powDifficulty: defaultPowDifficulty, - } - - if err := challengeRuntime.generateAndCacheChallengeJS(ctx); err != nil { - return nil, fmt.Errorf("failed to generate initial challenge bundle: %w", err) - } - - go challengeRuntime.challengeGenerator(ctx) - - return challengeRuntime, nil -} - -func (c *ChallengeRuntime) challengeGenerator(ctx context.Context) { - // Startup warm-up: grow the cache in background until full. - if err := c.fillCacheToCapacity(ctx); err != nil { - log.Errorf("failed to prefill challenge JS cache: %v", err) - } - - ticker := time.NewTicker(challengeJSRefreshInterval) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - variants, err := c.generateChallengeVariants(ctx, challengeJSCacheSize) - if err != nil { - log.Errorf("failed to regenerate full challenge JS cache: %v", err) - continue - } - - c.replaceCachedChallengeJS(variants) - case <-ctx.Done(): - return - } - } -} - -func (c *ChallengeRuntime) buildChallengeBundle() string { - return strings.NewReplacer( - "__CROWDSEC_SUBMIT_PATH__", ChallengeSubmitPath, - "__CROWDSEC_POW_WORKER_PATH__", ChallengePowWorkerPath, - ).Replace(challengejs.FPScannerBundle) -} - -func (c *ChallengeRuntime) generateAndCacheChallengeJS(ctx context.Context) error { - variants, err := c.generateChallengeVariants(ctx, 1) + // Pre-compile the obfuscator WASM once. Without this, every ObfuscateJS + // call re-parses the WASM bytes (~4-5s of overhead per call). Compiling + // once and instantiating on each call drops per-call overhead to the + // instantiation cost alone. + compiledMod, err := r.CompileModule(ctx, obfuscatorWasm) if err != nil { - return err + return nil, fmt.Errorf("failed to compile obfuscator wasm module: %w", err) } - c.appendCachedChallengeJS(variants) - - return nil -} - -func (c *ChallengeRuntime) generateChallengeVariants(ctx context.Context, count int) ([]obfuscatedScript, error) { - if count <= 0 { - return []obfuscatedScript{}, nil - } - - variants := make([]obfuscatedScript, 0, count) - - bundle := c.buildChallengeBundle() - - for range count { - o := obfuscatedScript{} - o.uuid = uuid.New() - obfuscatedJS, err := c.ObfuscateJS(ctx, bundle) - if err != nil { - return nil, err + challengeRuntime := &ChallengeRuntime{ + r: r, + obfuscatorMod: compiledMod, + obfuscatedJSCache: make([]obfuscatedScript, 0, challengeJSCacheSize), + powDifficulty: defaultPowDifficulty, + keys: keys, + dynamicModuleCache: make(map[int64]string), + cookieTTL: cookieTTL, + } + + // Seed the cache from the baked-in pre-obfuscated bundle so the service is + // immediately ready to serve challenges. The background generator below + // continues to add fresh runtime-generated variants and rotates them on + // the normal refresh interval. + if err := challengeRuntime.seedCacheFromInitialBundle(); err != nil { + // If the initial bundle is missing or corrupt, fall back to the old + // behavior of generating one variant synchronously. This keeps the + // service correct even if `go generate` was not run. + log.Warnf("failed to load baked-in initial challenge bundle (%v); falling back to synchronous generation", err) + if err := challengeRuntime.generateAndCacheChallengeJS(ctx); err != nil { + return nil, fmt.Errorf("failed to generate initial challenge bundle: %w", err) } - o.Code = obfuscatedJS - variants = append(variants, o) - } - - return variants, nil -} - -func (c *ChallengeRuntime) fillCacheToCapacity(ctx context.Context) error { - c.cacheMutex.RLock() - missing := challengeJSCacheSize - len(c.obfuscatedJSCache) - c.cacheMutex.RUnlock() - - if missing <= 0 { - return nil - } - - variants, err := c.generateChallengeVariants(ctx, missing) - if err != nil { - return err - } - - c.appendCachedChallengeJS(variants) - return nil -} - -func (c *ChallengeRuntime) appendCachedChallengeJS(variants []obfuscatedScript) { - if len(variants) == 0 { - return } - c.cacheMutex.Lock() - c.obfuscatedJSCache = append(c.obfuscatedJSCache, variants...) - if len(c.obfuscatedJSCache) > challengeJSCacheSize { - c.obfuscatedJSCache = c.obfuscatedJSCache[len(c.obfuscatedJSCache)-challengeJSCacheSize:] + // Pre-warm the dynamic key module for the current epoch so the very + // first GetChallengePage call doesn't pay the ~5s obfuscation cost + // on the request-serving path. The dynamic module is small enough + // that this stays well under the startup budget. + if _, err := challengeRuntime.currentDynamicModule(ctx); err != nil { + return nil, fmt.Errorf("warm dynamic key module: %w", err) } - c.cacheMutex.Unlock() -} -func (c *ChallengeRuntime) replaceCachedChallengeJS(variants []obfuscatedScript) { - if len(variants) == 0 { - return - } - - c.cacheMutex.Lock() - c.obfuscatedJSCache = append([]obfuscatedScript(nil), variants...) - c.cacheMutex.Unlock() -} - -func (c *ChallengeRuntime) getCachedChallengeJS() obfuscatedScript { - c.cacheMutex.RLock() - defer c.cacheMutex.RUnlock() - - cacheSize := len(c.obfuscatedJSCache) - if cacheSize == 0 { - return obfuscatedScript{} - } - - idx := rand.IntN(cacheSize) - return c.obfuscatedJSCache[idx] -} - -func (c *ChallengeRuntime) ObfuscateJS(ctx context.Context, inputJS string) (string, error) { - stdin := bytes.NewReader([]byte(inputJS)) - var stdout bytes.Buffer - var stderr bytes.Buffer - - config := wazero.NewModuleConfig(). - WithStdin(stdin). - WithStdout(&stdout). - WithStderr(&stderr) - - mod, err := c.r.InstantiateWithConfig(ctx, obfuscatorWasm, config) - if err != nil { - if stderr.Len() > 0 { - return "", fmt.Errorf("wasm runtime error: %v | stderr: %s", err, stderr.String()) - } - return "", fmt.Errorf("wasm instantiation error: %v", err) - } - - mod.Close(ctx) - - return stdout.String(), nil -} - -func computeTicket(ts string) string { - h := hmac.New(sha256.New, []byte(masterSecret)) - h.Write([]byte(ts)) + go challengeRuntime.challengeGenerator(ctx) + go challengeRuntime.dynamicModulePreWarmer(ctx) - return fmt.Sprintf("%x", h.Sum(nil)) + return challengeRuntime, nil } // GetChallengePage renders the challenge HTML page with the given PoW difficulty. @@ -343,114 +320,40 @@ func (c *ChallengeRuntime) GetChallengePage(userAgent string, difficulty int) (s } } - // All per-request values: ticket, timestamp, PoW salt, PoW MAC. + // All per-request values: timestamp, PoW salt, PoW MAC. // Fully stateless — no server-side storage, works across HA instances. + // + // The client recomputes the ticket itself from the per-epoch key + // embedded in the dynamic key module (so the signing material never + // appears in plain HTML). The server-side ticket here is only used to + // bind the PoW salt to ts via computePowMAC. ts := fmt.Sprintf("%d", time.Now().UnixNano()) - ticket := computeTicket(ts) + ticket := c.computeTicket(ts) powSalt := generatePowPrefix() - powMAC := computePowMAC(powSalt, ticket, ts) + powMAC := c.computePowMAC(powSalt, ticket, ts) + + // Build and (cheaply) obfuscate the dynamic key module for the current + // epoch. The static bundle in obfuscatedJS.Code only carries the hook + // registration; the dynamic module is what carries the per-epoch K — so + // K never appears in plain HTML. + dynamicModule, err := c.currentDynamicModule(context.Background()) + if err != nil { + return "", fmt.Errorf("build dynamic key module: %w", err) + } var renderedPage strings.Builder templateObj.Execute(&renderedPage, map[string]interface{}{ "JSChallenge": obfuscatedJS.Code, + "DynamicModule": dynamicModule, "PowDifficulty": difficulty, "PowPrefix": powSalt, "PowMAC": powMAC, - "Ticket": ticket, "Timestamp": ts, }) return renderedPage.String(), nil } -func generatePowPrefix() string { - buf := make([]byte, 16) - if _, err := crand.Read(buf); err != nil { - panic(fmt.Sprintf("failed to generate PoW prefix: %v", err)) - } - - return hex.EncodeToString(buf) -} - -// computePowMAC produces an HMAC that authenticates a PoW salt as server-generated -// and bound to a specific ticket window. Stateless: any instance sharing the -// masterSecret can verify it. -func computePowMAC(salt, ticket, ts string) string { - h := hmac.New(sha256.New, []byte(masterSecret)) - h.Write([]byte(salt)) - h.Write([]byte(ticket)) - h.Write([]byte(ts)) - - return fmt.Sprintf("%x", h.Sum(nil)) -} - -func hasLeadingZeroBits(hash []byte, bits int) bool { - fullBytes := bits / 8 - remainBits := bits % 8 - - for i := range fullBytes { - if hash[i] != 0 { - return false - } - } - - if remainBits > 0 { - mask := byte(0xFF << (8 - remainBits)) - if hash[fullBytes]&mask != 0 { - return false - } - } - - return true -} - -func (c *ChallengeRuntime) getSessionKey(ticket string, nonce string) string { - hash := sha256.Sum256([]byte(ticket + nonce)) - return fmt.Sprintf("%x", hash) -} - -func (c *ChallengeRuntime) decryptFingerprint(sessionKey string, encrypted string) (string, error) { - encryptedBytes, err := base64.StdEncoding.DecodeString(encrypted) - if err != nil { - return "", fmt.Errorf("failed to decode encrypted fingerprint: %w", err) - } - - decryptedBytes := make([]byte, len(encryptedBytes)) - - for i := range encryptedBytes { - decryptedBytes[i] = encryptedBytes[i] ^ sessionKey[i%len(sessionKey)] - } - - return string(decryptedBytes), nil -} - -// matchesChallenge verifies that the ticket/timestamp/PoW salt are authentically -// server-generated and the timestamp is recent. Fully stateless — any instance -// sharing masterSecret can verify. -func matchesChallenge(clientTicket, clientTS, clientPowSalt, clientPowMAC string) bool { - // Verify the ticket is an authentic HMAC of the timestamp. - expectedTicket := computeTicket(clientTS) - if !hmac.Equal([]byte(clientTicket), []byte(expectedTicket)) { - return false - } - - // Verify the timestamp is recent (within 2 refresh intervals for safety). - tsVal, err := strconv.ParseInt(clientTS, 10, 64) - if err != nil { - return false - } - - age := time.Since(time.Unix(0, tsVal)) - if age < 0 || age > 2*challengeJSRefreshInterval { - return false - } - - // Verify the PoW salt MAC is authentic and bound to this ticket+timestamp. - expectedMAC := computePowMAC(clientPowSalt, clientTicket, clientTS) - - return hmac.Equal([]byte(clientPowMAC), []byte(expectedMAC)) -} - func (c *ChallengeRuntime) ValidateChallengeResponse(request *http.Request, body []byte) (*cookie.AppsecCookie, FingerprintData, error) { vars, err := url.ParseQuery(string(body)) if err != nil { @@ -470,7 +373,7 @@ func (c *ChallengeRuntime) ValidateChallengeResponse(request *http.Request, body } // Verify ticket/timestamp match and PoW salt is authentically server-generated (stateless). - if !matchesChallenge(clientTicket, clientTS, clientPowSalt, clientPowMAC) { + if !c.matchesChallenge(clientTicket, clientTS, clientPowSalt, clientPowMAC) { return nil, FingerprintData{}, fmt.Errorf("invalid ticket in challenge response") } @@ -507,6 +410,8 @@ func (c *ChallengeRuntime) ValidateChallengeResponse(request *http.Request, body return nil, FingerprintData{}, fmt.Errorf("failed to decrypt fingerprint: %w", err) } + log.Errorf("challenge response: %s", fingerprint) + var fpData FingerprintData if err := json.Unmarshal([]byte(fingerprint), &fpData); err != nil { @@ -519,12 +424,18 @@ func (c *ChallengeRuntime) ValidateChallengeResponse(request *http.Request, body PowDifficulty: int32(c.powDifficulty), } - cookieValue, err := sealCookie(envelope, masterSecret, []byte(request.UserAgent())) + // Seal under the long-lived master cookie key; the sealed envelope + // embeds an explicit not_after timestamp so the server-side validity + // window is exactly c.cookieTTL, independent of the keyring's per- + // epoch rotation cadence. The browser-side Max-Age below is set to + // the same TTL so the browser drops the cookie at the same moment. + notAfter := time.Now().Add(c.cookieTTL).Unix() + cookieValue, err := sealCookieV0(envelope, c.keys.MasterCookieKey(), notAfter, []byte(request.UserAgent())) if err != nil { return nil, FingerprintData{}, fmt.Errorf("failed to seal challenge cookie: %w", err) } - ck := cookie.NewAppsecCookie(ChallengeCookieName).HttpOnly().Path("/").SameSite(cookie.SameSiteLax).ExpiresIn(2 * time.Hour).Value(cookieValue) + ck := cookie.NewAppsecCookie(ChallengeCookieName).HttpOnly().Path("/").SameSite(cookie.SameSiteLax).ExpiresIn(c.cookieTTL).Value(cookieValue) if request.URL.Scheme == "https" { ck = ck.Secure() } @@ -545,7 +456,7 @@ func (c *ChallengeRuntime) ValidCookie(ck *http.Cookie, userAgent string) (*Cook return nil, fmt.Errorf("nil cookie") } - envelope, err := openCookie(ck.Value, masterSecret, []byte(userAgent)) + envelope, err := openCookie(ck.Value, c.keys.MasterCookieKey(), []byte(userAgent)) if err != nil { return nil, fmt.Errorf("invalid challenge cookie: %w", err) } diff --git a/pkg/appsec/challenge/challenge.html.tmpl b/pkg/appsec/challenge/challenge.html.tmpl index 0d774387e69..089b898793c 100644 --- a/pkg/appsec/challenge/challenge.html.tmpl +++ b/pkg/appsec/challenge/challenge.html.tmpl @@ -387,9 +387,15 @@ })(); - + +