kiwicaptcha / kiwicaptcha-php
KiwiCaptcha: privacy-first, self-hosted proof-of-work anti-abuse engine for PHP. Zero third-party services or tracking; optional first-party telemetry; PHP-native verification (the optional RSW time-lock algorithm uses GMP).
Requires
- php: >=8.1
- ext-json: *
- ext-sodium: *
Requires (Dev)
- phpunit/phpunit: ^10.5
- predis/predis: ^3.5
- psr/cache: ^3.0
- psr/log: ^3.0
Suggests
- ext-apcu: Required for the APCu challenge storage adapter (ApcuStorage), the single-process shared-memory backend
- ext-gmp: Required only for the optional RSW time-lock algorithm
- ext-openssl: Optional: the core hashes and HMACs use ext-sodium/ext-hash primitives; install only if other parts of your stack need it
- ext-pdo_sqlite: Required for the SQLite challenge storage adapter (SqliteStorage), the zero-infrastructure single-node backend
- bel-consulting/kiwicaptcha-symfony: Symfony bundle integration (form type, validator constraint, Twig widget, challenge endpoint)
- psr/cache: Required for the PSR-6 challenge storage adapter (Psr6Storage)
- psr/log: Optional structured logging of verify outcomes
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-30 22:08:44 UTC
README
Privacy-preserving proof-of-work anti-abuse protection with first-party behavioral heuristics as a supplementary signal, for PHP 8.1+. There are no third-party services, requests, or tracking, and no iframes. The widget is an inline script that solves a SHA-256 (or memory-hard Argon2id) proof-of-work via an embedded WASM solver with a pure-JS fallback. This package is the framework-neutral core. The Symfony integration lives in the separate bel-consulting/kiwicaptcha-symfony bundle.
Distribution. This repository is the package source of record for Composer. The monorepo that develops it is
Bel-Consulting-OU/kiwicaptcha. Once the package is registered on Packagist
(kiwicaptcha/kiwicaptcha-php), Composer resolves
kiwicaptcha/kiwicaptcha-php from Packagist metadata and this repository's tagged releases (dist zipballs from GitHub).
KiwiCaptcha is not a reliable human-vs-bot discriminator. A human never solves the challenge; their CPU does, and a bot's CPU can do the same work. The core value is economic: every signup/login/reset/scraping attempt carries a real, tunable computational cost, making mass abuse uneconomical. Browser behavioral telemetry is a supplementary, client-controlled signal and is forgeable.
This package is fully self-contained. It implements the entire KiwiCaptcha protocol itself (challenge issuance, HMAC signing, IP binding, single-use storage, proof-of-work verification) and needs nothing but PHP + libsodium.
Protocol compatibility
Byte-for-byte compatible with the reference implementation in KiwiCaptcha (Rust):
Protocol v2 (current issuance, protocol_version 2):
- canonical payload =
v2|nonce|scope|binding_tag|issued_at|expires_at|algorithm|m_kib|t|p|target_bits|salt|min_duration_ms|region|policy_version|request_binding|issuer|kid - The canonical key set is
ChallengeRecord::WIRE_KEYS, the single source of truth for the record schema.region/request_binding/issuerrender as empty segments when unset.policy_versionis the security-policy epoch, default 1.issueris the deployment identity, a dev/staging/production compartment that holds even with shared secret keys.kidis the signing key id, default 1, the final canonical field. The verifier selects the signature secret per kid viasecretsByKidand rejects any record whose kid is in itsrevokedKidsset with UnknownKid immediately; compromise revocation overrides the rotation grace. - challenge =
base64(canonical_payload) + "." + hex(hmac_sha256(secret, canonical_payload)). Every record field that shapes verification is covered by the HMAC, so a tampered record cannot pass. - Protocol v3/v4 extensions: a decoy-armed issuance appends the
tagged
|d={decoy_field}segment afterkid(protocol 3); an execution-armed issuance additionally appends the tagged|e={execution_version},{execution_commitment}segment, the hex SHA-256 of the stored program (protocol 4). Armed issuance writes the higher version, and older verifiers reject the new version as unknown — the capability is inferable fromprotocol_version. - prefix =
challenge + "|" + salt + "|", with salt = base64 of 16 random bytes. - nonce-bound IP binding: the record's
binding_tagis an HMAC-SHA256 of the canonical IP form (4-byte IPv4 / 16-byte IPv6, IPv4-mapped IPv6 normalized to IPv4) keyed by the secret and bound to the challenge nonce (Issuer::bindingTag($nonce, $ip, $secret)). It is unique per challenge, so it cannot follow the client across requests as a stable identifier. Binding modes:none(verification without a client IP) ornonce_ip_hmac(the default issuance mode); an empty binding tag means binding is disabled for that record. - SHA-256 mode: verify
leading_zero_bits(sha256(prefix || counter || salt)) >= target_bits. SHA-256 is CPU-bound with extremely cheap server verification (one hash per attempt), so it is ideal for high-traffic endpoints. - Argon2id mode: verify via libsodium (
opslimit == t_cost,memlimit == m_kib*1024). Argon2id is memory-hard, increasing the cost of massively parallel and specialized solving (reducing the parallelism advantage of specialized hardware) at the price of more expensive server verification. KiwiCaptcha intentionally requirest >= 3 && p == 1for Argon2id mode (its supported protocol profile;p == 1reflects libsodium's raw Argon2id interface, and modern libsodium itself acceptst >= 1). PHPConfigthrows at construction and Rust issuance validates, so cross-language verification always works. SHA-256 mode has no such constraint. - counter bound: the browser/WASM solver caps its search at 20,000,000
hashes, so
SolutionToken::decode()rejects any counter with more than 8 canonical digits or at/above 20,000,000 (counter exceeds solver maximum). A huge counter is an abuse probe rather than a solution. - record validation: every field is validated on the verify path,
including scope, TTL, binding, the algorithm-specific parameter profile
(Argon2id
t >= 3 && p == 1,m_kib >= 8, the verifier's structural minimum; issuance recommends 8192+ KiB, e.g. 8192 low-memory shared hosting or 65536 desktop), and the PoW result. Malformed or out-of-profile records fail closed with a distinguishable error. - Argon2id memory profile space:
Configaccepts only power-of-twomKibvalues within 8..=65536 KiB (8192 low-memory, 16384/32768/65536 the named rungs), rejected at configuration time — never per request. The restriction is protocol-wide so every verifier, including log2-only bindings (e.g. Elixir'sargon2_elixir), can rederive what any profile mints; the verifier's structural record ceilings keep accepting any signed 8..=65536 record. - clock skew tolerance: verification absorbs up to 5 s of host-clock
skew (
Verifier::SKEW_TOLERANCE_US) for the server-measured minimum-duration floor. A receipt time preceding issuance beyond the tolerance is rejected. Hosts should be NTP-synced.
Protocol v1: legacy challenges
(challenge = base64(nonce|scope|ip_hash|issued_at) + "." + hex(hmac),
protocol_version 1). Protocol-v1 verification remains available only
through explicit acceptLegacyV1 opt-in for a controlled migration
window. Default verification rejects v1. Issuance never produces v1
challenges again; the v1 hash(sha256, secret || ip) IP hash is
retained as Issuer::hashIp() for this path only.
- minimum solve duration: enforced server-side, measured from challenge
issuance to verification receipt. It is a timing-anomaly heuristic, not
a security gate (a fast bot can always wait before submitting). Server
timing uses wall-clock epoch microseconds (
issuedAtNsin the record andnowNsinVerifier::verify(), bothmicrotime(true) * 1_000_000truncated) so the delta is comparable across hosts.hrtime()is never persisted; monotonic clocks are per-host and would break verification between machines. Hosts should be NTP-synced; a 5-second clock-skew tolerance (Verifier::SKEW_TOLERANCE_US) absorbs slightly unsynced hosts (the duration floor is skipped for that verification, the PoW check still applies), and a receipt time preceding issuance beyond the tolerance is rejected as TooFast. The client-reported duration is forgeable and used only as telemetry. - single-use: verification is one-shot. The consume-on-verify transition
moves the challenge record to consumed before the proof is checked, so
a wrong candidate burns the challenge. There is no
maxAttemptsparameter: the one-shot record is the attempt bound (each submitted token can cost at most one memory-hard hash). The record is kept until its TTL and carries the committed deterministic result. A retry on an already-consumed record reproduces the same outcome (Valid/InsufficientWork) without re-deriving only when it supplies the exact operation identity recorded by the original consume; without the matching identity the retry is reported as AlreadyConsumed. InsufficientWork replays remain deterministic. A consumed record without a committed result (crash between consume and commit) is reported as ConsumeIndeterminate. After the proof derives, a final revalidation re-checks expiry and the current policy/region/issuer expectations with the verifier's clock. A deployment that retains the consumed record can read its committed outcome back without re-deriving throughConsumedOutcomeRecovery::recover(): read-only, identity-gated and MAC-authenticated, with the re-derivingVerifier::resumeConsumedOperation()as the stronger resume path. Strict single-use under concurrency requires an atomic consume (RedisStorage, fused Lua transition, an atomic state flip on any Redis with Lua). PSR-6 pools are best-effort (see Storage). - shared language-neutral record format: challenge records are persisted
as JSON whose keys match the Rust crate's serde schema one-to-one. The
canonical
ChallengeRecord::WIRE_KEYSkey set, withissued_at_nsepoch microseconds in both implementations, lets a PHP service and a Rust service read each other's records from the same Redis instance. The storage layer wraps the canonical JSON with two runtime fields (state"pending"|"consumed" andconsumed_result{valid, binding}) that are stripped before parsing. The canonical record itself never changes.
The cross-language test suite (tests/Fixtures/Vectors.php) pins the PHP
implementation to fixtures generated by the Rust crate, so the two can never
drift apart.
Privacy Strict
The proof-of-work protocol itself collects no behavioral or device
telemetry and creates no stable client identifier. The binding tag is a
per-challenge HMAC bound to the nonce, and nothing in the record links a
challenge to a previous one. The optional first-party behavioral telemetry
is client-controlled, opt-in, and used only as a supplementary verification
signal. It is never sent anywhere but your own server and can be disabled
entirely. The widget collects no hardware-capability, device-memory, or
screen signals; minimal mode reports only aggregate widget interaction
counts, and full adds navigator.webdriver and at most 20 coarse
250 ms timing samples.
Installing
composer require kiwicaptcha/kiwicaptcha-php
If you use Symfony, the core works through the standalone bundle:
composer require bel-consulting/kiwicaptcha-symfony
which registers in config/bundles.php:
BelConsulting\KiwiCaptchaBundle\KiwiCaptchaBundle::class => ['all' => true],
and provides the Twig widget (kiwi_captcha_widget), the KiwiCaptchaType
form field, the KiwiCaptcha validator constraint, and the
POST /kiwi-captcha/challenge endpoint. See the
bundle documentation
for configuration. Without Symfony, use the core directly:
use KiwiCaptcha\Config; use KiwiCaptcha\Issuer; use KiwiCaptcha\Verifier; use KiwiCaptcha\PoWAlgorithm; use KiwiCaptcha\Storage\RedisStorage; $config = new Config( secretKey: 'your-32-byte-hmac-secret', algorithm: PoWAlgorithm::Sha256, targetBits: 18, // the ordinary default; 20 is the elevated rung (adaptive risk escalation) ); // Argon2id mode requires t >= 3 and p == 1 (Config throws otherwise), // and mKib must be a power of two within 8..=65536 — the protocol // profile space every verifier can rederive (Config rejects others at // configuration time). // Recommended profiles: 8192 KiB low-memory (shared hosting) or 65536 KiB // desktop, always t: 3, p: 1: // $config = new Config( // secretKey: 'your-32-byte-hmac-secret', // algorithm: PoWAlgorithm::Argon2id, // mKib: 8192, // low-memory profile; 65536 for desktop-class // t: 3, // p: 1, // argon2TargetBits: 4, // the target-4 default // ); // Production: shared atomic storage (Redis fused-Lua consume transition). // ArrayStorage is for tests/CLI/single-worker only. $storage = new RedisStorage($redis); $issuer = new Issuer($config, $storage); $challenge = $issuer->issue('login', $clientIp); // JSON -> widget // ... widget solves and submits kiwi__token ... $verifier = new Verifier($storage); $outcome = $verifier->verify( $token, $config->secretKey, expectedScope: 'login', clientIp: $clientIp, // IP binding (null disables the check) enforceTelemetry: true, // reject on hard bot signals nowNs: (int) (microtime(true) * 1_000_000), // epoch-µs receipt time (default) ); // One-shot model: verification consumes the record before checking the // proof, so a wrong token burns the challenge (no maxAttempts parameter). if ($outcome->isOk()) { /* allow */ }
Symfony integration
The Symfony integration is not bundled in this package. It lives in the
separate bel-consulting/kiwicaptcha-symfony bundle (see the
integration package),
which depends on this core via Composer. It provides:
- the Twig widget (
kiwi_captcha_widget), - the
KiwiCaptchaTypeform field, - the
KiwiCaptchavalidator constraint, - the
POST /kiwi-captcha/challengeendpoint (auto-registered, prefix configurable viaroute_prefix), - production hardening: per-IP rate limiting of challenge issuance and an Argon2id verification concurrency cap.
This package itself is framework-neutral: it requires only PHP + libsodium.
Storage
One-shot semantics are enforced by StorageInterface::consume(): it
transitions the record to consumed and keeps it until its TTL, so
replaying a token observes the consumed marker, plus the committed
deterministic result when one was stored, instead of a missing record.
StorageInterface::commitResult() stores that result atomically
(Redis) or best-effort (PSR-6). The verifier commits best-effort so a
storage failure never changes the outcome. Atomicity under concurrency is
opt-in: implementations that guarantee strict single-use (exactly one
caller wins the transition even when two requests race) implement
AtomicStorageInterface. The verifier is one-shot either way, but only
atomic backends can guarantee that two racing verifications never both
pass.
| Adapter | Use case |
|---|---|
ArrayStorage |
tests, CLI, single-worker only, never production |
Psr6Storage |
any PSR-6 pool (e.g. Symfony Cache). PSR-6 cannot express atomic get-and-transition, so single-use under concurrency is best-effort (read-then-mark). It carries NonAtomicStorageInterface and intentionally claims NO consumed-state recovery capability — the retained envelope is readable only through the off-interface inspectConsumedEnvelope() diagnostic |
RedisStorage |
atomic consume transition via a fused Lua script. Implements AtomicStorageInterface, recommended for production |
SqliteStorage |
zero-infrastructure single-node backend (one file, WAL, every durable transition in one begin-immediate transaction). Requires ext-pdo_sqlite; implements the same atomic and recovery interfaces as RedisStorage |
FilesystemStorage |
file-backed single-node backend with a layout/version marker and per-record atomic transitions. Intended for one host with a local disk, not a shared network filesystem |
ApcuStorage |
single-process APCu backend with an APCu-native transition lock. Shared-memory visibility is per process group; use it only where one worker serves the deployment |
In production, use RedisStorage (or any AtomicStorageInterface backed by
an atomic transition-style consume) so challenges are shared across workers
and consumed exactly once. RedisStorage persists records as
language-neutral JSON: the canonical ChallengeRecord::WIRE_KEYS key
set matching the Rust crate's serde schema, with the storage runtime
fields state/consumed_result wrapped around them and issued_at_ns
in epoch microseconds. A Rust service can read the same records from
the same Redis instance.
Redis script versioning
RedisStorage's atomic transitions (consume, commitResult) are plain
EVAL scripts embedded in the crate source (CONSUME_SCRIPT /
COMMIT_SCRIPT in src/Storage/RedisStorage.php). No Redis Functions
are used: nothing is registered server-side with the Functions API, so
there is no shared mutable server state to version or migrate. The EVAL
script text is the version: there is no separate script version number,
and a deployment's script set is fully determined by the application
version it ships.
Rolling deployments MUST ship the same script set on every node. The Lua text is part of the crate source, and the CI sha256 parity job pins the PHP and Rust crates' scripts byte-identical. A mixed fleet (new script on some nodes, old on others) would apply incompatible state transitions to the same shared records. Change scripts only as part of a versioned release, never ad-hoc against a running Redis.
The Symfony bundle (bel-consulting/kiwicaptcha-symfony) fails fast: if
storage is left at the in-memory default in any environment other than
test/dev, the container throws a LogicException with this guidance
instead of silently losing every challenge between requests under PHP-FPM.
Testing
composer install vendor/bin/phpunit
The suite (see CI for the current counts) covers cross-language parity
(SHA-256 + Argon2id fixtures generated by the Rust crate), protocol v2
(canonical payload,
nonce-bound binding tags over the canonical IP form incl. IPv4-mapped IPv6
normalization, binding_tag/protocol_version record schema evolution,
and counter bounds), token codec edge cases, replay, tampering, expiry,
IP binding, minimum duration, and clock-skew tolerance. They also cover
the one-shot consume-on-verify model and kid-keyed secrets with
compromise revocation, plus allocation/length and recursion hardening
(the 659-accepted differential fuzz corpus, 1 MB token and 10 MB body
caps, 100k-level nesting). The storage adapters (Array, PSR-6, Redis)
are covered too, including the language-neutral JSON record format.
The Symfony integration is tested in the bel-consulting/kiwicaptcha-symfony
package.
Limitations
- Proof of computation, not humanness. The verifier proves that a client spent CPU time; an automated client that spends the same work also passes. It protects against mass signups, credential-stuffing economics, scraping, and endpoint flooding; combine it with first-party server evidence (rate/reputation, passkeys, email verification) for high-value operations. Do not add fingerprinting to "prove" humanity; it is forgeable and sacrifices privacy.
- Telemetry is client-controlled and forgeable. Whatever the widget reports, a custom client can omit or fake. Telemetry is a supplementary signal; rely on server-side abuse signals (per-account/IP-HMAC/network velocity, PoW success ratios, concurrent unsolved challenges) and keep telemetry off by default.
- IP binding is best-effort and mode-dependent.
nonedisables it (purest privacy);bounduses a nonce-bound HMAC (no stable identifier, breaks under IP churn). IPs legitimately change behind NAT/proxies, so a strict binding would reject real users. It is a relay mitigation; operators can disable the check entirely. - Server-side timing needs a trusted clock, and is only a heuristic.
The minimum-duration floor is measured by your server. A fast bot can
always wait before submitting (it still pays the full PoW cost per
attempt), and a valid solution can occur at counter 0. The server
clock must be correct: timing uses wall-clock epoch microseconds
(persisted in the challenge record), so all hosts involved must keep
synchronized clocks. A 5s skew tolerance
(
Verifier::SKEW_TOLERANCE_US) keeps slightly unsynced hosts passing verification while the proof-of-work check still applies. - The WASM solver and its JS fallback are open source. An attacker can always write their own solver (or reuse the source). The value is the cost per attempt, not the impossibility of solving: they still cannot predict the nonce, salt, secret, or signed parameters, and must still perform the work.
Deployment privacy
Recommended CSP (Privacy Strict)
Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-{NONCE}' 'wasm-unsafe-eval'; style-src 'self' 'nonce-{NONCE}'; connect-src 'self'; object-src 'none'; frame-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self';
connect-src 'self' is the key privacy line: even if a future JavaScript
regression tried to send data elsewhere, the browser blocks it. The widget's
driver also refuses cross-origin challenge endpoints at runtime.
Nginx / reverse-proxy privacy
The library cannot control your proxy's logs; the operator must. For the challenge route, disable access logging entirely; for protected POST endpoints use a reduced format without body, query, cookies, or auth headers:
location = /kiwi-captcha/challenge { access_log off; proxy_pass http://php_backend; add_header Cache-Control "no-store, private, max-age=0" always; add_header Referrer-Policy "no-referrer" always; add_header X-Content-Type-Options "nosniff" always; } log_format kiwi_privacy '$time_iso8601 $request_method $uri $status $request_time'; location /login { access_log /var/log/nginx/privacy.log kiwi_privacy; proxy_pass http://php_backend; }
No $remote_addr, no $request_body.
Redis privacy
Use a private Redis (Unix socket or private network, ACLs, TLS when crossing hosts, no public listener). For ephemeral challenge state prefer a dedicated database with persistence disabled. Records already carry a short TTL and are atomically transitioned to consumed (fused Lua). Rate-limit identifiers are peppered HMACs, never raw IPs; challenge records hold a nonce-bound binding tag, never a raw IP and never a stable IP-derived identifier.
Logging
Never log tokens, nonces, binding tags, telemetry, or IPs. Log categorical results only (valid / expired / rate_limited / capacity_limited / invalid_pow / malformed). KiwiCaptcha logs no identifiers itself; the application must not add any.
License
MIT, see the license file. Copyright (c) 2026 Bel Consulting OÜ.