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 @@
})();
-
+
+