You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This commit was created on GitHub.com and signed with GitHub’s verified signature.
Added
One canonical form per identifier, owned by the hash boundary (#1313): SensitiveFieldRegistry declares how each CPF, RF and e-mail is canonicalised and applies it before encrypting or hashing. The hash function was always uniform; the string fed into it was decided per call site, and several had decided differently.
Two migrations that repair what was already stored (#1313): Canonicalise Stored Identifiers rewrites each identifier across submissions, appointments, candidacies and the user profile and rebuilds its hash — idempotent on the plaintext, never on the ciphertext, since a random IV would never converge. Backfill the Identity Index then copies the hashes already linked to a user into the index, filling empty columns only and refusing to start until the first completes.
The link audit now spans every store and reports the conflict count (#1313): three checks across submissions, appointments, candidacies and the index — one identifier on two accounts, one account with two identifiers, and identifiers the index lacks. The first is the number a merge policy would have to be designed against; the third reads zero once the backfill finishes. Report-only.
Three guards that close the identity shape rather than its instances (#1313, #1314): no value reaches Encryption::hash() outside the registry, nothing but UserCreator creates a user, and no file re-implements a length test, a digit strip or a case fold. The first two read the token stream, not lines: a registry description contains the text Encryption::hash() inside a translated string, and wp_create_user_request() is unrelated.
The identity-index invariant, checked against a real database (#1313): a fresh-install step plants an orphaned submission carrying a CPF hash, asks the plugin's own resolver to resolve it, then asserts the row was adopted and the index carries the hash. It plants first because the invariant would otherwise hold over nothing — the one guard of the three no static scan can make.
Three guards over the translation catalogues (#1266, #1284, #1209, #1282): .po ↔ .mo ↔ .l10n.php must agree both ways; every source string must have a translated entry, and every entry a call site. They had disagreed in production for two releases, and stayed internally consistent through the two in which 79 strings had no entry at all.
A guard that blocks Portuguese prose in comments and source strings (#1260): CommentLanguageTest flags a line carrying two distinct Portuguese function words, quoted spans removed. Two is what separates a sentence from example.com or DoS. Tuned against the 160 lines #1278 removed: 59 of their 60 blocks, zero false positives — and it found six more on a tree three passes had called clean.
Four guards over uninstall and schema reachability (#1290, #1291, #1302, #1311): the sweep's precondition is that every capability carries the ffc_ prefix; every CREATE TABLE must resolve to an ffc_ name and to a declarer the Loader reaches; and no FFC capability may sit on a role WordPress owns — that last one resolved at runtime, so no static scan reaches it.
A guard that a deprecation cannot outlive its own removal release (#1309): DeprecationDueTest reads @removal X.Y.Z on the code that is still there and fails in the release that must act, and requires the tag on every _deprecated_*() call so a cycle cannot ship with no end date.
A guard that only a refresher may name a captcha field (#1307, #1305): a script outside the two registered refreshers reaching for ffc_captcha_ans, ffc_captcha_hash or a name="altcha" position fails — the shape a hand-written field list shipped in.
A guard that a documentation page listing a capability group lists all of it (#1214): ten pages hand-write capability lists, and three had fallen behind by one each — two of them the capabilities that decide whether a screen shows somebody's CPF.
Changed
Identity resolution asks one indexed profile first, and every link feeds it (#1313): UserCreator consulted ffc_submissions alone while its own orphan-adoption happily claimed appointment rows, so the plugin declined to recognise someone it would adopt a record for moments later. ffc_user_profiles gains indexed cpf_hash / rf_hash; wp_usermeta indexes meta_key and never meta_value, so the question the hash exists to answer was a scan. Submissions stay as the fallback.
Certificates, appointments and the audience import all resolve through the index (#1313, #1321, #1302): the two highest-volume writers were still on the legacy single-hash entry point, which reads one table and feeds no index. The audience CSV gains optional cpf / rf columns and asks the index before the e-mail — a person registered under another address used to get a second account, or have their row dropped as User not found. An imported member now gets the audience context.
An e-mail is hashed in lowercase everywhere (#1313): sanitize_email() strips invalid characters and does not lowercase, so an appointment booked as Joao@Escola.gov.br wrote a hash no other module's lookup could find. The rule ships with the migration that rewrites the stored rows — alone it would canonicalise against rows that did not.
Forty-four sites asked the same three questions and each answered for itself (#1314): seven digits is an RF had nine copies, the canonical form of an address had three spellings with two wrong, and normalize_cpf_rf() — present since 6.6.1 — had its body inlined at twenty sites rather than called. The four entry validators keep their own length test on purpose: rejecting a length is not routing one.
The reregistration campaign gains a CSV import (#1214): two tables of in-flight state with a TTL, and a new ffc_import_reregistration capability carved out of ffc_manage_reregistration — loading a campaign's answers writes encrypted PII for people who never opened the form. Seeded onto current holders by bumping the one-shot migration's flag to _v2, since a flagged migration never revisits an install.
Four phases, and the report exists to be read (#1214): staging matches the header against one audience's ffc_custom_fields, exactly and never fuzzily; validation blocks the whole job on any failure, because promotion is batched across requests and cannot undo an earlier batch; promotion writes through ReregistrationDataProcessor, never the data JSON; commit deletes the staged cleartext. Two clicks, on purpose.
A read-only identity probe beside the resolver that creates (#1214): resolve_existing_user() runs the hash and e-mail steps and stops — the peek()/verify() split the captcha contract already uses. Both share one lookup, so a probe never finds more than promotion will. Two rows resolving to one account block the job, which is how a shared mailbox presents (#1295).
No temp file, and the plan had budgeted for one (#1214, #772): the PII-on-disk discipline — ffc-tmp, an .htaccess, a cleanup cron and the nginx caveat — does not apply. Staging reads the upload straight into the database, so the only file is PHP's own upload temp.
The import sends no e-mail, and each half had its own reason (#1214): 500 accounts meant 500 synchronous notifications from inside a batch loop, and the confirmation reads "we received your reregistration" to somebody who did nothing. Suppressed by an explicit flag, not the global kill switch. Errors render from a stored code, not a stored sentence, and every phase is fenced to the operator who started the job. Documented in-plugin (#1292).
The campaign invitation stops asking for a reregistration that is already complete (#1300): it selects on invited_at IS NULL whatever the status, so it reaches everyone imported by CSV and anyone who used the dashboard banner early. The sentence and the button label now come from the status as two tokens — they move together, since a line saying the record exists under a button reading Complete Reregistration contradicts itself.
The reregistration Ficha is now the Record (#1264): 2 classes, 2 methods, a JS function, a constant, an AJAX action and ~50 on-screen strings. The five public ffcertificate_ficha_* filters and the old ffc_generate_ficha action ride apply_filters_deprecated() until 6.28.0 — a rename there breaks an external integration in silence.
Four Portuguese values stay stored, and that is the point (#1264): the KIND_RECORD value, the ffc_reregistration_ficha_template option, the seeder's default_ficha slug and FilenameHelper's ficha type all reach the database or a public filter argument. Renaming the seeder slug alone would have inserted a second default template on every existing install. pt_BR reads exactly as before.
The codebase now reads in English (#1260): 39 runtime files, 35 test files, 15 production classes and the three most recent CHANGELOG sections, plus eleven Portuguese identifiers (get_sexo_options → get_gender_options, …) and a JS file renamed with its handle. Comments and messages only, bar the named renames: every token skeleton is byte-identical. divisao_setor and nomestay, being stored keys.
An error message shipped as a Portuguese source string (#1260): the key-rotation migration reported 'Nao foi possivel decifrar…', so a non-Portuguese install had no way to read it and no translation could fix it — a source string is the one place the language is not a preference.
The earlier slices' Portuguese scan was too narrow, and 160 comment lines survived it (#1260): a dictionary of accented function words misses unaccented prose (asercao, nao, so) and tier names it never listed, so 35 files merged as translated still carried a Portuguese clause mid-paragraph.
CLAUDE.md states the language rule, and its own numbers were audited (#1260, #1261, #1202, #1171, #1280): every versioned artifact is English while the conversation is not. 517 numeric tokens were traced to whatever owns them — three sentences stated a conclusion that had stopped being true, ten counts had drifted, and four claims were simply wrong. Counts carrying nothing were replaced by the invariant they stood in for.
CLAUDE.md splits its two overgrown sections (#1261, #1288): 31 KB of guard stories sat under a heading promising a list of CI gates, and 67% of "Architecture and patterns" was stylesheets. Both split, every subsection body byte-identical. Nine cross-references now name their section instead of numbering it — the renumbering had silently broken six.
The removal release is now a tag, not a sentence (#1309): the first version of the guard read the prose already in the tree and reported two docblocks that say the opposite of a promise — past-tense history about an argument removed in 6.24.0. until X is a promise or a finished story depending on a verb several clauses away, so the nine live sites carry @removal instead.
Deprecated
UserManager::get_or_create_user() and UserCreator::get_or_create_user() (#1313): the single-hash entry point, superseded by get_or_create_user_dual(), where argument position carries the identifier kind rather than a separate $identifier_type that could disagree with the value. No product caller remains, but both are public static on classes another plugin can call: notice in 6.26.0, removal in 6.28.0 (#1245 / #730).
Removed
A registry key nothing read (#1313): requires_column was declared on all eight migration cards and consulted nowhere; the real prerequisite check has always been each strategy's can_run().
Fixed
A masked CPF hashed its punctuation, in two places (#1313): UserProfileService hashed the value exactly as handed to it, so a reregistration submitting 123.456.789-09 — what the form's own mask produces — wrote a hash no other module could match. Admin submission search had the mirror defect, comparing one hash of the raw term against three columns at once.
40 of the 60 live capabilities survived uninstall on every install (#1290): removal intersected a hand-written list naming 31 of them, so ffc_manage_settings, both PII capabilities and 37 others stayed as user-meta and role grants after deletion. Now a prefix sweep over ffc_*, on users and on roles the plugin does not own.
Every install put an FFC capability on WordPress's own subscriber role (#1302): AudienceActivator granted ffc_view_own_audience_bookings there at activation, reaching every subscriber whether or not they belonged to any audience. It covered a real gap — only the self-join route granted it — so the grant moves to AudienceWriter::add_member(), with a migration seeding existing members first.
A new WordPress administrator saw "FFC Settings" and none of the six feature menus (#1302, #747): the move onto ffc_administrator had a one-shot back-fill, so an administrator created afterwards got neither tier, and every top-level menu registers against a raw ffc_view_* slug with no manage_options escape. ffc_administrator now follows administrator continuously.
Table removal no longer depends on somebody remembering the list (#1291): uninstall.php discovers the set with SHOW TABLES LIKE and drops the union of that and the declared list, so a table created but never declared cannot be left behind. Nothing was leaking: 34 declared, 34 created, zero either way.
The reregistration import shipped with no tables on every upgraded install (#1311, #1214): create_tables() had one caller, Activator::activate(), and neither a WordPress update nor the rsync deploy activates anything. The post-deploy smoke reported it on twelve consecutive deploys and nobody read it. UserDashboardActivator had the same gap, still latent; both now heal schema only, because re-running the rest would resurrect a page an administrator deleted.
Under ALTCHA, certificate verification could never succeed (#1305): the /valid handler built its payload from four named inputs, two of them the math captcha's, so the field the widget posts was never sent — the visitor watched the box turn green and was told "Please complete the verification". Two siblings went with it: the widget stayed green holding a spent token after a refusal, and the public CSV form could be hijacked by the verification handler when both shortcodes shared a page.
PDF filenames were Portuguese for every install, translatable or not (#1264): the three _x() prefixes shipped certificado / recibo / ficha as the source strings, each translating to itself, so an English site downloaded certificado_123.pdf. Sources are now certificate / receipt / record, with pt_BR translating back.
The shipped .mo was stale, so WP 6.4 never received the #1209 label fix (#1264): .l10n.php carried the fix while the .mo still said "Estado" and "Agradecimentos". WordPress reads .l10n.php only from 6.5 and this plugin's floor is 6.4, so on the floor the defect #1209 existed to fix was never delivered. The record-template option was also never deleted on uninstall — the fresh-install gate compares what activation writes, and an admin saves that one.
79 source strings had never reached the catalogue (#1282, #1284): the password-invite screen, the invitation controls, the captcha-cap fields and the key-rotation errors all rendered in English on pt_BR. The catalogue was complete against itself and against the .pot, because nothing compared either with the source. The .pot also declared one msgid twice, which msgfmt rejects outright: every reader keys the entries, and keying makes a duplicate vanish rather than fail.
Two documentation pages under-reported who can see decrypted PII (#1214): the appointments and submissions-list pages omitted their *_pii capability, so both read as though managing the screen were enough to unmask a CPF.
The import could have written past the form's own sanitizer (#1214): process_submission() encrypts and persists, it does not sanitize — the per-type sanitization lived behind a $_POST read. It is now a method both paths call. A concurrent seed would also have aborted a batch (#1234): the question that decides promotion is is there a row, not did my INSERT land.
Two test idioms passed with the code under test deleted (#1214): a Brain\Monkey when() in setUp() satisfies the call itself, so a later expect() counts zero; and overload: intercepts new, not a static call, so shouldReceive()->once() passes with zero calls. Both found the same way — by deleting the check and watching the test stay green.
Loading a certificate template with an empty name threw (#1309): the success notice read displayName || filename || '', and filename belonged to a branch the html/ removal made unreachable in 6.23.0 — a truthy name hides it, because || never evaluates its right side. The modal also still named the html/ folder the plugin no longer has.
Security
Uninstall left the encrypted CPF/RF/RG in wp_usermeta (#1316): step 7 deleted two meta keys by name while both of its own annotations claimed it swept by prefix, so everything the user profile writes survived an opt-in "Delete all plugin data" — ciphertext whose key material was gone with the rest. It is one prefix sweep now, with the underscore escaped. A list could not have worked: only three ffc_* meta keys exist as literals in the source.
The per-address submission limit counted nothing for an address typed with a capital (#1314): RateLimitRepository hashed the value as the visitor typed it and compared it against a hash written from the canonical form, so Joao@… matched no prior submission of joao@… and the daily, weekly and monthly caps all read zero.