diff --git a/.gitignore b/.gitignore index fb33167..c704684 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,18 @@ logs keys .env + +#ignore node_modules and build artifacts +internal/puzzle_ui/node_modules/ +internal/puzzle_ui/dist/client +internal/puzzle_ui/.cache/ + +#ignore dependency lock files if not needed +internal/puzzle_ui/package-lock.json +internal/puzzle_ui/yarn.lock + +.DS_Store +.vscode/ +internal/puzzle_ui/src/client/.DS_Store +internal/puzzle_ui/src/.DS_Store +internal/puzzle_ui/.DS_Store \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 9727047..f3452d0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -35,4 +35,4 @@ EXPOSE 8081 WORKDIR /opt/banjax -CMD ["./entrypoint.sh"] +CMD ["./entrypoint.sh"] \ No newline at end of file diff --git a/banjax-config.yaml b/banjax-config.yaml index 590b412..4d8ba27 100644 --- a/banjax-config.yaml +++ b/banjax-config.yaml @@ -8,6 +8,8 @@ global_decision_lists: - 70.80.90.100 challenge: - 8.8.8.8 + puzzle_challenge: + - 192.168.65.1 # These two should be the same, # if not, it will still work but API to query banned IP will be inconsistent expiring_decision_ttl_seconds: 300 @@ -135,3 +137,67 @@ sha_inv_path_exceptions: - /no_challenge # enable pprof for debugging profile: false +#puzzle captcha configs: +puzzle_error_log_file_path: "/var/log/banjax/puzzle_error.log" +puzzle_thumbnail_entropy_secret: "9a96ba30c1190b12360e1c59b0247e534145484bc5ebc635330677c76dc0212a" +puzzle_entropy_secret: "24c470da7acba2ad36bb6b98713148b6e8d0bbdac05d561d25fb1ae88cc8f2d6" +puzzle_click_chain_entropy_secret: "8a5ea4dd46011b8ab22f5fa3fee875bc6a2ad7f15593ab7a952090ea4a1db450" +puzzle_enable_gameplay_data_collection: false +puzzle_rate_limit_brute_force_solution_ttl_seconds: 60 +puzzle_difficulty_target_profile: medium_hard +puzzle_difficulty_profiles: + easy: + nPartitions: 9 #3x3 + nShuffles: [5, 8] + maxNumberOfMovesAllowed: 40 + removeTileIndex: 0 + timeToSolve_ms: 1_200_000 #20 mins + showCountdownTimer: false + + medium: + nPartitions: 16 #4x4 + nShuffles: [5, 8] + maxNumberOfMovesAllowed: 40 + removeTileIndex: 0 + timeToSolve_ms: 900_000 #15 mins + showCountdownTimer: false + + medium_hard: + nPartitions: 25 #5x5 + nShuffles: [3, 6] + maxNumberOfMovesAllowed: 60 + removeTileIndex: -1 #choose randomly from the board + timeToSolve_ms: 900_000 #15 mins + showCountdownTimer: false + + hard: + nPartitions: 16 #4x4 + nShuffles: [12, 22] + maxNumberOfMovesAllowed: 60 + removeTileIndex: -1 #choose randomly from the board + timeToSolve_ms: 600_000 + showCountdownTimer: false + + very_hard: + nPartitions: 25 #5x5 + nShuffles: [20, 40] + maxNumberOfMovesAllowed: 80 + removeTileIndex: -1 #choose randomly from the board + timeToSolve_ms: 420_000 + showCountdownTimer: false + + painful: + nPartitions: 49 #7x7 + nShuffles: [30, 60] + maxNumberOfMovesAllowed: 80 + removeTileIndex: -1 #choose randomly from the board + timeToSolve_ms: 420_000 + showCountdownTimer: false + + nightmare_fuel: + nPartitions: 100 #10x10 + nShuffles: [70, 80] + maxNumberOfMovesAllowed: 80 + removeTileIndex: -1 #choose randomly from the board + timeToSolve_ms: 360_000 + showCountdownTimer: false \ No newline at end of file diff --git a/banjax.go b/banjax.go index 46e448f..baa5e25 100644 --- a/banjax.go +++ b/banjax.go @@ -90,6 +90,11 @@ func main() { panic(err) } + puzzleImageController, err := internal.NewPuzzleImageController(config) + if err != nil { + panic(err) + } + dynamicDecisionLists := internal.NewDynamicDecisionLists() sighup_channel := make(chan os.Signal, 1) @@ -110,6 +115,7 @@ func main() { config := configHolder.Get() + puzzleImageController.UpdateFromConfig(config) staticDecisionLists.UpdateFromConfig(config) dynamicDecisionLists.Clear() passwordProtectedPaths.UpdateFromConfig(config) @@ -156,6 +162,7 @@ func main() { regexStates, failedChallengeStates, banner, + puzzleImageController, ) go internal.RunLogTailer( diff --git a/banjax_base_test.go b/banjax_base_test.go index 14d2440..cb948fa 100644 --- a/banjax_base_test.go +++ b/banjax_base_test.go @@ -34,7 +34,7 @@ func setUp() { setCommandLineFlags() log.SetFlags(log.LstdFlags | log.Lshortfile) // show line num in logs go main() - time.Sleep(1 * time.Second) + time.Sleep(10 * time.Second) //we need MORE time because of the image controller, it needs the time to partition the image BEFORE starting up } func tearDown() { @@ -225,6 +225,6 @@ func reloadConfig(path string, randomReqCount int, t *testing.T) { copyConfigFile(path) syscall.Kill(syscall.Getpid(), syscall.SIGHUP) - time.Sleep(1 * time.Second) + time.Sleep(10 * time.Second) //we need MORE time because of the image controller, it needs the time to partition the image BEFORE starting up <-done } diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 5e8e105..5aa6a51 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -30,4 +30,4 @@ services: context: ./supporting-containers/test-origin dockerfile: Dockerfile ports: - - "8080:8080" + - "8080:8080" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 9eb5652..1206275 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,4 +33,4 @@ services: context: ./supporting-containers/test-origin dockerfile: Dockerfile ports: - - "8080:8080" + - "8080:8080" \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh index 3bcc219..345378b 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -4,4 +4,4 @@ if [ -n "$ENABLE_AIR" ]; then exec air -c .air.toml else exec ./banjax -fi +fi \ No newline at end of file diff --git a/fixtures/banjax-config-test-persite-fail.yaml b/fixtures/banjax-config-test-persite-fail.yaml index f7605a1..bd3fe44 100644 --- a/fixtures/banjax-config-test-persite-fail.yaml +++ b/fixtures/banjax-config-test-persite-fail.yaml @@ -7,6 +7,8 @@ global_decision_lists: - 70.80.90.100 challenge: - 20.20.20.20 # test value change + # puzzle_challenge: + # - 21.21.21.21 iptables_ban_seconds: 10 iptables_unbanner_seconds: 5 kafka_brokers: @@ -63,3 +65,35 @@ sha_inv_cookie_ttl_seconds: 14400 # also modify internal/sha-inverse-challenge. hmac_secret: secret gin_log_file: /var/log/banjax/gin.log metrics_log_file: /var/log/banjax/metrics.log +#puzzle captcha configs: +puzzle_error_log_file_path: "/var/log/banjax/puzzle_error.log" +puzzle_thumbnail_entropy_secret: "thumbnailSecret" +puzzle_entropy_secret: "puzzleSecret" +puzzle_click_chain_entropy_secret: "clickChainSecret" +puzzle_enable_gameplay_data_collection: false +puzzle_rate_limit_brute_force_solution_ttl_seconds: 60 +puzzle_difficulty_target_profile: medium_hard +puzzle_difficulty_profiles: + easy: + nPartitions: 9 #3x3 + nShuffles: [5, 8] + maxNumberOfMovesAllowed: 40 + removeTileIndex: 0 + timeToSolve_ms: 1_200_000 #20 mins + showCountdownTimer: false + + medium: + nPartitions: 16 #4x4 + nShuffles: [5, 8] + maxNumberOfMovesAllowed: 40 + removeTileIndex: 0 + timeToSolve_ms: 900_000 #15 mins + showCountdownTimer: false + + medium_hard: + nPartitions: 25 #5x5 + nShuffles: [3, 6] + maxNumberOfMovesAllowed: 60 + removeTileIndex: -1 #choose randomly from the board + timeToSolve_ms: 900_000 #15 mins + showCountdownTimer: false \ No newline at end of file diff --git a/fixtures/banjax-config-test-puzzle-captcha.yaml b/fixtures/banjax-config-test-puzzle-captcha.yaml new file mode 100644 index 0000000..6f771df --- /dev/null +++ b/fixtures/banjax-config-test-puzzle-captcha.yaml @@ -0,0 +1,107 @@ +config_version: 2022-02-03_00:00:00 +global_decision_lists: + allow: [] # test remove + iptables_block: + - 30.40.50.60 + nginx_block: + - 70.80.90.100 + challenge: + - 20.20.20.20 # test value change + # puzzle_challenge: + # - 21.21.21.21 +iptables_ban_seconds: 10 +iptables_unbanner_seconds: 5 +kafka_brokers: + - "localhost:9092" +kafka_security_protocol: 'ssl' +kafka_ssl_ca: "/etc/banjax/caroot.pem" +#kafka_ssl_cert: "/etc/banjax/certificate.pem" +kafka_ssl_key: "/etc/banjax/key.pem" +kafka_ssl_key_password: password +kafka_report_topic: 'banjax_report_topic' +kafka_command_topic: 'banjax_command_topic' +password_protected_paths: + "localhost:8081": + - wp-admin + - wp-admin2 + - app/admin + "localhost": + - wp-admin +password_protected_path_exceptions: + "localhost:8081": + - wp-admin/admin-ajax.php + - app/admin/no-ban.php +# python3 -c "import hashlib; print(hashlib.sha256('password'.encode()).hexdigest())" +password_hashes: + "localhost:8081": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" + "localhost": "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" +per_site_decision_lists: + example.com: + allow: + - 90.90.90.90 + challenge: + - 91.91.91.91 + "localhost:8081": + allow: + - 91.91.91.91 # test change + challenge: [] # test remove + nginx_block: + - 92.92.92.92 +per_site_regexes_with_rates: {} +regexes_with_rates: + - decision: allow + hits_per_interval: 0 + interval: 1 + regex: .*allowme.* + rule: "unblock backdoor" + - decision: nginx_block + hits_per_interval: 0 + interval: 1 + regex: .*blockme.* + rule: "instant block" +sitewide_sha_inv_list: + example.com: block + foobar.com: no_block + "localhost:8081": block +server_log_file: /var/log/banjax/banjax-format.log +banning_log_file: /etc/banjax/ban_ip_list.log +expiring_decision_ttl_seconds: 10 +too_many_failed_challenges_interval_seconds: 10 +too_many_failed_challenges_threshold: 10000 # we don't want to test this here so set it very big +password_cookie_ttl_seconds: 14400 # also modify internal/password-protected-path.html:168 +sha_inv_cookie_ttl_seconds: 14400 # also modify internal/sha-inverse-challenge.html:94 +hmac_secret: secret +gin_log_file: /var/log/banjax/gin.log +metrics_log_file: /var/log/banjax/metrics.log +#puzzle captcha configs: +puzzle_error_log_file_path: "/var/log/banjax/puzzle_error.log" +puzzle_thumbnail_entropy_secret: "thumbnailSecret" +puzzle_entropy_secret: "puzzleSecret" +puzzle_click_chain_entropy_secret: "clickChainSecret" +puzzle_enable_gameplay_data_collection: false +puzzle_rate_limit_brute_force_solution_ttl_seconds: 60 +puzzle_difficulty_target_profile: medium_hard +puzzle_difficulty_profiles: + easy: + nPartitions: 9 #3x3 + nShuffles: [5, 8] + maxNumberOfMovesAllowed: 40 + removeTileIndex: 0 + timeToSolve_ms: 1_200_000 #20 mins + showCountdownTimer: false + + medium: + nPartitions: 16 #4x4 + nShuffles: [5, 8] + maxNumberOfMovesAllowed: 40 + removeTileIndex: 0 + timeToSolve_ms: 900_000 #15 mins + showCountdownTimer: false + + medium_hard: + nPartitions: 25 #5x5 + nShuffles: [3, 6] + maxNumberOfMovesAllowed: 60 + removeTileIndex: -1 #choose randomly from the board + timeToSolve_ms: 900_000 #15 mins + showCountdownTimer: false \ No newline at end of file diff --git a/fixtures/banjax-config-test-regex-banner.yaml b/fixtures/banjax-config-test-regex-banner.yaml index a0523f7..a555d57 100644 --- a/fixtures/banjax-config-test-regex-banner.yaml +++ b/fixtures/banjax-config-test-regex-banner.yaml @@ -9,6 +9,8 @@ global_decision_lists: - 70.80.90.100 challenge: - 8.8.8.8 + # puzzle_challenge: + # - 9.9.9.9 iptables_ban_seconds: 10 iptables_unbanner_seconds: 5 kafka_brokers: @@ -103,3 +105,35 @@ sha_inv_cookie_ttl_seconds: 14400 # also modify internal/sha-inverse-challenge. hmac_secret: secret gin_log_file: /var/log/banjax/gin.log metrics_log_file: /var/log/banjax/metrics.log +#puzzle captcha configs: +puzzle_error_log_file_path: "/var/log/banjax/puzzle_error.log" +puzzle_thumbnail_entropy_secret: "thumbnailSecret" +puzzle_entropy_secret: "puzzleSecret" +puzzle_click_chain_entropy_secret: "clickChainSecret" +puzzle_enable_gameplay_data_collection: false +puzzle_rate_limit_brute_force_solution_ttl_seconds: 60 +puzzle_difficulty_target_profile: medium_hard +puzzle_difficulty_profiles: + easy: + nPartitions: 9 #3x3 + nShuffles: [5, 8] + maxNumberOfMovesAllowed: 40 + removeTileIndex: 0 + timeToSolve_ms: 1_200_000 #20 mins + showCountdownTimer: false + + medium: + nPartitions: 16 #4x4 + nShuffles: [5, 8] + maxNumberOfMovesAllowed: 40 + removeTileIndex: 0 + timeToSolve_ms: 900_000 #15 mins + showCountdownTimer: false + + medium_hard: + nPartitions: 25 #5x5 + nShuffles: [3, 6] + maxNumberOfMovesAllowed: 60 + removeTileIndex: -1 #choose randomly from the board + timeToSolve_ms: 900_000 #15 mins + showCountdownTimer: false \ No newline at end of file diff --git a/fixtures/banjax-config-test-reload-cidr.yaml b/fixtures/banjax-config-test-reload-cidr.yaml index f985a0b..8537b14 100644 --- a/fixtures/banjax-config-test-reload-cidr.yaml +++ b/fixtures/banjax-config-test-reload-cidr.yaml @@ -10,6 +10,9 @@ global_decision_lists: challenge: - 8.8.8.8 - 60.60.60.60 + # puzzle_challenge: + # - 9.9.9.9 + # - 61.61.61.61 iptables_ban_seconds: 10 iptables_unbanner_seconds: 5 kafka_brokers: @@ -91,3 +94,35 @@ sha_inv_cookie_ttl_seconds: 14400 # also modify internal/sha-inverse-challenge. hmac_secret: secret gin_log_file: /var/log/banjax/gin.log metrics_log_file: /var/log/banjax/metrics.log +#puzzle captcha configs: +puzzle_error_log_file_path: "/var/log/banjax/puzzle_error.log" +puzzle_thumbnail_entropy_secret: "thumbnailSecret" +puzzle_entropy_secret: "puzzleSecret" +puzzle_click_chain_entropy_secret: "clickChainSecret" +puzzle_enable_gameplay_data_collection: false +puzzle_rate_limit_brute_force_solution_ttl_seconds: 60 +puzzle_difficulty_target_profile: medium_hard +puzzle_difficulty_profiles: + easy: + nPartitions: 9 #3x3 + nShuffles: [5, 8] + maxNumberOfMovesAllowed: 40 + removeTileIndex: 0 + timeToSolve_ms: 1_200_000 #20 mins + showCountdownTimer: false + + medium: + nPartitions: 16 #4x4 + nShuffles: [5, 8] + maxNumberOfMovesAllowed: 40 + removeTileIndex: 0 + timeToSolve_ms: 900_000 #15 mins + showCountdownTimer: false + + medium_hard: + nPartitions: 25 #5x5 + nShuffles: [3, 6] + maxNumberOfMovesAllowed: 60 + removeTileIndex: -1 #choose randomly from the board + timeToSolve_ms: 900_000 #15 mins + showCountdownTimer: false \ No newline at end of file diff --git a/fixtures/banjax-config-test-reload.yaml b/fixtures/banjax-config-test-reload.yaml index 26b4001..8566d69 100644 --- a/fixtures/banjax-config-test-reload.yaml +++ b/fixtures/banjax-config-test-reload.yaml @@ -7,6 +7,8 @@ global_decision_lists: - 70.80.90.100 challenge: - 20.20.20.20 # test value change + # puzzle_challenge: + # - 21.21.21.21 iptables_ban_seconds: 10 iptables_unbanner_seconds: 5 kafka_brokers: @@ -70,3 +72,35 @@ sha_inv_cookie_ttl_seconds: 14400 # also modify internal/sha-inverse-challenge. hmac_secret: secret gin_log_file: /var/log/banjax/gin.log metrics_log_file: /var/log/banjax/metrics.log +#puzzle captcha configs: +puzzle_error_log_file_path: "/var/log/banjax/puzzle_error.log" +puzzle_thumbnail_entropy_secret: "thumbnailSecret" +puzzle_entropy_secret: "puzzleSecret" +puzzle_click_chain_entropy_secret: "clickChainSecret" +puzzle_enable_gameplay_data_collection: false +puzzle_rate_limit_brute_force_solution_ttl_seconds: 60 +puzzle_difficulty_target_profile: medium_hard +puzzle_difficulty_profiles: + easy: + nPartitions: 9 #3x3 + nShuffles: [5, 8] + maxNumberOfMovesAllowed: 40 + removeTileIndex: 0 + timeToSolve_ms: 1_200_000 #20 mins + showCountdownTimer: false + + medium: + nPartitions: 16 #4x4 + nShuffles: [5, 8] + maxNumberOfMovesAllowed: 40 + removeTileIndex: 0 + timeToSolve_ms: 900_000 #15 mins + showCountdownTimer: false + + medium_hard: + nPartitions: 25 #5x5 + nShuffles: [3, 6] + maxNumberOfMovesAllowed: 60 + removeTileIndex: -1 #choose randomly from the board + timeToSolve_ms: 900_000 #15 mins + showCountdownTimer: false \ No newline at end of file diff --git a/fixtures/banjax-config-test-sha-inv.yaml b/fixtures/banjax-config-test-sha-inv.yaml index 4bab5a1..6f771df 100644 --- a/fixtures/banjax-config-test-sha-inv.yaml +++ b/fixtures/banjax-config-test-sha-inv.yaml @@ -7,6 +7,8 @@ global_decision_lists: - 70.80.90.100 challenge: - 20.20.20.20 # test value change + # puzzle_challenge: + # - 21.21.21.21 iptables_ban_seconds: 10 iptables_unbanner_seconds: 5 kafka_brokers: @@ -71,3 +73,35 @@ sha_inv_cookie_ttl_seconds: 14400 # also modify internal/sha-inverse-challenge. hmac_secret: secret gin_log_file: /var/log/banjax/gin.log metrics_log_file: /var/log/banjax/metrics.log +#puzzle captcha configs: +puzzle_error_log_file_path: "/var/log/banjax/puzzle_error.log" +puzzle_thumbnail_entropy_secret: "thumbnailSecret" +puzzle_entropy_secret: "puzzleSecret" +puzzle_click_chain_entropy_secret: "clickChainSecret" +puzzle_enable_gameplay_data_collection: false +puzzle_rate_limit_brute_force_solution_ttl_seconds: 60 +puzzle_difficulty_target_profile: medium_hard +puzzle_difficulty_profiles: + easy: + nPartitions: 9 #3x3 + nShuffles: [5, 8] + maxNumberOfMovesAllowed: 40 + removeTileIndex: 0 + timeToSolve_ms: 1_200_000 #20 mins + showCountdownTimer: false + + medium: + nPartitions: 16 #4x4 + nShuffles: [5, 8] + maxNumberOfMovesAllowed: 40 + removeTileIndex: 0 + timeToSolve_ms: 900_000 #15 mins + showCountdownTimer: false + + medium_hard: + nPartitions: 25 #5x5 + nShuffles: [3, 6] + maxNumberOfMovesAllowed: 60 + removeTileIndex: -1 #choose randomly from the board + timeToSolve_ms: 900_000 #15 mins + showCountdownTimer: false \ No newline at end of file diff --git a/fixtures/banjax-config-test.yaml b/fixtures/banjax-config-test.yaml index d311333..bb89e7f 100644 --- a/fixtures/banjax-config-test.yaml +++ b/fixtures/banjax-config-test.yaml @@ -8,6 +8,9 @@ global_decision_lists: - 8.8.8.8 - 60.60.60.60 - 192.168.1.0/24 + # puzzle_challenge: + # - 9.9.9.9 + # - 61.61.61.61 iptables_ban_seconds: 10 iptables_unbanner_seconds: 5 kafka_brokers: @@ -99,3 +102,35 @@ hmac_secret: secret gin_log_file: /var/log/banjax/gin.log metrics_log_file: /var/log/banjax/metrics.log standalone_testing: true +#puzzle captcha configs: +puzzle_error_log_file_path: "/var/log/banjax/puzzle_error.log" +puzzle_thumbnail_entropy_secret: "thumbnailSecret" +puzzle_entropy_secret: "puzzleSecret" +puzzle_click_chain_entropy_secret: "clickChainSecret" +puzzle_enable_gameplay_data_collection: false +puzzle_rate_limit_brute_force_solution_ttl_seconds: 60 +puzzle_difficulty_target_profile: medium_hard +puzzle_difficulty_profiles: + easy: + nPartitions: 9 #3x3 + nShuffles: [5, 8] + maxNumberOfMovesAllowed: 40 + removeTileIndex: 0 + timeToSolve_ms: 1_200_000 #20 mins + showCountdownTimer: false + + medium: + nPartitions: 16 #4x4 + nShuffles: [5, 8] + maxNumberOfMovesAllowed: 40 + removeTileIndex: 0 + timeToSolve_ms: 900_000 #15 mins + showCountdownTimer: false + + medium_hard: + nPartitions: 25 #5x5 + nShuffles: [3, 6] + maxNumberOfMovesAllowed: 60 + removeTileIndex: -1 #choose randomly from the board + timeToSolve_ms: 900_000 #15 mins + showCountdownTimer: false \ No newline at end of file diff --git a/go.mod b/go.mod index d8fcb48..93b157a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/deflect-ca/banjax -go 1.22 +go 1.23.0 + +toolchain go1.24.0 require ( github.com/coreos/go-iptables v0.7.0 @@ -16,7 +18,7 @@ require ( github.com/segmentio/kafka-go v0.4.47 golang.org/x/crypto v0.31.0 // indirect golang.org/x/sys v0.28.0 // indirect - golang.org/x/text v0.21.0 // indirect + golang.org/x/text v0.23.0 // indirect google.golang.org/protobuf v1.36.1 // indirect gopkg.in/fsnotify.v1 v1.4.7 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect @@ -25,6 +27,7 @@ require ( require ( github.com/brianvoe/gofakeit/v6 v6.16.0 + github.com/gin-contrib/pprof v1.5.2 github.com/gonetx/ipset v0.1.0 github.com/jeremy5189/ipfilter-no-iploc/v2 v2.0.3 github.com/stretchr/testify v1.9.0 @@ -37,7 +40,6 @@ require ( github.com/cloudwego/iasm v0.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/gabriel-vasile/mimetype v1.4.7 // indirect - github.com/gin-contrib/pprof v1.5.2 // indirect github.com/gin-contrib/sse v0.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect @@ -52,6 +54,7 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect golang.org/x/arch v0.12.0 // indirect + golang.org/x/image v0.25.0 // indirect golang.org/x/net v0.33.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 3299474..4867802 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,8 @@ github.com/brianvoe/gofakeit/v6 v6.16.0 h1:EelCqtfArd8ppJ0z+TpOxXH8sVWNPBadPNdCDSMMw7k= github.com/brianvoe/gofakeit/v6 v6.16.0/go.mod h1:Ow6qC71xtwm79anlwKRlWZW6zVq9D2XHE4QSSMP/rU8= -github.com/bytedance/sonic v1.12.1 h1:jWl5Qz1fy7X1ioY74WqO0KjAMtAGQs4sYnjiEBiyX24= -github.com/bytedance/sonic v1.12.1/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= github.com/bytedance/sonic v1.12.6 h1:/isNmCUF2x3Sh8RAp/4mh4ZGkcFAX/hLrzrK3AvpRzk= github.com/bytedance/sonic v1.12.6/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= -github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM= -github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E= github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= @@ -20,8 +16,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= -github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4= -github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4= github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA= github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU= github.com/gin-contrib/pprof v1.5.2 h1:Kcq5W2bA2PBcVtF0MqkQjpvCpwJr+pd7zxcQh2csg7E= @@ -36,12 +30,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao= -github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= -github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= -github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gonetx/ipset v0.1.0 h1:LFkRdTbedg2UYXFN/2mOtgbvdWyo+OERrwVbtrPVuYY= @@ -61,8 +51,6 @@ github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHU github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM= -github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY= github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8= github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= @@ -75,8 +63,6 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= -github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/pierrec/lz4/v4 v4.1.15/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= @@ -89,13 +75,11 @@ github.com/segmentio/kafka-go v0.4.47/go.mod h1:HjF6XbOKh0Pjlkr5GVZxt6CsjjwnmhVO github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce h1:fb190+cK2Xz/dvi9Hv8eCYJYvIGUTN2/KLq1pT6CjEc= @@ -111,17 +95,15 @@ github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3k github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -golang.org/x/arch v0.9.0 h1:ub9TgUInamJ8mrZIGlBG6/4TqWeMszd4N8lNorbrr6k= -golang.org/x/arch v0.9.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg= golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= -golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ= +golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -130,8 +112,6 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= -golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -147,8 +127,6 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= -golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -163,10 +141,10 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= -golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= @@ -174,8 +152,6 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= -google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/internal/config.go b/internal/config.go index fb116fb..b7abefb 100644 --- a/internal/config.go +++ b/internal/config.go @@ -72,6 +72,27 @@ type Config struct { SessionCookieNotVerify bool `yaml:"session_cookie_not_verify"` SitesToDisableBaskerville map[string]bool `yaml:"sites_to_disable_baskerville"` SitesToShaInvPathExceptions map[string][]string `yaml:"sha_inv_path_exceptions"` + + //puzzle captcha requirements + PuzzleErrorLogFilePath string `yaml:"puzzle_error_log_file_path"` + PuzzleThumbnailEntropySecret string `yaml:"puzzle_thumbnail_entropy_secret"` + PuzzleEntropySecret string `yaml:"puzzle_entropy_secret"` + PuzzleClickChainEntropySecret string `yaml:"puzzle_click_chain_entropy_secret"` + PuzzleEnableGameplayDataCollection bool `yaml:"puzzle_enable_gameplay_data_collection"` + PuzzleRateLimitBruteForceSolutionTTLSeconds int `yaml:"puzzle_rate_limit_brute_force_solution_ttl_seconds"` + PuzzleChallengeHTML []byte //see embed in config holder + PuzzleDifficultyTarget string `yaml:"puzzle_difficulty_target_profile"` + PuzzleDifficultyProfiles map[string]PuzzleDifficultyProfile `yaml:"puzzle_difficulty_profiles"` +} + +/*individual difficulty profiles as desired in the banjax-puzzle-difficulty-config.yaml file*/ +type PuzzleDifficultyProfile struct { + NPartitions int `yaml:"nPartitions"` + NShuffles [2]int `yaml:"nShuffles"` + MaxNumberOfMovesAllowed int `yaml:"maxNumberOfMovesAllowed"` + RemoveTileIndex int `yaml:"removeTileIndex"` + TimeToSolveMs int `yaml:"timeToSolve_ms"` + ShowCountdownTimer bool `yaml:"showCountdownTimer"` } type RegexWithRate struct { diff --git a/internal/config_holder.go b/internal/config_holder.go index 04334a1..cafe2bb 100644 --- a/internal/config_holder.go +++ b/internal/config_holder.go @@ -20,6 +20,9 @@ import ( //go:embed sha-inverse-challenge.html var shaInvChallengeEmbed []byte +//go:embed puzzle_ui/dist/index.html +var puzzleChallengeIndexEmbed []byte + //go:embed password-protected-path.html var passProtPathEmbed []byte @@ -157,5 +160,13 @@ func load(path string, restartTime int, standaloneTesting bool, debug bool) (*Co } log.Println("INIT: Kafka brokers: ", config.KafkaBrokers) + log.Printf("INIT: Reading Puzzle challenge HTML from embed") + config.PuzzleChallengeHTML = puzzleChallengeIndexEmbed + + _, exists := config.PuzzleDifficultyProfiles[config.PuzzleDifficultyTarget] + if !exists { + return nil, fmt.Errorf("target difficulty profile '%s' does not exist", config.PuzzleDifficultyTarget) + } + return config, nil } diff --git a/internal/decision.go b/internal/decision.go index 766f958..689a2fd 100644 --- a/internal/decision.go +++ b/internal/decision.go @@ -22,7 +22,10 @@ type Decision int const ( _ Decision = iota Allow - Challenge + + Challenge //int 2 + PuzzleChallenge //int 3 + NginxBlock IptablesBlock ) @@ -37,6 +40,8 @@ func ParseDecision(s string) (Decision, error) { return NginxBlock, nil case "iptables_block": return IptablesBlock, nil + case "puzzle_challenge": + return PuzzleChallenge, nil default: return 0, fmt.Errorf("invalid decision: %v", s) } @@ -52,6 +57,8 @@ func (d Decision) String() string { return "NginxBlock" case IptablesBlock: return "IptablesBlock" + case PuzzleChallenge: + return "puzzle_challenge" default: return "" } @@ -124,7 +131,7 @@ func (l *StaticDecisionLists) CheckPerSite(config *Config, site string, clientIp // PerSiteDecisionListsIPFilter has different struct as PerSiteDecisionLists // decision must iterate in order, once found in one of the list, break the loop - for _, iterateDecision := range []Decision{Allow, Challenge, NginxBlock, IptablesBlock} { + for _, iterateDecision := range []Decision{Allow, Challenge, NginxBlock, IptablesBlock, PuzzleChallenge} { if instanceIPFilter, ok := c.perSiteDecisionListsIPFilter[site][iterateDecision]; ok && instanceIPFilter != nil { if instanceIPFilter.Allowed(string(clientIp)) { if config.Debug { @@ -146,7 +153,7 @@ func (l *StaticDecisionLists) CheckGlobal(config *Config, clientIp string) (Deci if ok { return decision, true } else { - for _, iterateDecision := range []Decision{Allow, Challenge, NginxBlock, IptablesBlock} { + for _, iterateDecision := range []Decision{Allow, Challenge, NginxBlock, IptablesBlock, PuzzleChallenge} { // check if Ipfilter ref associated to this iterateDecision exists filter, ok := c.globalDecisionListsIPFilter[iterateDecision] if ok && filter.Allowed(clientIp) { diff --git a/internal/http_server.go b/internal/http_server.go index d1037f9..8b23135 100644 --- a/internal/http_server.go +++ b/internal/http_server.go @@ -9,7 +9,9 @@ package internal import ( "bytes" "context" + "encoding/base64" "encoding/json" + "errors" "fmt" "io" "log" @@ -24,8 +26,9 @@ import ( ) const ( - PasswordCookieName = "deflect_password3" - ChallengeCookieName = "deflect_challenge3" + PasswordCookieName = "deflect_password3" + ChallengeCookieName = "deflect_challenge3" + PuzzleChallengeCookieName = "deflect_challenge4" ) func RunHttpServer( @@ -37,6 +40,7 @@ func RunHttpServer( regexStates *RegexRateLimitStates, failedChallengeStates *FailedChallengeRateLimitStates, banner BannerInterface, + puzzleImageController *PuzzleImageController, ) { addr := "127.0.0.1:8081" // XXX config @@ -175,9 +179,52 @@ func RunHttpServer( passwordProtectedPaths, failedChallengeStates, banner, + puzzleImageController, ), ) + //handles refresh state & receiving errors + r.GET("/__banjax/:endpoint/:errorType", func(c *gin.Context) { + endpoint := c.Param("endpoint") + switch endpoint { + case "refresh": + handleRefreshPuzzleCAPTCHAState( + config, + c, + banner, + failedChallengeStates, + Block, //fail action is a constant + staticDecisionLists, + puzzleImageController, + ) + return + case "error": + + // puzzleErrorLogger, err := GetPuzzleLogger(ctx) + // if err != nil { + // log.Printf("Missing puzzle error logger due to: %v. Skipping logging errors...", err) + // } + puzzleErrorLogger, err := NewPuzzleErrorLogger(config.PuzzleErrorLogFilePath) + if err != nil { + log.Fatal(err) + } + defer puzzleErrorLogger.Close() + + handleLoggingPuzzleCAPTCHAErrorReport( + config, + c, + banner, + failedChallengeStates, + Block, //fail action is a constant + staticDecisionLists, + puzzleErrorLogger, + ) + return + default: + c.JSON(http.StatusNotFound, gin.H{}) + } + }) + r.GET("/info", func(c *gin.Context) { c.JSON(200, gin.H{ "config_version": configHolder.Get().ConfigVersion, @@ -356,6 +403,16 @@ func accessDenied(c *gin.Context, config *Config, decisionListResultString strin c.String(403, "access denied\n") } +func accessThrottled(c *gin.Context, config *Config, decisionListResultString string) { + msg := fmt.Sprintf("Too many failed attempts. Try again in %d seconds.", config.PuzzleRateLimitBruteForceSolutionTTLSeconds) + c.Header("X-Banjax-Decision", decisionListResultString) + c.Header("Cache-Control", "no-cache,no-store") // XXX think about caching + c.Header("X-Accel-Redirect", "@access_throttled") // nginx named location that gives a ban page + c.Header("X-Throttle-Message", msg) //msg to display + sessionCookieEndPoint(c, config) + c.String(429, msg) +} + func challenge( c *gin.Context, config *Config, @@ -370,6 +427,7 @@ func challenge( // Provide the domain so that the cookie is set for subdomains, EX: .example.com domainScope = c.Request.Header.Get("X-Requested-Host") } + // log.Printf("CALLED challenge() attaching new challenge cookie!: %s %s", cookieName, newCookie) c.SetCookie(cookieName, newCookie, cookieTtlSeconds, "/", domainScope, false, false) c.Header("Cache-Control", "no-cache,no-store") } @@ -484,8 +542,10 @@ func tooManyFailedChallenges( log.Printf("!! IP %s has failed too many challenges on host %s but in allowlisted, no iptable ban", ip, host) decisionType = NginxBlock } + // log.Println("IP has failed too many challenges; blocking them") banner.BanOrChallengeIp(config, ip, decisionType, host) + banner.LogFailedChallengeBan( config, ip, @@ -547,13 +607,16 @@ func sendOrValidateShaChallenge( failAction FailAction, decisionLists *StaticDecisionLists, ) (sendOrValidateShaChallengeResult SendOrValidateShaChallengeResult) { + clientIp := c.Request.Header.Get("X-Client-IP") requestedHost := c.Request.Header.Get("X-Requested-Host") requestedPath := c.Request.Header.Get("X-Requested-Path") clientUserAgent := c.Request.Header.Get("X-Client-User-Agent") challengeCookie, err := c.Cookie(ChallengeCookieName) requestedMethod := c.Request.Method + if err == nil { + err := ValidateShaInvCookie(config.HmacSecret, challengeCookie, time.Now(), getUserAgentOrIp(c, config), config.ShaInvExpectedZeroBits) if err != nil { // log.Println("Sha-inverse challenge failed") @@ -569,6 +632,7 @@ func sendOrValidateShaChallenge( } else { sendOrValidateShaChallengeResult.ShaChallengeResult = ShaChallengeFailedNoCookie } + ReportPassedFailedBannedMessage(config, "ip_failed_challenge", clientIp, requestedHost) if failAction == Block { tooManyFailedChallengesResult := tooManyFailedChallenges( @@ -590,10 +654,444 @@ func sendOrValidateShaChallenge( return sendOrValidateShaChallengeResult } } + shaInvChallenge(c, config) return sendOrValidateShaChallengeResult } +type PuzzleCAPTCHAResult uint + +const ( + _ PuzzleCAPTCHAResult = iota + PuzzleCAPTCHAPass + PuzzleCAPTCHAFailNoCookie + PuzzleCAPTCHAFailBadCookie + PuzzleCAPTCHAFailPuzzleIntegrity //detected tampering + PuzzleCAPTCHAFailPuzzleGeneration //failed to generate new puzzle + PuzzleCAPTCHAFailPuzzleValidation //solution to puzzle is not correct + +) + +var PuzzleCAPTCHAResultToString = map[PuzzleCAPTCHAResult]string{ + PuzzleCAPTCHAPass: "PuzzleCAPTCHAPass", + PuzzleCAPTCHAFailNoCookie: "PuzzleCAPTCHAFailNoCookie", + PuzzleCAPTCHAFailBadCookie: "PuzzleCAPTCHAFailBadCookie", + PuzzleCAPTCHAFailPuzzleIntegrity: "PuzzleCAPTCHAFailPuzzleIntegrity", + PuzzleCAPTCHAFailPuzzleGeneration: "PuzzleCAPTCHAFailPuzzleGeneration", + PuzzleCAPTCHAFailPuzzleValidation: "PuzzleCAPTCHAFailPuzzleValidation", +} + +func (pcr PuzzleCAPTCHAResult) String() string { + s, ok := PuzzleCAPTCHAResultToString[pcr] + if ok { + return s + } + return "Bad! unknown PuzzleCAPTCHAResult" +} +func (pcr PuzzleCAPTCHAResult) MarshalJSON() ([]byte, error) { + buffer := bytes.NewBufferString(`"`) + s, ok := PuzzleCAPTCHAResultToString[pcr] + if ok { + buffer.WriteString(s) + } else { + buffer.WriteString("nil") + } + buffer.WriteString(`"`) + return buffer.Bytes(), nil +} + +type SendOrValidatePuzzleCAPTCHAResult struct { + PuzzleCaptchaResult PuzzleCAPTCHAResult + TooManyFailedChallengesResult RateLimitResult +} + +func sendOrValidatePuzzleCAPTCHA( + + config *Config, + c *gin.Context, + banner BannerInterface, + rateLimitStates *FailedChallengeRateLimitStates, + failAction FailAction, + decisionLists *StaticDecisionLists, + puzzleImageController *PuzzleImageController, + +) (sendOrValidatePuzzleCAPTCHAResult SendOrValidatePuzzleCAPTCHAResult) { + + clientIp := c.Request.Header.Get("X-Client-IP") + requestedHost := c.Request.Header.Get("X-Requested-Host") + requestedPath := c.Request.Header.Get("X-Requested-Path") + clientUserAgent := c.Request.Header.Get("X-Client-User-Agent") + + requestedMethod := c.Request.Method + + challengeCookie, err := c.Cookie(PuzzleChallengeCookieName) + + if err == nil { + err = ValidateShaInvCookie(config.HmacSecret, challengeCookie, time.Now(), getUserAgentOrIp(c, config), 0) + if err == nil { + _, err := c.Cookie("__banjax_sol") + if err == nil { + return handleValidatePuzzleCAPTCHASolution(config, c, banner, rateLimitStates, failAction, decisionLists, puzzleImageController) + } + /* + they have a challenge cookie BUT it is either invalid OR it is a puzzle captcha solution cookie + so let it fallthrough to the next check. If it was a challege solution cookie, then good, otherwise + their cookie was probably an old cookie that expired. Therefore, issue the challenge. Note, we cannot yet + comment as to whether or not the challenge cookie was not valid, we need to let the validate puzzle decide + */ + } + + err := ValidatePuzzleCAPTCHACookie(config, puzzleImageController, challengeCookie, time.Now(), getUserAgentOrIp(c, config)) + if err != nil { + sendOrValidatePuzzleCAPTCHAResult.PuzzleCaptchaResult = PuzzleCAPTCHAFailBadCookie + } else { + accessGranted(c, config, PuzzleCAPTCHAResultToString[PuzzleCAPTCHAPass]) + ReportPassedFailedBannedMessage(config, "ip_passed_challenge", clientIp, requestedHost) + sendOrValidatePuzzleCAPTCHAResult.PuzzleCaptchaResult = PuzzleCAPTCHAPass + return + } + } else { + sendOrValidatePuzzleCAPTCHAResult.PuzzleCaptchaResult = PuzzleCAPTCHAFailNoCookie + } + + ReportPassedFailedBannedMessage(config, "ip_failed_challenge", clientIp, requestedHost) + if failAction == Block { + tooManyFailedChallengesResult := tooManyFailedChallenges( + config, + clientIp, + clientUserAgent, + requestedHost, + requestedPath, + banner, + "captcha_puzzle", + rateLimitStates, + requestedMethod, + decisionLists, + ) + sendOrValidatePuzzleCAPTCHAResult.TooManyFailedChallengesResult = tooManyFailedChallengesResult + if tooManyFailedChallengesResult.Exceeded { + ReportPassedFailedBannedMessage(config, "ip_banned", clientIp, requestedHost) + accessDenied(c, config, "TooManyFailedChallenges") + return sendOrValidatePuzzleCAPTCHAResult + } + } + + solutionCookieNamesToDelete := make([]string, 0) + _, err = ParsePuzzleSolutionCookie(c, &solutionCookieNamesToDelete) + if err == nil { + StripPuzzleSolutionCookieIfExist(c, solutionCookieNamesToDelete) + } + + handlePuzzleCAPTCHAChallenge(config, c, puzzleImageController) + return sendOrValidatePuzzleCAPTCHAResult +} + +/* +puzzleCAPTCHAChallenge sends the index.html (which has also had the css and js bundle injected into it at build) and +subsequently injects the initial puzzle state such that there are no follow up requests. +*/ +func handlePuzzleCAPTCHAChallenge( + + config *Config, + c *gin.Context, + puzzleImageController *PuzzleImageController, + +) (sendOrValidatePuzzleCAPTCHAResult SendOrValidatePuzzleCAPTCHAResult) { + + userChallengeCookieValue := NewChallengeCookie(config.HmacSecret, config.ShaInvCookieTtlSeconds, getUserAgentOrIp(c, config)) + + serializedCAPTCHAChallenge, err := GeneratePuzzleCAPTCHA(config, puzzleImageController, userChallengeCookieValue) + if err != nil { + log.Printf("Error generating CAPTCHA: %v", err) + sendOrValidatePuzzleCAPTCHAResult.PuzzleCaptchaResult = PuzzleCAPTCHAFailPuzzleGeneration + return sendOrValidatePuzzleCAPTCHAResult + } + + var escapedGameState bytes.Buffer + json.HTMLEscape(&escapedGameState, serializedCAPTCHAChallenge) + scriptTag := fmt.Sprintf(``, escapedGameState.String()) + + modifiedHTML := strings.Replace( + string(config.PuzzleChallengeHTML), + "", + scriptTag, + 1, //replace only the first occurrence + ) + + c.SetCookie(PuzzleChallengeCookieName, userChallengeCookieValue, config.ShaInvCookieTtlSeconds, "/", "", false, false) + c.Header("Content-Type", "text/html") + c.Header("Cache-Control", "no-cache,no-store") + sessionCookieEndPoint(c, config) + c.String(http.StatusTooManyRequests, modifiedHTML) + c.Abort() + return sendOrValidatePuzzleCAPTCHAResult +} + +func handleValidatePuzzleCAPTCHASolution( + + config *Config, + c *gin.Context, + banner BannerInterface, + rateLimitStates *FailedChallengeRateLimitStates, + failAction FailAction, + decisionLists *StaticDecisionLists, + puzzleImageController *PuzzleImageController, + +) (sendOrValidatePuzzleCAPTCHAResult SendOrValidatePuzzleCAPTCHAResult) { + + clientIp := c.Request.Header.Get("X-Client-IP") + requestedHost := c.Request.Header.Get("X-Requested-Host") + requestedPath := c.Request.Header.Get("X-Requested-Path") + clientUserAgent := c.Request.Header.Get("X-Client-User-Agent") + requestedMethod := c.Request.Method + + //we would not have made it this far if we were not certain of having access to this + userChallengeCookieValue, _ := c.Cookie(PuzzleChallengeCookieName) + + solutionCookieNamesToDelete := make([]string, 0) + //because appending may trigger reallocation, and we dont know how many are going to be added to slice ahead of time we use ref + userSubmission, err := ParsePuzzleSolutionCookie(c, &solutionCookieNamesToDelete) + if err != nil { + sendOrValidatePuzzleCAPTCHAResult.PuzzleCaptchaResult = PuzzleCAPTCHAFailNoCookie + accessDenied(c, config, PuzzleCAPTCHAResultToString[PuzzleCAPTCHAFailNoCookie]) + return sendOrValidatePuzzleCAPTCHAResult + } + + err = ValidatePuzzleCAPTCHASolution(config, puzzleImageController, userChallengeCookieValue, *userSubmission) + if err != nil { + + StripPuzzleSolutionCookieIfExist(c, solutionCookieNamesToDelete) + + integrityErrors := []error{ + ErrFailedClickChainIntegrityCheck, + ErrFailedClickChainMoveIntegrityCheck, + } + + isIntegrityError := false + + for _, integrityError := range integrityErrors { + if errors.Is(err, integrityError) { + isIntegrityError = true + break + } + } + + ReportPassedFailedBannedMessage(config, "ip_failed_challenge", clientIp, requestedHost) + if failAction == Block { + + tooManyFailedChallengesResult := tooManyFailedChallenges( + config, + clientIp, + clientUserAgent, + requestedHost, + requestedPath, + banner, + "captcha_puzzle", + rateLimitStates, + requestedMethod, + decisionLists, + ) + + sendOrValidatePuzzleCAPTCHAResult.TooManyFailedChallengesResult = tooManyFailedChallengesResult + + if tooManyFailedChallengesResult.Exceeded { + + if isIntegrityError { + /* + because an integrity error means we detected they were tampering with state and may warrant us outright banning them + ie we just will not overwrite ban with rate limit when they are literally just cheating + */ + sendOrValidatePuzzleCAPTCHAResult.PuzzleCaptchaResult = PuzzleCAPTCHAFailPuzzleIntegrity + ReportPassedFailedBannedMessage(config, "block_ip", clientIp, requestedHost) + accessDenied(c, config, PuzzleCAPTCHAResultToString[PuzzleCAPTCHAFailPuzzleIntegrity]) + return sendOrValidatePuzzleCAPTCHAResult + } + + /* + if too many, ban them for a preset amount of time like 60 seconds to behave as a rate limiter. The duration should + be set in config at the total amount of time you have to solve the puzzle / 4. This way, if you do trigger the rate + limiter 4 times, the puzzle is no longer solvable by time constraint forcing them to get a new puzzle + */ + banner.OverwriteBanWithRateLimit(config, clientIp, config.PuzzleRateLimitBruteForceSolutionTTLSeconds) + sendOrValidatePuzzleCAPTCHAResult.PuzzleCaptchaResult = PuzzleCAPTCHAFailPuzzleValidation + ReportPassedFailedBannedMessage(config, "block_ip", clientIp, requestedHost) + accessThrottled(c, config, "TooManyFailedChallenges") + return sendOrValidatePuzzleCAPTCHAResult + } + } + + puzzleResultCode := PuzzleCAPTCHAFailPuzzleValidation + if isIntegrityError { + puzzleResultCode = PuzzleCAPTCHAFailPuzzleIntegrity + } + + sendOrValidatePuzzleCAPTCHAResult.PuzzleCaptchaResult = puzzleResultCode + accessDenied(c, config, PuzzleCAPTCHAResultToString[puzzleResultCode]) + return sendOrValidatePuzzleCAPTCHAResult + } + + StripPuzzleSolutionCookieIfExist(c, solutionCookieNamesToDelete) + sendOrValidatePuzzleCAPTCHAResult.PuzzleCaptchaResult = PuzzleCAPTCHAPass + encodedValue := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s[sol]%s", userChallengeCookieValue, userSubmission.Solution))) + c.SetCookie(PuzzleChallengeCookieName, encodedValue, config.ShaInvCookieTtlSeconds, "/", "", false, false) + accessGranted(c, config, PuzzleCAPTCHAResultToString[PuzzleCAPTCHAPass]) + ReportPassedFailedBannedMessage(config, "ip_passed_challenge", clientIp, requestedHost) + c.Abort() + return sendOrValidatePuzzleCAPTCHAResult +} + +/* +handleRefreshPuzzleCAPTCHAState generates a new CAPTCHA challenge STATE for the client. This is only used +when the user clicks on the new challenge button as it doesnt require anything other than the challenge payload itself +*/ +func handleRefreshPuzzleCAPTCHAState( + + config *Config, + c *gin.Context, + banner BannerInterface, + rateLimitStates *FailedChallengeRateLimitStates, + failAction FailAction, + decisionLists *StaticDecisionLists, + puzzleImageController *PuzzleImageController, + +) (sendOrValidatePuzzleCAPTCHAResult SendOrValidatePuzzleCAPTCHAResult) { + + clientIp := c.Request.Header.Get("X-Client-IP") + requestedHost := c.Request.Header.Get("X-Requested-Host") + requestedPath := c.Request.Header.Get("X-Requested-Path") + clientUserAgent := c.Request.Header.Get("X-Client-User-Agent") + requestedMethod := c.Request.Method + + ReportPassedFailedBannedMessage(config, "ip_failed_challenge", clientIp, requestedHost) + if failAction == Block { + + tooManyFailedChallengesResult := tooManyFailedChallenges( + config, + clientIp, + clientUserAgent, + requestedHost, + requestedPath, + banner, + "captcha_puzzle_refresh", + rateLimitStates, + requestedMethod, + decisionLists, + ) + + if tooManyFailedChallengesResult.Exceeded { + banner.OverwriteBanWithRateLimit(config, clientIp, config.PuzzleRateLimitBruteForceSolutionTTLSeconds) + accessThrottled(c, config, "TooManyFailedChallenges") + return sendOrValidatePuzzleCAPTCHAResult + } + } + + userChallengeCookieValue := NewChallengeCookie(config.HmacSecret, config.ShaInvCookieTtlSeconds, getUserAgentOrIp(c, config)) + serializedCAPTCHAChallenge, err := GeneratePuzzleCAPTCHA(config, puzzleImageController, userChallengeCookieValue) + if err != nil { + sendOrValidatePuzzleCAPTCHAResult.PuzzleCaptchaResult = PuzzleCAPTCHAFailPuzzleGeneration + accessDenied(c, config, PuzzleCAPTCHAResultToString[PuzzleCAPTCHAFailPuzzleGeneration]) + return sendOrValidatePuzzleCAPTCHAResult + } + + c.SetCookie(PuzzleChallengeCookieName, userChallengeCookieValue, config.ShaInvCookieTtlSeconds, "/", "", false, false) + c.Header("Cache-Control", "no-cache,no-store") + c.Data(http.StatusOK, "application/json", serializedCAPTCHAChallenge) + c.Abort() + return sendOrValidatePuzzleCAPTCHAResult +} + +/* +any non critical errors that do not break gameplay will be reported here by the entrypoint client side script +with payloads such that we create recreate the environment that gave rise to them + +NOTE: for critical errors, there exists a fallback function that is immediately called. We need only provide +a hardcoded backup challenge, for example putting a hardcoded version of shaInvChallenge inside of the runfallback() +since we are using the same cookie generation function, the sha challenge can still be validated normally +*/ +func handleLoggingPuzzleCAPTCHAErrorReport( + + config *Config, + c *gin.Context, + banner BannerInterface, + rateLimitStates *FailedChallengeRateLimitStates, + failAction FailAction, + decisionLists *StaticDecisionLists, + puzzleErrorLogger *PuzzleErrorLogger, + +) (sendOrValidatePuzzleCAPTCHAResult SendOrValidatePuzzleCAPTCHAResult) { + + if puzzleErrorLogger == nil { + //if for whatever reason, we were not able to get the error logger + //from ctx on server startup ignore all error non critical client side error reports + return sendOrValidatePuzzleCAPTCHAResult + } + + clientIp := c.Request.Header.Get("X-Client-IP") + requestedHost := c.Request.Header.Get("X-Requested-Host") + clientUserAgent := c.Request.Header.Get("X-Client-User-Agent") + challengeCookie, err := c.Cookie(PuzzleChallengeCookieName) + if err != nil { + challengeCookie = "" + } + + errorType := c.Param("errorType") + if errorType != "" { + errorType = strings.TrimPrefix(errorType, "/") + } + + stackTrace, err := c.Cookie("__banjax_error") + if err != nil { + stackTrace = "" + } + + var errorReport strings.Builder + errorReport.WriteString(fmt.Sprintf("[CAPTCHA PUZZLE ERROR] Type: %s Hostname: %s UserAgent: %s IP Address: %s", + errorType, requestedHost, clientUserAgent, clientIp)) + + if challengeCookie != "" { + errorReport.WriteString(fmt.Sprintf(" Challenge cookie: %s", challengeCookie)) + } + + if stackTrace != "" { + errorReport.WriteString(fmt.Sprintf(" Stack Trace: %s", stackTrace)) + } + + err = puzzleErrorLogger.WritePuzzleErrorLog(errorReport.String()) + if err != nil { + log.Printf("Failed to write puzzle error log: %v", err) + } + + /* + //if the endpoint is being abused, then ban them. + + //NOTE: we also have rate limiting + //in place at the level of nginx so I don't know if this is necessary + + requestedPath := c.Request.Header.Get("X-Requested-Path") + requestedMethod := c.Request.Method + tooManyFailedChallengesResult := tooManyFailedChallenges( + config, + clientIp, + clientUserAgent, + requestedHost, + requestedPath, + banner, + "captcha_puzzle_error_log", + rateLimitStates, + requestedMethod, + decisionLists, + ) + sendOrValidatePuzzleCAPTCHAResult.TooManyFailedChallengesResult = tooManyFailedChallengesResult + if tooManyFailedChallengesResult.Exceeded { + ReportPassedFailedBannedMessage(config, "ip_banned", clientIp, requestedHost) + accessDenied(c, config, "TooManyFailedPassword") + return sendOrValidatePuzzleCAPTCHAResult + } + */ + c.JSON(http.StatusNoContent, gin.H{}) + return sendOrValidatePuzzleCAPTCHAResult +} + type PasswordChallengeResult uint const ( @@ -777,6 +1275,7 @@ type DecisionForNginxResult struct { DecisionListResult DecisionListResult PasswordChallengeResult *PasswordChallengeResult // these are pointers so they can be optionally nil ShaChallengeResult *ShaChallengeResult + PuzzleCAPTCHAResult *PuzzleCAPTCHAResult TooManyFailedChallengesResult *RateLimitResult } @@ -787,6 +1286,7 @@ func decisionForNginx( passwordProtectedPaths *PasswordProtectedPaths, failedChallengeStates *FailedChallengeRateLimitStates, banner BannerInterface, + puzzleImageController *PuzzleImageController, ) gin.HandlerFunc { return func(c *gin.Context) { config := configHolder.Get() @@ -798,6 +1298,7 @@ func decisionForNginx( passwordProtectedPaths, failedChallengeStates, banner, + puzzleImageController, ) if config.Debug { bytes, err := json.MarshalIndent(decisionForNginxResult, "", " ") @@ -822,6 +1323,7 @@ func decisionForNginx2( passwordProtectedPaths *PasswordProtectedPaths, failedChallengeStates *FailedChallengeRateLimitStates, banner BannerInterface, + puzzleImageController *PuzzleImageController, ) (decisionForNginxResult DecisionForNginxResult) { // XXX duplication clientIp := c.Request.Header.Get("X-Client-IP") @@ -883,18 +1385,16 @@ func decisionForNginx2( default: } - decision, foundInPerSiteList := staticDecisionLists.CheckPerSite( - config, - requestedHost, - clientIp, - ) + decision, foundInPerSiteList := staticDecisionLists.CheckPerSite(config, requestedHost, clientIp) if foundInPerSiteList { switch decision { + case Allow: accessGranted(c, config, DecisionListResultToString[PerSiteAccessGranted]) // log.Println("access granted from per-site lists") decisionForNginxResult.DecisionListResult = PerSiteAccessGranted return + case Challenge: // log.Println("challenge from per-site lists") sendOrValidateShaChallengeResult := sendOrValidateShaChallenge( @@ -909,6 +1409,23 @@ func decisionForNginx2( decisionForNginxResult.ShaChallengeResult = &sendOrValidateShaChallengeResult.ShaChallengeResult decisionForNginxResult.TooManyFailedChallengesResult = &sendOrValidateShaChallengeResult.TooManyFailedChallengesResult return + + case PuzzleChallenge: + puzzleCAPTCHAResult := sendOrValidatePuzzleCAPTCHA( + config, + c, + banner, + failedChallengeStates, + Block, // FailAction + staticDecisionLists, + puzzleImageController, + ) + + decisionForNginxResult.DecisionListResult = PerSiteChallenge + decisionForNginxResult.PuzzleCAPTCHAResult = &puzzleCAPTCHAResult.PuzzleCaptchaResult + decisionForNginxResult.TooManyFailedChallengesResult = &puzzleCAPTCHAResult.TooManyFailedChallengesResult + return + case NginxBlock, IptablesBlock: accessDenied(c, config, DecisionListResultToString[PerSiteBlock]) // log.Println("block from per-site lists") @@ -939,6 +1456,23 @@ func decisionForNginx2( decisionForNginxResult.ShaChallengeResult = &sendOrValidateShaChallengeResult.ShaChallengeResult decisionForNginxResult.TooManyFailedChallengesResult = &sendOrValidateShaChallengeResult.TooManyFailedChallengesResult return + + case PuzzleChallenge: + puzzleCAPTCHAResult := sendOrValidatePuzzleCAPTCHA( + config, + c, + banner, + failedChallengeStates, + Block, // FailAction + staticDecisionLists, + puzzleImageController, + ) + + decisionForNginxResult.DecisionListResult = PerSiteChallenge + decisionForNginxResult.PuzzleCAPTCHAResult = &puzzleCAPTCHAResult.PuzzleCaptchaResult + decisionForNginxResult.TooManyFailedChallengesResult = &puzzleCAPTCHAResult.TooManyFailedChallengesResult + return + case NginxBlock, IptablesBlock: accessDenied(c, config, DecisionListResultToString[GlobalBlock]) // log.Println("access denied from global lists") @@ -962,6 +1496,7 @@ func decisionForNginx2( decisionForNginxResult.DecisionListResult = ExpiringAccessGranted return case Challenge: + // apply exception to both challenge from baskerville and regex banner if checkPerSiteShaInvPathExceptions(config, requestedHost, requestedPath) { accessGranted(c, config, DecisionListResultToString[PerSiteShaInvPathException]) @@ -973,6 +1508,7 @@ func decisionForNginx2( if expiringDecision.fromBaskerville && disabled { log.Printf("DIS-BASK: domain %s disabled baskerville, skip expiring challenge for %s", requestedHost, clientIp) } else { + // log.Println("challenge from expiring lists") sendOrValidateShaChallengeResult := sendOrValidateShaChallenge( config, @@ -987,6 +1523,37 @@ func decisionForNginx2( decisionForNginxResult.TooManyFailedChallengesResult = &sendOrValidateShaChallengeResult.TooManyFailedChallengesResult return } + + case PuzzleChallenge: + + //following the sha challenge example above + if checkPerSiteShaInvPathExceptions(config, requestedHost, requestedPath) { + accessGranted(c, config, DecisionListResultToString[PerSiteShaInvPathException]) + decisionForNginxResult.DecisionListResult = PerSiteShaInvPathException + return + } + // Check if expiringDecision.fromBaskerville, if true, check if domain disabled baskerville + _, disabled := config.SitesToDisableBaskerville[requestedHost] + if expiringDecision.fromBaskerville && disabled { + log.Printf("DIS-BASK: domain %s disabled baskerville, skip expiring challenge for %s", requestedHost, clientIp) + } else { + + puzzleCAPTCHAResult := sendOrValidatePuzzleCAPTCHA( + config, + c, + banner, + failedChallengeStates, + Block, // FailAction + staticDecisionLists, + puzzleImageController, + ) + + decisionForNginxResult.DecisionListResult = ExpiringChallenge + decisionForNginxResult.PuzzleCAPTCHAResult = &puzzleCAPTCHAResult.PuzzleCaptchaResult + decisionForNginxResult.TooManyFailedChallengesResult = &puzzleCAPTCHAResult.TooManyFailedChallengesResult + return + } + case NginxBlock, IptablesBlock: accessDenied(c, config, DecisionListResultToString[ExpiringBlock]) // log.Println("access denied from expiring lists") @@ -1007,6 +1574,25 @@ func decisionForNginx2( decisionForNginxResult.DecisionListResult = SiteWideChallengeException accessGranted(c, config, DecisionListResultToString[SiteWideChallengeException]) } else { + + //this would have to be something anton sends over? + + // if config.USE_PUZZLE_CHALLENGE { + // puzzleCAPTCHAResult := sendOrValidatePuzzleCAPTCHA( + // config, + // c, + // banner, + // failedChallengeStates, + // Block, // FailAction + // staticDecisionLists, + // ) + + // decisionForNginxResult.DecisionListResult = SiteWideChallenge + // decisionForNginxResult.PuzzleCAPTCHAResult = &puzzleCAPTCHAResult.PuzzleCaptchaResult + // decisionForNginxResult.TooManyFailedChallengesResult = &puzzleCAPTCHAResult.TooManyFailedChallengesResult + // return + // } + sendOrValidateShaChallengeResult := sendOrValidateShaChallenge( config, c, diff --git a/internal/iptables.go b/internal/iptables.go index 34efdeb..30e7876 100644 --- a/internal/iptables.go +++ b/internal/iptables.go @@ -123,6 +123,9 @@ type BannerInterface interface { IPSetTest(config *Config, ip string) bool IPSetList() (*ipset.Info, error) IPSetDel(ip string) error + + //allows us to ban with a controlled timeout + OverwriteBanWithRateLimit(config *Config, ip string, banTimeSeconds int) } type Banner struct { @@ -311,7 +314,9 @@ func (b Banner) IPSetDel(ip string) error { } func banIp(config *Config, ip string, banner BannerInterface) { + log.Println("banIp:", ip, "timeout", config.IptablesBanSeconds) + if ip == "127.0.0.1" { log.Println("banIp: Not going to block localhost") return @@ -329,3 +334,30 @@ func banIp(config *Config, ip string, banner BannerInterface) { log.Printf("banIp ipset add failed: %v", banErr) } } + +func (b Banner) OverwriteBanWithRateLimit(config *Config, ip string, banTimeSeconds int) { + if ip == "127.0.0.1" { + log.Println("RateLimitWithBan: Not banning localhost") + return + } + if config.StandaloneTesting { + log.Println("RateLimitWithBan: Skipping in test mode") + return + } + + if b.IPSetTest(config, ip) { + //if we detect a double ban attempt, we can override with the rate limit + err := b.IPSetDel(ip) + if err != nil { + log.Printf("Failed to remove existing ban for IP: %s, proceeding with new ban. Error: %v", ip, err) + return + } + } + + // log.Printf("RateLimitWithBan: Banning IP: %s for %d seconds", ip, banTimeSeconds) + banErr := b.IPSetInstance.Add(ip, ipset.Timeout(time.Duration(banTimeSeconds)*time.Second)) + if banErr != nil { + log.Printf("RateLimitWithBan: Failed to ban %s: %v", ip, banErr) + } + +} diff --git a/internal/puzzle_click_chain.go b/internal/puzzle_click_chain.go new file mode 100644 index 0000000..42f231e --- /dev/null +++ b/internal/puzzle_click_chain.go @@ -0,0 +1,399 @@ +package internal + +import ( + "encoding/json" + "errors" + "fmt" + "time" +) + +/* +It is important to ensure that the ChainTile and ClickChainEntry match the client side definition with respect order when serializing. If their orders +do not match, then even if the data is correct, the resultant hash will not be the same. +*/ + +type ClickChainTile struct { + Row int `json:"row"` + Col int `json:"col"` + Id string `json:"id"` +} + +func (ct *ClickChainTile) MarshalBinary() ([]byte, error) { + return json.Marshal(ct) +} + +func (ct *ClickChainTile) UnmarshalBinary(data []byte) error { + return json.Unmarshal(data, ct) +} + +type ClickChainEntry struct { + TimeStamp string `json:"time_stamp"` + TileClicked ClickChainTile `json:"tile_clicked"` + TileSwappedWith ClickChainTile `json:"tile_swapped_with"` + + ClickCount int `json:"click_count"` + Hash string `json:"hash"` +} + +func (chainEntry *ClickChainEntry) MarshalBinary() ([]byte, error) { + return json.Marshal(chainEntry) +} + +func (chainEntry *ClickChainEntry) UnmarshalBinary(data []byte) error { + return json.Unmarshal(data, chainEntry) +} + +func (chainEntry *ClickChainEntry) JSONBytesToString(data []byte) string { + return string(data) +} + +var ( + ErrFailedClickChainIntegrityCheck = errors.New("failed click chain integrity check") + ErrFailedClickChainMoveIntegrityCheck = errors.New("failed click chain moves sequence validation") + ErrFailedClickChainMoveToBoardStateIntegrityCheck = errors.New("failed to recreate successfully solved puzzle using users submitted steps") + ErrClickChainEmpty = errors.New("click chain empty, expected at least genesis + 1 valid operation to solve") + ErrGenesisFailedMarshalBinary = errors.New("failed to marshal genesis click chain item") + ErrFailedClickChainItemMarshalBinary = errors.New("failed to marshal click chain item") + ErrGenesisEntryVerification = errors.New("failed genesis click chain verification") + ErrChainVerification = errors.New("failed click chain verification") +) + +/* +NewClickChain is used to create a ClickChain which is a mini blockchain of clicks (clickChainEntries) that a user makes as they try to solve the captcha +where each block references the previous and we create the genesis with a secret initialization vector the user doesn't have access to +*/ +func NewPuzzleClickChain(userChallengeCookieString, clickChainEntroy string) ([]ClickChainEntry, error) { + + clickChain := make([]ClickChainEntry, 0) + + genesis := ClickChainEntry{ + TimeStamp: time.Now().UTC().Format(time.RFC3339), + ClickCount: 0, + TileClicked: ClickChainTile{Id: "", Row: -1, Col: -1}, + TileSwappedWith: ClickChainTile{Id: "", Row: -1, Col: -1}, + Hash: "", + } + + genesisChainEntryAsBytes, err := genesis.MarshalBinary() + if err != nil { + return nil, err + } + + challengeEntroy := fmt.Sprintf("%s%s", clickChainEntroy, userChallengeCookieString) + genesis.Hash = GenerateHMACFromString(genesis.JSONBytesToString(genesisChainEntryAsBytes), challengeEntroy) + clickChain = append(clickChain, genesis) + + return clickChain, nil +} + +/* +IntegrityCheckClickChain checks the integrity of the click chain starting from the genesis entry until the last move to make sure that the click chain +is not a replay of a past chain that happens to match the same initial configuration of the board. Then we check that the operations of the click chain +follow the rulles and that the click start operations actually lead to the submitted board. + +We start by verifying that the entire chain is valid. This ensures the entire chain is unaltered before trusting timestamps, counts, or moves. +We then check that each of the moves of the click chain are valid as by the rules of the game +finally, we recreate their submitted board using the steps they took to see that it does result in the outcome board proving that this chain of clicks did indeed create the resultant board. + +NOTE: This does NOT prove their answer was right. ONLY that given the initial board they started with it was THIS series of steps in particular that got them to the final result they submitted. +*/ +func IntegrityCheckPuzzleClickChain( + + userSubmittedSolutionHash, userChallengeCookieString, clickChainEntroy string, + userSubmittedClickChain []ClickChainEntry, + locallyStoredShuffledGameBoard [][]*PuzzleTileWithoutImage, + locallyStoredUnShuffledGamboard [][]*PuzzleTileWithoutImage, + +) error { + + err := verifyPuzzleClickChainIntegrity(userChallengeCookieString, clickChainEntroy, userSubmittedClickChain) + if err != nil { + return fmt.Errorf("%w: %v", ErrFailedClickChainIntegrityCheck, err) + } + + err = verifyPuzzleClickChainMoveValidity(userSubmittedClickChain) + if err != nil { + return fmt.Errorf("%w: %v", ErrFailedClickChainMoveIntegrityCheck, err) + } + + err = recreateAndIntegrityCheckFinalPuzzleBoardFromClickChain(userSubmittedClickChain, locallyStoredShuffledGameBoard, locallyStoredUnShuffledGamboard, userSubmittedSolutionHash, userChallengeCookieString) + if err != nil { + return fmt.Errorf("%w: %v", ErrFailedClickChainMoveToBoardStateIntegrityCheck, err) + } + + return nil +} + +/* +verifyClickChainIntegrity verifies the integrity of the entire payload itself by recreating each hash ourselves using what we expect and the secret info required to make the genesis entry. +This requires iterating over their map, taking the data they presented in the time, tile_clicked and tile_swapped +fields putting them into a new entry, setting the current count ourselves and setting the current hash ourselves by taking the previous +items hash as the initial hash field value of the current object, producing the hash of that object and storing that item to be the hash and then hashing that object +to produce the hash of that object in particular. +*/ +func verifyPuzzleClickChainIntegrity( + + userChallengeCookieString, clickChainEntroy string, + userClickChain []ClickChainEntry, + +) error { + + copiedClickChain := make([]ClickChainEntry, len(userClickChain)) + copy(copiedClickChain, userClickChain) + + if len(copiedClickChain) == 0 { + return fmt.Errorf("%w: No entries in click chain", ErrClickChainEmpty) + } + + if len(copiedClickChain) == 1 { + return fmt.Errorf("%w: Only the genesis is in the click chain, solution cannot be valid", ErrClickChainEmpty) + } + + isValidGenesisHash, err := verifyPuzzleClickChainGenesisHash(userChallengeCookieString, clickChainEntroy, copiedClickChain[0]) + if err != nil { + return fmt.Errorf("%w: %v", ErrGenesisFailedMarshalBinary, err) + } + + if !isValidGenesisHash { + return fmt.Errorf("%w: Invalid genesis block", ErrGenesisEntryVerification) + } + + previousHash := copiedClickChain[0].Hash + + //at this point we know that the genesis entry is valid, so we can continue to verify every other entry is valid using "i" as the click count + //starting at 1 since that would have been the first click + for i := 1; i < len(copiedClickChain); i++ { + expectedHash, err := verifyPuzzleClickChainEntry(userChallengeCookieString, i, previousHash, copiedClickChain[i]) + if err != nil { + return fmt.Errorf("%w: Entry %d, expected hash: %s, got: %s", ErrChainVerification, i, expectedHash, copiedClickChain[i].Hash) + } + previousHash = copiedClickChain[i].Hash + } + + return nil +} + +/* +since the user does not know our initialization vector, they are not able to forge their own genesis. Note, this is different +from the direct match comparison as it is meant to also tie the user challenge cookie string and confirm the first hash in the +entire click chain which is necessary for being able to confirm all subsequent hashes +*/ +func verifyPuzzleClickChainGenesisHash( + + userChallengeCookieString, clickChainEntroy string, + userGenesisEntry ClickChainEntry, + +) (bool, error) { + + submittedHash := userGenesisEntry.Hash + userGenesisEntry.Hash = "" //in order to recreate how the genesis entry was created + + challengeEntropy := fmt.Sprintf("%s%s", clickChainEntroy, userChallengeCookieString) + + genesisBytes, err := userGenesisEntry.MarshalBinary() + if err != nil { + return false, err + } + + expectedHash := GenerateHMACFromString(userGenesisEntry.JSONBytesToString(genesisBytes), challengeEntropy) + userGenesisEntry.Hash = submittedHash //reset to be able to use it for verifying the next + + return expectedHash == submittedHash, nil +} + +/* +we remove the hash and index user provided for this entry, and replace them with the previous entry hash and expected index respectively +we produce the hash of this entry and compare it to what they actually had to confirm it was indeed correct +*/ +func verifyPuzzleClickChainEntry( + + userChallengeCookieString string, + expectedIndex int, + previousHash string, + userSubmittedChainEntry ClickChainEntry, + +) (string, error) { + + recreatedEntry := ClickChainEntry{ + TimeStamp: userSubmittedChainEntry.TimeStamp, + TileClicked: userSubmittedChainEntry.TileClicked, + TileSwappedWith: userSubmittedChainEntry.TileSwappedWith, + ClickCount: expectedIndex, + Hash: previousHash, + } + + asBytes, err := recreatedEntry.MarshalBinary() + if err != nil { + return "", fmt.Errorf("%w: %v", ErrFailedClickChainItemMarshalBinary, err) + } + + marshaledBytesAsString := recreatedEntry.JSONBytesToString(asBytes) + expectedHash := GenerateHMACFromString(marshaledBytesAsString, userChallengeCookieString) + + if expectedHash != userSubmittedChainEntry.Hash { + return expectedHash, fmt.Errorf("%w: %v", ErrChainVerification, errors.New("hash mismatch")) + } + + return expectedHash, nil +} + +func verifyPuzzleClickChainMoveValidity(userClickChainWithGenesis []ClickChainEntry) error { + + if len(userClickChainWithGenesis) == 0 { + return fmt.Errorf("%w: %v", ErrChainVerification, errors.New("ErrInvalidClickChain: Missing genesis")) + } + + //since we integrity checked the userClickChainWithGenesis, we start by removing the genesis entry as its not one of the users entries + copiedClickChain := make([]ClickChainEntry, len(userClickChainWithGenesis)) + copy(copiedClickChain, userClickChainWithGenesis) + + userClickChain := copiedClickChain[1:] + + if len(userClickChain) == 0 { + return fmt.Errorf("%w: %v", ErrChainVerification, errors.New("ErrInvalidClickChain: Expected at least one move for a valid answer, puzzles are not issued already solved")) + } + + for userMove := 0; userMove < len(userClickChain); userMove++ { + currentTileThatWasClicked := userClickChain[userMove].TileClicked + tileSwappedWith := userClickChain[userMove].TileSwappedWith + if tileSwappedWith.Id != "null_tile" { + return fmt.Errorf("%w: ErrInvalidMove: Detected impossible swap: swapping tile clicked: %s with tile:%s", ErrChainVerification, currentTileThatWasClicked.Id, tileSwappedWith.Id) + } + + if !isValidPuzzleMove(currentTileThatWasClicked, tileSwappedWith) { + return fmt.Errorf("%w: ErrInvalidMove: Swap should not have been possible: tile clicked: %s with tile:%s", ErrChainVerification, currentTileThatWasClicked.Id, tileSwappedWith.Id) + } + } + + return nil +} + +func isValidPuzzleMove(tileClicked, tileSwappedWith ClickChainTile) bool { + + validMoves_X := []int{1, -1, 0, 0} + validMoves_Y := []int{0, 0, 1, -1} + + var isValidMove = false + + for i := 0; i < 4; i++ { + potential_X := validMoves_X[i] + tileClicked.Row + potential_Y := validMoves_Y[i] + tileClicked.Col + + if potential_X == tileSwappedWith.Row && potential_Y == tileSwappedWith.Col { + //this was a valid move as there exists a possible way to swap the tile that was clicked for the null tile + isValidMove = true + break + } + } + + return isValidMove +} + +/* +this will check that the solution they submitted was derived from the set of operations they performed on the gameboard we provided +them by playing back the operations on the gameboard we saved locally and seeing that it results in the state which produces the hash they would get if they applied +the operations they claim to have used via the click chain on the board we gave them. This ONLY proves that the steps they applied to the board result in the hash +they submitted. It does NOT prove that the hash they submitted is correct. +*/ +func recreateAndIntegrityCheckFinalPuzzleBoardFromClickChain( + + userClickChainWithGenesis []ClickChainEntry, + locallyStoredShuffledGameBoard, locallyStored_Un_ShuffledGamboard [][]*PuzzleTileWithoutImage, + userSubmittedSolutionHash, userChallengeCookieString string, + +) error { + + if len(locallyStoredShuffledGameBoard) == 0 { + return errors.New("ErrInvalidGameboard: Local gameboard empty") + } + + if len(userClickChainWithGenesis) == 0 { + return errors.New("ErrInvalidClickChain: Missing genesis") + } + + copiedClickChain := make([]ClickChainEntry, len(userClickChainWithGenesis)) + copy(copiedClickChain, userClickChainWithGenesis) + + userClickChain := copiedClickChain[1:] + + if len(userClickChain) == 0 { + return errors.New("ErrInvalidClickChain: Expected at least one move for a valid answer, puzzles are not issued already solved") + } + + //since we removed the genesis, the indexes of the clicks are off by 1 as genesis gets index 0, + //so users first click is always 1. so i+1 will be userClickChain.ClickCount + for i := 0; i < len(userClickChain); i++ { + userMove := userClickChain[i] + expectedClickChainIndex := i + 1 + if userMove.ClickCount != expectedClickChainIndex { + return fmt.Errorf("ErrInconsistentIndex: Expected click count %d but got: %d", expectedClickChainIndex, userMove.ClickCount) + } + + currentTileThatWasClicked := userMove.TileClicked + tileSwappedWith := userMove.TileSwappedWith + + clickedItemOnOriginalMap := locallyStoredShuffledGameBoard[currentTileThatWasClicked.Row][currentTileThatWasClicked.Col] + if clickedItemOnOriginalMap == nil { + return fmt.Errorf("ErrTileNotFound: Tile at row:%d col:%d could not be found in the server-side gameboard", currentTileThatWasClicked.Row, currentTileThatWasClicked.Col) + } + + if clickedItemOnOriginalMap.TileGridID != currentTileThatWasClicked.Id { + return fmt.Errorf("ErrTileIDMismatch: Expected tile ID: %s, but got: %s at row:%d col:%d", clickedItemOnOriginalMap.TileGridID, currentTileThatWasClicked.Id, currentTileThatWasClicked.Row, currentTileThatWasClicked.Col) + } + + swappedItemOnOriginalMap := locallyStoredShuffledGameBoard[tileSwappedWith.Row][tileSwappedWith.Col] + if tileSwappedWith.Id != "null_tile" || swappedItemOnOriginalMap != nil { + return fmt.Errorf("ErrInvalidNullTileSwap: Attempted swap with non-null tile at row:%d col:%d. Expected null tile with id: 'null_tile'", tileSwappedWith.Row, tileSwappedWith.Col) + } + + SwapPuzzleTile(locallyStoredShuffledGameBoard, currentTileThatWasClicked.Row, currentTileThatWasClicked.Col, tileSwappedWith.Row, tileSwappedWith.Col) + } + + /* + here we are recreating the solution that the user found. We start with the shuffled gameboard that we stored locally. We playback the users steps (from their click chain) + we check that the final solution that THEY submitted to us MATCHES what they WOULD have calculated GIVEN the click chain they submitted. + + NOTE this is NOT the same thing as checking their answer. All this does is check that the solution they submitted was actually derived from the click + chain steps they submitted. Their solution MAY STILL be wrong, HOWEVER at this stage we know for a fact that they started with the board we gave them, they applied the steps + and got their solution as a result of these steps. This is why we still need to compare their submitted solution to the precomputed solution we saved locally + */ + expectedSolutionDerivedFromGrid := CalculateExpectedPuzzleSolution(locallyStoredShuffledGameBoard, userChallengeCookieString) + + if expectedSolutionDerivedFromGrid != userSubmittedSolutionHash { + return errors.New("ErrTamperedSolution: Users submitted solution hash was NOT derived from this game board") + } + + /* + since we are now confident that the users steps match the board they submitted, we apply a comparison to the UN-shuffled version (ie the FINAL solution) board, to confirm that these match ID by ID + if so, what remains is integrity checking properties (ie completed within the time and clicks allowed) and subsequently confirming that the hash is a match. This is the first confirmation that the + board ITSELF was in a correct state when submitted because we applied the steps the user took to the shuffled board and ended up at the unshuffled board (as deired) + + now we need only iteratively check that the submitted board and the original board match id for id in the SAME order + we already confirmed the size of the game boards are the same, so the dimensions we use are guarenteed to work for both + */ + nRows := len(locallyStoredShuffledGameBoard) + nCols := len(locallyStoredShuffledGameBoard[0]) + + for r := 0; r < nRows; r++ { + for c := 0; c < nCols; c++ { + userEntry := locallyStored_Un_ShuffledGamboard[r][c] + localEntry := locallyStoredShuffledGameBoard[r][c] + + //either they're both nil (ie they're the same so great) or they both not nil otherwise, they're necessarily different, so return error + + if localEntry == nil && userEntry != nil { + return fmt.Errorf("ErrNullTilePositionMismatch: Null tile position mismatch at (%d,%d). Expected null, but got: %s", r, c, userEntry.TileGridID) + } + + if localEntry != nil && userEntry == nil { + return fmt.Errorf("ErrFinalBoardMismatch: Expected tile ID %s at (%d,%d) but received null", localEntry.TileGridID, r, c) + } + + if localEntry != nil && userEntry != nil && localEntry.TileGridID != userEntry.TileGridID { + return fmt.Errorf("ErrFinalBoardMismatch: Expected tile ID %s at (%d,%d) but received %s", localEntry.TileGridID, r, c, userEntry.TileGridID) + } + } + } + + return nil +} diff --git a/internal/puzzle_generator.go b/internal/puzzle_generator.go new file mode 100644 index 0000000..9f9ed48 --- /dev/null +++ b/internal/puzzle_generator.go @@ -0,0 +1,306 @@ +package internal + +import ( + "encoding/json" + "errors" + "fmt" + "log" + "math" +) + +type PuzzleCAPTCHAChallenge struct { + GameBoard [][]*PuzzleTile `json:"gameBoard"` + ThumbnailBase64 string `json:"thumbnail_base64"` + MaxAllowedMoves int `json:"maxNumberOfMovesAllowed"` + TimeToSolveMS int `json:"timeToSolve_ms"` + CollectDataEnabled bool `json:"collect_data"` + ClickChain []ClickChainEntry `json:"click_chain"` +} + +var ( + ErrTargetDifficultyDoesNotExist = errors.New("target difficulty profile does not exist") + ErrFailedNewCAPTCHAGeneration = errors.New("failed to generate CAPTCHA") + ErrFailedNewGameboard = errors.New("failed to create new game board") + ErrFailedRemovingTile = errors.New("failed to remove specified (row, col)") + ErrFailedShuffling = errors.New("failed to shuffle") + ErrMissingNullTile = errors.New("missing null tile: unable to shuffle without having removed one") + ErrFailedNewClickChain = errors.New("failed to generate new click chain with genesis entry") + ErrFailedThumbnailCreation = errors.New("failed to create thumbnail base64 representation") + ErrFailedMarhsalingChallenge = errors.New("failed to marshal new CAPTCHAChallenge struct") +) + +func GeneratePuzzleCAPTCHA(config *Config, puzzleImageController *PuzzleImageController, userChallengeCookie string) ([]byte, error) { + + targetDifficulty, exists := PuzzleDifficultyProfileByName(config, config.PuzzleDifficultyTarget, userChallengeCookie) + if !exists { + return nil, ErrTargetDifficultyDoesNotExist + } + + includeB64ImageData := true + tileMap, err := PuzzleTileMapFromImage[PuzzleTile](config, puzzleImageController, userChallengeCookie, includeB64ImageData) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrFailedNewCAPTCHAGeneration, err) + } + + if len(tileMap) != targetDifficulty.NPartitions { + return nil, fmt.Errorf("%w: expected %d partitions, got: %d", ErrFailedNewCAPTCHAGeneration, targetDifficulty.NPartitions, len(tileMap)) + } + + gameBoard, err := NewPuzzleCAPTCHABoard(tileMap, targetDifficulty) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrFailedNewCAPTCHAGeneration, err) + } + + deepCopyGameBoard, err := DeepCopyPuzzleTileBoard(gameBoard) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrFailedNewGameboard, err) + } + + err = RemovePuzzleTileFromBoard(gameBoard, targetDifficulty) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrFailedRemovingTile, err) + } + + row, col := PuzzleTileIndexToRowCol(targetDifficulty.RemoveTileIndex, targetDifficulty.NPartitions) + thumbnailAsB64, err := PuzzleThumbnailFromImage(config, puzzleImageController, config.PuzzleThumbnailEntropySecret, row, col) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrFailedThumbnailCreation, err) + } + + nReShuffles := 0 + err = ShufflePuzzleBoard(gameBoard, deepCopyGameBoard, targetDifficulty, nReShuffles, config.PuzzleEntropySecret, userChallengeCookie) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrFailedShuffling, err) + } + + captchaClickChain, err := NewPuzzleClickChain(userChallengeCookie, config.PuzzleClickChainEntropySecret) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrFailedNewClickChain, err) + } + + captchaToIssueToUser := &PuzzleCAPTCHAChallenge{ + GameBoard: gameBoard, + ThumbnailBase64: thumbnailAsB64, + MaxAllowedMoves: targetDifficulty.MaxNumberOfMovesAllowed, + TimeToSolveMS: targetDifficulty.TimeToSolveMs, + CollectDataEnabled: config.PuzzleEnableGameplayDataCollection, + ClickChain: captchaClickChain, + } + + serializedCAPTCHAChallenge, err := json.Marshal(captchaToIssueToUser) + if err != nil { + return nil, fmt.Errorf("%w, %v", ErrFailedMarhsalingChallenge, err) + } + + return serializedCAPTCHAChallenge, nil +} + +var ( + ErrMissingTile = errors.New("gamboard controller expected a tile to exist") + ErrBoardEmpty = errors.New("board is empty or nil") + ErrBoardHieghtWidthMismatch = errors.New("gameboard must be a perfect square") +) + +func NewPuzzleCAPTCHABoard[T PuzzleTileIdentifier](tileMap PuzzleTileMap[T], difficultyProfile PuzzleDifficultyProfile) ([][]*T, error) { + nTiles := len(tileMap) + size := int(math.Sqrt(float64(nTiles))) + + gameBoard := make([][]*T, size) + for i := range gameBoard { + gameBoard[i] = make([]*T, size) + } + + //iterate over them in order as maps don't preserve order but we need them to initially be placed in order + for i := 0; i < nTiles; i++ { + tile, ok := tileMap[i] + if !ok { + return nil, fmt.Errorf("%w: Expected: %d", ErrMissingTile, i) + } + row, col := PuzzleTileIndexToRowCol(i, difficultyProfile.NPartitions) + gameBoard[row][col] = &tile + } + + return gameBoard, nil +} + +/* Returns a deep copy of a game board for any type implementing TileIdentifier */ +func DeepCopyPuzzleTileBoard[T PuzzleTileIdentifier](original [][]*T) ([][]*T, error) { + if len(original) == 0 || len(original[0]) == 0 { + return nil, fmt.Errorf("%w: deepCopyBoard: original board is empty or nil", ErrBoardEmpty) + } + + deepCopyGameBoard := make([][]*T, len(original)) + + for i := range original { + if len(original[i]) != len(original[0]) { + return nil, fmt.Errorf("%w: deepCopyBoard: inconsistent row lengths in original board", ErrBoardHieghtWidthMismatch) + } + deepCopyGameBoard[i] = make([]*T, len(original[i])) + + for j, tile := range original[i] { + if tile != nil { // protect against nil ptr deref + newTile := *tile // copy value + deepCopyGameBoard[i][j] = &newTile // pointer to new copy + } else { + deepCopyGameBoard[i][j] = nil // keeps `nil` for missing tiles + } + } + } + + return deepCopyGameBoard, nil +} + +/* +shuffleBoard will shuffle the board in a deterministically random way. NOTE: the shuffling procedure amounts to playing the game backward. + +Ie, provided the SAME userChallengeCookie (and puzzleSecret as this is called directly from inside of the deriveEntropyInRange() func), +we will always be able to re-shuffle a board in exactly the same way. This is an essential part of being able to verify the solution +of the user by recreating the board we provided them without needing to store anything server side. + +So, if a request comes in on a different server instance (or after a restart), as long as userChallengeCookie and puzzleSecret are unchanged, the board will +be reconstructed exactly as it was when the challenge was issued. +*/ +func ShufflePuzzleBoard[T PuzzleTileIdentifier](boardRef [][]*T, boardCopy [][]*T, targetDifficulty PuzzleDifficultyProfile, nReShuffles int, puzzleSecret, userChallengeCookie string) error { + + MAX_RESHUFFLES := 5 + + if nReShuffles > MAX_RESHUFFLES { + return errors.New("ErrExceededMaxReshuffleAttempts") + } + + foundNil := false + rowNil := -1 + colNil := -1 + + for r, row := range boardRef { + for c, tile := range row { + if tile == nil { + foundNil = true + rowNil = r + colNil = c + break + } + } + } + + if !foundNil || rowNil == -1 || colNil == -1 { + return ErrMissingNullTile + } + + //floor(random * (max-min+1)) + min + maxNShuffles := targetDifficulty.NShuffles[1] + minNShuffles := targetDifficulty.NShuffles[0] + numberOfShufflesToPerform := PuzzleEntropyFromRange(puzzleSecret, userChallengeCookie, minNShuffles, maxNShuffles) + + //now we just apply numberOfShufflesToPerform valid puzzle moves to shuffle! + + var lastRow int + var lastCol int + + for numberOfShufflesToPerform > 0 { + row, col, err := getNextValidPuzzleShuffleMove(boardRef, rowNil, colNil, lastRow, lastCol, puzzleSecret, userChallengeCookie) + if err != nil { + return err + } + SwapPuzzleTile(boardRef, rowNil, colNil, row, col) + lastRow = rowNil + lastCol = colNil + rowNil = row + colNil = col + numberOfShufflesToPerform-- + } + + if IsPuzzleBoardIdentical(boardRef, boardCopy) { + if nReShuffles >= MAX_RESHUFFLES { + return errors.New("ErrExceededMaxReshuffleAttempts: Unable to generate a sufficiently shuffled board") + } + + log.Printf("Detected identical board, reshuffling... %d/%d", nReShuffles, MAX_RESHUFFLES) + return ShufflePuzzleBoard(boardRef, boardCopy, targetDifficulty, nReShuffles+1, puzzleSecret, userChallengeCookie) + } + + return nil +} + +/* +getNextValidShuffleMove is used when shuffling to check whether the next move is valid as the entire shuffling procedure amounts +to playing the game backward. + +The important thing to note is that again, the randomness is dependent on the users challenge cookie as its source of entropy. This is +an essential part of how we verify solutions during runtime without needing to store anything in memory on a per challenge basis. + +So, if a request comes in on a different server instance (or after a restart), as long as userChallengeCookie and puzzleSecret are unchanged, the board will +be reconstructed exactly as it was when the challenge was issued. +*/ +func getNextValidPuzzleShuffleMove[T PuzzleTileIdentifier](boardRef [][]*T, rowNil, colNil, lastRow, lastCol int, puzzleSecret, userChallengeCookie string) (row int, col int, err error) { + valid_X_Moves := []int{1, -1, 0, 0} + valid_Y_Moves := []int{0, 0, 1, -1} + var possible_valid_moves [][2]int + + for i := 0; i < 4; i++ { + potential_x := rowNil + valid_X_Moves[i] + potential_y := colNil + valid_Y_Moves[i] + + if 0 <= potential_x && potential_x < len(boardRef) && 0 <= potential_y && potential_y < len(boardRef[0]) && !(lastRow == potential_x && lastCol == potential_y) { + possible_valid_moves = append(possible_valid_moves, [2]int{potential_x, potential_y}) + } + } + + if len(possible_valid_moves) == 0 { + err = errors.New("ErrFailedExpectation: Expected at least one valid move, got none") + return + } + + /* + It is really important that we derive the randomness from a deterministic source (the users challenge cookie) + this way when it comes to validating, we can reconstruct their map as desired + */ + randomChoice := PuzzleEntropyFromRange(puzzleSecret, userChallengeCookie, 0, len(possible_valid_moves)) + + next_valid_move := possible_valid_moves[randomChoice] + + row = next_valid_move[0] + col = next_valid_move[1] + + return +} + +func SwapPuzzleTile[T PuzzleTileIdentifier](boardRef [][]*T, rowNil, colNil, row2, col2 int) { + nilValue := boardRef[rowNil][colNil] + boardRef[rowNil][colNil] = boardRef[row2][col2] + boardRef[row2][col2] = nilValue +} + +/*returns "not identical" (ie false) if theres at least one difference between the two boards*/ +func IsPuzzleBoardIdentical[T PuzzleTileIdentifier](boardRef, copyOfBoard [][]*T) bool { + for row := 0; row < len(boardRef); row++ { + for col := 0; col < len(boardRef[row]); col++ { + if (boardRef[row][col] == nil) != (copyOfBoard[row][col] == nil) { // Nil mismatch check + return false + } + /*goes does not automatically deref it because its an interface method*/ + if boardRef[row][col] != nil && copyOfBoard[row][col] != nil && (*boardRef[row][col]).GetTileGridID() != (*copyOfBoard[row][col]).GetTileGridID() { + return false + } + } + } + return true +} + +func RemovePuzzleTileFromBoard[T PuzzleTileIdentifier](boardRef [][]*T, targetDifficulty PuzzleDifficultyProfile) error { + row, col := PuzzleTileIndexToRowCol(targetDifficulty.RemoveTileIndex, targetDifficulty.NPartitions) + boardRef[row][col] = nil //JSON will serialize this as `null` + return nil +} + +/* +converts a specific index of a perfect square number of partitions into a (row, col) +is required due to the possibility of a 'random' RemoveTileIndex being supplied, requiring the ability +to recalculate a (row, col) pair for any given difficulty profile +*/ +func PuzzleTileIndexToRowCol(index, nPartitions int) (row int, col int) { + square := int(math.Sqrt(float64(nPartitions))) + row = index / square + col = index % square + return +} diff --git a/internal/puzzle_image_controller.go b/internal/puzzle_image_controller.go new file mode 100644 index 0000000..26112b9 --- /dev/null +++ b/internal/puzzle_image_controller.go @@ -0,0 +1,550 @@ +package internal + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "image" + "image/color" + "image/png" + "math" + "math/rand" + "sync" + "time" + + "golang.org/x/image/draw" +) + +var ( + ErrFailedInitImagePartition = errors.New("failed to partition image during PuzzleImageController initialization") + ErrFailedInitInvalidNumberOfPartitions = errors.New("PuzzleImageController initialization failed expected nPartitions to be a perfect square") + ErrUnsupportedPuzzleType = errors.New("puzzle images can be provided as either TileWithoutImage or Tile") + ErrFailedEncoding = errors.New("failed to encode PNG image.Image to base64") + ErrFailedDecoding = errors.New("failed to decode base64 encoded PNG to image.Image") +) + +// TileNoiseMask represents a grid of RGB noise adjustments +type PuzzleTileNoiseMask struct { + Offsets [][][3]int // [y][x][R, G, B] pixel modifications +} + +const NumberOfTileNoiseMasks = 512 +const thumbnailSize = 400 + +type PuzzleTileMetadata struct { + RGBAImagePtr *image.RGBA + Hash string +} + +/*to save memory, we store only the tileIDs without the base64 when storing the copy of the original for verification*/ +type PuzzleTileWithoutImage struct { + TileGridID string `json:"tile_grid_id"` +} + +type PuzzleTile struct { + Base64Image string `json:"base64_image"` + TileGridID string `json:"tile_grid_id"` +} + +/* +for generics on functions that can apply to both TileWithoutImage and Tile types +this is particularly important for functions that are used to recreate the gameboard +when validating a users solution without needing to store state server side +*/ +type PuzzleTileIdentifier interface { + GetTileGridID() string +} + +func (t PuzzleTile) GetTileGridID() string { + return t.TileGridID +} + +func (t PuzzleTileWithoutImage) GetTileGridID() string { + return t.TileGridID +} + +type PuzzleTileMap[T PuzzleTileIdentifier] map[int]T + +/* +Init +- Load & partition image once O(N) (one-time) +- Convert tiles to RGBA once O(N) (one-time) +- Precompute 128 noise masks O(N^2) (one-time) +- Precompute tile pixel hashes O(N^2) (one-time) + +Runtime +- Lookup stored RGBA tile O(1) +- Lookup stored tile hash O(1) +- Select noise mask (HMAC) O(1) +- Apply precomputed noise O(1) *** +- Encode Base64 & hash O(1) + +*** technically this is N^2 BUT we are applying the operation to a fixed size tile that is never going to grow - +it does not scale dynamically, its always made up of the same number of pixels. So I argue its constant and the conventional +Big-O N^2 notation is misleading—this is a constant-time operation in practice. + +Despite adding noise to the thumbnail so users cannot just partition the thumbnail and recreate the solution +on their own by matching the b64 hashes directly, we could still be vulnerable to replays if the user has seen +"this" image in particular. If they knew the final order and recorded the b64 of the images, they could just +map the b64 of each tile and then look up the IDs. To beat this vector, we now add noise to the tiles themselves. + +HOWEVER, one really important thing is that some tiles may be blank. Adding noise to blank tiles might seem problematic, +but since identical tiles (like blanks) always have the **same b64 representation** AND are passed the same **entropy**, +they receive the **exact same noise**, keeping them interchangeable. + +Previously, we avoided adding noise to blank tiles to ensure interchangeability, but with deterministic noise application, +we can now apply noise to **all tiles**, making **every puzzle unique across all tiles while maintaining interchangeability** where needed. + +For example, if a puzzle solution consists of tiles [A, B, A], the hash calculation remains the same whether the first and last tiles are swapped: + + hash(A, B, A) == hash(A, B, A) + +Since blank tiles are identical, they will always be given the same noise, ensuring their interchangeability is preserved, +while every puzzle remains uniquely coded to each user, destroying any replay attack vectors. + +Additionally, since we now use a separate **thumbnail entropy**, even the thumbnail cannot be used as a reference to map tile hashes, +further ensuring that previously seen solutions **cannot be reused**. + +To guarantee full security, noise is applied **before computing HMAC hashes**, ensuring each puzzle is cryptographically distinct +and resistant to brute-force attacks. + +Even with dedicated attacks attempting to brute-force noise values, using the rate limiting strategy explain in docs and +simply rotating images periodically further mitigates any long-term risks. +*/ +type PuzzleImageController struct { + tileNoiseMasks [NumberOfTileNoiseMasks]PuzzleTileNoiseMask + partitionedImageTileMetadata map[int]PuzzleTileMetadata + thumbnailNoiseMasks [NumberOfTileNoiseMasks]PuzzleTileNoiseMask + thumbnailPtr *image.RGBA + partitionTileHeight int + partitionTileWidth int + numberOfPartitions int + + /* + Although we are making copies for operations and never directly modifying the pointers to tile metadata or thumbnail pointers, becuase we may + need to deal with hot reloading configs, we use an rwLock such that we acquire and release it when reading from the TileMapFromImage or ThumbnailFromImage + functions and acquire the write lock when invoking the newPuzzleImageController at the level of the configHolder.Load() + */ + rwLock sync.RWMutex +} + +func NewPuzzleImageController(config *Config) (*PuzzleImageController, error) { + if config.PuzzleDifficultyProfiles == nil { + return nil, errors.New("ErrFailedToLoadDifficultyProfiles") + } + /* + right now I am assuming that there is just one image to be served for all challenges. However, if you wanted to make + it such that each hostname has its own logo, there would need to be a map of "Image controllers" and "targets" indexed + by hostnames such that each hostname has its own difficulty. Then modify the PuzzleDifficultyProfileByName such that it also + takes as argument the hostname and performs the lookup to get the target before then using that to lookup the difficulty + profile itself. The idea of having a "target" is to be able to create the difficulties ahead of time and then + make looking up the profile more convenient by just specifying target. + */ + + /* + if you wanted to store multiple images for example different hostnames have different logs & you wanted to issue a puzzle + with that organizations hostname, this would be a map[string]*PuzzleImageController such that on challenge just lookup the appropriate one to use + at the level of the Generate Puzzle function when invoking the PuzzleTileMapFromImage() and PuzzleThumbnailFromImage() functions + */ + + var puzzleImageController = &PuzzleImageController{} + err := puzzleImageController.UpdateFromConfig(config) + if err != nil { + return nil, fmt.Errorf("ErrFailedLoadingImageControllerState: %v", err) + } + + return puzzleImageController, nil +} + +/* +NewPuzzleImageController is created one time on init with the goal of performing the most costly operations one time. After +paying this price once on init, we can simply apply O(1) operations when issueing challenges to user during runtime. This +also applies to validating solutions such that we can avoid storing precomputed results ahead of time (which does not +scale well if we are under attack as we would allocate memory for each challenge). Instead, we allocate the memory we need +one time, and subsequently reuse the tools to generate deterministically random results! +*/ +func (imgController *PuzzleImageController) UpdateFromConfig(config *Config) error { + + imgController.rwLock.Lock() + defer imgController.rwLock.Unlock() + + //not redudant as its subsequently called on hot reload directly via UpdateFromConfig so its an important check + if config.PuzzleDifficultyProfiles == nil { + return errors.New("ErrFailedToLoadDifficultyProfiles") + } + + if config.PuzzleDifficultyTarget == "" { + return errors.New("ErrMissingTargetPuzzleDifficultyProfile") + } + + targetDifficulty, exists := PuzzleDifficultyProfileByName(config, config.PuzzleDifficultyTarget, "") + if !exists { + return errors.New("ErrMissingTargetPuzzleDifficultyProfile") + } + + sqrt := int(math.Sqrt(float64(targetDifficulty.NPartitions))) + if sqrt*sqrt != targetDifficulty.NPartitions { + return ErrFailedInitInvalidNumberOfPartitions + } + + b64Img := LoadDefaultPuzzleImageBase64() + + img, err := decodeBase64ToImage(b64Img) + if err != nil { + return fmt.Errorf("%w: %v", ErrFailedDecoding, err) + } + + thumbnail := resizeToThumbnail(img) + thumbnailRGBA := convertToRGBA(thumbnail) + thumbnailHeight := thumbnailRGBA.Bounds().Dy() + thumbnailWidth := thumbnailRGBA.Bounds().Dx() + + var thumbnailNoiseMasks [NumberOfTileNoiseMasks]PuzzleTileNoiseMask + for i := 0; i < NumberOfTileNoiseMasks; i++ { + //we use i*54321 as entropy so we get deterministic masks across restarts while maintaining pseudo random noise + thumbnailNoiseMasks[i] = generateTileNoiseMask(thumbnailHeight, thumbnailWidth, i*54321) + } + + imgPartitionedAsTiles, err := partitionImage(img, targetDifficulty.NPartitions) + if err != nil { + return fmt.Errorf("%w: %v", ErrFailedInitImagePartition, err) + } + + //since each partition is the same dimension, we can create our mask for one + //as the puzzle is always a perfect square number of tiles + targetImg := imgPartitionedAsTiles[0] + rgbaImgPartition := convertToRGBA(targetImg) + tileHeight := rgbaImgPartition.Bounds().Dy() + tileWidth := rgbaImgPartition.Bounds().Dx() + + var tileNoiseMasks [NumberOfTileNoiseMasks]PuzzleTileNoiseMask + for i := 0; i < NumberOfTileNoiseMasks; i++ { + //we use i*12345 as entropy so we get deterministic masks across restarts while maintaining pseudo random noise + tileNoiseMasks[i] = generateTileNoiseMask(tileHeight, tileWidth, i*12345) + } + + partitionedImageTiles := make(map[int]PuzzleTileMetadata) + for i, tile := range imgPartitionedAsTiles { + rgbaTile := convertToRGBA(tile) + hashOfTilePixels := hashTilePixels(rgbaTile) + partitionedImageTiles[i] = PuzzleTileMetadata{RGBAImagePtr: rgbaTile, Hash: hashOfTilePixels} + } + + imgController.partitionedImageTileMetadata = partitionedImageTiles + + imgController.numberOfPartitions = targetDifficulty.NPartitions + imgController.tileNoiseMasks = tileNoiseMasks + imgController.partitionTileHeight = tileHeight + imgController.partitionTileWidth = tileWidth + + imgController.thumbnailNoiseMasks = thumbnailNoiseMasks + imgController.thumbnailPtr = thumbnailRGBA + + return nil +} + +/* +TileMapFromImage is used to generate the tileMaps for each individual challenge at runtime. +This performs O(1) operations in order to generate the map as all costly operations were performed +on initialization. + +NOTE: when invoking the applyNoiseMask function, we are making a copy inside that function so we +can modify noisedImg freely without affecting tileRGBA (which is just a pointer to the original stored image) + +NOTE: set includeBase64Png to false when calculating the TileID's for validation as we need not have the image data when validating + +If T == Tile, return tiles with Base64 images (Tile) +If T == TileWithoutImage, return tiles without Base64 images (TileWithoutImage) +*/ +func PuzzleTileMapFromImage[T PuzzleTileIdentifier](config *Config, puzzleImageController *PuzzleImageController, userChallengeCookie string, includeBase64Png bool) (PuzzleTileMap[T], error) { + + // imgController := config.PuzzleImageController + + puzzleImageController.rwLock.RLock() + defer puzzleImageController.rwLock.RUnlock() + + tileMap := make(PuzzleTileMap[T]) + for i := range puzzleImageController.numberOfPartitions { + + tileRGBA := puzzleImageController.partitionedImageTileMetadata[i].RGBAImagePtr + tileHash := puzzleImageController.partitionedImageTileMetadata[i].Hash + + var noisyTileB64 string + + if includeBase64Png { + + var err error + + /* + selectionEntropy is unique per challenge per user becuase of the cookie being different per challenge per user + by also including the hash of the image itself, identical images get the same hashes => they use the same mask but + different images use different masks making identifying patterns harder + + + The reasons we use BOTH the users challenge cookie as well as the hash of the target tile as entropy when selecting which noise mask + to apply are: + + 1) we want to make sure that per user per challenge (ie for each challenge cookie) we get different masks so users cannot + just find some trivial defeat mechanism like a map of b64 data and just lookup their way to the right answer. + + 2) we also take into consideration the hash of the tile itself such that the same tiles get the same mask so that they preserve + their interchangability property. That way if we have identical tiles, we can apply noise to them (to make them different per + user per challenge) BUT have the identical tiles remain interchangeable so the user can submit them in any order as they are identical + */ + + selectionEntropy := fmt.Sprintf("%s%s", userChallengeCookie, tileHash) + randomIndex := PuzzleEntropyFromRange(config.PuzzleEntropySecret, selectionEntropy, 0, NumberOfTileNoiseMasks) + tileMask := &puzzleImageController.tileNoiseMasks[randomIndex] + + // tileMask := selectNoiseMask(userChallengeCookie, tileHash, imgController.puzzleSecret, &imgController.tileNoiseMasks) + noisyTile := applyNoiseMask(tileRGBA, tileMask, puzzleImageController.partitionTileHeight, puzzleImageController.partitionTileWidth) + + noisyTileB64, err = encodeImageToBase64(noisyTile) + if err != nil { + return nil, fmt.Errorf("%w: failed to encode noisy tile: %w", ErrFailedEncoding, err) + } + } + + tileID := GenerateHMACFromString(tileHash, userChallengeCookie) + /* + notice that we get the tileID from the hash against the tileHash as opposed to against the noisyTileB64. This is done + for several reasons, namely, using the precomputed tileHash instead of hashing the noisy b64 encoded tile is a massive + performance improvement AND we still maintain all required security and uniqueness properties. + + In particular, + + - Since the tileHashs that were calculated on init are done so on the base64 data, identical images + admit identical hashes. Since we are using a deterministic masking procedure to pick how to add noise, if we were + to rehash the noisy tiles, we would notice that the hashes that were identical remain identical and the ones + that were not again remain not. The only difference is the overhead required to compute the hmac against the cookie + because the b64 data is much bigger. + + - Since the hash is generated using the userChallengeCookie, each of the TileIDs would STILL be unique + per user per challenge, so we are not somehow allowing a replay, forgery or trivial mapping attacks + + - Since the tileHash is derived from the original image pixels, ensuring that identical tiles have identical hashes. + - Because the HMAC uses tileHash as input, identical tiles will always get the same TileID across different + challenges for the same user, preserving their interchangeability. + + so, calculating the HMAC over the b64 encoded noisy tile instead of the precomputed tileHash would yield the + exact same results but with significantly worse performance due to the overhead of hashing large Base64 data. + */ + + var tile T + switch any(tile).(type) { + case PuzzleTile: + tile = any(PuzzleTile{Base64Image: noisyTileB64, TileGridID: tileID}).(T) + case PuzzleTileWithoutImage: + tile = any(PuzzleTileWithoutImage{TileGridID: tileID}).(T) + default: + return nil, fmt.Errorf("%w: unsupported tile type", ErrUnsupportedPuzzleType) + } + + tileMap[i] = tile + } + + return tileMap, nil +} + +/* +Returns a copy with the changes made (removing the tile after generating puzzle) such that we do not affect the og image + +NOTE: We use unix timestamp at the time we issue the challenge as part of the entropy for picking a mask because the +thumbnail itself has no effect on the users calcualted result and we need not ever recreate it. We also want to guarentee +there exists no correlation between the users current challenge grid image and the thumbnail to avoid a user trying to cheat +the puzzle by partitioning the thumbnail or trying to get cute in any other way. +*/ +func PuzzleThumbnailFromImage(config *Config, puzzleImageController *PuzzleImageController, thumbnailEntropy string, removeRow, removeCol int) (string, error) { + + // imgController := config.PuzzleImageController + + puzzleImageController.rwLock.RLock() + defer puzzleImageController.rwLock.RUnlock() + + thumbnailCopy := image.NewRGBA(puzzleImageController.thumbnailPtr.Bounds()) //copy to not affect the ptr for the next guy + draw.Draw(thumbnailCopy, thumbnailCopy.Bounds(), puzzleImageController.thumbnailPtr, puzzleImageController.thumbnailPtr.Bounds().Min, draw.Src) + + tileWidth := thumbnailCopy.Bounds().Dx() / int(math.Sqrt(float64(puzzleImageController.numberOfPartitions))) + tileHeight := thumbnailCopy.Bounds().Dy() / int(math.Sqrt(float64(puzzleImageController.numberOfPartitions))) + + transparencyFactor := 0.9 + for y := 0; y < tileHeight; y++ { + for x := 0; x < tileWidth; x++ { + idxX := removeCol*tileWidth + x + idxY := removeRow*tileHeight + y + origColor := thumbnailCopy.RGBAAt(idxX, idxY) + grayValue := uint8(170) + + thumbnailCopy.SetRGBA(idxX, idxY, color.RGBA{ + R: uint8(float64(origColor.R)*(1-transparencyFactor) + float64(grayValue)*transparencyFactor), + G: uint8(float64(origColor.G)*(1-transparencyFactor) + float64(grayValue)*transparencyFactor), + B: uint8(float64(origColor.B)*(1-transparencyFactor) + float64(grayValue)*transparencyFactor), + A: uint8(float64(origColor.A)*(1-transparencyFactor) + 120*transparencyFactor), + }) + } + } + + selectionEntropy := fmt.Sprintf("%s%d", thumbnailEntropy, time.Now().UnixNano()) + randomIndex := PuzzleEntropyFromRange(config.PuzzleEntropySecret, selectionEntropy, 0, NumberOfTileNoiseMasks) + tileMask := &puzzleImageController.thumbnailNoiseMasks[randomIndex] + + noisyThumbnail := applyNoiseMask(thumbnailCopy, tileMask, thumbnailCopy.Bounds().Dy(), thumbnailCopy.Bounds().Dx()) + + thumbnailBase64, err := encodeImageToBase64(noisyThumbnail) + if err != nil { + return "", fmt.Errorf("%w: failed to encode thumbnail: %w", ErrFailedEncoding, err) + } + + return thumbnailBase64, nil +} + +/* +NOTE: +This is happening at runtime, although technically this is N^2 it is important to notice that we are applying the operation +to a fixed size tile that is never going to grow - it does not scale dynamically, its always made up of the same number of pixels. +So I argue the conventional Big-O N^2 notation is misleading — this is a constant-time operation in practice. + +Also note that noisedImg := image.NewRGBA(img.Bounds()) creates a copy. So we are never actually changing the data in the map +so you can modify noisedImg freely without affecting tileRGBA (which is just a pointer to the original stored image) +*/ +func applyNoiseMask(img *image.RGBA, mask *PuzzleTileNoiseMask, tileHeight, tileWidth int) *image.RGBA { + noisedImg := image.NewRGBA(img.Bounds()) + + for y := 0; y < tileHeight; y++ { + for x := 0; x < tileWidth; x++ { + origColor := img.RGBAAt(x, y) + noise := mask.Offsets[y][x] + + //apply noise, ensuring values stay in [0,255] + r := clampColor(int(origColor.R) + noise[0]) + g := clampColor(int(origColor.G) + noise[1]) + b := clampColor(int(origColor.B) + noise[2]) + + noisedImg.SetRGBA(x, y, color.RGBA{uint8(r), uint8(g), uint8(b), origColor.A}) + } + } + return noisedImg +} + +func clampColor(val int) uint8 { + if val < 0 { + return 0 + } else if val > 255 { + return 255 + } + return uint8(val) +} + +/* +Note that here we use a maskSeed was is meant to always be the same such that +on restarts we generate the exact same masks. This ensures even if a machine crashes +while solving a puzzle, it it is back up by the time they submit their solution, we +will still be able to validate their solution and/or cookie +*/ +func generateTileNoiseMask(tileHeight, tileWidth, maskSeed int) PuzzleTileNoiseMask { + seed := int64(maskSeed) + r := rand.New(rand.NewSource(seed)) + + //anything from [16, 26] results in solid noise, doesn't disturb peoples ability to see + minNoise := 16 + maxNoise := 26 + noiseLevel := r.Intn(maxNoise-minNoise+1) + minNoise + + mask := PuzzleTileNoiseMask{Offsets: make([][][3]int, tileHeight)} + for y := 0; y < tileHeight; y++ { + mask.Offsets[y] = make([][3]int, tileWidth) + for x := 0; x < tileWidth; x++ { + mask.Offsets[y][x] = [3]int{ + r.Intn(noiseLevel*2+1) - noiseLevel, // R offset + r.Intn(noiseLevel*2+1) - noiseLevel, // G offset + r.Intn(noiseLevel*2+1) - noiseLevel, // B offset + } + } + } + + return mask +} + +/*gets us the entropy from the tile itself which is much gaster than encoding and decoding to and from base64*/ +func hashTilePixels(img *image.RGBA) string { + h := sha256.New() + for y := img.Bounds().Min.Y; y < img.Bounds().Max.Y; y++ { + for x := img.Bounds().Min.X; x < img.Bounds().Max.X; x++ { + c := img.RGBAAt(x, y) + h.Write([]byte{c.R, c.G, c.B, c.A}) + } + } + return fmt.Sprintf("%x", h.Sum(nil)) +} + +/* +resizes thumbnail image to specified constant. Helps to make the image different from grid tiles to mitigate +partitioning attempts at recreating the grid and also makes for less data to send +*/ +func resizeToThumbnail(img image.Image) *image.RGBA { + dst := image.NewRGBA(image.Rect(0, 0, thumbnailSize, thumbnailSize)) + draw.CatmullRom.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Over, nil) + return dst +} + +/*converts image to RGBA*/ +func convertToRGBA(img image.Image) *image.RGBA { + if rgbaImg, ok := img.(*image.RGBA); ok { + return rgbaImg + } + rgba := image.NewRGBA(img.Bounds()) + draw.Draw(rgba, rgba.Bounds(), img, img.Bounds().Min, draw.Src) + return rgba +} + +/*from base64 encoded png image to image.Image */ +func decodeBase64ToImage(data string) (image.Image, error) { + decoded, err := base64.StdEncoding.DecodeString(data) + if err != nil { + return nil, err + } + img, err := png.Decode(bytes.NewReader(decoded)) + if err != nil { + return nil, err + } + return img, nil +} + +/*from image.Image to base64 encoded png*/ +func encodeImageToBase64(img image.Image) (string, error) { + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(buf.Bytes()), nil +} + +/* +partitions an image.Image into nPartition tiles +Requires nPartitions be a perfect square as all +puzzle grids are meant to be squares +*/ +func partitionImage(img image.Image, nPartitions int) ([]image.Image, error) { + sqrt := int(math.Sqrt(float64(nPartitions))) + if sqrt*sqrt != nPartitions { + return nil, errors.New("nPartitions must be a perfect square") + } + tileWidth := img.Bounds().Dx() / sqrt + tileHeight := img.Bounds().Dy() / sqrt + tiles := []image.Image{} + + for row := 0; row < sqrt; row++ { + for col := 0; col < sqrt; col++ { + tile := image.NewRGBA(image.Rect(0, 0, tileWidth, tileHeight)) + for y := 0; y < tileHeight; y++ { + for x := 0; x < tileWidth; x++ { + tile.Set(x, y, img.At(col*tileWidth+x, row*tileHeight+y)) + } + } + tiles = append(tiles, tile) + } + } + return tiles, nil +} diff --git a/internal/puzzle_shared_tools.go b/internal/puzzle_shared_tools.go new file mode 100644 index 0000000..00b88f3 --- /dev/null +++ b/internal/puzzle_shared_tools.go @@ -0,0 +1,516 @@ +package internal + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log" + "math/big" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/gin-gonic/gin" +) + +func LoadDefaultPuzzleImageBase64() string { + relativePath := "internal/static/images/default_baskerville_logo.png" + absolutePath, err := getAbsolutePath(relativePath) + if err != nil { + log.Fatalf("Failed to create absolute path: %v", err) + } + + imageData, err := os.ReadFile(absolutePath) + if err != nil { + log.Fatal("Error loading default image:", err) + } + return base64.StdEncoding.EncodeToString(imageData) +} + +func getAbsolutePath(relativePath string) (string, error) { + basePath, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("failed to get working directory: %w", err) + } + return filepath.Join(basePath, relativePath), nil +} + +/* +GenerateHMACFromString is used across the generator & verifier because it matches the behaviour +of the client side (written in typescript). If we use go idiomatic function signatures for this, +it can result in hashing being incosistent and since the validation strategy relies on verifying a blockchain +it is really important to consistently use this function in particular when generating hashes +*/ +func GenerateHMACFromString(message string, key string) string { + h := hmac.New(sha256.New, []byte(key)) + h.Write([]byte(message)) //msg is a string, converted to bytes + return hex.EncodeToString(h.Sum(nil)) //ensures no padding +} + +/* +EntropyFromRange is used to generate entropy anytime we need a soure of randomness across any of the +CAPTCHA puzzle components. + +WARNING: It is really important that we derive the randomness from a deterministic source (the users challenge cookie) +since validation requires recreating parts of the initial challenge we issued. In order to guarentee this type of +deterministic psuedo reandomness by taking, as arugment, key properties such as: + + - `entropyInitalizationVector` (like a `puzzleSecret`) + Where `puzzleSecret` is a secret only we know that allows us to ensure no one can cheat/forge/replay + + - `entropyContext` (like a `userChallengeCookieString`) + Where `userChallengeCookieString` allows generating a different challenge for each user, wrt tileRemoved, noise, etc + +and use them AS the source of entropy enabling generation of pseudo-random numbers for verifiying solutions +by recreating the initial challenge we issued at runtime. Anytime the same info is passed in, it will generate +the same "random" sequence. +*/ +func PuzzleEntropyFromRange(entropyInitalizationVector, entropyContext string, minValue, maxValue int) int { + + // log.Printf("EntropyFromRange called: minValue:%d, maxValue:%d", minValue, maxValue) + + h := hmac.New(sha256.New, []byte(entropyInitalizationVector)) + h.Write([]byte(entropyContext)) + hash := h.Sum(nil) + + hashInt := new(big.Int).SetBytes(hash) + + //ensure we get a number in the range [minValue, maxValue] + rangeSize := maxValue - minValue // WARNING: DO NOT add +1, maxValue is already inclusive! + + // if rangeSize <= 0 { + // log.Printf("FATAL: Invalid range detected in EntropyFromRange. minValue: %d, maxValue: %d", minValue, maxValue) + // } + + return int(new(big.Int).Mod(hashInt, big.NewInt(int64(rangeSize))).Int64()) + minValue +} + +/* +same as the function on the client side such that the user can submit their sol to us and we +can compute our expectation of that sol for comparison +*/ +func CalculateExpectedPuzzleSolution[T PuzzleTileIdentifier](boardRef [][]*T, userChallengeCookie string) string { + + var boardIDHashesInOrder strings.Builder + for _, row := range boardRef { + for _, tile := range row { + if tile == nil { + boardIDHashesInOrder.WriteString("null_tile") + } else { + boardIDHashesInOrder.WriteString((*tile).GetTileGridID()) //dereferencing needed + } + } + } + + //this is the part of the solution that the user computes on their end + expectedSolution := GenerateHMACFromString(boardIDHashesInOrder.String(), userChallengeCookie) + return expectedSolution +} + +// func LogPuzzleGameBoard[T TileIdentifier](gameBoard [][]*T) { +// log.Println("=== GAMEBOARD ===") +// for i, row := range gameBoard { +// rowStr := fmt.Sprintf("Row %d: ", i) +// for _, tile := range row { +// if tile != nil { +// rowStr += fmt.Sprintf("[%s...] ", (*tile).GetTileGridID()) // Preview first 20 chars +// } else { +// rowStr += "[nil] " +// } +// } +// log.Println(rowStr) +// } +// } + +func ParsePuzzleSolutionCookie(c *gin.Context, cookiesToDelete *[]string) (userSolutionSubmission *ClientPuzzleSolutionSubmissionPayload, err error) { + + defer func() { + if err != nil { + // log.Println("stripping solution cookies due to error while parsing solution cookie") + StripPuzzleSolutionCookieIfExist(c, *cookiesToDelete) + *cookiesToDelete = make([]string, 0) + } + }() + + userSolutionAsB64EncodedCookieString, err := c.Cookie("__banjax_sol") + if err != nil { + return nil, err + } + *cookiesToDelete = append(*cookiesToDelete, "__banjax_sol") + + cookies := c.Request.Cookies() + userJSONSerializedClickChain, cookieNames, err := extractPuzzleClickChainFromCookies(cookies) + //the cookieNames are always guarenteed to return at least empty array + *cookiesToDelete = append(*cookiesToDelete, cookieNames...) + if err != nil { + // log.Println("Unable to verify solution without users click chain cookie(s)") + return nil, err + } + + var userSubmittedClickChain []ClickChainEntry + err = json.Unmarshal(userJSONSerializedClickChain, &userSubmittedClickChain) + if err != nil { + // log.Printf("Failed to unmarshal user click chain from cookies due to error: %v", err) + return nil, err + } + + userSolutionString, err := base64.StdEncoding.DecodeString(userSolutionAsB64EncodedCookieString) + if err != nil { + // log.Printf("Failed to decode user solution string due to error: %v", err) + return nil, err + } + + userSolutionSubmission = &ClientPuzzleSolutionSubmissionPayload{ + Solution: string(userSolutionString), + ClickChain: userSubmittedClickChain, + } + + return + +} + +/* +ExtractClickChainFromCookies parses the cookies available in the requests cookies header +to recreate the click chain bytes. The ClickChain is written into cookies when the user submits +a solution. Because there is a 4096 byte limit per cookie, the click chain itself is initially +encoded into base64, then parsed into segments < 4096 bytes. In order to ensure we can recreate the +solution in order, the cookies are written with metadata that tells us how many to expect: + +For example, suppose the click chain needs 3 cookies to be sent, then we will receive cookies with names: +__banjax_cc_1_3, __banjax_cc_2_3, __banjax_cc_3_3 + +In order to guarentee always being able to receive user solutions, we cap the number of moves required +for any puzzle solution at < 80. This ensures that at most 8 cookies will be attached. In practice, most puzzles are +solvable in < 10 moves so it should only require attaching a single __banjax_cc_1_1 cookie +*/ +func extractPuzzleClickChainFromCookies(cookies []*http.Cookie) ([]byte, []string, error) { + + clickChainParts := make(map[int]string) + cookieNames := make([]string, 0) + totalParts := 0 + + for _, cookie := range cookies { + //match only __banjax_cc_x_y format since we know that is guarenteed to be there, we just dont know + //what will come after. If there are 2 then it will be __banjax_cc_1_2, but if only 1 it will be __banjax_cc_1_1 + if strings.HasPrefix(cookie.Name, "__banjax_cc_") { + //get the indices from the name "__banjax_cc_x_y" + parts := strings.Split(cookie.Name, "_") + //which should produce: ['', '', 'banjax', 'cc', '1', '2'] + if len(parts) != 6 { + log.Printf("skipping malformed click chain cookie: %s", cookie.Name) + continue + } + + parts = parts[2:] + + partIndex, err1 := strconv.Atoi(parts[2]) // x (current part) + total, err2 := strconv.Atoi(parts[3]) // y (total parts) + + if err1 != nil || err2 != nil || partIndex < 1 || total < 1 || partIndex > total { + continue + } + + cookieNames = append(cookieNames, cookie.Name) + clickChainParts[partIndex] = cookie.Value + + //this way we know the highest seen + if partIndex > totalParts { + totalParts = partIndex + } + } + } + + if len(clickChainParts) == 0 { + return nil, cookieNames, fmt.Errorf("no valid click chain cookies found") + } + + if len(clickChainParts) != totalParts { + return nil, cookieNames, fmt.Errorf("incomplete click chain cookies") + } + + var clickChainPartsInOrder []string + //now we can look for any missing parts explicitly + for i := 1; i <= totalParts; i++ { + _, exists := clickChainParts[i] + if !exists { + return nil, cookieNames, fmt.Errorf("missing click chain part: %d", i) + } + clickChainPartsInOrder = append(clickChainPartsInOrder, clickChainParts[i]) + } + + clickChainBase64 := strings.Join(clickChainPartsInOrder, "") + clickChainJSON, err := base64.StdEncoding.DecodeString(clickChainBase64) + if err != nil { + return nil, cookieNames, fmt.Errorf("failed to decode Base64 click chain: %w", err) + } + + return clickChainJSON, cookieNames, nil +} + +func StripPuzzleSolutionCookieIfExist(c *gin.Context, cookieNamesToDelete []string) { + for _, name := range cookieNamesToDelete { + _, err := c.Cookie(name) + if err == nil { + c.SetCookie(name, "", -1, "/", "", false, false) + } + } +} + +/* +ValidatePuzzleCAPTCHACookie is used to validate captcha puzzle cookies. The goal is simiilar to how the ValidateShaInvCookie works in the +context of sendOrValidateShaInvChallenge: We need to distinguish between whether the cookie is is a challenge cookie or a solution cookie. +If it is a solution, we need to check the validity of the solution. + +Validity of a solution: + +In the puzzle captcha flow, a "challenge cookie" is a the same as the shaChallenge. This is because this cookie format is reliable, admits +integrity checking as well as built in expiry and IP Address / user agent which ties the cookie to a specific user (network). However since +we are not issuing a proof of work challenge, the expected zero bits is hardcoded to 0 + +The users solution submission has 2 parts: + + 1. Click Chain + As the user is solving the puzzle they are adding to their click chain by using the challenge cookie to calculate hashes starting with the + reference hash we provided initially when issueing the challenge (the genesis click chain item). This ties their solution to a specific puzzle. + + 2. Solution hash + On click submit, the tile IDs of the users board are concatinated in the order the user positioned them, and they are signed using the + users challenge cookie + +When we receive the submission, we validate the click chain for integrity proving they actually did work to get the solution hash and used the +challenge cookie to perform all of the operations. Then we compare the solution hash to the expected solution by recreating the challenge we +initially issued them and calculate the solution ourselves using their cookie. + +If both of these steps were valid, then the user passed the challenge. So, we generate the solution cookie and return it to them. This solution +cookie is comprised of 2 parts: The initial challenge cookie we provided and the solution hash they provided after solving delimited by "[sol]". + +ie the solution cookie is: + + base64(original_challenge_cookie[sol]solution_hash_from_submission) + +In order to verify that the user did work (ie the proof of work) to earn this cookie, whenever we receive a request, we parse out the original cookie +as well as the solution hash. We first start by validating the original cookie (as mentioned before because it is tied to the IP address, has integrity +checking built it etc). As long as that is valid, we use this challenge cookie to recreate the puzzle we initially issued to the user and recalculate +the expected solution hash. As long as this solution hash matches the only provided, we know that this solution could have only been generated from +this original challenge cookie. + +NOTE: It is important to note that by base 64 encoding the entire solution cookie, the original_challenge_cookie (which is already base64) gets encoded again +and as a result, needs to be base64 decoded in order to get it back to the form that would be parsed properly by validateShaInvCookie. This is an essential +part of how we distinguish a refresh request from a request made by a user who has passed as a refresh request will have sent the original challenge cookie, +whereas a user who has the solution cookie will send a cookie not parsable by the validateShaInvCookie until it reaches this function. +*/ +func ValidatePuzzleCAPTCHACookie(config *Config, puzzleImageController *PuzzleImageController, cookieString string, nowTime time.Time, clientIp string) error { + + decodedCookieValue, err := base64.StdEncoding.DecodeString(cookieString) + if err != nil { + return errors.New("failed to decode CAPTCHA cookie") + } + + parts := strings.Split(string(decodedCookieValue), "[sol]") + + if len(parts) == 1 { + //no solution submitted yet, validate only the original challenge portion + return ValidateShaInvCookie(config.HmacSecret, parts[0], nowTime, clientIp, 0) + + } else if len(parts) == 2 { + + originalCookiePortion := parts[0] + puzzleSolutionPortion := strings.TrimSpace(parts[1]) + + //if no solution portion is provided, treat as missing solution + if len(puzzleSolutionPortion) == 0 { + return ValidateShaInvCookie(config.HmacSecret, originalCookiePortion, nowTime, clientIp, 0) + } + + //validate the original challenge first + err := ValidateShaInvCookie(config.HmacSecret, originalCookiePortion, nowTime, clientIp, 0) + if err != nil { + return err // If the challenge is expired/invalid, we don't care about the solution + } + + //validate the solution against the original challenge portion + var expectedSolution string + _, _, expectedSolution, err = GeneratePuzzleExpectedSolution(config, puzzleImageController, originalCookiePortion) + + if err != nil { + return fmt.Errorf("ErrRecreatingExpectedSolution: %v", err) + } + + if expectedSolution == "" { + return errors.New("failed to calculate existing solution") + } + + return VerifyPuzzleSolutionHash(puzzleSolutionPortion, expectedSolution) + + } else { + //malformed cookie (too many `[sol]` delimiters) + return errors.New("malformed CAPTCHA cookie") + } +} + +type PuzzleErrorLogger struct { + mu sync.Mutex + file *os.File + logger *log.Logger +} + +func NewPuzzleErrorLogger(logFilePath string) (*PuzzleErrorLogger, error) { + file, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return nil, err + } + + return &PuzzleErrorLogger{ + file: file, + logger: log.New(file, "", log.LstdFlags), + }, nil +} + +func (l *PuzzleErrorLogger) WritePuzzleErrorLog(format string, v ...interface{}) error { + l.mu.Lock() + defer l.mu.Unlock() + + if l.logger == nil { + return fmt.Errorf("ErrLoggerNil") + } + + _, err := l.logger.Writer().Write([]byte(fmt.Sprintf(format+"\n", v...))) + return err +} + +func (l *PuzzleErrorLogger) Close() error { + return l.file.Close() +} + +/* +Returns the profile associated with "difficulty" key in the yaml +NOTE: You can access the target using configs.PuzzleDifficultyProfiles.Target and use that as the "difficulty" argument + +accepts userChallengeCookie as argument such that if the profile specifies a random index, +the source of entropy used is the users challenge cookie +*/ +func PuzzleDifficultyProfileByName(config *Config, difficulty string, userChallengeCookie string) (PuzzleDifficultyProfile, bool) { + // profileConfig.configLock.RLock() + difficultyProfile, exists := config.PuzzleDifficultyProfiles[difficulty] + + //if dne return early + if !exists { + // profileConfig.configLock.RUnlock() + return PuzzleDifficultyProfile{}, false + } + + //if we need not make changes, return the valid profile + if difficultyProfile.RemoveTileIndex != -1 { + // profileConfig.configLock.RUnlock() + return difficultyProfile, true + } + + //if we need to pick a random tile to remove, we need to upgrade locks + // profileConfig.configLock.RUnlock() + // profileConfig.configLock.Lock() + // defer profileConfig.configLock.Unlock() + + if difficultyProfile.RemoveTileIndex == -1 { //check again just in case another thread in the time we upgraded changed it... + /* + We use the users challenge cookie so that we can guarentee given the same cookie we can + produce the exact same result. This is required for validation! + + NOTE on initVector: + - I would use profileConfig.Target as opposed to "tile_index_noise", but we first need to + confirm we are going to be selecting difficulty that way and not dynamically otherwise we risk not being able to + recreate their solution if it changes while the puzzle was being solved by a user + */ + initVector := "tile_index_noise" + difficultyProfile.RemoveTileIndex = PuzzleEntropyFromRange(initVector, userChallengeCookie, 0, difficultyProfile.NPartitions) + } + + return difficultyProfile, true +} + +/*Loads the profiles from the yaml file and stores them in a map for user when the unmarshal is called by config_holder*/ +// func (profileConfig *PuzzleDifficultyProfileConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { +// profileConfig.configLock.Lock() +// defer profileConfig.configLock.Unlock() + +// var loadedConfig struct { +// Target string `yaml:"target"` +// Profiles map[string]PuzzleDifficultyProfile `yaml:"profiles"` +// } + +// if err := unmarshal(&loadedConfig); err != nil { +// return fmt.Errorf("failed to unmarshal difficulty profiles: %w", err) +// } + +// validProfiles := make(map[string]PuzzleDifficultyProfile) +// for profileName, difficultyProfile := range loadedConfig.Profiles { +// if !profileConfig.isValidProfile(difficultyProfile, profileName) { +// continue +// } +// validProfiles[profileName] = difficultyProfile +// } + +// if len(validProfiles) == 0 { +// log.Println("Requires at least one valid profile!") +// return errors.New("ErrInvalidDifficultyProfileSettings: Require at least one valid profile") +// } + +// if _, ok := validProfiles[loadedConfig.Target]; !ok { +// log.Printf("Target profile '%s' does not exist in valid profiles. Aborting config load.", loadedConfig.Target) +// return fmt.Errorf("ErrTargetProfileDoesNotExist: %s", loadedConfig.Target) +// } + +// profileConfig.Profiles = validProfiles +// profileConfig.Target = loadedConfig.Target + +// return nil +// } + +/* +checks to see if the properties specified in the profile definitions are valid. In order to avoid +unnecessarily breaking due to a misconfiguration, the function returns boolean. However, this functionality +is tightly coupled with the calling function (UnmarshalYAML) as it will only return an error if none of the +profiles are valid or if the target profile difficulty you are issuing was invalid and therefore not registered +*/ +// func (profileConfig *PuzzleDifficultyProfileConfig) isValidProfile(difficultyProfile PuzzleDifficultyProfile, profileName string) bool { +// //check to see if profile nPartitions are perfect square +// sqrt := math.Sqrt(float64(difficultyProfile.NPartitions)) +// if sqrt != float64(int(sqrt)) { +// log.Printf("Detected invalid nPartition specification. Expected perfect square, %d is not a perfect square. Skipping profile: %s", difficultyProfile.NPartitions, profileName) +// return false +// } + +// //check to see if the difficulty is either -1 (meaning randomly choose what tile to remove), or is ∈ [0, nPartitions] +// if difficultyProfile.RemoveTileIndex < -1 || difficultyProfile.RemoveTileIndex >= difficultyProfile.NPartitions { +// log.Printf("Invalid RemoveTileIndex (%d) for profile %s. Must be in range [0, %d) or -1 (for random selection). Skipping profile.", +// difficultyProfile.RemoveTileIndex, profileName, difficultyProfile.NPartitions) +// return false +// } + +// /* +// - Each click chain entry requires approx 350 bytes +// - with a cap of 4096 per cookie, we have 11 click chain entries per cookie +// - most browsers allow 50 cookies, but some stricter browsers (mobile in particular) allow only 25 and chrome caps aat 180kb +// => at most 300 clicks before hitting limits of even the stricter browsers. +// - To avoid any issues of other domain cookies being evicted as well as latency issues, we set a cap for our puzzles to 100 clicks +// as its well within the safe limits of even the strictest browsers + +// - however in order to avoid needing to allow nginx to accept 4 64k headers, we reduce this to 80 clicks max such we are guarenteed that nginx +// can handle it with no issue +// */ + +// if difficultyProfile.MaxNumberOfMovesAllowed > 80 { +// log.Printf("Maximum number of clicks for any profile CANNOT exceed 80 due to cookie constraints. Got: %d", difficultyProfile.MaxNumberOfMovesAllowed) +// return false +// } + +// //other validations as needed +// return true +// } diff --git a/internal/puzzle_ui/README.md b/internal/puzzle_ui/README.md new file mode 100644 index 0000000..be91e74 --- /dev/null +++ b/internal/puzzle_ui/README.md @@ -0,0 +1,665 @@ +## Deflect CAPTCHA puzzle Client Side + +## Table of Contents + +
+ Introduction - Overview of the type of puzzle and our goals. + +- [Introduction](#introduction) + - [State-Space Search Problem](#state-space-search-problem) + - [Why State-Space Search?](#objective) + - [The High Level Objective](#the-high-level-objective) + - [What We Have Achieved & What Comes Next](#what-we-have-achieved--what-comes-next) +
+ +
+ User Interaction and Client Side System Design - How users engage with the puzzle & High-level design. + +- [How the client side works](#how-the-client-side-works) +- [User Interaction Flow](#how-the-client-side-works) + - [Receiving a Challenge](#receiving-a-challenge) + - [Solving & Submitting](#solving-and-submitting) + - [Accesssibility Considerations](#accessability-considerations) +
+ + +
+ Security - How we prevent tampering & automated solvers. + +- [Security Principles](#security-principles) + - [Preventing Automated Solvers](#preventing-automated-solvers) + - [Rate Limiting](#rate-limiting) + - [Client Side Rate Limiting](#client-side-rate-limiting) + - [Server Side Rate Limiting](#server-side-rate-limiting) + - [Click-Chain Validation](#client-side-integrity-checking) + - [Client-Side Integrity Checks](#client-side-integrity-checking) + - [Trust Boundaries: Client vs. Server](#trust-boundaries) +
+ + +
+ Developer Guide - Understanding the filesystem & Instructions for setting up, deploying, and contributing to the project. + +- [Developer Guide](#developer-guide) + - [Languages & Tools](#languages--tools) + - [Languages](#languages) + - [Tools](#tools) + - [Project Structure](#project-structure) + - [Deployment Guide](#deployment-guide) + - [Serving In Production](#serving-in-production) + - [Contributing](#contributing) + - [Setting up the development environment](#setting-up-the-development-environment) + - [Package.json Commands](#package.json-commands) + - [Typical Development Workflow](#typical-development-workflow) + - [Typical Production Workflow](#typical-production-workflow) +
+ + +--- + + +# Introduction + +## State-Space Search Problem + +- A state-space search problem is a computer science task that involves finding a solution by navigating through a set of states + +#### Components of a state-space search problem + +- States: A set of possible configurations of a problem +- Start state: The initial configuration of the problem +- Goal state: The desired configuration of the problem +- Actions: The actions that can be taken to move from one state to another +- Goal test: A specification of what constitutes a solution + +- Examples of state-space search: + + - Solving puzzles like the 8-puzzle or Rubik's cube + - A robot navigating through a maze + +[For more on State Space Search problems see wiki/State_space_search](https://en.wikipedia.org/wiki/State_space_search) + +### Why State-Space Search? + +- This puzzle was designed as an experiment—it is intentionally built as a state-space search problem. +- The motivation behind this is that bots, LLMs, and automated solvers are not particularly strong at this class of problem, but humans also struggle with it—just in different ways. +- The hypothesis is that humans and bots will approach the puzzle in fundamentally different ways, and by analyzing how they play, we may uncover meaningful differences. + +#### The High Level Objective + +- This is not a reverse Turing test—the objective isn’t just to prove whether someone is a bot or not. Instead, the goal is to study how people play compared to automated systems. +- In the future, we may develop an API for major LLMs to play, allowing us to collect gameplay data and run comparative analyses. +- The ultimate aim is to train an in-house model that uses gameplay behavior as a distinguishing factor, rather than relying solely on conventional CAPTCHA mechanisms. + +#### What We Have Achieved & What Comes Next + +- The puzzle itself is complete: we can cryptographically verify whether a submitted solution is correct or incorrect, with each challenge being unique to the user. +- However, correctness alone is only half the solution—the real challenge is distinguishing how the game is played and whether that behavior indicates a human or a bot. +- In theory, this could mean that getting the exact right solution may not even be necessary. If we weight behavioral analysis more heavily than correctness, we could allow slightly incorrect solutions as long as the player's interactions strongly indicate human behavior. +- The really neat part of the project will be in collecting and analyzing gameplay data, identifying patterns that separate human problem-solving strategies from automated solvers. + +#### Cool fact about the puzzle: +- Finding a solution for n-puzzle is easy. However, finding a shortest solution is NP-hard. + +--- + + + +# User Interaction and Client Side System Design + +## How the Client Side Works + +- The Deflect CAPTCHA client operates as a self-contained, pre-bundled system delivered to the user's browser in a single request. This ensures a seamless experience without requiring additional external dependencies or network requests beyond the initial page load. + +## User Interaction Flow + +### Receiving a Challenge + +1) The client receives an `index.html` file containing: + - Prebundled CSS, JavaScript, dependencies, and polyfills. + - The initial game state, injected at the time of delivery. + - If no initial state is found, the puzzle phones home to request a challenge. +2) The puzzle immediately starts, prompting the user to solve it. +3) The challenge issued to the user has the following structure: + + ``` + type CAPTCHAChallenge struct { + GameBoard [][]*Tile `json:"gameBoard"` + ThumbnailBase64 string `json:"thumbnail_base64"` + MaxAllowedMoves int `json:"maxNumberOfMovesAllowed"` + TimeToSolveMS int `json:"timeToSolve_ms"` + CollectDataEnabled bool `json:"collect_data"` + ClickChain []ClickChainEntry `json:"click_chain"` + } + ``` + +### Solving & Submitting + +1) The puzzle consists of an nxn grid, where one tile is missing. +2) The user can only move tiles adjacent to the missing space by clicking on them. Clicking a tile swaps its position with the missing tile. +3) The objective is to rearrange the tiles until they recreate the original reference image. + +4) Each tile contains: + ``` + type Tile struct { + Base64Image string `json:"base64_image"` + TileGridID string `json:"tile_grid_id"` + } + ``` +- Where: + - Base64Image: Encoded PNG of the puzzle segment. + - TileGridID: A hashed identifier derived from: + - Hmac(The tile's base64 image + The user's challenge cookie + A server-side secret) + - The TileGridID ensures that each puzzle instance is unique and prevents replay attacks. + +- When the user clicks Solve, the system: + 1) Extracts the TileGridID of each tile in order. + 2) Concatenates them into a single string. + 3) Computes an HMAC hash of the string using the challenge cookie as a key. + 4) Submits this computed hash as the solution. + +### Accessibility Considerations + +- These are as of yet not addressed and remain and important TODO +- Perhaps an auditory challenge for the visually impaired? + + +--- + + + +# Security + +## Security Principles + +- The following outlines the client-side security mechanisms implemented in Deflect CAPTCHA to prevent spam, mitigate automated solvers, and ensure the integrity of submitted solutions. + +### Preventing Automated Solvers + +- State-Space Search Problem + + - Deflect CAPTCHA is designed as a state-space search problem—a well-studied class of problems in computer science where solving involves transitioning through valid states. + +- As mentioned in the introduction: + + - Bots and LLMs struggle with this type of problem due to combinatorial complexity. + - Humans also find it difficult, but they approach it differently, which allows us to analyze behavioral patterns. + - We can study interactions over time to differentiate bots from real users based on how they play rather than solely correctness. + +- Configurable Difficulty to Deter Bots: + - Configurations dynamically adjust puzzle difficulty based on detected behavior. + - If a bot is suspected, we have the capability of making the puzzle exponentially harder: + + ``` + profiles: + easy: + nPartitions: 9 # 3x3 grid + nShuffles: [5, 8] + maxNumberOfMovesAllowed: 160 + timeToSolve_ms: 1_200_000 # 20 minutes + + medium: + nPartitions: 16 # 4x4 grid + nShuffles: [5, 8] + maxNumberOfMovesAllowed: 200 + timeToSolve_ms: 900_000 # 15 minutes + + painful: + nPartitions: 49 # 7x7 grid + nShuffles: [30, 50] + maxNumberOfMovesAllowed: 300 + timeToSolve_ms: 420_000 + + nightmare_fuel: + nPartitions: 100 # 10x10 grid + nShuffles: [1000, 2000] + maxNumberOfMovesAllowed: 6000 + timeToSolve_ms: 360_000 + ``` + +## How This Prevents Bots: + +- The most important reason has to do with **several components working together**: + + 1) Click-Chain Validation & Uniqueness + + - A click-chain blockchain cryptographically proves that the user performed a valid sequence of moves leading to the final board state. + - Each puzzle board is unique per user, preventing replay attacks. + - Rate limiting ensures only a few submissions within the allotted time, making brute-force infeasible—getting rate-limited 3-4 times can drain the available time, forcing a restart. + + 2) Dynamic Puzzle Adjustments + + - The system dynamically modifies puzzles by: + - Changing the missing tile position. + - Altering the image, time limit, and max moves. + - Adjusting the grid size (number of partitions) and shuffle complexity. + + These parameters scale difficulty based on behavior, making automated solving exponentially harder. + + 3) Built-in Anti-Cheat Mechanisms + + - Noise is deliberately added to the thumbnail image to prevent trivial reconstruction. + - Even if an attacker partitions the thumbnail, the Base64-encoded pieces won’t match due to injected noise, preventing automated board reconstruction. + - Each tile is also encoded with its own noise using different entropy from that which was applied to the thumbnail ensuring there is no correlation between the two + - Each puzzle tile base64 encoded PNG is guarenteed to be unique per challenge per user + - Each thumbnail is guarenteed to be unique per challenge per user + - Each thumbnail is guarenteed to be different from the puzzle grid tile base64 images even when partitioned + - This ensures even replay attacks are not possible as even if you record the base64 images you put into order in one puzzle, the next time you receive it, + the base64 PNG will not be the same as they have different noise applied to them. Since the TileID's are derived from the base64 PNGs which as mentioned are unique + we can guarentee that the solution that is derived from placing them in order is guarenteed to be unique as well. + - Finally, for any given challenge, if there are tiles on the board that are the same, for example if there are blank tiles which have the same b64 data, then after adding noise + they will continue to be the same ensuring they remain interchangeable! Between different challenges, these blank tiles, as with all other tiles are guarenteed to be different from one another! + - Integrity checking and click-chain solution guarentee that no replay or forgery attacks can occur. + - ClickChain also ensures that the solution itself is correct in that each step the user took while solving does indeed lead to the final result being what they submitted + + For more on the ClickChain or how integrity, noise or uniqueness are guarenteed, see [Server-Side Documentation](../internal/puzzle-util/README.md). + + 4) Machine Learning (behaviour analysis) - NOT YET DONE + + - Once the data collection is complete, we wiil be able to collect data about gameplay and use it to make predictions about whether a human or bot was playing the game + - A combination of the correct solution and human-like behaviour while playing will be used to produce the final decision + + This **layered approach** ensures that bots cannot brute-force, replay, or reconstruct the puzzle *while keeping it solvable for real users*. + +### Rate Limiting + +#### Client-Side Rate Limiting + +- Client-side protections prevent spamming by users pressing submit repeatedly (especially useful under heavy load). This is enforced via delays and UI locking mechanisms to slow down consecutive attempts + +#### Server-Side Rate Limiting + +- Server-side rate limiting is designed to: + + 1) Throttle requests to prevent brute-force guessing. + 2) Enforce a max of 4 solution submissions per unit time. + 3) Ensure users run out of time before brute-forcing a solution. + +### Click-Chain Validation + +- Each puzzle challenge is unique per user and validated via a cryptographic click-chain (similar to how a block chain works) + + - A unique puzzle board is generated for each user, where each tile has a hashed ID derived from the tile’s image and the user’s challenge cookie. + - A Genesis Block (initial entry) is created, linked to the user’s challenge cookie and an internal secret. + - Each valid move is appended to the click-chain, referencing the previous move's hash, ensuring an immutable sequence. + - Solution validation: The final board state is verified against the expected target solution. + +- Security Benefits: + + - Ensures puzzle integrity – every move is logged and cryptographically linked. + - Prevents tampering – since the chain is HMAC-signed, users cannot forge solutions. + - Stops replay attacks – the secret key ensures that click-chains are tied to individual challenges. + + +### Client-Side Integrity Checks + +- To prevent tampering or bypassing, the server side will perform integrity checks: + + 1) Ensuring the click-chain hash is valid. + 2) Confirming that move sequences are logically possible. + 3) Detecting unnatural solving patterns indicative of automation. + +For more on how this works, see [Server-Side Documentation](../internal/puzzle-util/README.md). + + +### Trust Boundaries: Client vs. Server + +- The client only knows its cookie and board state. +- The server holds the entropy for challenge verification (a secret only we know concatenated with the users challenge cookie) + - Even with the entire click-chain, users cannot forge a solution because the secret key remains unknown to them. + +--- + + +# Developer Guide + +## Languages & Tools + +### Languages + +- The Client-Side Puzzle UI is written in TypeScript (v4.0+ recommended). +- No frameworks (e.g., React, Vue) are used—event listeners are attached directly to ensure maximum compatibility with legacy browsers as these frameworks depend a lot on ES6+ features which break older environments even when using transpilation. + +### Tools + +#### Bundler: Rollup + +- Rollup is used to bundle, optimize, and minify the client-side JavaScript, CSS, and dependencies into a single deliverable. + +##### What is Being included by Rollup + +- The following assets are bundled and optimized: + + 1) JavaScript - All client-side logic and dependencies (when using production environment variables the JS will be obfuscated) + 2) CSS - Embedded directly into `index.html`. + 3) Polyfills - Ensures compatibility with older browsers. (The required polyfills are imported in the entrypoint-deflect-captcha.ts file such that rollup knows what is needed when bundling) + 4) utility functions required for compatibility with legacy environments. + +###### How Rollup Works in This Project + +- Entry Point: entrypoint-deflect-captcha.ts + - Specified in the `input` field of the rollup + +- Bundling Process: + - The script is compiled and minified. + - Polyfills are included for older browsers. + - The final bundle is injected into index.html. + +- Rollup Configuration Breakdown: + + - JavaScript and TypeScript: + + - The entrypoint-deflect-captcha.ts script serves as the entry point. + - Babel is used to transpile the code, ensuring compatibility with older browsers. + - TypeScript is processed using @rollup/plugin-typescript. + + - CSS Handling: + + - By default, CSS is embedded directly into index.html. + - If you want to bundle CSS inside bundle.js, uncomment the PostCSS plugin in rollup.config.js. + + - Legacy Browser Support: + + - Babel targets Internet Explorer 11+. + - Ensures compatibility by using core-js for polyfills. + + - Security & Performance Enhancements: + + - The bundle is obfuscated in production (rollup-plugin-obfuscator). + - Minification is done using terser. + +##### Compatibility with Legacy Browsers + +- One of the **most important requirements** for this project is ensuring that it runs on **legacy browsers**. +- Some older browsers do not support modern cryptographic APIs and other tooling we take for granted today, so we must fallback to pre-bundled dependencies when necessary. + +- These tools are all included in the `src/client/scripts/utils` directory and are imported by the event listener attachment functions that need them + - Since these are being imported into the entrypoint (via these event listener attachment functions), rollup knows to include them in the `bundle.js` + + Example: Modern browsers support crypto.subtle, but legacy browsers do not. For this reason we have a util function: + + ``` + import {HmacSHA256, enc} from 'crypto-js' + + export async function generateHmacWithFallback(key: string, message: string): Promise { + if (window.crypto && window.crypto.subtle) { + const encKey = new TextEncoder().encode(key) + const encMessage = new TextEncoder().encode(message) + const cryptoKey = await crypto.subtle.importKey('raw', encKey, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) + const signature = await crypto.subtle.sign('HMAC', cryptoKey, encMessage) + return Array.from(new Uint8Array(signature)).map((b) => b.toString(16).padStart(2, '0')).join('') + } else { + return HmacSHA256(message, key).toString(enc.Hex) + } + } + ``` + + For browsers that support crypto.subtle, they will use the standard API provided by the browser. However, for those that do not, we have bundled `crypto-js` + such that their browsers can still invoke the `generateHmacWithFallback()` function + + +###### Polyfills + +- For legacy browsers that do not support native ES6+ features, rollup bundles all the polyfills needed + +- At the top of the entrypoint-deflect-captcha.ts file, the necessary polyfills are explicitly imported: + ``` + import 'core-js/stable' + import 'regenerator-runtime/runtime' + ``` + +- Each polyfill serves a different purpose, for example: + + - core-js/stable: Provides shims for missing JavaScript features. + - regenerator-runtime/runtime: Ensures async/await support for older browsers. + + +## Project Structure + +``` + . + ├── README.md <- You are here + ├── captchaRollup.config.mjs <- Rollup config for bundling + ├── dist <- Production-ready build output + │   ├── client + │   │   └── scripts + │   │   └── bundle.js <- Bundled JS for the client + │   └── index.html <- Fully self-contained, bundled page. **This is the ONLY thing that need be served by the server.** + ├── injectBundleJSToIndexHTML.js <- Post-bundling script injector + ├── package-lock.json + ├── package.json + ├── src <- Main source directory + │   ├── client + │   │   ├── scripts <- Client-side logic + │   │   │   ├── attach-footer-and-header-info.ts + │   │   │   ├── check-initial-state.ts + │   │   │   ├── client-captcha-solver.ts + │   │   │   ├── entrypoint-deflect-captcha.ts <- Main entrypoint, initializes everything + │   │   │   ├── inspect-target-image-modal.ts + │   │   │   ├── puzzle-instructions-info-button.ts + │   │   │   ├── request-different-puzzle.ts + │   │   │   └── utils <- Helper functions (containing functions with prebundled dependencies as fallbacks for legacy browsers) + │   │   │   ├── cookie-utils.ts + │   │   │   └── hmac-utils.ts + │   │   └── styles <- CSS for the UI (Note: Currently ALL css is already injected directly into the tags of the `index.html` - these are here for convenience) + │   │   ├── main.css + │   │   ├── puzzle-container.css + │   │   ├── puzzle-grid.css + │   │   ├── puzzle-instructions.css + │   │   ├── puzzle-messages-to-user.css + │   │   ├── puzzle-refresh.css + │   │   ├── puzzle-submission.css + │   │   └── puzzle-thumbnail.css + │   ├── deflect_logo.svg <- Deflect Logo (injected into `index.html` during bundling) + │   └── index.html <- The HTML template **before** bundling (**not to be served to user**) + ├── tsconfig.json <- TypeScript configuration + └── types <- Shared type definitions + └── shared.d.ts +``` + + +### types (`puzzle_ui/types`) + +- Contains TypeScript type definitions shared across the UI. + +### src (`puzzle_ui/src`) + +#### src/index.html + +- `src/index.html` is the template HTML file **before** bundling. +- It gets modified during the build process to embed scripts, styles, and the Deflect logo. + +#### src/deflect_logo.svg + +- The Deflect CAPTCHA logo injected into the final `index.html`. + +#### src/client + +- Houses all scripts and styles required for the CAPTCHA UI. + +##### src/client/scripts + +- The core client side logic that runs in the browser + +- **Entrypoint:** `entrypoint-deflect-captcha.ts` + - Initializes the CAPTCHA system. + - Checks if an initial state was injected or needs to be fetched. + - Handles error reporting and retry logic. + - Implements a fallback mechanism for worst-case scenarios. + +###### src/client/scripts/utils + +- These are utilities that are prebundled with the dependencies required for legacy browsers to function. + - For example, not all browsers admit crypto.subtle API. Therefore, we provide `hmac-utils.ts` such that all browsers can either use their `crypto.subtle` API should they have it, or fallback to the pre bundled depdency (`crypto-js`) + +- `cookie-utils.ts`: Handles cookies for authentication and state management. +- `hmac-utils.ts`: Cryptographic helper functions. + +##### src/client/styles + +- Defines the visual styling for different puzzle components. + +- By default, styles are embedded directly into `index.html`. + +- If you prefer to bundle CSS with JS, you must: + 1) Enable the postcss Rollup hook. + 2) Uncomment the import statements in `entrypoint-deflect-captcha.ts`. + 3) Remove the tags from `src/index.html`. + +### dist (`puzzle_ui/dist`) + +- Contains the production-ready assets. + +- Key files: + - `dist/index.html`: The final, self-contained page (fully bundled). + - `dist/client/scripts/bundle.js`: The compiled JavaScript bundle. + +- **Note:** + - The `index.html` already includes all required `scripts/styles`. + - *Only* `index.html` and the user's cookie are needed for deployment. + +### root (`puzzle_ui/`) + +- houses `injectBundleJSToIndexHTML.js` + +- This is a custom Rollup hook that modifies index.html after bundling. + +- Automatically injects bundle.js into index.html, ensuring that: + + 1) All assets are inline (to be served in a single request). + 2) The Deflect CAPTCHA system remains self-contained. + +## Deployment Guide + +### Serving in Production + +- The bundling process ensures that all required assets are packaged into a **single deliverable** for easy deployment. This includes: + +1) JavaScript - Bundled with Rollup, optimized, and obfuscated (for production only) & is injected directly into `index.html` via the `injectBundleJSToIndexHTML.js` script. +2) Deflect Logo SVG - Also injected directly into index.html via the `injectBundleJSToIndexHTML.js` script. +3) CSS - Embedded directly inside `index.html`. + **Note:** The css may also be included with the `bundle.js` if you: + 1) remmove it from `index.html` + 2) uncomment the entrypoint `*.css` imports + 3) uncomment the postcss() rollup code +4) Polyfills - Included to support legacy browsers (which is an important requirement). + +#### What needs to be served? + +- This means that the only *file* you need to serve is: `dist/index.html` +- You must also attach a *challenge cookie* along with the `dist/index.html` response payload using the cookie name: `deflect_challenge4` +- That's it. Nothing else is required. + +#### You do **not** need to serve: + +- Logos, JS, CSS, or external assets—they are already embedded in the `index.html`. +- Separate API endpoints for fetching assets—everything needed is in the single file. + +#### What the Server Needs to Do + +- To properly issue a unique challenge per user, the **server** can follow one of two procedures: + +- **1) Recommended Approach (Production Best Practice):** inject the initial state into index.html + - The server reads index.html before serving it. + - The server injects a dynamically generated initial state (per user). + - The user receives index.html with the puzzle state already embedded. + - This is how Deflect works in production and ensures a seamless, efficient challenge issuance. + + +- **2) Alternative Approach:** do not inject the initial state into index.html, but have an endpoint prepared to handle a request for the puzzle state + - If the initial state is not injected, the puzzle will immediately phone home requesting it from the server. + - In this case, the server must provide an endpoint to handle these state requests dynamically. + - This approach may be useful for development but is not recommended for production. + + +- **Note:** You *will* need to have the endpoint to serve a puzzle state on request regardless of what option you choose as the puzzle includes a rate limited "refresh" button that requets a new puzzle state and updates the current state. This was included to provide the user the option of trying a different one if they deem the current board too difficult. However, it is still recommended to inject the initial state into `index.html` as this is a requirement for how Deflect works. + +- For details on how the server should inject the initial state, refer to the [Server-Side Documentation](../internal/puzzle-util/README.md). + +## Contributing + +### Setting Up the Development Environment + +- Step 1) Clone this repository + +- Step 2) Install dependencies + + ### The UI is built using Node.js and Rollup. Ensure you have Node.js installed (v18 or later). Then, install the required dependencies: + ``` + cd puzzle_ui + npm install + ``` + +- Step 3) Create a **.env.production** and **.env.development** files + + #### .env.production: + ``` + MINIFY_CSS=true + SOURCE_MAP=false + OBFUSCATE=true + ``` + + #### .env.development: + ``` + MINIFY_CSS=false + SOURCE_MAP=true + OBFUSCATE=false + ``` + +- Step 4) run the following commands: + ``` + npm run clean + npm run build + ``` + +#### Package.json Commands + +- ```npm run dev``` + - deletes the dist/ directory and rebuilds from scratch, bundling all dependencies, and watching for changes to client side code before rebundling (uses dev env variables) + +- ```npm run build``` + - runs Rollup to bundle client-side code (which injects the bundle into the` index.html`) + +- ```npm run clean``` + - clears the dist/ directory if you want a fresh build + +- ```npm run watch``` + - watches for changes in client code & automatically rebundles + +- ```npm run prod``` + - deletes the dist/ directory and rebuilds from scratch using production environment variables + + + +#### Typical Development Workflow + +- Either run: + ``` + 1) npm run clean + 2) npm run build + ``` +- Or: + ``` + 1) npm run dev + ``` + +- The only difference is that the npm run dev will continue monitoring for changes such that when you make a change, it will automatically clean and build such that your server serves the most recent one + +- **In both cases**, you must serve from **`dist/index.html`** + +- This will not only include the html, but also the css as well as the js and all dependencies and polyfills +- The only thing that remains to do when serving it is to inject the initial state at runtime. Since each puzzle is unique to the user, the initial state cannot be precomputed and must be dynamically generated. The server handles this by issuing a state-specific challenge upon request. This is injected directly into the `index.html` as per Deflect requirements. For more details, check the [Server-Side Documentation](../internal/puzzle-util/README.md). + - **Note:** If the initial state is **not injected** at runtime, the puzzle **will automatically request it** from the server. In this case, you **must have an endpoint** to handle this request and provide the state dynamically. + +#### Typical Production Workflow + - run: + ``` + npm run prod + ``` +- You can now serve the CAPTCHA directly from `dist/index.html` + - This will contain the HTML, CSS, JS, Polyfills & all dependencies (such as for calculating HMAC) + - It is also obfuscated via rollup + - The only thing that remains to do when serving it is to inject the initial state at runtime. Since each puzzle is unique to the user, the initial state cannot be precomputed and must be dynamically generated. The server handles this by issuing a state-specific challenge upon request. This is injected directly into the `index.html` as per Deflect requirements. For more details, check the [Server-Side Documentation](../internal/puzzle-util/README.md). + - **Note:** If the initial state is **not injected** at runtime, the puzzle **will automatically request it** from the server. In this case, you **must have an endpoint** to handle this request and provide the state dynamically. + +--- diff --git a/internal/puzzle_ui/captchaRollup.config.mjs b/internal/puzzle_ui/captchaRollup.config.mjs new file mode 100644 index 0000000..7ba94dc --- /dev/null +++ b/internal/puzzle_ui/captchaRollup.config.mjs @@ -0,0 +1,99 @@ +import injectBundleJSIntoIndexHTML from './injectBundleJSToIndexHTML.js' +import obfuscatorPlugin from 'rollup-plugin-obfuscator' +import typescript from '@rollup/plugin-typescript' +import resolve from '@rollup/plugin-node-resolve' +import commonjs from '@rollup/plugin-commonjs' +import postcss from 'rollup-plugin-postcss' +import terser from '@rollup/plugin-terser' +import babel from '@rollup/plugin-babel' +import dotenv from 'dotenv' + + + + + + + +//load the appropriate .env file based on NODE_ENV +dotenv.config({path: process.env.NODE_ENV === 'production' ? '.env.production' : '.env.development'}) + +/* + In the index.html, we inject the result of the bundling procedure (bundle.js) into the index.html directly. + This will already include the entrypoint-deflect-captcha.ts file which is the entrypoint to the captcha. + + This allows us to use bundled dependencies for hmac operations to support legacy browsers which need + not necessarily admit the subtle.crypto api that modern browsers do. Therefore, it is important + to ALWAYS use the generateHmacWithFallback() in any of the client-side code. + + For server-side code, do whatever you want +*/ +export default { + input: 'src/client/scripts/entrypoint-deflect-captcha.ts', //entry point + output: { + file: 'dist/client/scripts/bundle.js', + format: 'iife', //or 'esm' if using modules in HTML but iife is important for us because we need to support legacy browseres + name: 'DeflectCaptcha', //this is the name of the global variable for access if needed + sourcemap: process.env.SOURCE_MAP === 'true', + }, + plugins: [ + + resolve({ + extensions: ['.js', '.ts'] + }), + commonjs(), + typescript(), + /* + NOTE: SINCE WE DECIDED TO INCLUDE THE CSS DIRECTLY INTO THE INDEX.HTML WE + HAVE COMMENTED OUT THE POSTCSS SECTION IN ROLLUP. IF YOU EVER DECIDE YOU WANT TO + RE-BUNDLE THE CSS ALONG WITH JS, YOU NEED ONLY UNCOMMENT THE import *.css AT THE + LEVEL OF THE ENTRYPOINT SCRIPT, REMOVE THE CSS STYLE TAGS (AND ALL CSS) FROM INDEX.HTML + AND UNCOMMENT THE POSTCSS BELOW + */ + // postcss({ + // // NOTE: You could extract as it would extract css is a seperate file - we dont want that as we want 1 bundle to serve with everything it needs to run so we use inject to inline everything into the bundle + // //extract: false, + + // inject: true, //inlines the css directly into the js bundle + // minimize: process.env.MINIFY_CSS === 'true', + // sourceMap: process.env.SOURCE_MAP === 'true' + // }), + process.env.OBFUSCATE === "true" && + obfuscatorPlugin({ + compact: true, + controlFlowFlattening: true, + deadCodeInjection: true, + debugProtection: true, + stringArray: true, + rotateStringArray: true, + stringArrayThreshold: 0.75 + }), + //for legacy support we add babel for es5 transpilation + babel({ + babelHelpers: 'bundled', + presets: [ + ['@babel/preset-env', { + targets: "> 0.25%, not dead, IE 11", + useBuiltIns: 'entry', //forces Babel to use explicit polyfill imports instead of dynamically injecting them where it sees fit. + corejs: 3 //specifies the version of core-js (v3) + }] + ] + }), + terser(), + { + name: "inline-bundle", + writeBundle() { //use the rollups writeBundle hook + console.log("Writing bundle into the index.html...") + + try { + const writeToDestination = "dist/index.html" + injectBundleJSIntoIndexHTML(writeToDestination) + console.log(`[SUCCESS] inlined bundle.js into ${writeToDestination}!`) + console.log(`\n\nYou can serve to user from: ${writeToDestination}\nthe puzzle will call out for everything else automatically\n\n`) + } catch(error) { + console.error(`[FAIL] unable to inline bundle.js into index.html due to error:\n${error}`) + } + } + } + + ].filter(Boolean), //to remove false or undefined that results when process.env.OBFUSCASE !== "true" +} \ No newline at end of file diff --git a/internal/puzzle_ui/dist/index.html b/internal/puzzle_ui/dist/index.html new file mode 100644 index 0000000..6168943 --- /dev/null +++ b/internal/puzzle_ui/dist/index.html @@ -0,0 +1,1010 @@ + + + + + Deflect CAPTCHA + + + + + + + + + + + + + + + + + +
+
+
+ + +
+
+
+ + +
+
+ +

Recreate this image by selecting which squares to move

+ +
+
+ Complete Puzzle +
+ +
+ +
+
+ × + +
+
+ +
+ +
+
+ +
+
+ +
+ +
+ +
+ +
+ + + +
+ + + + +
+
+ × +
+ +

Rearrange the tiles to match the thumbnail.

+ +

Click on Tiles Adjacent to the Empty Space:

+ +

- Only tiles directly next to the empty slot can be moved.

+

- Click on a tile to slide it into the empty space.

+ +

Recreate the Full Image:

+ +

- Keep moving the tiles until the image is complete, matching the reference.

+

- Pay attention to the details—logos, text, and shapes must align.

+
+
+ deflect-puzzle GIF preview +
+
+
+ +
+ + + +
+ +
+
+
+
+ + + + + + + + + diff --git a/internal/puzzle_ui/injectBundleJSToIndexHTML.js b/internal/puzzle_ui/injectBundleJSToIndexHTML.js new file mode 100644 index 0000000..31748a9 --- /dev/null +++ b/internal/puzzle_ui/injectBundleJSToIndexHTML.js @@ -0,0 +1,37 @@ +import path from "path" +import fs from "fs" + + +export default function injectBundleJSToIndexHTML(writeToDestination = "dist/index.html") { + const bundlePath = path.resolve('dist', 'client', 'scripts', 'bundle.js') + const svgPath = path.resolve("src", 'deflect_logo.svg') + const htmlPath = path.resolve('src', 'index.html') + + if (!fs.existsSync(bundlePath)) { + throw new Error(`Error: ${bundlePath} not found.`) + } + + if (!fs.existsSync(htmlPath)) { + throw new Error(`Error: ${htmlPath} not found.`) + } + + const bundleContent = fs.readFileSync(bundlePath, 'utf-8') + const svgContent = fs.existsSync(svgPath) ? fs.readFileSync(svgPath, 'utf-8') : '' + let htmlContent = fs.readFileSync(htmlPath, 'utf-8') + + //replace the "LOGO_PLACEHOLDER" that we wrote as a comment in the index.html in src with the inlined js bundle + // htmlContent = htmlContent.replace( + // //, + // svgContent + // ) + + //replace the "BUNDLE_PLACEHOLDER" that we wrote as a comment in the index.html in src with the inlined js bundle + htmlContent = htmlContent.replace( + //, + `` + ) + + fs.writeFileSync(writeToDestination, htmlContent) + console.log(`Successfully inlined bundle.js and replaced logo placeholder in ${writeToDestination}`) +} + diff --git a/internal/puzzle_ui/package.json b/internal/puzzle_ui/package.json new file mode 100644 index 0000000..8117dc4 --- /dev/null +++ b/internal/puzzle_ui/package.json @@ -0,0 +1,56 @@ +{ + "name": "deflectpuzzlecaptchawithrollup", + "version": "1.0.0", + "type": "module", + "description": "", + "main": "captchaRollup.config.mjs", + "scripts": { + "dev": "NODE_ENV=development npm run clean && npm run build && concurrently \"npm run watch\"", + "build": "npx rollup -c captchaRollup.config.mjs", + "clean": "rm -rf dist && mkdir dist", + "watch": "npx rollup -c captchaRollup.config.mjs --watch", + "prod": "NODE_ENV=production npm run clean && npm run build" + }, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@babel/core": "^7.26.9", + "@babel/preset-env": "^7.26.9", + "@rollup/plugin-alias": "^5.1.1", + "@rollup/plugin-babel": "^6.0.4", + "@rollup/plugin-commonjs": "^28.0.2", + "@rollup/plugin-node-resolve": "^16.0.0", + "@rollup/plugin-terser": "^0.4.4", + "@rollup/plugin-typescript": "^12.1.2", + "@types/cors": "^2.8.17", + "@types/crypto-js": "^4.2.2", + "@types/express": "^5.0.0", + "concurrently": "^9.1.2", + "cookie": "^1.0.2", + "glob": "^10.4.5", + "nodemon": "^3.1.9", + "postcss": "^8.5.3", + "rollup": "^4.34.8", + "rollup-plugin-obfuscator": "^1.1.0", + "rollup-plugin-postcss": "^4.0.2", + "ts-node": "^10.9.2", + "typescript": "^5.7.3" + }, + "dependencies": { + "@types/pngjs": "^6.0.5", + "@types/validator": "^13.12.2", + "core-js": "^3.40.0", + "cors": "^2.8.5", + "crypto-js": "^4.2.0", + "dotenv": "^16.4.7", + "express": "^4.21.2", + "fast-text-encoding": "^1.0.6", + "js-base64": "^3.7.7", + "pngjs": "^7.0.0", + "regenerator-runtime": "^0.14.1", + "tsconfig-paths": "^4.2.0", + "tslib": "^2.8.1", + "validator": "^13.12.0" + } +} diff --git a/internal/puzzle_ui/src/client/scripts/attach-footer-and-header-info.ts b/internal/puzzle_ui/src/client/scripts/attach-footer-and-header-info.ts new file mode 100644 index 0000000..cae4db8 --- /dev/null +++ b/internal/puzzle_ui/src/client/scripts/attach-footer-and-header-info.ts @@ -0,0 +1,34 @@ + +/** + attachFooterHeaderHostname allows us to dynamically inject the footer and header hostname in to match the hostname the requester was accessing +*/ +export default function attachFooterHeaderHostname():{success:boolean, error:Error | null} { + + try { + + const footerSiteName = document.querySelector(".website-title-footer") + const headerSiteName = document.querySelector(".website-title") + + if (!footerSiteName && !headerSiteName) { + const errorPayload = {footer_status:footerSiteName, header_status:headerSiteName} + return {success:false, error:new Error(`ErrFailedToAttachHostname: ${JSON.stringify(errorPayload)}`)} + } + + //otherwise, attach it where possible + + if (footerSiteName) { + footerSiteName.textContent = document.location.hostname + } + + if (headerSiteName) { + headerSiteName.textContent = document.location.hostname + } + + + return {success:true, error:null} + + } catch(error) { + //because its most likely that you didnt provide something that could be expressed as url (ie TypeError: Failed to construct 'URL': Invalid URL) + return {success:false, error:new Error(`ErrCaughtException: while attachFooterHeaderHostname() ${error}`)} + } +} \ No newline at end of file diff --git a/internal/puzzle_ui/src/client/scripts/check-initial-state.ts b/internal/puzzle_ui/src/client/scripts/check-initial-state.ts new file mode 100644 index 0000000..8d026c5 --- /dev/null +++ b/internal/puzzle_ui/src/client/scripts/check-initial-state.ts @@ -0,0 +1,60 @@ + +/** + checkForInitialState allows us to check for the presence of dynamically injected initial state payload such that we can cut the extra round trip out + it will only request state if this is not here or if the user clicks on the refresh button +*/ +export default function checkForInitialState():{success:boolean, error:Error | null, initialState:PuzzleChallenge | null} { + + try { + const stateElement = document.getElementById("initial-game-state") + + if (!stateElement) { + const errorPayload = {initial_state_status:stateElement} + return {success:false, error:new Error(`ErrInitialStateNotFound: ${JSON.stringify(errorPayload)}`), initialState:null} + } + + + //to prevent parsing an empty state, we can check to see if trimming gets us to an empty string + const rawState = stateElement.textContent?.trim() + if (!rawState) { + return {success: false, error: new Error("ErrInitialStateEmpty"), initialState: null} + } + + //otherwise, get the initial state, parse it and provide it to the client side solver + const initialState:PuzzleChallenge = JSON.parse(rawState) + + if (!initialState) { + return {success:false, error:new Error("ErrFailedToParseInitialState"), initialState:null} + } + + if (!isValidPuzzleChallenge(initialState)) { + return {success:false, error:new Error(`ErrInvalidInitialPuzzleChallenge`), initialState:null} + } + + return {success:true, error:null, initialState:initialState} + + } catch(error) { + return {success: false, error: new Error(`ErrCaughtException: ${error.message}\nStack: ${error.stack}`), initialState: null} + } +} + +function isValidPuzzleChallenge(data: any): data is PuzzleChallenge { + return ( + typeof data === "object" && data !== null && + + //gameBoard must be a non-empty 2D array of (TileImagePartitionValue | null) + Array.isArray(data.gameBoard) && data.gameBoard.length > 0 && data.gameBoard.every(row => Array.isArray(row) && row.length > 0) && + + //thumbnail_base64 must be a non-empty string + typeof data.thumbnail_base64 === "string" && data.thumbnail_base64.trim() !== "" && + + //maxNumberOfMovesAllowed must be a positive integer + typeof data.maxNumberOfMovesAllowed === "number" && data.maxNumberOfMovesAllowed > 0 && + + //timeToSolve_ms must be a positive integer + typeof data.timeToSolve_ms === "number" && data.timeToSolve_ms > 0 && + + //click_chain must contain exactly ONE entry at the start + Array.isArray(data.click_chain) && data.click_chain.length === 1 + ) +} diff --git a/internal/puzzle_ui/src/client/scripts/client-captcha-solver.ts b/internal/puzzle_ui/src/client/scripts/client-captcha-solver.ts new file mode 100644 index 0000000..a3fef33 --- /dev/null +++ b/internal/puzzle_ui/src/client/scripts/client-captcha-solver.ts @@ -0,0 +1,776 @@ +import {stringToBase64WithFallback, clickChainToBase64WithFallback} from "./utils/b64-utils" +import {attachCookie, getCookieValue} from "./utils/cookie-utils" +import {generateHmacWithFallback} from "./utils/hmac-utils" + + + + + + + + +export default class ClientCaptchaSolver { + + private puzzleChallenge: PuzzleChallenge + + private gameBoard:(TileImagePartitionValue | null)[][] + + private maxNumberOfMovesAllowed:number + private clickCountTracker:number + private clickChain:iClickChainEntry[] + + private timerId: number | null = null + private startTime: number = 0 + private totalTimeAllowed: number = 0 + // private challengeIssuedAtTime:string + + private puzzleContainerElement:HTMLElement + private thumbnailElement: HTMLImageElement + private submitSolutionButton: HTMLButtonElement + private isSubmittingSolution:boolean + + + currentMessageTimeout: number | null = null + + // private challengeDifficulty:difficulty + + private tileElements: HTMLElement[][] = [] + + private gameplayDataCollectionEnabled:boolean + + // private desiredEndpoint:string + + // private VERIFY_SOLUTION_ENDPOINT:string + private CAPTCHA_COOKIE_NAME: string + + private debug:boolean + + + constructor(puzzleChallenge: PuzzleChallenge, CAPTCHA_COOKIE_NAME:string, debug?:boolean) { + + this.debug = debug ?? false + + this.isSubmittingSolution = false + + // this.VERIFY_SOLUTION_ENDPOINT = VERIFY_SOLUTION_ENDPOINT + this.CAPTCHA_COOKIE_NAME = CAPTCHA_COOKIE_NAME + + this.gameBoard = puzzleChallenge.gameBoard + + this.maxNumberOfMovesAllowed = puzzleChallenge.maxNumberOfMovesAllowed + this.clickCountTracker = 0 + + this.totalTimeAllowed = puzzleChallenge.timeToSolve_ms + + this.puzzleChallenge = puzzleChallenge + + this.gameplayDataCollectionEnabled = puzzleChallenge.collect_data + + // this.desiredEndpoint = puzzleChallenge.users_intended_endpoint + + // this.challengeIssuedAtTime = puzzleChallenge.challenge_issued_date + + this.clickChain = puzzleChallenge.click_chain + + // this.challengeDifficulty = puzzleChallenge.challenge_difficulty + + const thumbnailElement = document.getElementById("deflect-puzzle-thumbnail") as HTMLImageElement | null + if (!thumbnailElement) { + throw new Error(`ErrMissingRequirement: thumnailElement`) + } + this.thumbnailElement = thumbnailElement + + const puzzleContainerElement = document.getElementById('deflect-puzzle') as HTMLElement | null + if (!puzzleContainerElement) { + throw new Error(`ErrMissingRequirement: puzzleContainerElement`) + } + this.puzzleContainerElement = puzzleContainerElement + + const submitSolutionButton = document.getElementById("submit-deflect-captcha-solution") as HTMLButtonElement | null + if (!submitSolutionButton) { + throw new Error(`ErrMissingRequirement: submitSolutionButton`) + } + this.submitSolutionButton = submitSolutionButton + + + //60ms debounce to prevent accidental clicks & double clicks etc + this.moveTile = this.debounce(this.moveTile.bind(this), 60) + // this.puzzleContainerElement.addEventListener("mousedown", this.moveTile) + //we attach it in such a way that if something is thrown we can bubble it back to the entry point to exploit the retry/fallback + this.puzzleContainerElement.addEventListener("mousedown", async (event) => { + try { + await this.moveTile(event) + } catch (error) { + console.error(`Error bubbled to top level: ${error}`); + // throw new Error(`ErrCaughtException: moveTile event listener: ${error}`) + window.dispatchEvent(new CustomEvent("captchaError", {detail: error})) + } + }) + + + + //no debounce on submit since we have a function guard. When submission starts we immediately set it to true + //and only set it back to false (allowing another submission) when the previous submission completes + this.submitSolution = this.submitSolution.bind(this) + this.submitSolutionButton.addEventListener("click", this.submitSolution) + } + + + initCaptcha():{success:boolean, error:Error | null} { + + try { + + //check for missing requirements + if (this.thumbnailElement === null || !this.thumbnailElement) { + return {success:false, error: new Error(`ErrMissingThumbnail: expected HTMLImageElement, got: type: ${typeof this.thumbnailElement} - ${this.thumbnailElement}`)} + } + + if (this.puzzleContainerElement === null || !this.puzzleContainerElement) { + return {success:false, error: new Error(`ErrMissingPuzzleContainer: expected HTMLElement, got: type: ${typeof this.puzzleContainerElement} - ${this.puzzleContainerElement}`)} + } + + //at this point we know we have access to all elements needed to run challenge + + //figure out the dimensions of the puzzle user is meant to solve and apply the css grid styling required + const rows = this.puzzleChallenge.gameBoard.length + const columns = this.puzzleChallenge.gameBoard[0].length + + //set the thumbnail image + //we cannot apply the grid directly to the tags, so we wrap it to apply the grid to the wrapper instead + this.thumbnailElement.src = `data:image/png;base64,${this.puzzleChallenge.thumbnail_base64}` + const wrapper = document.getElementById("thumbnail-wrapper") as HTMLElement + wrapper.style.gridTemplateColumns = `repeat(${columns}, 1fr)` + wrapper.style.gridTemplateRows = `repeat(${rows}, 1fr)` + + //set up the puzzle grid, event listener and assign tiles their locations according to dimensions + this.puzzleContainerElement.style.display = 'grid' + this.puzzleContainerElement.style.gridTemplateColumns = `repeat(${columns}, 1fr)` + this.puzzleContainerElement.style.gridTemplateRows = `repeat(${rows}, 1fr)` + + /* + NOTE: + + We add a row-col id to each tile we put in the DOM tree. These are FIXED. The tiles themselves NEVER move. It is the CONTENTS of + the tile (ie the gameboard we superimpose onto the grid) which changes the contents within the 2D array. The TILES THEMSELVES DO NOT CHANGE. + + For example, suppose you have a 2x2 grid: + + [[0,1], + [2,3]] + + then the gameboard will also be a 2x2 but comprised of objects + + [[obj0,obj1], + [obj2,obj3]] + + when the user makes a move, the OBJECTS in the GAMEBOARD will move, BUT the TILEs of the GRID do NOT. + + Ie, if we swap obj0 and obj1, we end up with an UNCHANGED 2x2 grid: + + [[0,1], + [2,3]] + + but with a gameboard that has changed: + + [[obj1,obj0], + [obj2,obj3]] + + This is really important to understand as it not only ensures that positional information is not encoded into the gameboard making it + resistant to cheating/tampering BUT it also means that if the user decides to request a different grid (by clicking the refresh puzzle button) + we need only change the gameboard and not the grid. HOWEVER, it does mean we are RE-USING the SAME grid. Therefore, we MUST ALWAYS check to see + if the DOM tree already admits that TILE (by id: `${row}-${col}`) in the GRID before we draw the contents of the gameboard onto the grid. + IF it exists, we must remove it: + + if the resetPuzzle() is called to get a new puzzle, we need to remove EXISTING tiles otherwise they stack up and + we end up with visual glitches. So we have to check to see if there exists a tile there BEFORE and pruining the DOM tree as we go + about adding the tildDivs to the tree (drawing) + + This is why we always check to see if there exists a tile by that ID: + + const tileAlreadyExists = document.getElementById(tileID) + if (tileAlreadyExists) { + tileAlreadyExists.remove() + } + + BEFORE adding the tile to the DOM tree. + + WARNING: + If at any point you add something else to the tree, you MUST remember to check for its existence and remove it such that on puzzle + refresh, we do not end up with any surprises. + */ + + this.gameBoard.forEach((row, rowIndex) => { + //track the tile row/cols - note these are fixed, we only move the content on top of the tiles, but the tiles themselves never move. + this.tileElements[rowIndex] = [] + row.forEach((tile, colIndex) => { + const tileDiv = document.createElement('div') + const tileID = `${rowIndex}-${colIndex}` + const tileAlreadyExists = document.getElementById(tileID) + if (tileAlreadyExists) { + tileAlreadyExists.remove() + } + tileDiv.id = tileID + tileDiv.className = tile ? 'tile' : 'tile empty-tile' + tileDiv.style.backgroundImage = tile ? `url(data:image/png;base64,${tile.base64_image})` : 'none' + this.puzzleContainerElement.appendChild(tileDiv) + this.tileElements[rowIndex][colIndex] = tileDiv + }) + }) + + //set timer and start + this.startTime = Date.now() + this.startTimer(this.puzzleChallenge.timeToSolve_ms) + + this.logIfDebug("CAPTCHA successfully initialized!", "info") + return {success:true, error:null} + + } catch (error) { + return {success:false, error: new Error(`ErrCaughtException: ${error}`)} + } + } + + + /** + * resetPuzzle is used only when the refresh button is clicked because the user wants a new puzzle. The refresh + * button is ratelimited both on the client side as well as the server side. If they want a new gameboard, we would + * request the gameboard and provide the result to the resetPuzzle function. This does NOT affact any of the existing + * eventListeners. Instead, it simply resets all of the states to their default configurations + * with the new gameboard being drawn by the initCaptcha function + * @param newPuzzleChallenge + * @returns + */ + resetPuzzle(newPuzzleChallenge:PuzzleChallenge):{success:boolean, error:Error | null} { + this.gameBoard = newPuzzleChallenge.gameBoard + + this.maxNumberOfMovesAllowed = newPuzzleChallenge.maxNumberOfMovesAllowed + this.clickCountTracker = 0 + + this.totalTimeAllowed = newPuzzleChallenge.timeToSolve_ms //reset in csae they tried and time elapsed, so we reset the time for them + // this.challengeIssuedAtTime = newPuzzleChallenge.challenge_issued_date + + this.puzzleChallenge = newPuzzleChallenge + + this.gameplayDataCollectionEnabled = newPuzzleChallenge.collect_data + + this.isSubmittingSolution = false + + this.clickChain = newPuzzleChallenge.click_chain + + // this.challengeDifficulty = newPuzzleChallenge.challenge_difficulty + + //verify endpoint does not change + + //desired endpoint will not change when users asks for a different puzzle only when they refresh + // this.desiredEndpoint = newPuzzleChallenge.users_intended_endpoint + + return this.initCaptcha() + } + + + private async computeUserPuzzleSolution(): Promise { + const orderedHashes = this.gameBoard.flat().map(tile => tile !== null ? tile.tile_grid_id : "null_tile").join("") //otherwise .join defaults to "," breaking validation + + const challengeCookieValue: string | Error = getCookieValue(this.CAPTCHA_COOKIE_NAME) + if (challengeCookieValue instanceof Error) { + throw new Error("ErrMissingHmacKey: Expected non zero length string, got: undefined") + } + + if (challengeCookieValue.trim() === "") { + throw new Error("ErrMissingHmacKey: Expected non zero length string, got: ''") + } + + return await generateHmacWithFallback(challengeCookieValue, orderedHashes) + } + + + private async gatherSolution():Promise { + const solutionHash = await this.computeUserPuzzleSolution() + + const resultToVerify:iClientSolutionSubmissionPayload = { + solution:solutionHash, + click_chain:this.clickChain, + } + + return resultToVerify + } + + private partitionString(str: string, maxChunkSize: number): string[] { + const chunks: string[] = [] + for (let i = 0; i < str.length; i += maxChunkSize) { + chunks.push(str.slice(i, i + maxChunkSize)) + } + return chunks + } + + + + private async submitSolution() { + + try { + + if (this.isSubmittingSolution) { + return //already submitting + } + + this.toggleSubmitButtonLoading(true) + + this.isSubmittingSolution = true + + const elapsedTime = Date.now() - this.startTime + if (this.timerId !== null) { + clearTimeout(this.timerId) + } + + const usersSolution:iClientSolutionSubmissionPayload = await this.gatherSolution() + + //testing the cookie strategy + const solutionStringAsCookie:string = stringToBase64WithFallback(usersSolution.solution) + const clickChainAsBase64Strings:string[] = this.partitionString(clickChainToBase64WithFallback(usersSolution.click_chain), 4000) + + const expiryDate = new Date() + expiryDate.setSeconds(expiryDate.getSeconds() + 30) // 30 seconds from now + + //const isHTTPSConnection = window.location.protocol === "https" //if you dont do this, safari misbehaves on dev making testing a pain + + attachCookie("__banjax_sol", solutionStringAsCookie) + + //set solution hash as a cookie + // let solutionCookie = `__banjax_sol=${solutionStringAsCookie}; path=/; SameSite=Lax; Max-Age=30; expires=${expiryDate.toUTCString()};` + // if (isHTTPSConnection) { + // solutionCookie += " Secure;" + // } + // document.cookie = solutionCookie + // document.cookie = `__banjax_sol=${solutionStringAsCookie}; path=/; Secure; SameSite=Lax; Max-Age=30; expires=${expiryDate.toUTCString()};` + + //partition click chain & store in multiple cookies + const nClickChainCookies = clickChainAsBase64Strings.length + for (let i = 0; i < nClickChainCookies; i++) { + + attachCookie(`__banjax_cc_${i+1}_${nClickChainCookies}`, clickChainAsBase64Strings[i]) + + // document.cookie = `__banjax_cc_${i+1}_${nClickChainCookies}=${clickChainAsBase64Strings[i]}; path=/; Secure; SameSite=Lax; Max-Age=30; expires=${expiryDate.toUTCString()};` + // let clickChainCookie = `__banjax_cc_${i+1}_${nClickChainCookies}=${clickChainAsBase64Strings[i]}; path=/; SameSite=Lax; Max-Age=30; expires=${expiryDate.toUTCString()};` + // if (isHTTPSConnection) { + // clickChainCookie += " Secure;" + // } + // document.cookie = clickChainCookie + } + + const solutionRequest = await fetch(document.location.href, { + method:"GET", + headers: {"Content-Type":"application/json"}, + credentials:"include", + }) + + this.toggleSubmitButtonLoading(false) + + if (solutionRequest.ok) { + if (solutionRequest.status === 200) { + this.handleRedirect() + } + + } else { + + if (solutionRequest.status === 403 || solutionRequest.status === 429) { + const response = await solutionRequest.text() + + if (response.trim() === "access denied") { + + //goto error handling section outside of the else + //ie continue from const messageToUser_type:'success' | 'warning' | 'error' = "error" + + } else { + + const rateLimitResponse = response + + const match = rateLimitResponse.match(/(\d+)\s+seconds/) + const duration_MS = match ? parseInt(match[1], 10) * 1000 : 60_000;//default to 60 seconds if no match found + + this.toggleRateLimit(true) + + setTimeout(()=> { + this.toggleRateLimit(false) + }, duration_MS) + + this.showUserMessage( + rateLimitResponse, + "warning", + duration_MS, + true //prioritize this over any other error message + ) + return + } + + } else if (solutionRequest.status === 404) { + //if there is no cookie and you're sending a solution restart + //or if the cookie on our end dne, restart + this.handleRedirect() + return + } + } + + const messageToUser_type:'success' | 'warning' | 'error' = "error" + + this.showUserMessage("Incorrect solution. Please try again", messageToUser_type, 5_000, true) //5 seconds is enough for them to read it was wrong + + this.logIfDebug("Invalid solution", "warn") + //continue timer + this.totalTimeAllowed -= elapsedTime //update the total time to what remains in aggregate + if (this.totalTimeAllowed <= 0) { + this.restartIfExceedMaxTimeAllowed() + } + this.startTime = Date.now() + this.startTimer(this.totalTimeAllowed) + + + } catch(error) { + this.isSubmittingSolution = false + this.toggleSubmitButtonLoading(false) + //throwing will be caught by the entrypoint + throw new Error(`ErrCaughtException: while ClientCaptchaSolver.submitSolution: ${error}`) + + } finally { + this.isSubmittingSolution = false + this.toggleSubmitButtonLoading(false) + } + } + + + private handleRedirect():void { + this.submitSolutionButton.classList.add("success") + this.submitSolutionButton.textContent = "Verified!" + setTimeout(()=> { + document.body.classList.add('fade-out') + setTimeout(() => window.location.reload(), 500) + }, 200) + } + + //moveTile is what is called when a click occurs. We only consider "valid" clicks (ie on a tile) + //as part of the overall click count. + private async moveTile(event: MouseEvent) { + try { + const tileElement = (event.target as HTMLElement).closest('.tile') + if (tileElement === null) { + this.logIfDebug(`clicked on gap or non tile`, "warn") + return //user click on gap + } + const tileID = (event.target as HTMLDivElement).id + const clickedTileRow = parseInt(tileID.split('-')[0], 10) + const clickedTileCol = parseInt(tileID.split('-')[1], 10) + + this.logIfDebug(`clicked on row: ${clickedTileRow} col: ${clickedTileCol}`) + + if (0 <= clickedTileRow && clickedTileRow < this.gameBoard.length) { + if (0 <= clickedTileCol && clickedTileCol < this.gameBoard[0].length) { + const payloadOfTileClickedOn: TileImagePartitionValue | null = this.gameBoard[clickedTileRow][clickedTileCol] + if (payloadOfTileClickedOn === null) { + //return early since the user just clicked on the empty tile. This is pointless + //as it doesnt do anything. We do not consider it a "valid click" + return + } + } + } + + const move = this.clickedTileCanBeSwappedWithNullTile(clickedTileRow, clickedTileCol) + if (!move.nullIsNeighbour) { + //disregard the click + return + } + + if (move.nullTile === null) { + //this is a bug report it + this.logIfDebug(`Expected null neighbour coords, got null`, "error") + return + } + + this.clickCountTracker++ + this.restartIfExceedsMaxClicksAllowed() //automatically restarts if too many clicks are made + + //at this point we know that the user clicked on a tile that is next to the null, therefore we can swap + //their contents in state and invoke draw! + const nullTileRow = move.nullTile[0] + const nullTileCol = move.nullTile[1] + + const tileClickedByUser:iTileProperties = {row:clickedTileRow, col:clickedTileCol, id:this.gameBoard[clickedTileRow][clickedTileCol].tile_grid_id} + const nullTileToSwapWith:iTileProperties = {row:nullTileRow, col:nullTileCol, id:"null_tile"} + await this.newClickChainRecord(tileClickedByUser, nullTileToSwapWith) + + this.swapTileContent(clickedTileRow, clickedTileCol, nullTileRow, nullTileCol) + this.draw(clickedTileRow, clickedTileCol, nullTileRow, nullTileCol) + + } catch(error) { + throw new Error(`ErrCaughtException: ClientCaptchaSolver.moveTile: ${error}`) + } + } + + /** + * newClickChainRecord takes the tile clicked and tile swapped info to keep track of it. We input the hash of the last entry in the chain + * (starting from the genesis click chain entry for the very first user entry), and produce an hmac over that entry which we then push + * into the array + * + * NOTE: We ONLY consider a "valid" click as a counter-affecting click. Ie, if the user clicks a tile that is not next to the null tile such that + * nothing happens, no tiles are swapped this is NOT considered valid and will therefore NOT be considered as part of the click chain. ONLY if the counter is + * actually incremented will we necessarily create a click chain record. + * + * @param tileClicked - tile that was clicked + * @param tileSwapped - tile it swapped with + */ + private async newClickChainRecord(tileClicked:iTileProperties, tileSwapped:iTileProperties):Promise { + try { + let clickChainEntry:iClickChainEntry = { + time_stamp:new Date().toISOString(), + tile_clicked: tileClicked, + tile_swapped_with: tileSwapped, + click_count:this.clickCountTracker, + hash:this.clickChain[this.clickChain.length-1].hash + } + + const challengeCookieValue: string | Error = getCookieValue(this.CAPTCHA_COOKIE_NAME) + + if (challengeCookieValue instanceof Error) { + throw new Error("ErrMissingHmacKey: Expected non zero length string, got: undefined") + } + + if (challengeCookieValue.trim() === "") { + throw new Error("ErrMissingHmacKey: Expected non zero length string, got: ''") + } + + const serializedPayload = JSON.stringify(clickChainEntry) + // console.log("payload serialized as: ", serializedPayload) + + const entryHash = await generateHmacWithFallback(challengeCookieValue, serializedPayload) + // console.log("generated hash: ", entryHash) + clickChainEntry.hash = entryHash + + this.clickChain.push(clickChainEntry) + + } catch(error) { + throw new Error(`ErrCaughtException: ClientCaptchaSolver.newClickChainRecord: ${error}`) + } + } + + private clickedTileCanBeSwappedWithNullTile(row:number, col:number):{nullIsNeighbour:boolean, nullTile:[number, number] | null} { + if (0 <= row && row < this.gameBoard.length) { + if (0 <= col && col < this.gameBoard[0].length) { + const payloadOfTileClickedOn: TileImagePartitionValue | null = this.gameBoard[row][col] + if (payloadOfTileClickedOn === null) { + return {nullIsNeighbour:false, nullTile:null} + } + return this.tileIsNeighboursWithNull(row, col) + } + } + return {nullIsNeighbour:false, nullTile:null} + } + + //tileIsNeighboursWithNull takes the row and col of the tile that has been clicked on + //it then looks for the null tile using only valid moves. If the tile that was clicked on does indeed have the null + //tile next to it via a valid move, then this tile can indeed by swapped with the null tile, so we return true. Otherwise, + //the user just clicked on a tile that isn't next to the null tile and therefore cannot be moved + private tileIsNeighboursWithNull(row:number, col:number):{nullIsNeighbour:boolean, nullTile:[number, number] | null} { + const possible_x = [1, -1, 0, 0] + const possible_y = [0, 0, 1, -1] + for (let i=0; i<4;i++) { + const neighbour_x = row + possible_x[i] + const neighbour_y = col + possible_y[i] + if (0 <= neighbour_x && neighbour_x < this.gameBoard.length) { + if (0 <= neighbour_y && neighbour_y < this.gameBoard[0].length) { + if (this.gameBoard[neighbour_x][neighbour_y] === null) { + return {nullIsNeighbour:true, nullTile:[neighbour_x, neighbour_y]} + } + } + } + } + return {nullIsNeighbour:false, nullTile:null} + } + + //swaps only the contents, the tile ID's do not change + private swapTileContent(clickedTileRow:number, clickedTileCol:number, nullTileRow:number, nullTileCol:number) { + this.gameBoard[nullTileRow][nullTileCol] = this.gameBoard[clickedTileRow][clickedTileCol] + this.gameBoard[clickedTileRow][clickedTileCol] = null + } + + //draw will only draw those tiles whose contents were swapped without changing anything else about the tile like its ID + //ensuring that the ID's of the tiles remain fixed and our state is just superimposed ontop of it via the gameBoard content + private draw(clickedTileRow:number, clickedTileCol:number, nullTileRow:number, nullTileCol:number) { + + const clickedTile = this.gameBoard[clickedTileRow][clickedTileCol] + this.tileElements[clickedTileRow][clickedTileCol].className = clickedTile !== null ? 'tile' : 'tile empty-tile' + this.tileElements[clickedTileRow][clickedTileCol].style.backgroundImage = clickedTile !== null ? `url(data:image/png;base64,${clickedTile.base64_image})` : 'none' + + const nullTile = this.gameBoard[nullTileRow][nullTileCol] + this.tileElements[nullTileRow][nullTileCol].className = nullTile !== null ? 'tile' : 'tile empty-tile' + this.tileElements[nullTileRow][nullTileCol].style.backgroundImage = nullTile !== null ? `url(data:image/png;base64,${nullTile.base64_image})` : 'none' + } + + + + + + + + //extra helpers - not core to functionality + + + //max time limit to solve the puzzle before we start over + private startTimer(duration: number) { + if (this.timerId !== null) { + clearTimeout(this.timerId) + } + this.logIfDebug(`restarting timer to duration remaining: ${duration}`) + this.timerId = window.setTimeout(() => this.restartIfExceedMaxTimeAllowed(), duration) + } + + private cleanup() { + this.logIfDebug("cleaning up!") + this.puzzleContainerElement.removeEventListener("mousedown", this.moveTile) + this.submitSolutionButton.removeEventListener("mousedown", this.submitSolution) + this.isSubmittingSolution = false + } + + + private restartIfExceedMaxTimeAllowed() { + this.logIfDebug(`Times up, restarting...`) + this.cleanup() + document.body.classList.add('fade-out') + setTimeout(() => window.location.reload(), 500) + return + } + + + private restartIfExceedsMaxClicksAllowed() { + if (this.clickCountTracker > this.maxNumberOfMovesAllowed) { + this.logIfDebug(`Too many clicks! Restarting...`) + this.cleanup() + document.body.classList.add('fade-out') + setTimeout(() => window.location.reload(), 500) + return + } + } + + private debounce Promise>(func: T, delay: number): (...args: Parameters) => Promise { + let timeoutId: ReturnType + const context = this + + return async function (...args: Parameters): Promise { + if (timeoutId) clearTimeout(timeoutId) + + return new Promise((resolve, reject) => { + timeoutId = setTimeout(async () => { + try { + await func.apply(context, args) + resolve() + } catch (error) { + reject(error) + } + }, delay) + }) + } + } + + //NOTE: all messages are display UNDER the grid + private showUserMessage(message: string, type: 'success' | 'warning' | 'error' = 'error', duration = 5000, prioritizeMessage: boolean = false) { + const messageElement = document.querySelector(".display-message-to-user") + + if (messageElement) { + // If prioritizing, clear any currently displayed message before showing the new one + if (prioritizeMessage) { + this.hideUserMessage() + clearTimeout(this.currentMessageTimeout) // Clear any pending timeout + } + + messageElement.className = 'display-message-to-user' // Reset classes + messageElement.classList.add('show', type) // Apply the correct type + messageElement.textContent = message + + // Store the timeout reference so we can clear it if needed + this.currentMessageTimeout = setTimeout(() => { + this.hideUserMessage() + }, duration) + } + } + + private hideUserMessage() { + const messageElement = document.querySelector(".display-message-to-user") + if (messageElement) { + messageElement.classList.remove('show', 'success', 'warning', 'error') + } + } + + /** + * toggleSubmitButtonLoading shows the loading spinner on top of the submit button + * @param isLoading - true if you want to show the loading spinner, false if you want to stop it + * @returns + */ + private toggleSubmitButtonLoading(isLoading: boolean) { + + if (!this.submitSolutionButton) { + return + } + + if (isLoading) { + this.submitSolutionButton.classList.add('loading') + + this.submitSolutionButton.classList.add("disabled") + this.submitSolutionButton.disabled = true + + } else { + + this.submitSolutionButton.classList.remove('loading') + + this.submitSolutionButton.classList.remove("disabled") + this.submitSolutionButton.disabled = false + } + } + + //since the rate limit happens in the middle of the submission, we need to stop + //the toggleSubmitButtonLoading, and then after the rate limit expired, put that style back on + private toggleRateLimit(isRateLimitEnabled: boolean) { + + //also apply to the request puzzle button + const requestPuzzleButton = document.getElementById("request-different-puzzle-icon") + + if (isRateLimitEnabled) { + this.toggleSubmitButtonLoading(false) + + setTimeout(() => { + this.submitSolutionButton.classList.add("disabled") + this.submitSolutionButton.removeAttribute("disabled") + + requestPuzzleButton.classList.add("not-currently-clickable") + requestPuzzleButton.classList.remove("enabled") + requestPuzzleButton.style.opacity = "0.3" + }, 0) + + } else { + this.submitSolutionButton.classList.remove("disabled") + this.submitSolutionButton.removeAttribute("disabled") + + + requestPuzzleButton.classList.remove("not-currently-clickable") + // this.requestPuzzleButton.style.cursor = "pointer" + requestPuzzleButton.classList.add("enabled") + requestPuzzleButton.style.opacity = "1" + } + } + + private logIfDebug(msg:string, type:"debug"| "info" | "warn"|"error" = "debug") { + if (this.debug) { + switch(type) { + case "debug": + console.log(msg) + break + case "info": + console.info(msg) + break + case "warn": + console.warn(msg) + break + case "error": + console.error(msg) + break + default: + console.log(msg) + } + } + } +} \ No newline at end of file diff --git a/internal/puzzle_ui/src/client/scripts/entrypoint-deflect-captcha.ts b/internal/puzzle_ui/src/client/scripts/entrypoint-deflect-captcha.ts new file mode 100644 index 0000000..a156347 --- /dev/null +++ b/internal/puzzle_ui/src/client/scripts/entrypoint-deflect-captcha.ts @@ -0,0 +1,305 @@ +/* + for future reference: + + - since we need to support legacy browsers and they dont have JS features we are using like Promise/Array.prototype.find, URLSearchParams etc + - we need to import these so that rollup can bundle the requirements for core-js polyfilling + + - always import polyfills you need explicitly at the very top of this entrypoint file (as we are using "entry" in the rollup) + - entry mode provides better predictability when dealing with complex setups involving Rollup, Babel, CSS injection, and legacy browser support. + + order matters: + - Polyfills first + - CSS second + - App logic last +*/ +import 'core-js/es/string/starts-with' //for String.prototype.startsWith +import 'core-js/web/url-search-params' //for URLSearchParams +import 'regenerator-runtime/runtime' //for async/await support +import 'core-js/es/string/pad-start' // String.prototype.padStart for hex formatting +import 'core-js/es/array/find' // for Array.prototype.find +import 'fast-text-encoding' // Polyfill for TextEncoder/TextDecoder +import 'core-js/es/promise' //for promises +import 'core-js/stable' + +//extra polyfills for IE11/Older Browsers as needed depending on legacy browsers we need to support +// import 'core-js/es/object/entries' //Object.entries() +// import 'core-js/es/object/values' //Object.values() +// import 'core-js/es/array/includes' //Array.prototype.includes() +// import 'core-js/es/number/is-nan' //Number.isNaN() + + +/* + NOTE: SINCE WE DECIDED TO INCLUDE THE CSS DIRECTLY INTO THE INDEX.HTML + WE NEED NOT IMPORT THE FILES HERE FOR THE BUNDLER TO FIND THEM. IF YOU WANT TO + START RE-BUNDLING THE CSS ALONG WITH THE JS, YOU NEED ONLY UNCOMMENT THESE LINES + AND SUBSEQUENTLY MAKE SURE THAT THE PostCSS() hook in rollup IS ALSO UNCOMMENTED. + + also, you will need to delete the style tags and the css from the index.html +*/ +//import css such that rolllup will bundle all of the css as its imported in the entrypoint +//this allows us to replace multiple requests of assets with 1 request for the bundle +// import '../styles/puzzle-messages-to-user.css' +// import '../styles/puzzle-instructions.css' +// import '../styles/puzzle-submission.css' +// import '../styles/puzzle-container.css' +// import '../styles/puzzle-thumbnail.css' +// import '../styles/puzzle-refresh.css' +// import '../styles/puzzle-grid.css' +// import '../styles/main.css' + + +import attachFooterHeaderHostname from "./attach-footer-and-header-info" +import attachInfoOverlay from "./puzzle-instructions-info-button" +import attachThumbnailOverlay from "./inspect-target-image-modal" +import {stringToBase64WithFallback} from './utils/b64-utils' +import ClientCaptchaSolver from "./client-captcha-solver" +import checkForInitialState from './check-initial-state' +import RefreshPuzzle from "./request-different-puzzle" +import {attachCookie} from './utils/cookie-utils' + + + + + + + + +/* + entrypoint to the captcha + + phones home to get the CAPTCHA + if any critical failure occurs, retry until exhaust attempts or server response indicates to stop trying + -NOTE: A critical failure is an inability to setup the challenge or run the puzzle in any way. + - Some issues like failure to setup thumbail inspection or additional info listeners are non critical, so the captcha will proceed (but it will report them) + if server indicates to stop trying, run hardcoded fallback challenge +*/ + +let clientSideAttempt = 0 + +const ENABLE_DEBUG = false +const MAX_CLIENT_SIDE_RETRIES = 3 +const CLIENT_SIDE_RATE_LIMIT_WINDOW = { + maxNumberOfNewPuzzles: 3, + unitTimeInMs: 60000 +} + +const CAPTCHA_COOKIE_NAME = "deflect_challenge4" + +const HOSTNAME_FOOTER_HEADER_ERROR_ENDPOINT = "/__banjax/error/hostname-footer-header-error" +const REQUEST_NEW_PUZZLE_ERROR_ENDPOINT = "/__banjax/error/request-different-puzzle-error" +const DETAILED_INSTRUCTION_ERROR_ENDPOINT = "/__banjax/error/detail-instruction-error" +const INSPECT_THUMBNAIL_ERROR_ENDPOINT = "/__banjax/error/inspect-thumbnail-error" +const ENTRYPOINT_INIT_ERROR_ENDPOINT = "/__banjax/error/entrypoint-init-error" +const CAPTCHA_INIT_ERROR_ENDPOINT = "/__banjax/error/captcha-init-error" + + +const REFRESH_PUZZLE_STATE_ENDPOINT = "/__banjax/refresh/puzzle-state" + +// const VERIFY_SOLUTION_ENDPOINT = "/__banjax/validate_puzzle_solution" + + +//init CAPTCHA after dom has fully loaded +document.addEventListener('DOMContentLoaded', async function() { + window.addEventListener("captchaError", captchaErrorListener) + await runCaptcha(clientSideAttempt) +}) + + + + +/* + Some error cannot be dealt with inside of the functions themselves, for example if a puzzle + is missing a cookie, we cannot fix that problem internally, so instead we bubble the error + all the way back out and exploit the existing retry+fallback mechanisms that exist +*/ +const captchaErrorListener = async (event: CustomEvent) => { + if (ENABLE_DEBUG) { + console.error(`ErrGlobalCaptcha: ${event.detail}`) + } + await runCaptcha(clientSideAttempt, event.detail) +} + + + + + +/* + NOTE for future: + + we can use this space here (ie at the level of the runCaptcha() prior to phoning home) to run checks to see if their browser supports + things like webworkers, wasm etc and send that info when phoning home so that we can respond back with the right captcha that is most secure + or best performing for their system etc.. up to you! +*/ +async function runCaptcha(clientSideAttempt:number=0, error?: any):Promise { + + clientSideAttempt++ + + try { + + if (error) { + throw new Error(`ErrCaughtUnhandledGlobalError: ${error}`) + } + + await phoneHomeForCaptcha() + + } catch(error) { + + let errEndpoint = ENTRYPOINT_INIT_ERROR_ENDPOINT + + if (error instanceof Error && error.message.includes("ErrFailedClientCaptchaInit")) { + errEndpoint = CAPTCHA_INIT_ERROR_ENDPOINT + } + + const requestToRetry = await reportError(new Error(`ErrCaughtException: ${error}`), errEndpoint) + + if (requestToRetry.allowedToRetry && clientSideAttempt < MAX_CLIENT_SIDE_RETRIES) { + await runCaptcha(clientSideAttempt) + return + } else { + await runFallbackCaptcha() + return + } + } +} + + +async function phoneHomeForCaptcha() { + + let skipRequestForPuzzle = false + + const initialInjectedState:{success:boolean, error:Error | null, initialState:PuzzleChallenge | null} = checkForInitialState() + + //the null check is a bit redundant given that the checkForInitialState only returns success if it has access but its important since + //its up to the dev to make sure that this is the case and we could mess it up. Plus typescript gets all mad and stuff + if (initialInjectedState.success && initialInjectedState !== null) { + skipRequestForPuzzle = true + } + + let puzzleChallenge:PuzzleChallenge + + + if (skipRequestForPuzzle) { + + puzzleChallenge = initialInjectedState.initialState + + } else { + + //this request will already admit the challenge cookie, so we can acceess that from headers + //NOTE this uses the refresh endpoint + const requestForPuzzle = await fetch(REFRESH_PUZZLE_STATE_ENDPOINT, { + method:"GET", + credentials:"include" + }) + + if (!requestForPuzzle.ok) { + throw new Error("ErrNoResponse: Failed to get response") + } + + //at this point we know we got a response back (response.ok) + + if (requestForPuzzle.status !== 200) { + throw new Error(`ErrUnexpectedStatus: Expected status 200, got: ${requestForPuzzle.status}}`) + } + + //at this point we know we received the result as desired (status 200) + //parse challenge we received + puzzleChallenge = await requestForPuzzle.json() + } + + + + //the constructor sets up the entire puzzle - ie just initializing is enough + //NOTE: Since this is a critical error, we will immediately check for retry by throwing and catching in the runChallenge. + // This is unlike the remaining scripts which are nice to have for user experience, but not critical to functionality + const clientSideSolver = new ClientCaptchaSolver(puzzleChallenge, CAPTCHA_COOKIE_NAME, ENABLE_DEBUG) + const successfullyInitializedCaptcha = clientSideSolver.initCaptcha() + if (!successfullyInitializedCaptcha.success) { + throw new Error(`ErrFailedClientCaptchaInit: ${successfullyInitializedCaptcha.error}`) + } + + //attaches client side rate limited request new puzzle button. (NOTE: We also rate limit server side) + //we also provide a reference to the clientSideSolver defined above such that we can just update state + //on receiving new puzzle without needing to reattach all listeners + const {maxNumberOfNewPuzzles, unitTimeInMs} = CLIENT_SIDE_RATE_LIMIT_WINDOW + const rateLimitedPuzzleRefresher = new RefreshPuzzle(maxNumberOfNewPuzzles, unitTimeInMs, clientSideSolver, REFRESH_PUZZLE_STATE_ENDPOINT, ENABLE_DEBUG) + const successfullyAttachedRefresh = rateLimitedPuzzleRefresher.initPuzzleRefresh() + if (!successfullyAttachedRefresh.success) { + if (successfullyAttachedRefresh.error instanceof Error) { + await reportError(successfullyAttachedRefresh.error, REQUEST_NEW_PUZZLE_ERROR_ENDPOINT) + } + } + + //attaches capability to inspect the thumbnail, reports error if fails + const successfullAttachedThumbnailInspection = attachThumbnailOverlay() + if (!successfullAttachedThumbnailInspection.success) { + if (successfullAttachedThumbnailInspection.error instanceof Error) { + await reportError(successfullAttachedThumbnailInspection.error, INSPECT_THUMBNAIL_ERROR_ENDPOINT) + } + } + + //attaches capability to open detailed instructions overlay on click info button, reports error if fails + const successfullyAttachedInstructions = attachInfoOverlay() + if (!successfullyAttachedInstructions.success) { + if (successfullyAttachedInstructions.error instanceof Error) { + await reportError(successfullyAttachedInstructions.error, DETAILED_INSTRUCTION_ERROR_ENDPOINT) + } + } + + const successfullyAttachedHostname = attachFooterHeaderHostname() + if (!successfullyAttachedHostname.success) { + if (successfullyAttachedHostname.error instanceof Error) { + await reportError(successfullyAttachedHostname.error, HOSTNAME_FOOTER_HEADER_ERROR_ENDPOINT) + } + } +} + +/** +reportError is limited for the time being to reporting the `errorType` that occured (inferred from the endpoint) and +we rely on the user agent in order to recreate the issue. We send additional information about the stack trace through a cookie + */ +async function reportError(error:Error, endpoint:string):Promise<{allowedToRetry:boolean}> { + + try { + + const metadata = stringToBase64WithFallback(error.stack ?? error.message).slice(0, 4000) //to guarentee fitting into a cookie + + attachCookie("__banjax_error", metadata, {expirySecondsFromNow:10}) + + //we will add additional info later + const somethingWentWrong_requestPermissionToRetry = await fetch(endpoint, { + method:"GET", + headers: {"Content-Type": "application/json"}, + credentials:"include", + }) + + if (somethingWentWrong_requestPermissionToRetry.ok) { + if (somethingWentWrong_requestPermissionToRetry.status === 204) { + //we got permission to retry, otherwise we would be blocked server side + return {allowedToRetry:true} + } + } + + //no answer or not correct status code, fallback + return {allowedToRetry:false} + + } catch(error) { + return {allowedToRetry:false} + } + +} + +async function runFallbackCaptcha() { + + if (ENABLE_DEBUG) { + console.debug("RUNNING FALLBACK!") + } + + /* + classic POW challenge - either hardcoded or fetch for the existing deflect challenge? That way the user always has something... + + NOTE: + the fallback would need to remove the entire gameboard and then run its own challenge + + */ +} + diff --git a/internal/puzzle_ui/src/client/scripts/inspect-target-image-modal.ts b/internal/puzzle_ui/src/client/scripts/inspect-target-image-modal.ts new file mode 100644 index 0000000..4c12362 --- /dev/null +++ b/internal/puzzle_ui/src/client/scripts/inspect-target-image-modal.ts @@ -0,0 +1,55 @@ + + + +/** + attachThumbnailOverlay allows the user to click on the thumbnail itself to inspect the target image +*/ +export default function attachThumbnailOverlay():{success:boolean, error:Error | null} { + + try { + const thumbnail = document.getElementById("deflect-puzzle-thumbnail") as HTMLImageElement + const modalImg = document.getElementById("thumbnail-modal-img") as HTMLImageElement + const closeModal = document.querySelector(".thumbnail-modal-close") as Element + const modal = document.getElementById("thumbnail-modal") as HTMLElement + + //if there is no thumbnail, we are not in good shape... + if (!thumbnail) { + const errorPayload = {modal_status:modal, modal_image_status:modalImg, close_modal_status:closeModal, thumbnail_status:thumbnail} + return {success:false, error:new Error(`ErrFailedToAttachThumbnailInspectionAbility: ${JSON.stringify(errorPayload)}`)} + } + + //otherwise if for whatever reason the thumbnail exists but we couldn't find one of these, remove the cursor: pointer style + //so that users aren't confused by that thinking it can be clicked + if (!modal || !modalImg || !closeModal) { + thumbnail.style.cursor = "default" + const errorPayload = {modal_status:modal, modal_image_status:modalImg, close_modal_status:closeModal, thumbnail_status:thumbnail} + return {success:false, error:new Error(`ErrFailedToAttachThumbnailInspectionAbility: ${JSON.stringify(errorPayload)}`)} + } + + //otherwise, we provide the ability to inspect the thumbnail more closely + + //opens the modal when you click the thumbnail + thumbnail.addEventListener("click", () => { + modal.style.display = "flex" + modalImg.src = thumbnail.src + }) + + //closes the modal when you click on the x + closeModal.addEventListener("click", () => { + modal.style.display = "none" + }) + + //closes the moodal when you click anywhere on the page outside the image + modal.addEventListener("click", (event) => { + if (event.target === modal) { + modal.style.display = "none" + } + }) + return {success:true, error:null} + + } catch(error) { + return {success:false, error: new Error(`ErrCaughtException: ${error}`)} + } +} + + diff --git a/internal/puzzle_ui/src/client/scripts/puzzle-instructions-info-button.ts b/internal/puzzle_ui/src/client/scripts/puzzle-instructions-info-button.ts new file mode 100644 index 0000000..d7da8fd --- /dev/null +++ b/internal/puzzle_ui/src/client/scripts/puzzle-instructions-info-button.ts @@ -0,0 +1,44 @@ + +/** + attachInfoOverlay allows the user to click on the info icon in the submission section to pull up the instructions overlay and see an example gif + with more detailed instructions on how the puzzle works +*/ +export default function attachInfoOverlay():{success:boolean, error:Error | null} { + + try { + //detailed-instructions-overlay + const info_icon = document.getElementById("detailed-instructions-info-icon") as HTMLElement + const closeModal = document.querySelector(".detailed-instructions-modal-close") as Element + const overlayModal = document.getElementById("detailed-instructions-overlay") as HTMLElement + + if (!info_icon || !closeModal || !overlayModal) { + const errorPayload = {info_icon_status:info_icon, close_modal_status:closeModal, overlay_modal_status:overlayModal} + return {success:false, error:new Error(`ErrFailedToAttachInstructionsOverlay: ${JSON.stringify(errorPayload)}`)} + } + + + //if we have access to everything we need we can have the cursor be a pointer so users know the can click it + info_icon.style.cursor = "pointer" + + // //opens the modal when you click the thumbnail + info_icon.addEventListener("click", () => { + overlayModal.style.display = "flex" + }) + + // //closes the modal when you click on the x + closeModal.addEventListener("click", () => { + overlayModal.style.display = "none" + }) + + // //closes the moodal when you click anywhere on the page outside the image + overlayModal.addEventListener("click", (event) => { + if (event.target === overlayModal) { + overlayModal.style.display = "none" + } + }) + return {success:true, error:null} + + } catch(error) { + return {success:false, error:new Error(`ErrCaughtException: ${error}`)} + } +} \ No newline at end of file diff --git a/internal/puzzle_ui/src/client/scripts/request-different-puzzle.ts b/internal/puzzle_ui/src/client/scripts/request-different-puzzle.ts new file mode 100644 index 0000000..e61cd3e --- /dev/null +++ b/internal/puzzle_ui/src/client/scripts/request-different-puzzle.ts @@ -0,0 +1,264 @@ +import ClientCaptchaSolver from "./client-captcha-solver" + +/** + RefreshPuzzle allows the user to request a new puzzle. + This will be rate limited so they cannot keep looking for a new puzzle arbitrarily + rate limiting will be applied to both client side and server side + + example: if you want to allow 3 puzzle requests per minute: new RefreshPuzzle(3, 60000) +*/ +export default class RefreshPuzzle { + + private puzzleCount: number = 0 + private maxNumberOfNewPuzzles: number + private unitTime: number + private resetTimeout: ReturnType | null = null + + private cooldownMessageElement: Element | null = null + private requestPuzzleButton:HTMLElement | null = null + + clientSideSolver: ClientCaptchaSolver + + currentMessageTimeout:number | null = null + + REFRESH_PUZZLE_STATE_ENDPOINT:string + + private debug:boolean + + constructor(maxNumberOfNewPuzzles: number, unitTimeInMs: number, clientSideSolver: ClientCaptchaSolver, REFRESH_PUZZLE_STATE_ENDPOINT:string, debug?:boolean) { + this.maxNumberOfNewPuzzles = maxNumberOfNewPuzzles + this.unitTime = unitTimeInMs + this.clientSideSolver = clientSideSolver //reference to existing solver + this.REFRESH_PUZZLE_STATE_ENDPOINT = REFRESH_PUZZLE_STATE_ENDPOINT + this.debug = debug ?? false + } + + initPuzzleRefresh():{success:boolean, error:Error | null} { + + this.logIfDebug("Refreshing puzzle", "info") + + try { + const cooldownMessageElement = document.querySelector(".display-message-to-user") + const requestPuzzleButton = document.getElementById("request-different-puzzle-icon") + + if (!cooldownMessageElement) { + //not having access to cooldown is non critical to functionality as it just means we can't display + //the message that they are being rate limited but the underlying request functionality will continue to work + //as long as requestPuzzleButton is found + this.logIfDebug(`cooldown message element not found: ${cooldownMessageElement}`, "warn") + } + + if (!requestPuzzleButton) { + const err = {cool_down_status: cooldownMessageElement, new_puzzle_button_status: requestPuzzleButton} + return {success:false, error:new Error(`ErrMissingPuzzleRefresh: ${JSON.stringify(err)}`)} + } + + if (cooldownMessageElement) { + this.cooldownMessageElement = cooldownMessageElement + } + + if (requestPuzzleButton !== null) { + requestPuzzleButton.removeEventListener("click", this.requestNewPuzzle) + requestPuzzleButton.addEventListener("click", this.debounce(this.requestNewPuzzle.bind(this), 150)) + //if we have access to everything we need we can have the cursor be a pointer so users know the can click it + requestPuzzleButton.classList.add("enabled") + requestPuzzleButton.style.opacity = "1" + this.requestPuzzleButton = requestPuzzleButton + } + + return {success:true, error:null} + + } catch(error) { + return {success:false, error:new Error(`ErrCaughtException: while initPuzzleRefresh: ${error}`)} + } + } + + private async requestNewPuzzle() { + + const messageToUser_type:'success' | 'warning' | 'error' = "warning" + if (this.puzzleCount >= this.maxNumberOfNewPuzzles) { + + this.showCooldownMessage("You've requested a new puzzle too many times. Please wait 60 seconds before trying again.", messageToUser_type, 60_000, true) + if (this.requestPuzzleButton) { + this.requestPuzzleButton.classList.add("not-currently-clickable") + this.requestPuzzleButton.classList.remove("enabled") + this.requestPuzzleButton.style.opacity = "0.3" + } + return + } + + this.puzzleCount++ + this.logIfDebug(`New puzzle requested. Count: ${this.puzzleCount}/${this.maxNumberOfNewPuzzles}`) + + + const newPuzzleResult = await this.fetchNewPuzzle() + if (!newPuzzleResult.success) { + //if the fetchNewPuzzle fails, throw, this will be caught by the entrypoint script which will be able to relay the error back to the server + //to ask what to do (try again from the beginning or use hardcoded fallback puzzle) + throw new Error(`ErrFailedRequestNewPuzzle: ${newPuzzleResult.error}`) + } + + //at this point we successfully got the puzzle and reset it, so we can proceed with removing the cooldown message + + //reset the counter after the unitTime window + if (!this.resetTimeout) { + this.resetTimeout = setTimeout(() => { + this.puzzleCount = 0 + this.hideCooldownMessage() + this.resetTimeout = null + }, this.unitTime) + } + } + + private async fetchNewPuzzle():Promise<{success:boolean, error:Error | null}> { + try { + + this.logIfDebug("Fetching new puzzle from server", "info") + + if (this.requestPuzzleButton) { + this.requestPuzzleButton.classList.add("rotating-while-waiting-on-new-puzzle-request-response") + } + + const thumbnailWrapper = document.getElementById("thumbnail-wrapper") as HTMLElement | null + if (thumbnailWrapper !== null) { + //attach the scroll wheel class overlay while loading + this.addLoadingOverlay(thumbnailWrapper) + } + const puzzleContainerElement = document.getElementById('deflect-puzzle') as HTMLElement | null + if (puzzleContainerElement) { + //attack the scroll wheel class overlay while loading + this.addLoadingOverlay(puzzleContainerElement) + } + + const requestForPuzzle = await fetch(this.REFRESH_PUZZLE_STATE_ENDPOINT, { + method:"GET", + credentials:"include" + }) + + if (!requestForPuzzle.ok) { + return {success:false, error: new Error("ErrNoResponse: Failed to get response")} + } + + if (requestForPuzzle.status !== 200) { + return {success:false, error: new Error(`ErrUnexpectedStatus: Expected status 200, got: ${requestForPuzzle.status}}`)} + } + + //at this point we received the new challenge payload, so invoking clientSideSOlver.resetPuzzle will re-use the same event listeners + //but reset all of the puzzle requirements like the board etc + + const newPuzzleChallenge:PuzzleChallenge = await requestForPuzzle.json() + const resultOfReset = this.clientSideSolver.resetPuzzle(newPuzzleChallenge) + this.logIfDebug("New puzzle loaded successfully!", "info") + + if (thumbnailWrapper !== null) { + //remove the scroll wheel class overlay while loading + this.removeLoadingOverlay(thumbnailWrapper) + } + + if (puzzleContainerElement) { + //remove the scroll wheel class overlay while loading + this.removeLoadingOverlay(puzzleContainerElement) + } + + + return resultOfReset + + } catch(error) { + + this.logIfDebug(`caught exception while attempting to get new puzzle due to error: ${error}`, "error") + return {success:false, error:new Error(`ErrCaughtException: while RefreshPuzzle.fetchNewPuzzle: ${error} `)} + + } finally { + if (this.requestPuzzleButton) { + this.requestPuzzleButton.classList.remove("rotating-while-waiting-on-new-puzzle-request-response") + } + } + } + + + + private debounce void>(func: T, delay: number): (...args: Parameters) => void { + let timeoutId: ReturnType + return (...args: Parameters) => { + if (timeoutId) { + clearTimeout(timeoutId) + } + timeoutId = setTimeout(() => func(...args), delay) + } + } + + + + + //NOTE: all messages are display UNDER the grid + private showCooldownMessage(message: string, type: 'success' | 'warning' | 'error' = 'error', duration = 5000, prioritizeMessage: boolean = false) { + + if (this.cooldownMessageElement) { + // If prioritizing, clear any currently displayed message before showing the new one + if (prioritizeMessage) { + this.hideCooldownMessage() + clearTimeout(this.currentMessageTimeout) // Clear any pending timeout + } + + this.cooldownMessageElement.classList.add('show', type) + this.cooldownMessageElement.textContent = message + + // Store the timeout reference so we can clear it if needed + this.currentMessageTimeout = setTimeout(() => { + this.hideCooldownMessage() + }, duration) + } + } + + private hideCooldownMessage() { + if (this.cooldownMessageElement) { + this.cooldownMessageElement.classList.remove('show', 'success', 'warning', 'error') + } + if (this.requestPuzzleButton) { + this.requestPuzzleButton.classList.remove("not-currently-clickable") + // this.requestPuzzleButton.style.cursor = "pointer" + this.requestPuzzleButton.classList.add("enabled") + this.requestPuzzleButton.style.opacity = "1" + } + } + + + private addLoadingOverlay(element: HTMLElement) { + element.classList.add("position-relative") + const overlay = document.createElement("div") + overlay.classList.add("loading-overlay") + overlay.innerHTML = `
` + overlay.id = `${element.id}-loading-overlay` + element.appendChild(overlay) + } + + + private removeLoadingOverlay(element: HTMLElement) { + const overlay = document.getElementById(`${element.id}-loading-overlay`) + if (overlay) { + overlay.remove() + } + } + + + private logIfDebug(msg:string, type:"debug"| "info" | "warn"|"error" = "debug") { + if (this.debug) { + switch(type) { + case "debug": + console.log(msg) + break + case "info": + console.info(msg) + break + case "warn": + console.warn(msg) + break + case "error": + console.error(msg) + break + default: + console.log(msg) + } + } + } +} \ No newline at end of file diff --git a/internal/puzzle_ui/src/client/scripts/utils/b64-utils.ts b/internal/puzzle_ui/src/client/scripts/utils/b64-utils.ts new file mode 100644 index 0000000..b628fd6 --- /dev/null +++ b/internal/puzzle_ui/src/client/scripts/utils/b64-utils.ts @@ -0,0 +1,27 @@ +import {encode as base64Encode} from "js-base64" + +/** + * Encodes a string to Base64, using `btoa` when available, + * and `js-base64` as a fallback. + */ +export function stringToBase64WithFallback(str: string): string { + try { + return typeof btoa === "function" ? btoa(str) : base64Encode(str) + } catch (err) { + console.error("Base64 encoding failed, using fallback:", err) + return base64Encode(str) + } +} + +/** + * Converts a click chain array to a Base64 string. + */ +export function clickChainToBase64WithFallback(clickChain: iClickChainEntry[]): string { + try { + const jsonString = JSON.stringify(clickChain) + return stringToBase64WithFallback(jsonString) + } catch (err) { + console.error("Failed to encode click chain:", err) + return "" + } +} \ No newline at end of file diff --git a/internal/puzzle_ui/src/client/scripts/utils/cookie-utils.ts b/internal/puzzle_ui/src/client/scripts/utils/cookie-utils.ts new file mode 100644 index 0000000..d1215f5 --- /dev/null +++ b/internal/puzzle_ui/src/client/scripts/utils/cookie-utils.ts @@ -0,0 +1,55 @@ +import cookie from "cookie" + +export function getCookieValue(targetCookieName: string): string | Error { + + const cookieHeadersString = document.cookie + + if (!cookieHeadersString) { + return new Error(`ErrHeadersMissingCookieString: expected parsable cookie string, got: ${cookieHeadersString}`) + } + + const cookieMap: Record = cookie.parse(cookieHeadersString) + const targetCookieValue = cookieMap[targetCookieName] + + if (targetCookieValue === undefined) { + return new Error(`ErrMissingTargetCookie: cookie with name: ${targetCookieName} not found`) + } + + if (!targetCookieValue) { + return new Error(`ErrInvalidTargetCookie: cookie with name: ${targetCookieName} returned empty value`) + } + + return targetCookieValue +} + +export function attachCookie( + + name:string, + value:string, + args?:{ + expirySecondsFromNow?:number, + path?:string, + sameSite?:"Strict" | "Lax" | "None" + } + +) : void { + + let expiryValue = args?.expirySecondsFromNow ?? 30 + let sameSiteValue = args?.sameSite ?? "Lax" + let pathValue = args?.path ?? "/" + + const expiryDate = new Date() + expiryDate.setSeconds(expiryDate.getSeconds() + expiryValue) + + const isHTTPSConnection = window.location.protocol === "https" + + let cookieToAttach = `${name}=${value}; path=${pathValue}; SameSite=${sameSiteValue}; Max-Age=${expiryValue}; expires=${expiryDate.toUTCString()};` + + //if its https or samesite is 'none', we need to add 'secure' since some + //browsers (like safari) require it and make testing a pain when working over http + if (isHTTPSConnection || sameSiteValue === "None") { + cookieToAttach += " Secure;" + } + + document.cookie = cookieToAttach +} \ No newline at end of file diff --git a/internal/puzzle_ui/src/client/scripts/utils/hmac-utils.ts b/internal/puzzle_ui/src/client/scripts/utils/hmac-utils.ts new file mode 100644 index 0000000..3f5f116 --- /dev/null +++ b/internal/puzzle_ui/src/client/scripts/utils/hmac-utils.ts @@ -0,0 +1,20 @@ +import {HmacSHA256, enc} from 'crypto-js' + + +/** + * generateHmacWithFallback allows for hashing using either the crypto.subtle api for modern browsers or the bundled crypto-js if crypto.subtle not found + * @param key hmac key + * @param message payload + * @returns hmac + */ +export async function generateHmacWithFallback(key: string, message: string): Promise { + if (window.crypto && window.crypto.subtle) { + const encKey = new TextEncoder().encode(key) + const encMessage = new TextEncoder().encode(message) + const cryptoKey = await crypto.subtle.importKey('raw', encKey, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']) + const signature = await crypto.subtle.sign('HMAC', cryptoKey, encMessage) + return Array.from(new Uint8Array(signature)).map((b) => b.toString(16).padStart(2, '0')).join('') + } else { + return HmacSHA256(message, key).toString(enc.Hex) + } +} diff --git a/internal/puzzle_ui/src/client/styles/main.css b/internal/puzzle_ui/src/client/styles/main.css new file mode 100644 index 0000000..7307029 --- /dev/null +++ b/internal/puzzle_ui/src/client/styles/main.css @@ -0,0 +1,149 @@ +body, +html { + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", + sans-serif; + overflow-x: hidden; + position: relative; +} +a { + color: #e33624; + text-decoration: none; +} +h1, +h2 { + font-weight: 400; +} +.header { + height: 33%; + display: flex; + justify-content: center; + align-items: center; +} +.header-wrapper { + display: flex; + justify-content: center; + align-items: center; + text-align: center; + flex-direction: column; + + top:50px; + position: absolute; +} + + +.website { + display: flex; + align-items: center; + justify-content: center; +} +.website-favicon { + width: 32px; + margin-right: 15px; +} +.website-title { + font-size: 32px; +} + +.footer { + position: fixed; + bottom: 0; + left: 0; + width: 100%; + height: 67vh; + background-color: #ecece2; + z-index: -1; + + display: flex; + flex-direction: column-reverse; + justify-content: flex-end; + align-items: center; + + transition: all 0.3s ease-in-out; + + padding-bottom:10px; +} + +/* .footer-logo { + margin-bottom: 20px; +} */ + + +.footer-wrapper { + position: absolute; + top: calc(240px + 100% / 3 + 20px); + left: 0; + width: 100%; + /* display: flex; + justify-content: space-between; + flex-direction: column; */ + text-align: center; + transition: color 0.5s ease-in-out, opacity 0.5s ease-in-out; +} + +.footer-title { + font-size: 24px; + transition: color 0.5s ease-in-out, opacity 0.5s ease-in-out; + padding-top:20px; +} + + + +.footer-logo svg { + width: 50px; + height: 80px; +} + +.footer-logo-caption { + font-size: 18px; +} + +@media screen and (min-width: 2400px) { + .footer-logo svg { + width: 2vw; + height: 3vw; + } + .footer-logo-caption { + font-size: 1vw; + } + .website-title { + font-size: 2vw; + } + .website-favicon { + width: 2vw; + } + .footer-title { + font-size: 1.7vw; + } +} + + +/* for all width > 800px make the "verifiing if your connection secure" and deflect branding disappear just before going under puzzle board*/ +@media screen and (min-width: 801px) and (max-height: 1290px) { + .footer-title { + color: transparent; + opacity: 0; + } +} + +@media screen and (min-width: 801px) and (max-height: 1090px) { + .footer-wrapper { + color: transparent; + opacity: 0; + } +} + +/* for all width <= 800px make the "verifiing if your connection secure" and deflect branding disappear later since the puzzle board also shrinks to screen dimensions*/ +@media screen and (max-width: 800px) and (max-height: 1060px) { + .footer-title { + color: transparent; + opacity: 0; + } +} + +@media screen and (max-width: 800px) and (max-height: 960px) { + .footer-wrapper { + color: transparent; + opacity: 0; + } +} diff --git a/internal/puzzle_ui/src/client/styles/puzzle-container.css b/internal/puzzle_ui/src/client/styles/puzzle-container.css new file mode 100644 index 0000000..9e4a792 --- /dev/null +++ b/internal/puzzle_ui/src/client/styles/puzzle-container.css @@ -0,0 +1,119 @@ + + + +.captcha-container { + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; + display: grid; + grid-template-rows: auto 1fr auto; /* top: Instructions, middle: Puzzle, bottom: submit */ + justify-content: center; + align-items: center; + width: 50%; + max-width: 500px; + min-width: 300px; + + + /* aspect-ratio: 3 / 1; */ + /*enforce 3:1 aspect ratio manually cause safari is mean*/ + height: calc((100% / 3)); + max-height: 860px; + + + margin: 140px auto 20px; + padding: 16px; + background-color: white; /* For dev purposes */ + border-radius: 10px; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15); + gap: 20px; + user-select: none; + + + + + position: relative; + z-index: 1; +} + + + + + +/*INSTRUCTIONS & THUMNAIL SECTION*/ + +.instructions-and-thumbnail-container { + display: grid; + grid-template-columns: 75% 25%; + align-items: center; + width: 100%; + padding: 20px; + margin: 0; + box-sizing: border-box; + border-radius: 8px; + gap: 10px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); +} + +.instructions-and-thumbnail-container h4 { + margin: 0; + word-wrap: break-word; +} + +/*THUMBNAIL - note the rest of it is in the thumbnail.css*/ + +.thumbnail-display-container { + display: flex; + justify-content: center; + align-items: center; + background-color: rgb(200, 200, 200); + padding: 4px; + border-radius: 4px; +} + + + + + + +/*PUZZLE SECTION - note the rest of it is in the puzzle-grid.css*/ + +.deflect-puzzle-container { + background-color: rgb(252, 252, 252); + border-radius: 10px; + padding: 16px; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15); + display: flex; + justify-content: center; + align-items: center; + width: fit-content; + margin: 20px auto; + width: 80%; + aspect-ratio: 1 / 1; + + + + + position: relative; + z-index: 2; + + +} + + + + + + +/*SUBMIT SECTION - note the rest of it is in the puzzle-submission.css*/ + +.puzzle-submission-container { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + padding: 16px; + margin: 0; + box-sizing: border-box; + border-radius: 8px; + box-shadow: inset 0 1px 4px rgba(0, 0, 0, 0.4); +} + + diff --git a/internal/puzzle_ui/src/client/styles/puzzle-grid.css b/internal/puzzle_ui/src/client/styles/puzzle-grid.css new file mode 100644 index 0000000..f6097fa --- /dev/null +++ b/internal/puzzle_ui/src/client/styles/puzzle-grid.css @@ -0,0 +1,63 @@ + + + +/*deflect puzzle container is in te container.css file*/ + +#deflect-puzzle { + display: grid; + /* width: 500px; */ + width:100%; + /* height: 500px; */ + /* height: 100%; */ + aspect-ratio: 1 / 1; + gap: 1.6px; + background-color: #e8e8e8; + + position: relative; + border-radius: 10px; + box-sizing: border-box; + + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +/*so that the bottom row doesn't show a gap that looks weird*/ +#deflect-puzzle::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 0px; + background-color: #eaeaea; +} + + + +.tile { + width: 100%; + height: 100%; + background-repeat: no-repeat; + background-position:center; + background-size: cover; + cursor: pointer; + background-color: #eaeaea; + border: 0.1px solid #ddd; + box-sizing: border-box; + background-size: cover; + + border-radius: 4px; + transition: transform 0.2s ease, box-shadow 0.2s ease; + + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.12); +} + +.tile:hover:not(.empty-tile) { + transform: translateY(-3.4px); + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.18); +} + +.empty-tile { + background-color: lightgrey; + cursor: default; +} \ No newline at end of file diff --git a/internal/puzzle_ui/src/client/styles/puzzle-instructions.css b/internal/puzzle_ui/src/client/styles/puzzle-instructions.css new file mode 100644 index 0000000..b07c8cd --- /dev/null +++ b/internal/puzzle_ui/src/client/styles/puzzle-instructions.css @@ -0,0 +1,109 @@ + + +.detailed-instructions-overlay { + display: none; /*note that its hidden by default until the info button is clicked*/ + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.8); + justify-content: center; + align-items: center; + z-index: 1000; + animation: fadeIn 0.2s ease-in-out; +} + +.detailed-instructions-with-gif { + position: relative; + background-color: white; + padding: 20px; + border-radius: 8px; + max-width: 640px; + width: 100%; + height: 70%; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 20px; + animation: zoomIn 0.2s ease-in-out; + +} + +.puzzle-detailed-intructions { + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", + sans-serif; + padding: 20px; + line-height: 1.6; +} + +.puzzle-detailed-intructions h2 { + font-size: 1.4rem; + margin-bottom: 10px; +} + +.puzzle-detailed-intructions h3 { + font-size: 1rem; + margin-top: 20px; +} + +.puzzle-detailed-intructions p { + margin-left: 10px; + margin-bottom: 6px; + font-size: 1rem; +} + +.detailed-instructions-modal-close { + position: absolute; + top: 0px; + right: 10px; + color: black; + font-size: 40px; + font-weight: bold; + cursor: pointer; + user-select: none; + transition: color 0.2s ease; +} + +.detailed-instructions-modal-close:hover { + color: #f1f1f1; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes zoomIn { + from { + transform: scale(0.8); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } +} + + +.puzzle-example-gif { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; + overflow: hidden; +} + +.puzzle-example-gif img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} diff --git a/internal/puzzle_ui/src/client/styles/puzzle-messages-to-user.css b/internal/puzzle_ui/src/client/styles/puzzle-messages-to-user.css new file mode 100644 index 0000000..81e0b44 --- /dev/null +++ b/internal/puzzle_ui/src/client/styles/puzzle-messages-to-user.css @@ -0,0 +1,48 @@ + + +.display-message-to-user { + display: none; + color: red; + margin-top: 10px; + padding: 10px; + border-radius: 5px; + background-color: rgba(255, 0, 0, 0.1); + text-align: center; + font-weight:normal; + opacity: 0; + transition: opacity 0.5s ease-in-out, transform 0.5s ease-in-out; + transform: translateY(-10px); +} + +/* + if we need to communicate something to the user, add the .show class such that message is visible +*/ +.display-message-to-user.show { + display: block; + opacity: 1; + transform: translateY(0); +} + +/* + to communicate success to the user, add .success class +*/ +.display-message-to-user.success { + background-color: rgba(0, 128, 0, 0.1); + color: green; +} + +/* + to communicate warning to the user, add .warning class +*/ +.display-message-to-user.warning { + background-color: rgba(255, 165, 0, 0.1); + color: orange; +} + +/* + to communicate error to the user, add .error class +*/ +.display-message-to-user.error { + background-color: rgba(255, 0, 0, 0.1); + color: red; +} diff --git a/internal/puzzle_ui/src/client/styles/puzzle-refresh.css b/internal/puzzle_ui/src/client/styles/puzzle-refresh.css new file mode 100644 index 0000000..bae5ee0 --- /dev/null +++ b/internal/puzzle_ui/src/client/styles/puzzle-refresh.css @@ -0,0 +1,54 @@ + + + +/*REFRESH is just for what happens when the user literally refreshes the page or when the challenge runs out of time or user exceeds buttons clicks triggering AUTOMATIC REFRESH*/ +.fade-out { + opacity: 0; + transform: scale(1.05); + filter: blur(5px); + transition: opacity 0.5s ease, transform 0.5s ease, filter 0.5s ease; +} + + +/* +overlay that can be used to cover any individual part that might be updated during the refresh like the puzzle grid or thumbnail +this way we can then add a spinner to each of these and show they are loading +*/ +.loading-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(64, 64, 64, 0.6); + display: flex; + justify-content: center; + align-items: center; + z-index: 100; +} + +/* spinner itself */ +.loading-spinner { + border: 6px solid #f3f3f3; + border-top: 6px solid #3498db; + border-radius: 50%; + width: 50px; + height: 50px; + animation: spin 1s linear infinite; + + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + pointer-events: none; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* we need to make sure that the parent is positioned correctly for absolute overlay */ +.position-relative { + position: relative; +} diff --git a/internal/puzzle_ui/src/client/styles/puzzle-submission.css b/internal/puzzle_ui/src/client/styles/puzzle-submission.css new file mode 100644 index 0000000..a7af504 --- /dev/null +++ b/internal/puzzle_ui/src/client/styles/puzzle-submission.css @@ -0,0 +1,192 @@ + +/*HELPER BUTTONS ON THE LEFT SIDE OF THE SUBMISSION CONTAINER*/ +.helper-button-group { + display: flex; + align-items: center; + gap: 20px; + font-size:1.6rem; + color:rgb(148, 148, 148); + flex-wrap: wrap; /* allow wrapping on smaller screens so that as it gets smaller the icons stack up on one another*/ +} + + +#detailed-instructions-info-icon:hover { + color:black; +} + +/* +when we add the .enabled class we get the pointer and darker color on hover, +but when we remove the .enabled class, we fallback to the default (unless not-currently-clickable) +*/ +.request-different-puzzle-icon.enabled:hover:not(.not-currently-clickable) { + color: black; + cursor: pointer; +} + +/*cursor is default UNLESS .enabled class is added (see above)*/ +.request-different-puzzle-icon:not(.not-currently-clickable) { + cursor: not-allowed; + opacity: 0.3; +} + +.not-currently-clickable { + /* cursor:not-allowed; */ + pointer-events: none !important; + opacity: 0.3; +} + +/* .not-currently-clickable::after { + content: "Please wait before requesting a new puzzle."; + position: absolute; + top: -25px; + left: 50%; + transform: translateX(-50%); + background-color: rgba(0, 0, 0, 0.7); + color: white; + padding: 5px 10px; + border-radius: 4px; + font-size: 0.8rem; + white-space: nowrap; +} */ + + +/* +since the audio functionality is not implemented yet, we keep it opaque +and when user hovers on it we show not allowed +*/ +#request-audio-puzzle-icon { + opacity: 0.3; +} +#request-audio-puzzle-icon:hover { + /* color:black; */ + cursor: not-allowed; +} + + +/*rotate the fontawesome icon when refreshing the puzzle*/ +@keyframes rotateIcon { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +/* we apply this class while waiting on the response to the request for refreshing the puzzle */ +.rotating-while-waiting-on-new-puzzle-request-response { + animation: rotateIcon 1s linear infinite; + pointer-events: none; /*to inform user its not clickable while spinning*/ + opacity: 0.7; +} + + + + + + + + + + + + +/*SUBMIT BUTTON AND LOGO ON THE RIGHT SIDE OF THE SUBMISSION CONTAINER*/ + +.right-button-logo-group { + display: flex; + align-items: center; + gap: 10px; + /*flex-wrap: wrap; allow wrapping on smaller screens so that as it gets smaller the logo and submit button stack up on one another*/ +} + + + +#deflect-logo { + width: auto; + height: auto; +} + + + + +.submit-deflect-captcha-solution { + padding: 10px 40px; + border-radius: 50px; + border: 2px solid #5dd39e; + background-color: #2c2f33; + color: #ffffff; + font-size: 18px; + /* font-family: 'Arial', sans-serif; */ + cursor: pointer; + outline: none; + transition: all 0.4s ease; + display: inline-block; + text-align: center; + + transition: all 0.4s ease, color 0.4s ease, background-color 0.4s ease; + width: 140px; +} + + +.submit-deflect-captcha-solution:hover:not(.disabled):not(.success) { + background-color: #3498db; + color: #ffffff; + border-color: #3498db; + box-shadow: 0 0 15px rgba(52, 152, 219, 0.5); + transition: all 0.4s ease; +} + + +.submit-deflect-captcha-solution.success { + background-color: #5dd39e; + border-color: #5dd39e; + color: #2c2f33; + /* box-shadow: 0 0 15px rgba(93, 211, 158, 0.5); */ /*no green glow around it*/ + box-shadow: 0 0 20px rgba(46, 204, 113, 0.7); /*green glow around it */ + transition: all 0.4s ease; + pointer-events: none; + + transition: all 0.4s ease, color 0.4s ease, background-color 0.4s ease; +} + + +/* .submit-deflect-captcha-solution.disabled { + cursor: not-allowed; + opacity: 0.6; + background-color: #444; + border-color: #888; +} */ +.submit-deflect-captcha-solution.disabled { + opacity: 0.6 !important; + background-color: #444 !important; + border-color: #888 !important; + pointer-events: none !important; +} + + +.submit-deflect-captcha-solution.loading { + position: relative; + cursor: not-allowed !important; + /* pointer-events: none; */ +} + +.submit-deflect-captcha-solution.loading::after { + content: ""; + border: 3px solid #f3f3f3; + border-top: 3px solid #3498db; + border-radius: 50%; + width: 24px; + height: 24px; + animation: spin 1s linear infinite; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + transition: opacity 0.3s ease; +} + +@keyframes spin { + 0% { transform: translate(-50%, -50%) rotate(0deg); } + 100% { transform: translate(-50%, -50%) rotate(360deg); } +} diff --git a/internal/puzzle_ui/src/client/styles/puzzle-thumbnail.css b/internal/puzzle_ui/src/client/styles/puzzle-thumbnail.css new file mode 100644 index 0000000..a6ab79f --- /dev/null +++ b/internal/puzzle_ui/src/client/styles/puzzle-thumbnail.css @@ -0,0 +1,120 @@ + + + +.thumbnail-wrapper { + position: relative; + display: grid; + width: 100%; + height: auto; + max-width: 600px; + + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15); + border-radius: 4px; + background-color: white; + overflow: hidden; +} + +.thumbnail-wrapper img { + grid-column: 1 / -1; + grid-row: 1 / -1; + width: 100%; + height: auto; + object-fit: contain; +} + +#deflect-puzzle-thumbnail { + width: 100%; + height: auto; + border-radius: 2px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + object-fit: contain; + border-radius: 4px; + + /* + so that users know they can click on it + to more closely inspect the image they need to recreate + but note that this can be disabled if the script cannot find + the necessary attachment points + */ + cursor: pointer; +} + +/*model for close inspection of target image*/ +.thumbnail-modal-background { + position: relative; + background-color: white; + padding: 30px; + border-radius: 12px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.3); + + animation: zoomIn 0.2s ease-in-out; + max-width: 500px; + + width: 40vw; + aspect-ratio: 1/1; + display: flex; + justify-content: center; + align-items: center; +} + +.thumbnail-modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100vw; + height: 100vh; + background-color: rgba(0, 0, 0, 0.8); + justify-content: center; + align-items: center; + animation: fadeIn 0.2s ease-in-out; +} + +.thumbnail-modal-content { + width:86%; + max-width: 460px; + aspect-ratio: 1/1; + padding:0px; + border-radius: 4px; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.5); + + user-select: none; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes zoomIn { + from { + transform: scale(0.8); + opacity: 0; + } + to { + transform: scale(1); + opacity: 1; + } +} + + +.thumbnail-modal-close { + position: absolute; + top: 0px; + right: 10px; + color: black; + font-size: 40px; + font-weight: bold; + cursor: pointer; + user-select: none; + transition: color 0.2s ease; +} + +.thumbnail-modal-close:hover { + color: #f1f1f1; +} diff --git a/internal/puzzle_ui/src/deflect_logo.svg b/internal/puzzle_ui/src/deflect_logo.svg new file mode 100644 index 0000000..02cbcc7 --- /dev/null +++ b/internal/puzzle_ui/src/deflect_logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/internal/puzzle_ui/src/index.html b/internal/puzzle_ui/src/index.html new file mode 100644 index 0000000..dfe7580 --- /dev/null +++ b/internal/puzzle_ui/src/index.html @@ -0,0 +1,995 @@ + + + + + Deflect CAPTCHA + + + + + + + + + + + + + + + + + +
+
+
+ + +
+
+
+ + +
+
+ +

Recreate this image by selecting which squares to move

+ +
+
+ Complete Puzzle +
+ +
+ +
+
+ × + +
+
+ +
+ +
+
+ +
+
+ +
+ +
+ +
+ +
+ + + +
+ + + + +
+
+ × +
+ +

Rearrange the tiles to match the thumbnail.

+ +

Click on Tiles Adjacent to the Empty Space:

+ +

- Only tiles directly next to the empty slot can be moved.

+

- Click on a tile to slide it into the empty space.

+ +

Recreate the Full Image:

+ +

- Keep moving the tiles until the image is complete, matching the reference.

+

- Pay attention to the details—logos, text, and shapes must align.

+
+
+ deflect-puzzle GIF preview +
+
+
+ +
+ + + +
+ +
+
+
+
+ + + + + + + + + diff --git a/internal/puzzle_ui/tsconfig.json b/internal/puzzle_ui/tsconfig.json new file mode 100644 index 0000000..3e85e0e --- /dev/null +++ b/internal/puzzle_ui/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "types": ["./types/shared"], + "module": "ESNext", + //we set target ES5 for legacy support + "target": "ES5", + "moduleResolution": "node", + "lib": ["ES5", "ES2015", "ES2016", "ES2017", "ES2018", "ES2019", "DOM"], + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "baseUrl": "./src", + "outDir": "./dist", + "skipLibCheck": true + }, + "include": ["src/**/*", "types/**/*"] +} \ No newline at end of file diff --git a/internal/puzzle_ui/types/shared.d.ts b/internal/puzzle_ui/types/shared.d.ts new file mode 100644 index 0000000..42eb911 --- /dev/null +++ b/internal/puzzle_ui/types/shared.d.ts @@ -0,0 +1,46 @@ + +export {} + +declare global { + + //click chain entry tile properties + interface iTileProperties { + id:string + row:number + col:number + } + + //interface for the client chain type + interface iClickChainEntry { + time_stamp:string + tile_clicked: iTileProperties + tile_swapped_with: iTileProperties + + click_count:number + hash:string + } + + //data we are collecting for submission + interface iClientSolutionSubmissionPayload { + solution:string + click_chain:iClickChainEntry[] + } + + /* client side game board item satisfies the following */ + type TileImagePartitionValue = { + base64_image: string + tile_grid_id: string + } + + //the interface that describes the shape the the CAPTCHA challenge payload we send to users + interface PuzzleChallenge { + gameBoard:(TileImagePartitionValue | null)[][] + thumbnail_base64:string + maxNumberOfMovesAllowed:number + timeToSolve_ms:number + collect_data:boolean + click_chain:iClickChainEntry[] + } + +} + diff --git a/internal/puzzle_verifier.go b/internal/puzzle_verifier.go new file mode 100644 index 0000000..3ec8d15 --- /dev/null +++ b/internal/puzzle_verifier.go @@ -0,0 +1,231 @@ +package internal + +import ( + "crypto/subtle" + "errors" + "fmt" + "time" +) + +type ClientPuzzleSolutionSubmissionPayload struct { + Solution string `json:"solution"` + ClickChain []ClickChainEntry `json:"click_chain"` +} + +var ( + ErrInvalidGenesisClickChainEntry = errors.New("submitted click chain entry does not match expectation") + ErrCookieDeleteOrNeverExisted = errors.New("solution was either submitted too late so the cookie was deleted or never existed") + ErrFailedClickChainIntegrity = errors.New("failed click chain integrity check") + ErrFailedCaptchaPropertiesIntegrity = errors.New("failed captcha properties integrity check") + ErrFailedGameboardIntegrity = errors.New("failed game board integrity check") + ErrVerificationFailedTimeLimit = errors.New("submitted solution failed time limit check") + ErrVerificationFailedClickLimit = errors.New("submitted solution failed click limit check") + ErrVerificationFailedBoardTiles = errors.New("submitted solution failed board tiles check") + ErrVerificationFailedSolutionHash = errors.New("submitted solution failed hash check") + ErrRecreatingTileMap = errors.New("failed to recreate tile map for validation") + ErrRecreating = errors.New("failed to recreate the puzzle that gave rise to the solution") +) + +/* +VerifySolution verifies the solution payload submitted by the user. + +The solution is a hash and integrity/anti-cheat is a click chain (similar to how a blockchain works) that we integrity check and verify. + +After the integrity check of the click chain is complete we can trust the timestamp embedded into the genesis click chain item +as well as the users challenge cookie string value being the one we issued to them to complete this challenge in particular because: + 1. The genesis click chain item was created using the challenge cookie and + 2. their original TileIDs were re-computed using their cookie value which would not produce the correct tileIDs and subsequently solution otherwise + +NOTE: +Technically, this is just 1/2 of the solution. We are also meant to collect data about how the +game was played and make a prediction about bot or not as the hypothesis behind state the "state-space search problem" +puzzle was that bots and people would play the game differently. Regardless, we need to make sure that the solution +itself is indeed correct and we do so with a call to VerifySolution +*/ +func ValidatePuzzleCAPTCHASolution(config *Config, puzzleImageController *PuzzleImageController, userChallengeCookieString string, userCaptchaSolution ClientPuzzleSolutionSubmissionPayload) error { + + //we need to derive a solution to their challenge, and recompute their shuffled & unshuffled board + var shuffledBoard [][]*PuzzleTileWithoutImage + var unshuffledBoard [][]*PuzzleTileWithoutImage + var expectedSolution string + var err error + shuffledBoard, unshuffledBoard, expectedSolution, err = GeneratePuzzleExpectedSolution(config, puzzleImageController, userChallengeCookieString) + if err != nil { + return fmt.Errorf("%w: %v", ErrRecreating, err) + } + + if shuffledBoard == nil || unshuffledBoard == nil || expectedSolution == "" { + return fmt.Errorf("%w: %v", ErrRecreating, errors.New("one or more generated outputs were invalid")) + } + + //at this point we have recomputed everything, we can now perform the integrity check on the click chain as well as the time limit, click limit and solution checks + + //integrity checks + + err = IntegrityCheckPuzzleClickChain(userCaptchaSolution.Solution, userChallengeCookieString, config.PuzzleClickChainEntropySecret, userCaptchaSolution.ClickChain, shuffledBoard, unshuffledBoard) + if err != nil { + return fmt.Errorf("%w: %v", ErrFailedClickChainIntegrity, err) + } + + //constraints & solutions checks + + err = verifyPuzzleTimeLimit(config, userCaptchaSolution.ClickChain, userChallengeCookieString) + if err != nil { + return fmt.Errorf("%w: %v", ErrVerificationFailedTimeLimit, err) + } + + err = verifyPuzzleClickLimit(config, userCaptchaSolution.ClickChain, userChallengeCookieString) + if err != nil { + return fmt.Errorf("%w: %v", ErrVerificationFailedClickLimit, err) + } + + err = VerifyPuzzleSolutionHash(userCaptchaSolution.Solution, expectedSolution) + if err != nil { + return fmt.Errorf("%w: %v", ErrVerificationFailedBoardTiles, err) + } + + return nil +} + +func GeneratePuzzleExpectedSolution(config *Config, puzzleImageController *PuzzleImageController, userChallengeCookieString string) (shuffledBoard [][]*PuzzleTileWithoutImage, unshuffledBoard [][]*PuzzleTileWithoutImage, expectedSolution string, err error) { + + includeB64ImageData := false // dont need b64 image data when verifying the solution + var tileMap PuzzleTileMap[PuzzleTileWithoutImage] + + tileMap, err = PuzzleTileMapFromImage[PuzzleTileWithoutImage](config, puzzleImageController, userChallengeCookieString, includeB64ImageData) + if err != nil { + err = fmt.Errorf("%w: %v", ErrRecreatingTileMap, err) + return + } + + var exists bool + targetDifficulty, exists := PuzzleDifficultyProfileByName(config, config.PuzzleDifficultyTarget, userChallengeCookieString) + if !exists { + err = ErrTargetDifficultyDoesNotExist + return + } + + shuffledBoard, err = NewPuzzleCAPTCHABoard(tileMap, targetDifficulty) + if err != nil { + err = fmt.Errorf("%w: %v", ErrFailedNewCAPTCHAGeneration, err) + return + } + + // Note: We need to remove the tile prior to making a deep copy such that both shuffled and unshuffled boards have the null tile as needed for validation. + if err = RemovePuzzleTileFromBoard(shuffledBoard, targetDifficulty); err != nil { + err = fmt.Errorf("%w: %v", ErrFailedRemovingTile, err) + return + } + + unshuffledBoard, err = DeepCopyPuzzleTileBoard(shuffledBoard) + if err != nil { + err = fmt.Errorf("%w: %v", ErrFailedNewGameboard, err) + return + } + + nReShuffles := 0 + if err = ShufflePuzzleBoard(shuffledBoard, unshuffledBoard, targetDifficulty, nReShuffles, config.PuzzleEntropySecret, userChallengeCookieString); err != nil { + err = fmt.Errorf("%w: %v", ErrFailedShuffling, err) + return + } + + expectedSolution = CalculateExpectedPuzzleSolution(unshuffledBoard, userChallengeCookieString) + + return +} + +func verifyPuzzleTimeLimit(config *Config, submittedClickChain []ClickChainEntry, userChallengeCookieString string) error { + + //every click chain will at least admit the genesis block + if len(submittedClickChain) == 0 { + return errors.New("ErrExpectedAtLeastGenesisBlock") + } + + //we do not issue challenges that have not been shuffled + if len(submittedClickChain) == 1 { + return errors.New("ErrExpectedAtleastOneClickRequiredToSolve") + } + + genesisChainEntryIssuedAtTime := submittedClickChain[0].TimeStamp + + dateOfIssuanceByClickChain, err := isValidISOString(genesisChainEntryIssuedAtTime) + if err != nil { + return fmt.Errorf("ErrFailedToParseData: Expected date of issuance from click chain to be valid ISO 3399 compliant string, got: %s", genesisChainEntryIssuedAtTime) + } + + difficultyProfile, exists := PuzzleDifficultyProfileByName(config, config.PuzzleDifficultyTarget, userChallengeCookieString) + if !exists { + return ErrTargetDifficultyDoesNotExist + } + + //now compare the time it was issued with the time we get from the challenge difficulty to know whether or not they actually exceeded the time limit + maxTimeAllowed := time.Duration(difficultyProfile.TimeToSolveMs) * time.Millisecond + + //adjust issuedAtDate by the allowed solving time in order to compare to nowTime + expiryTime := dateOfIssuanceByClickChain.Add(maxTimeAllowed) + + //we account for potential network/processing delay by adding 2 seconds (2000 ms) to nowTime + now := time.Now().Add(2 * time.Second) + + if now.After(expiryTime) { + return fmt.Errorf("ErrTimeExpired: Expected puzzle (issued at: %s) to be solved within: %d ms (by: %s), but received result at: %s", dateOfIssuanceByClickChain, difficultyProfile.TimeToSolveMs, expiryTime, now) + } + + return nil +} + +func verifyPuzzleClickLimit(config *Config, submittedClickChain []ClickChainEntry, userChallengeCookieString string) error { + + //every click chain will at least admit the genesis block + if len(submittedClickChain) == 0 { + return errors.New("ErrExpectedAtLeastGenesisBlock") + } + + //we do not issue challenges that have not been shuffled + if len(submittedClickChain) == 1 { + return errors.New("ErrExpectedAtleastOneClickRequiredToSolve") + } + + difficultyProfile, exists := PuzzleDifficultyProfileByName(config, config.PuzzleDifficultyTarget, userChallengeCookieString) + if !exists { + return ErrTargetDifficultyDoesNotExist + } + + //click chain will contain the genesis, so we account for it by subtracting by 1 to make sure that the number of clicks is indeed within allowed limit + nClicksMade := len(submittedClickChain) - 1 + + if nClicksMade > difficultyProfile.MaxNumberOfMovesAllowed { + return fmt.Errorf("ErrClickLimitExceeded: expected nClicksMade: %d < maximum allowed number of clicks to solve puzzle: %d", nClicksMade, difficultyProfile.MaxNumberOfMovesAllowed) + } + + return nil +} + +/* +because "==" leaks info that can be used for timing attacks (users can just keep making strings bigger and bigger to see how it behaves) +we use crypto.subtle's ConstantTimeCompare +*/ +func VerifyPuzzleSolutionHash(userSubmittedSolution, locallyStoredPrecomputedSolution string) error { + + solutionA := []byte(userSubmittedSolution) + solutionB := []byte(locallyStoredPrecomputedSolution) + + if len(solutionA) != len(solutionB) { + return fmt.Errorf("ErrInvalidSolution: Solutions have different lengths") + } + + if subtle.ConstantTimeCompare(solutionA, solutionB) == 1 { + return nil + } + + return fmt.Errorf("ErrInvalidSolution: Expected %s, received %s", locallyStoredPrecomputedSolution, userSubmittedSolution) +} + +/*checks if dateString provided as argument is ISO 8601 timestamp*/ +func isValidISOString(dateString string) (time.Time, error) { + parsedTime, err := time.Parse(time.RFC3339, dateString) + if err != nil { + return time.Time{}, errors.New("invalid ISO 8601 date string") + } + return parsedTime, nil +} diff --git a/internal/regex_rate_limiter_test.go b/internal/regex_rate_limiter_test.go index c0e82a0..bed8cae 100644 --- a/internal/regex_rate_limiter_test.go +++ b/internal/regex_rate_limiter_test.go @@ -25,7 +25,8 @@ import ( ) type MockBanner struct { - bannedIp string + bannedIp string + banTimeSeconds int } // XXX confused why this (with a pointer receiver) and the one in iptables.go @@ -58,19 +59,32 @@ func (mb *MockBanner) LogRegexBan( // log.Printf("LogRegexBan: %s %s %s\n", ip, ruleName, logLine) } +func (mb *MockBanner) OverwriteBanWithRateLimit(config *Config, ip string, banTimeSeconds int) { + mb.bannedIp = ip + mb.banTimeSeconds = banTimeSeconds +} + func (mb *MockBanner) IPSetAdd(config *Config, ip string) error { + mb.bannedIp = ip return nil } func (mb *MockBanner) IPSetTest(config *Config, ip string) bool { - return false + //simulate whether an ip is already banned + return mb.bannedIp == ip } func (mb *MockBanner) IPSetList() (*ipset.Info, error) { return nil, nil } +// to simulate removing as we do in the context of overwrite func (mb *MockBanner) IPSetDel(ip string) error { + // Simulate removing the ban + if mb.bannedIp == ip { + mb.bannedIp = "" + mb.banTimeSeconds = 0 + } return nil } @@ -377,3 +391,29 @@ func TestMarshalRateLimitMatchType(t *testing.T) { assert.Equal(t, expected, string(json)) } } + +func TestOverwriteBanWithRateLimit(t *testing.T) { + config := Config{ + StandaloneTesting: false, //make sure we are not skipping bans in test mode + } + mockBanner := MockBanner{} + + //this is the first ban attempt + ip := "192.168.1.1" + mockBanner.OverwriteBanWithRateLimit(&config, ip, 30) + + //make sure that the ip is banned for the right amount of time + assert.Equal(t, ip, mockBanner.bannedIp, "Expected IP to be banned") + assert.Equal(t, 30, mockBanner.banTimeSeconds, "Expected ban duration to be 30 seconds") + + //try to ban the SAME ip again but with a different duration + mockBanner.OverwriteBanWithRateLimit(&config, ip, 60) + + //confirm that this actually went through as desired (ie was overwritten) + assert.Equal(t, 60, mockBanner.banTimeSeconds, "Expected ban duration to update to 60 seconds") + + //remove the ban and check that it resets as desired + mockBanner.IPSetDel(ip) + assert.Equal(t, "", mockBanner.bannedIp, "Expected IP to be unbanned") + assert.Equal(t, 0, mockBanner.banTimeSeconds, "Expected ban duration to reset") +} diff --git a/internal/static/images/default_baskerville_logo.png b/internal/static/images/default_baskerville_logo.png new file mode 100644 index 0000000..388ebfe Binary files /dev/null and b/internal/static/images/default_baskerville_logo.png differ diff --git a/supporting-containers/nginx/nginx.conf b/supporting-containers/nginx/nginx.conf index ff47b7d..e1f38e6 100644 --- a/supporting-containers/nginx/nginx.conf +++ b/supporting-containers/nginx/nginx.conf @@ -5,6 +5,19 @@ events { } http { + + #in order to allow large headers through for the puzzle submission + #nginx allows up to 4 buggers @ 32kb each before rejecting w/ 400 + client_header_buffer_size 16k; + large_client_header_buffers 4 32k; + # client_header_buffer_size 32k; + # large_client_header_buffers 4 64k; + + + #we have a rate limit for the request new puzzle and error logging endpoints which allow + #5 requests per second, burst up to 5 & allocate 10 megabytes of memory for tracking) + limit_req_zone $binary_remote_addr zone=puzzle_ratelimit:10m rate=5r/s; + # init var by map map $host $banjax_decision { default "-"; @@ -101,12 +114,45 @@ http { proxy_pass http://test-origin:8080; } + #when user asks for a refresh on a new puzzle state, or if the entrypoint script needs to report + #any non critical event listener failure (error), they can contact us on /__banjax/ + #we rate limit this endpoint in 2 ways: + #The first is using Nginx directives to control burstiness. If they exceed the stated burst rate we return 429. + #The second relies on using the throttled banjax function which will ban them for 60 seconds. + location /__banjax/ { + + #nodelay is used such that if an IP sends 5 requests instantly (burst), all are processed immediately. + limit_req zone=puzzle_ratelimit burst=5 nodelay; + limit_req_status 429; + add_header Retry-After 1 always; + error_page 429 = @access_ratelimited; + satisfy any; + + set $loc_in "slash_block"; + proxy_intercept_errors on; + error_page 500 @fail_open; + error_page 502 @fail_open; + proxy_cache_key "$remote_addr $host $cookie_deflect_challenge4"; + proxy_set_header X-Requested-Host $host; + proxy_set_header X-Client-IP $remote_addr; + proxy_set_header X-Requested-Path $request_uri; + proxy_set_header X-Client-User-Agent $http_user_agent; + proxy_pass_request_body off; + proxy_pass http://127.0.0.1:8081/__banjax/; + } + + #for nginx ratelimiting + location @access_ratelimited { + return 429 "Too many failed attempts."; + } + + location / { set $loc_in "slash_block"; proxy_intercept_errors on; error_page 500 @fail_open; error_page 502 @fail_open; - proxy_cache_key "$remote_addr $host $cookie_deflect_challenge3"; + proxy_cache_key "$remote_addr $host $cookie_deflect_challenge3 $cookie_deflect_challenge4"; proxy_set_header X-Requested-Host $host; proxy_set_header X-Client-IP $remote_addr; proxy_set_header X-Requested-Path $request_uri; @@ -123,6 +169,24 @@ http { return 403 "access denied"; } + #for banjax rate limiting - ie as determined internally but the more flexible time + location @access_throttled { + set $loc_out "access_throttled"; + set $banjax_decision "$upstream_http_x_banjax_decision"; + set $deflect_session "$upstream_http_x_deflect_session"; + set $deflect_session_new "$upstream_http_x_deflect_session_new"; + + # Capture and use the throttle message from upstream headers + set $throttle_msg "Too many failed attempts. Please try again later."; # Default fallback + + if ($upstream_http_x_throttle_message) { + set $throttle_msg "$upstream_http_x_throttle_message"; + } + + return 429 "$throttle_msg"; + } + + location @access_granted { set $loc_out "access_granted"; set $banjax_decision "$upstream_http_x_banjax_decision";