Skip to content

feat(captcha): ALTCHA proof-of-work as a selectable strategy (#1053 PR3) - #1065

Merged
rpgmem merged 4 commits into
developfrom
claude/altcha-captcha-integration-4vvuer
Sep 5, 2026
Merged

feat(captcha): ALTCHA proof-of-work as a selectable strategy (#1053 PR3)#1065
rpgmem merged 4 commits into
developfrom
claude/altcha-captcha-integration-4vvuer

Conversation

@rpgmem

@rpgmem rpgmem commented Sep 5, 2026

Copy link
Copy Markdown
Owner

PR3 do épico #1053. Entrega o ALTCHA atrás do contrato de estratégia que a PR2 criou. Nada muda para instalações existentes: o padrão continua o desafio matemático, e a tela que permite trocar chega na PR4.

Summary

  • Core\Captcha\AltchaCaptcha — emite o desafio, verifica a solução, implementa peek(). Reusa ChallengeSigner (chave derivada de wp_salt) e ChallengeStore (consumo único), então não há option nova, nada em uninstall.php e nada para o gate de fresh-install reconciliar.
  • Core\Captcha\AltchaChallengeEndpointffc_altcha_challenge, priv + nopriv, com throttle por endereço.
  • Core\Captcha\CompositeCaptcha — modo A: widget mais o desafio matemático em <noscript>.
  • assets/js/ffc-captcha.js — registra as traduções no store do widget, reseta em toda rejeição do servidor, e transforma um estado de erro em algo acionável.
  • Widget MIT vendorizado, enfileirado apenas nos modos que o renderizam.

Correções ao plano do épico

O reconhecimento do bundle 3.2.2 contradisse quatro decisões travadas. Todas verificadas no código do widget, não em documentação:

Decisão do plano O que o bundle diz
Vendorizar altcha-3.2.2.min.js Esse é o build ESM, que exige <script type="module"> — emitido só por wp_enqueue_script_module(), WP 6.5+, e o plugin declara 6.4. Vendorizei o UMD: script clássico, mesmo custom element, mesmo tamanho
hideLogo / hideFooter / humanInteractionSignature como opções do widget O elemento 3.x aceita nove atributos: auto, challenge, configuration, display, language, name, theme, type, workers. Os demais viajam como JSON em configuration; escritos como atributo são ignorados em silêncio
Guardrail sobre o produto cost × counter maxnumber não existe no 3.x — a palavra não ocorre no bundle. O solver conta sem teto até reproduzir o hash, então a dificuldade é o tamanho do número secreto e nada mais. O guardrail da PR4 precisa ser reescrito sobre esse N
Bloquear o modo C sem HTTPS por prudência Não é prudência: if (!isSecureContext) throw new Error("Secure context (HTTPS) required."). O widget falha, não degrada. O modo A deixa de ser conveniência e vira a única opção viável em HTTP puro

Duas decisões confirmadas: challenge="<url>" está certo (string iniciada em { é lida como JSON, o resto é buscado como URL), e o widget devolve o payload clássico {algorithm, challenge, number, salt, signature, took} quando o desafio vem no formato v1 — logo o verificador PHP no formato v1 é o correto.

Eu mesmo caí na armadilha dos atributos ao escrever strings="…" no elemento. Não existe: em 3.x as traduções vivem em globalThis.$altcha.i18n, selecionadas por language. Teria aparecido em inglês, sem erro nenhum no console. Corrigido, e o template carrega a nota para o próximo leitor.

Decisões de implementação

O endpoint responde JSON cru, não {success, data}. O widget lê o corpo como desafio ALTCHA e não acharia os campos dentro de um envelope.

nocache_headers() é obrigatório. Um desafio cacheado é um desafio compartilhado, e com consumo único o primeiro a resolver gasta o de todo mundo.

Throttle próprio, não RateLimiter::check_ip_limit(). Aquele grava tentativa nas tabelas ffc_rate_limit_*, que contam envios — gastar a cota de alguém porque o navegador carregou um widget estrangularia quem não preencheu um campo sequer. Balde temporal fixo, idioma que check_global_limit() já usa, IP hasheado na chave.

Reset em toda rejeição, com um fio só. FFC.request passa a emitir ffc:request-rejected em qualquer recusa do servidor. Uma solução é gasta assim que chega ao servidor, seja ou não o captcha o que falhou, então um widget ainda mostrando "verificado" mandaria um token que nunca mais verifica. Ligar isso em cada ponto de rejeição exigiria achar todos — e todos os futuros.

Ordem da verificação. Assinatura primeiro (autenticidade, antes de acreditar em qualquer byte do payload), depois a solução (que amarra salt e número ao desafio autenticado), só então a expiração — porque até a solução bater, o salt que a carrega não é sabidamente nosso.

Type of change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would alter existing behavior)
  • Refactor / chore (no functional change)
  • Documentation only

Test plan

  • composer test7561 testes / 21740 asserções, verde
  • composer lint (PHPStan) — nível 8 limpo nos arquivos novos e tocados
  • WPCS, phpcs-tests.xml.dist, ESLint e Stylelint limpos
  • Vitest — 117 arquivos, 1750 testes
  • Manual smoke test in WordPress admin / frontend — pendente: sem a PR4 não há como trocar de modo pela interface; validar exige gravar ffc_settings['captcha_provider'] à mão
  • Updated or added unit tests where relevant

Os testes PHP resolvem o próprio desafio, contando até reproduzir o hash, exatamente como o widget faz — sem browser. Um teste que montasse o payload a partir dos internos da classe passaria mesmo se o formato divergisse do que o widget posta. Cobrem: formato do fio, solução válida, uso único, peek que não gasta, peek que recusa gasto, assinatura forjada, número errado, expirado, salt sem expiração, e nove payloads malformados (o array onde se espera string chega ao hash() como TypeError sob strict_types — a classe do #1058).

Três achados nos próprios testes

A AjaxWiringTest pegou um órfão real e, ao fazê-lo, mostrou dois pontos cegos seus. O regex do idioma literal retrocedia para fora do prefixo nopriv_ em add_action( 'wp_ajax_nopriv_' . self::ACTION, … ) e capturava nopriv_ como nome de ação — um handler que nada poderia chamar. Grupo atômico não resolve, porque o ? ainda pode descartar o grupo inteiro; o quantificador tem de ser possessivo. E uma classe-endpoint que guarda a ação numa constante é chamada por essa constante, nunca pelo literal, então a direção A passa a aceitar Classe::CONSTANTE como chamador — sem isso, todo handler desse tipo lê como órfão.

Quatro classes de teste passavam por acidente de ordem de arquivos. LabelSorter::locale() tem guarda function_exists( 'get_locale' ), e elas exercitavam o ramo "sem WordPress" apenas porque nada no processo havia definido a função. Meus testes a definem; a guarda passou e a chamada não-mockada estourou em 20 testes. Agora fixam o locale explicitamente, no mesmo valor que o fallback produzia — dependência escolhida, não herdada.

A suíte JS media cargas, não comportamento. O módulo registra listeners em document, e recarregá-lo por teste os empilhava: a asserção de reset contava 5 em vez de 1. O módulo passou a ler o payload localizado no uso, não na carga, e o teste o carrega uma vez.

Checklist

  • Source CSS/JS changes were re-minified (npm run build) and the resulting *.min.* files are committed
  • CHANGELOG.md updated — [Unreleased] → Added; sem bump de FFC_VERSION
  • No new PHPStan baseline entries unless explicitly justified
  • No secrets, tokens, or personally identifiable information in the diff

Sobre o widget vendorizado: bytes idênticos ao upstream, sem banner de licença prefixado, então o sha256 confere contra o manifesto publicado do jsDelivr — HQdoDu5mw7WRxmOGQPe/djESv4jXIjozH30rSfDOSWg=. A licença MIT está em libs/js/altcha-3.2.2.LICENSE.txt, ao lado.

Nota sobre o PHPStan local: rodo o PHAR, que não carrega szepeviktor/phpstan-wordpress, então ele reporta 3 erros em arquivos que esta PR não toca (csv-writer, url-shortener-cleaner, audience-admin-audience-renderer). São artefato do meu ambiente; a CI é a fonte de verdade.

Ainda em aberto no épico

🤖 Generated with Claude Code

https://claude.ai/code/session_012XWx9qJdjZdAq8crxM9GCU


Generated by Claude Code

UMD, not the ESM build the plan named. `wp_enqueue_script()` emits no
`type="module"`; `wp_enqueue_script_module()` does, and it needs WP 6.5 while
this plugin declares 6.4. The UMD build registers the same `altcha-widget`
custom element, costs the same 113 KB, and matches how thumbmarkjs is already
vendored here.

Kept byte-identical to upstream — no licence banner prepended — so its
sha256 still matches jsDelivr's published manifest
(HQdoDu5mw7WRxmOGQPe/djESv4jXIjozH30rSfDOSWg=) and the vendored copy stays
verifiable. The MIT licence sits beside it instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XWx9qJdjZdAq8crxM9GCU
Verification in the order that matters: signature before anything in the
payload is believed, then the solution binding salt and number to the
authenticated challenge, then the expiry read from that now-trusted salt,
then the one-time ledger. `peek()` refuses a spent proof too — reporting it
valid and failing only at the step that consumes it is the contradiction
#1061 removed.

Not yet wired: the challenge endpoint and the template it renders do not
exist, so this class is unreachable and untested for now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XWx9qJdjZdAq8crxM9GCU
The endpoint exists because a challenge baked into the HTML goes stale — and
worse, is shared — behind a full-page cache. Three things it does on purpose:
`nocache_headers()`, because a cached challenge is one token the first solver
spends for everyone; a bare JSON body rather than the `{success, data}`
envelope, which the widget would not find its fields inside; and its own
per-address throttle instead of `RateLimiter::check_ip_limit()`, which records
an attempt in the submission tables and would spend a visitor's budget because
their browser loaded a widget. Fixed windows via a bucketed key, the idiom
`check_global_limit()` already uses, so a steady caller crosses into a new
bucket instead of holding one open.

Two corrections to what the plan assumed about the widget, both the same
mistake — treating an ALTCHA option as an HTML attribute. The 3.x element
accepts exactly nine: auto, challenge, configuration, display, language, name,
type, theme, workers. `hideLogo`, `hideFooter`, `humanInteractionSignature`
and `setCookie` are not among them and travel as JSON in `configuration`;
written as attributes they are ignored in silence. Translations are not an
attribute either: they live in the `globalThis.$altcha.i18n` store and are
selected by `language`, which is why the upstream 52 KB i18n bundle stays out
of the page.

`humanInteractionSignature` defaults to true and collects pointer and keyboard
timings. Turned off: these are public-sector forms under the LGPD, and the
proof of work already carries the anti-automation load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XWx9qJdjZdAq8crxM9GCU
Registers the two widget modes behind the existing contract — ALTCHA alone,
and ALTCHA with the math challenge in <noscript> — enqueues the vendored
bundle only for the modes that render one, and adds the glue the widget does
not provide.

`CompositeCaptcha` routes on which proof was actually posted rather than
trying both: with two error messages in hand, reporting the wrong one is how
a visitor gets told to redo arithmetic they never saw. It is accessibility,
not security — the server accepts either proof, so an attacker takes the
cheaper one and the effective strength is the math challenge's. Its docblock
and the settings copy say so.

The reset-on-rejection requirement is met with one wire rather than N call
sites: `FFC.request` now dispatches `ffc:request-rejected` on every
server-side refusal, and the captcha module listens. A solved challenge is
spent the moment it reaches the server, whether or not the captcha is what
failed, so a widget still showing "verified" after any rejection would send a
token that can never verify again.

Three test findings worth keeping.

`AjaxWiringTest` caught a real orphan and, doing so, showed two blind spots of
its own. Its literal-idiom regex backtracked out of the `nopriv_` prefix on
`add_action( 'wp_ajax_nopriv_' . self::ACTION, … )`, capturing `nopriv_` as an
action name — a handler nothing could ever call. An atomic group does not fix
it, since the `?` may still discard the group whole; the quantifier has to be
possessive. And an endpoint class holding its action in a constant is called
through that constant, never the literal, so direction A now accepts
`TheClass::THE_CONST` as a caller — otherwise every such handler reads as an
orphan.

Four test classes were passing only because `get_locale` had never been
defined in the process: `LabelSorter::locale()` guards on
`function_exists( 'get_locale' )`, so they were exercising the no-WordPress
branch by accident of file ordering. Adding tests that stub it made the guard
pass and the call land unmocked. They now pin the locale explicitly, to the
same value the fallback produced.

The JS suite first measured loads instead of behaviour: the module registers
document-level listeners, and re-running it per test stacked them. It now
reads its localised payload on use rather than at load, so it is loaded once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XWx9qJdjZdAq8crxM9GCU
@rpgmem
rpgmem marked this pull request as ready for review September 5, 2026 18:05
@rpgmem
rpgmem enabled auto-merge (squash) September 5, 2026 18:05
@rpgmem
rpgmem merged commit 6da63da into develop Sep 5, 2026
19 checks passed
@rpgmem
rpgmem deleted the claude/altcha-captcha-integration-4vvuer branch September 5, 2026 18:08
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 33982408725

Warning

Build has drifted: This PR's base is out of sync with its target branch, so coverage data may include unrelated changes.
Quick fix: rebase this PR. Learn more →

Warning

No base build found for commit bb34774 on develop.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 89.899%

Details

  • Patch coverage: 44 uncovered changes across 4 files (132 of 176 lines covered, 75.0%).

Uncovered Changes

File Changed Covered %
includes/core/captcha/class-ffc-altcha-captcha.php 99 77 77.78%
includes/frontend/class-ffc-frontend.php 20 1 5.0%
includes/core/captcha/class-ffc-altcha-challenge-endpoint.php 26 24 92.31%
includes/core/captcha/class-ffc-composite-captcha.php 19 18 94.74%
Total (6 files) 176 132 75.0%

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 53700
Covered Lines: 48276
Line Coverage: 89.9%
Coverage Strength: 4.84 hits per line

💛 - Coveralls

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants