Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 19 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<krynox-captcha>` 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
Expand All @@ -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: <https://krynox.id/docs>
Built for [Krynox Captcha](https://krynox.net) · docs: <https://krynox.net/docs>
81 changes: 59 additions & 22 deletions krynox-captcha.php
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
<?php
/**
* Plugin Name: Krynox Captcha
* Plugin URI: https://krynox.id
* Plugin URI: https://krynox.net
* Description: Privacy-first, proof-of-work CAPTCHA for WordPress — protects login, registration, lost-password and comment forms. No cookies, no puzzles.
* Version: 0.1.0
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: Krynox
* Author URI: https://krynox.id
* Author URI: https://krynox.net
* License: GPL-2.0-or-later
* License URI: https://www.gnu.org/licenses/gpl-2.0.html
* Text Domain: krynox-captcha
Expand Down Expand Up @@ -96,8 +96,8 @@ public function options() {
array(
'site_key' => '',
'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,
Expand Down Expand Up @@ -164,33 +164,70 @@ 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
*/
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 );
}

/**
Expand Down Expand Up @@ -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;
}
Expand All @@ -320,7 +357,7 @@ public function render_settings() {
?>
<div class="wrap">
<h1>Krynox Captcha</h1>
<p>Privacy-first, proof-of-work CAPTCHA. Get your keys at <a href="https://app.krynox.id" target="_blank" rel="noopener">app.krynox.id</a>.</p>
<p>Privacy-first, proof-of-work CAPTCHA. Get your keys at <a href="https://app.krynox.net" target="_blank" rel="noopener">app.krynox.net</a>.</p>
<form method="post" action="options.php">
<?php settings_fields( 'krynox_captcha' ); ?>
<table class="form-table" role="presentation">
Expand Down
4 changes: 2 additions & 2 deletions readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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 ==

Expand All @@ -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? =
Expand Down
Loading