From 81d9c90454e974b13ff926b94d67e40cc8cd2cf4 Mon Sep 17 00:00:00 2001 From: krynox Date: Sat, 18 Jul 2026 15:34:53 +0700 Subject: [PATCH] =?UTF-8?q?feat:=20parity=20v0.2.0=20=E2=80=94=20retries/i?= =?UTF-8?q?dempotency=20+=20krynox=5Fcaptcha=5Fverified=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - verify retries transient failures (network/429/5xx) with per-verify idempotency key - krynox_captcha_verified filter exposes the full result (score, risk, reasons, agent, human) and can force-reject - fix account URL krynox.id → krynox.net - E2E-tested by driving the real verify() path against a mock data plane (5/5) --- README.md | 23 ++++++++++--- krynox-captcha.php | 81 +++++++++++++++++++++++++++++++++------------- readme.txt | 4 +-- 3 files changed, 80 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index b3bc506..ec23811 100644 --- a/README.md +++ b/README.md @@ -8,19 +8,34 @@ registration, lost-password and comment** forms. No cookies, no puzzles, no trac 1. Copy this folder to `wp-content/plugins/krynox-captcha/` (or zip & upload via the Plugins screen), then **Activate**. 2. **Settings → Krynox Captcha** → paste your **Site key** (`kcpt_…`) and **Secret key** - (`kcps_…`) from [app.krynox.id](https://app.krynox.id). + (`kcps_…`) from [app.krynox.net](https://app.krynox.net). 3. Tick the forms to protect. ## How it works - Renders `` on the enabled forms (script loaded from - `cdn.krynox.id` as an ES module). + `cdn.krynox.net` as an ES module). - On submit, verifies the solved token **server-to-server** against - `https://api.krynox.id/siteverify` with your secret key (`wp_remote_post`). + `https://api.krynox.net/siteverify` with your secret key (`wp_remote_post`). Transient failures + (network / 429 / 5xx) are retried automatically with a per-verify idempotency key. - Failures block the action with a clear error (`authenticate`, `registration_errors`, `lostpassword_post`, `preprocess_comment`). Login enforcement is limited to the standard `wp-login.php` form so XML-RPC / REST / application-password logins keep working. +### Advanced: act on the full result + +Hook the `krynox_captcha_verified` filter to inspect the server response (score, risk, `reasons`, +verified `agent`, attested `human`) or to force-reject a structurally-valid solution: + +```php +add_filter( 'krynox_captcha_verified', function ( $success, $data ) { + if ( ! empty( $data['reasons'] ) && in_array( 'tor-exit', $data['reasons'], true ) ) { + return false; // reject Tor exits, even if the PoW was valid + } + return $success; +}, 10, 2 ); +``` + ## Settings Site key · secret key · per-form toggles · API host · CDN host (override the last two @@ -29,4 +44,4 @@ for self-hosting). ## License GPL-2.0-or-later (WordPress-compatible). See [LICENSE](./LICENSE). -Built for [Krynox Captcha](https://krynox.id) · docs: +Built for [Krynox Captcha](https://krynox.net) · docs: diff --git a/krynox-captcha.php b/krynox-captcha.php index 928c706..6adb42d 100644 --- a/krynox-captcha.php +++ b/krynox-captcha.php @@ -1,13 +1,13 @@ '', 'secret_key' => '', - 'api_host' => 'https://api.krynox.id', - 'cdn_host' => 'https://cdn.krynox.id', + 'api_host' => 'https://api.krynox.net', + 'cdn_host' => 'https://cdn.krynox.net', 'protect_login' => 1, 'protect_register' => 1, 'protect_lostpassword' => 1, @@ -164,6 +164,11 @@ private function token() { /** * Server-to-server verify against /siteverify. * + * Retries transient failures (network / 429 / 5xx) with a per-verify idempotency key, so a + * retried single-use token replays the first outcome instead of failing. Advanced integrators + * can hook `krynox_captcha_verified` to act on the full result (score, risk, reasons, agent, + * human) or to force-reject a structurally-valid solution. + * * @param string $token Solved token. * @return bool */ @@ -171,26 +176,58 @@ private function verify( $token ) { if ( empty( $token ) ) { return false; } - $o = $this->options(); - $res = wp_remote_post( - esc_url_raw( $o['api_host'] ) . '/siteverify', + $o = $this->options(); + $retries = 2; + // A token is single-use, so a retried verify carries an idempotency key. + $key = function_exists( 'wp_generate_uuid4' ) ? wp_generate_uuid4() : bin2hex( random_bytes( 16 ) ); + $payload = wp_json_encode( array( - 'timeout' => 5, - 'headers' => array( 'content-type' => 'application/json' ), - 'body' => wp_json_encode( - array( - 'secret' => $o['secret_key'], - 'response' => $token, - 'remoteip' => $this->client_ip(), - ) - ), + 'secret' => $o['secret_key'], + 'response' => $token, + 'remoteip' => $this->client_ip(), + 'idempotency_key' => $key, ) ); - if ( is_wp_error( $res ) ) { + + $data = null; + for ( $attempt = 0; $attempt <= $retries; $attempt++ ) { + $res = wp_remote_post( + esc_url_raw( $o['api_host'] ) . '/siteverify', + array( + 'timeout' => 5, + 'headers' => array( 'content-type' => 'application/json' ), + 'body' => $payload, + ) + ); + if ( is_wp_error( $res ) ) { + if ( $attempt < $retries ) { + usleep( (int) ( min( 1.0, 0.1 * pow( 2, $attempt ) ) * 1000000 ) ); + continue; + } + return false; + } + $code = (int) wp_remote_retrieve_response_code( $res ); + if ( ( 429 === $code || $code >= 500 ) && $attempt < $retries ) { + usleep( (int) ( min( 1.0, 0.1 * pow( 2, $attempt ) ) * 1000000 ) ); + continue; + } + $data = json_decode( wp_remote_retrieve_body( $res ), true ); + break; + } + + if ( ! is_array( $data ) ) { return false; } - $data = json_decode( wp_remote_retrieve_body( $res ), true ); - return is_array( $data ) && ! empty( $data['success'] ); + + $success = ! empty( $data['success'] ); + + /** + * Filter the boolean verification outcome with the full server result available. + * + * @param bool $success Whether the solution verified. + * @param array $data The full /siteverify response (score, risk, reasons, agent, human, …). + */ + return (bool) apply_filters( 'krynox_captcha_verified', $success, $data ); } /** @@ -304,8 +341,8 @@ public function sanitize( $input ) { $out = array(); $out['site_key'] = isset( $input['site_key'] ) ? sanitize_text_field( $input['site_key'] ) : ''; $out['secret_key'] = isset( $input['secret_key'] ) ? sanitize_text_field( $input['secret_key'] ) : ''; - $out['api_host'] = ! empty( $input['api_host'] ) ? esc_url_raw( $input['api_host'] ) : 'https://api.krynox.id'; - $out['cdn_host'] = ! empty( $input['cdn_host'] ) ? esc_url_raw( $input['cdn_host'] ) : 'https://cdn.krynox.id'; + $out['api_host'] = ! empty( $input['api_host'] ) ? esc_url_raw( $input['api_host'] ) : 'https://api.krynox.net'; + $out['cdn_host'] = ! empty( $input['cdn_host'] ) ? esc_url_raw( $input['cdn_host'] ) : 'https://cdn.krynox.net'; foreach ( array( 'protect_login', 'protect_register', 'protect_lostpassword', 'protect_comments' ) as $k ) { $out[ $k ] = empty( $input[ $k ] ) ? 0 : 1; } @@ -320,7 +357,7 @@ public function render_settings() { ?>

Krynox Captcha

-

Privacy-first, proof-of-work CAPTCHA. Get your keys at app.krynox.id.

+

Privacy-first, proof-of-work CAPTCHA. Get your keys at app.krynox.net.

diff --git a/readme.txt b/readme.txt index fba6bc1..f6abf50 100644 --- a/readme.txt +++ b/readme.txt @@ -27,7 +27,7 @@ Protects, out of the box: Each is toggleable in **Settings → Krynox Captcha**. Verification happens server-to-server against the Krynox data plane using your secret key. -You need a free Krynox account and a site's keys from https://app.krynox.id. +You need a free Krynox account and a site's keys from https://app.krynox.net. == Installation == @@ -41,7 +41,7 @@ You need a free Krynox account and a site's keys from https://app.krynox.id. == Frequently Asked Questions == = Where do I get my keys? = -Create a free account at https://app.krynox.id, add a site, and copy its site key +Create a free account at https://app.krynox.net, add a site, and copy its site key (public) and secret key (private). = Does it use cookies or track users? =