== Changelog ==

The full release history for Rapls Passkey. The most recent releases are also
listed in readme.txt; older entries live here.

= 0.13.45 =
* Build only: the package is now assembled from the files git tracks, so a local file this repository ignores — an editor directory, a local CI setting — cannot be swept into it. The plugin code is unchanged.

= 0.13.44 =
* Build provenance only: the previous package was assembled from a working copy that had uncommitted changes, so its recorded commit could not reproduce it. The plugin code is unchanged; this release is built from a clean checkout, and the build now refuses to run otherwise.

= 0.13.43 =
* **Giving an attempt back now proves which one.** The previous release gave back "every attempt up to mine", which was still wrong: an attempt number is a position, it does not say who holds it, and it repeats in the next window — so a sign-in could cancel attempts belonging to requests that were still being checked, and more than the limit could be verified. A request now gives back only the one attempt it holds, proved by a token. Earlier mistakes stay counted for the rest of the window.
* A two-factor answer is checked before the pending sign-in is discarded, so a correct fifth answer works.

= 0.13.42 =
* **A successful sign-in no longer cancels attempts that other requests are still making.** Signing in cleared the whole attempt counter for the address, including slots held by requests that were on their way to being checked — so the next arrival re-used them and more than the limit could be verified in one window. On a shared address, one person signing in repeatedly erased everyone else's failed attempts with it. A success now gives back only the attempts it made itself.
* **A second-factor challenge is no longer issued when the browser cannot be given the token for it.** The cookie's result was ignored, so the token looked present for the rest of that request and nowhere else: the first factor was already spent — a magic link consumed, a recovery code used up — and the user was sent to a screen they could not complete. The sign-in is refused instead, with nothing left half-made.
* **Two-factor answers are counted before they are checked, not after.** Counting afterwards limited how many wrong answers were recorded rather than how many were checked, so simultaneous submissions all had their code validated first.

= 0.13.41 =
* Build and packaging only: the package now records "unknown" rather than "clean" when it cannot check whether the source was modified, an alternative packaging tool passed on the command line is checked against the pinned one, and the verification bundle refuses to be built if anything shaped like a credential is in it.

= 0.13.40 =
* The build now refuses to guess: without the source metadata it stops and asks for it, rather than stamping the current time into the package. Nothing about the machine that runs the build goes into the result — the same source produces the same file on PHP 8.2 and on PHP 8.5 alike — and the packaging tool is pinned by checksum.

= 0.13.39 =
* The distribution package is now built reproducibly: building the same source again produces a byte-identical file, because every timestamp inside it comes from the source itself rather than from the clock. One version number can therefore only ever mean one package, and anyone can rebuild and compare.

= 0.13.38 =
* Housekeeping only: no functional change since 0.13.36. Each of these numbers identifies exactly one package; a number is never reused for a second build.

= 0.13.36 =
* Sign-in options now answer with a proper error, rather than an unexpected one, when the record behind the ceremony could not be saved.
* Build checks: the test runner now treats a run that produced no result — a file that stopped early, a directory with no tests in it, a process that died — as a failure instead of silence. The check that runs the suite inside the distribution package does the same, and the suites it cannot run there are named in a fixed list rather than matched by a pattern.

= 0.13.35 =
* Sign-in and registration now stop if the short-lived record behind them could not be saved, instead of handing the browser a challenge that can never be completed. Previously a failing cache or database could leave a passkey created on your device that this site would then refuse — one you would have to find and delete yourself.
* The two-factor hand-off does the same: if the half-finished sign-in cannot be saved, you are told, rather than sent to a code screen that has nothing to check against.
* The distribution package is now itself put through the test suite as part of the build checks, so what is shipped is exercised rather than only inspected. Each package also records the exact inputs it was built from.

= 0.13.34 =
* The plugin update now corrects an account's stored identity even when the two copies differ only in capitalisation — these identifiers are case-sensitive, while the database compares text without regard to case by default, so such a pair was previously left alone. The correction that happens as each account is used compares them byte for byte and is what guarantees this; the bulk pass is only there to get it over with sooner.
* Cached account data is cleared on both sides of that correction, so a site with a persistent object cache cannot keep serving the outdated copy.

= 0.13.33 =
* An account whose two records of its WebAuthn identity disagreed — the registry entry and the copy kept with the account — could hand out one or the other depending on which a request happened to read. The registry entry is now the record: it decides, the copy is corrected from it, and the plugin update corrects any account where the two differ.
* Expected collisions — a passkey slot another registration just took, a rate-limit slot, a registry entry that already exists — are no longer written to the site's error log. They were never failures, and logging them buried real database problems.

= 0.13.32 =
* More of the same audit, all about one account keeping exactly one WebAuthn identity:
* An account whose identity was recorded by an older version now has that fact registered the first time it is used, so a later request that cannot read it can no longer start a second one. While the table update is still outstanding no new identity is created at all — an account that looks new may simply be one the update has not reached.
* An identity whose copy in the account's profile data is lost is recovered from the record itself instead of leaving the account permanently unable to register a passkey.
* Registration establishes the identity once per request and carries it through, rather than asking twice and risking two different answers on a database whose reads lag behind its writes.

= 0.13.31 =
* A passkey confirmation now completes. Pro's step-up held risky password sign-ins for a passkey check — and then held the passkey sign-in that answered it, so with the strictest setting nobody could get in at all. A sign-in now says what kind it is, and the confirmation is never held.
* An account can no longer be given a second WebAuthn identity. When the handle an account already has cannot be read — a database whose reads lag behind its writes — registration is refused rather than started under a newly derived one, and the migration records every existing handle so that "does this account have one?" is answered by the database instead of by a read.
* The table update after a plugin upgrade now also runs for the Pro QR and sign-up ceremonies, not only the free plugin's own, and at most once every few minutes on a site where it cannot complete.

= 0.13.30 =
* Fixed: a site updated without anyone opening the admin screens — a background update, or WP-CLI — could be left with the previous table layout, and a passkey sign-in would then be refused until an administrator visited the dashboard. The table is now brought up to date by the sign-in itself.

= 0.13.29 =
* Further findings from the security audit, all about not trusting a value the plugin has just read back:
* Signing in with a username no longer tells anyone whether that account exists or holds a passkey. Sign-in now uses the browser's own passkey picker, and the answer to a username is identical whatever you type. A site that must support older security keys (which store nothing themselves and so have to be named by the server) can switch the old behaviour back on; the response is then padded to a fixed shape, and the trade-off is documented.
* A passkey suspended or deleted while someone is signing in with it no longer completes that sign-in, even on installations that send reads to a replica database.
* Each user's WebAuthn identifier is now derived rather than generated and stored. Concurrent first registrations, retries and lagging replicas all arrive at the same value, so an account's passkeys can no longer end up split across two identifiers.
* Recovery codes: a site whose database is lagging could report that generation failed after the previous codes had already been replaced — and then tell the user their old codes still worked. The result now comes from the write itself, and the message no longer makes that claim.

= 0.13.28 =
* Findings from a full-codebase security audit:
* SECURITY (multisite): a user marked as spam on a network — or whose primary site is — could still sign in with a passkey, a QR approval, a magic link or a recovery code. Those methods set the login cookie directly and so never reached WordPress's own spam check, which a password login passes through. All of them now apply it, before any site filter.
* A passkey login no longer proceeds if the credential was suspended or removed while the sign-in was in progress; the check now happens at the moment the login is committed rather than only when it started.
* Asking for sign-in options with a username no longer reveals whether that account exists or holds a passkey: a name with nothing behind it now receives plausible decoy entries derived from the name and a site secret, so the answer looks the same either way.
* A shared relying-party ID and Related Origins configured for a network are now actually applied to registration and sign-in. They were read before the settings that provide them had loaded, so a network-wide shared ID silently had no effect.
* Recovery codes are no longer displayed unless the site could store them. Previously a storage failure produced codes that looked usable but none of which would have been accepted — the opposite of a way back in.
* Adaptive step-up no longer fails open: if the record that holds a session for passkey confirmation cannot be written, that session is ended instead of continuing unchallenged. A database error while checking for held sessions now holds rather than releases.
* Passwordless sign-up confirms the account adopted the identifier its passkey was created against, and undoes the sign-up if not, rather than leaving an account whose credential cannot resolve.
* Enforcement's grace period no longer restarts on every request when its start time cannot be saved (which would have postponed the deadline indefinitely).
* Uninstall and the personal-data eraser now remove the current attempt-limit rows, the per-user handle lock and per-session step-up records, which earlier versions left behind.

= 0.13.27 =
* Tenth re-review fix: the check that proves the passkey limit's constraint on the database now also requires that it could remove its own temporary rows. If those deletions keep failing — a database that accepts writes but cannot complete them — the limit is reported as unenforceable and registration refuses, instead of reporting success while leaving rows behind.

= 0.13.26 =
* Ninth re-review fixes (both about not trusting a read, and about proving it):
* Claiming an attempt or a quota slot no longer reads anything at all. The insert either wrote the row — which, with a unique row name, means this request holds the slot — or it did not. Previously the plugin confirmed ownership with a follow-up read, and a read can be answered by a replica that is behind the writer, which could make a single request write a row for every slot and use up that key's whole allowance for the window. Each failed insert is now followed by a removal of that request's own row, so a request that ends up refused provably leaves nothing behind.
* The test that checks the passkey limit's constraint on the write server now calls that check directly, with and without the constraint in place, and asserts it reports true only when the database really refuses a duplicate — previously the scenario could pass without the check having run.

= 0.13.25 =
* Eighth re-review fixes (the passkey limit's safety check, and one availability fix):
* Before applying a passkey limit the plugin now proves the constraint on the server that takes the writes — it writes two rows claiming one slot and requires the second to be refused — instead of only asking whether an index with the right name exists. A read can be answered by a replica whose schema differs from the writer's, so an index that only the replica still has can no longer make the limit look enforced.
* The index check also verifies its shape: it must be UNIQUE and over exactly (user_id, slot_no) in that order. A same-named index that is not unique, or covers other columns, enforces nothing and is no longer mistaken for the real constraint.
* When a limit cannot be confirmed, registration refuses (503) rather than proceeding unprotected.
* Availability fix: if the row a request has just written is not yet visible (a replica lagging behind), the request now stops instead of trying the next attempt slot. Previously one request could write a row for every slot and use up that IP's whole login / recovery / sign-up budget for the window.

= 0.13.24 =
* Seventh re-review fixes. Every attempt limit is now enforced the same way the passkey limit is — by a database constraint rather than by comparing a number the plugin has read:
* Login, two-factor, recovery-code, magic-link, QR-code and sign-up limits each claim a numbered attempt row whose uniqueness the database guarantees. Because nothing is decided from a value that was read, the limits hold even where a read/write-splitting database drop-in can serve a stale count from a replica — a configuration in which a counter-based limit can be bypassed entirely.
* Time windows are now clock-aligned and half-open, so every request places itself in the same window. Previously the exact second a window ended was read inconsistently, which let a burst of requests arriving on that boundary through.
* The per-user passkey limit now verifies its unique index on the database at the moment of registration instead of trusting a flag stored during the upgrade. If the index is ever lost (a restore, a manual change), registration refuses with an error rather than silently letting the limit be exceeded.
* A failed upgrade no longer records itself as complete: if the slot numbering or the index cannot be created, the schema version is left behind so the next admin request retries it.
* Verified against a real MySQL server, running the plugin's own code: upgrading from the previous schema, limit 1 storing exactly 1 passkey and limit 3 exactly 3 under 20 simultaneous processes, registration refusing when the index is dropped, refusing on a database error, and a replayed registration storing one passkey rather than two. The test ships in the repository (tests/db/integration.php) and runs in CI against MySQL 8.0/8.4 and MariaDB 10.11/11.4.

= 0.13.23 =
* Sixth re-review fixes. The per-user passkey limit and the sign-up quota are now enforced by DATABASE CONSTRAINTS instead of an application lock:
* Each passkey occupies a numbered slot under a new UNIQUE (user_id, slot_no) index, and only slots within the configured limit are ever offered — so two simultaneous registrations can never both take the last one. Unlike the previous named lock, this holds even when WordPress transparently reconnects and replays a statement, when a db.php drop-in (HyperDB and similar) routes queries to a different server, and on any storage engine. A replayed registration after a reconnect now stores exactly one passkey instead of two.
* The sign-up quota reserves a numbered row and confirms ownership by reading it back, so it no longer depends on a database session, an advisory lock, or affected-row counts. A reservation is handed back by its own token, so a hand-back can never disturb another request's slot or mis-count the quota.
* Login attempt limits are now consumed BEFORE the passkey assertion (or recovery code) is checked, not after it fails. Previously a batch of simultaneous attempts could all read the same under-limit count and all proceed; now exactly one can take the last attempt, which also caps the verification work a flood of requests can start.
* Verified against a real MySQL server with 100 simultaneous processes: limit 1 stores 1 passkey, limit 3 stores 3, and with one attempt left exactly one of 20 concurrent logins is admitted. The test ships in the plugin's repository (tests/db/concurrency.php) and runs in CI against MySQL 8.0/8.4 and MariaDB 10.11/11.4.
* With the passkey limit set to 0 (unlimited, the default) registration no longer performs any limit bookkeeping at all.

= 0.13.22 =
* Fifth re-review fixes (concurrency correctness, isolation- and engine-independence):
* The passkey per-user limit and the atomic quota reservation are now serialised with a MySQL advisory named lock (GET_LOCK) instead of a transaction + row lock. Named locks work regardless of the table's storage engine (they hold even on MyISAM, where SELECT ... FOR UPDATE does not), do not touch the shared connection's transaction state (so they cannot implicitly commit another plugin's open transaction), signal success with an explicit result rather than an affected-row count (so a quota can no longer be miscounted when the connection uses MYSQLI_CLIENT_FOUND_ROWS), and release automatically if the connection drops. A lock that cannot be taken fails closed.
* Every SQL step in the registration path is now checked, and the per-user count fails closed on a database error (a failed count no longer reads as "under the limit").
* The passkey-login REST routes and the Pro recovery-code login now use the shared fail-closed rate counter as well, so a database error blocks instead of silently allowing, on every login path.

= 0.13.21 =
* Fourth re-review fixes (concurrency correctness):
* The passkey per-user limit is now enforced under a real database row lock (a short transaction on a per-user row), so it holds exactly regardless of the server's transaction isolation level — not just the default. The lock releases automatically on commit/rollback, so a crashed request cannot leave it stuck.
* The atomic quota primitive reads its own result before the opportunistic cleanup runs (a stray cleanup query could otherwise flip a reservation's success/failure), and a slot is now handed back only to the exact time-window it was taken in, so a late failure from an expired window cannot subtract from a newer one.
* Re-opening the anonymous passkey-login REST routes when a security plugin locks the REST API to logged-in users is now an explicit, off-by-default admin setting (Settings -> Rapls Passkey -> REST API). It never overrides a firewall / IP-block / maintenance response, and does nothing unless you turn it on.

= 0.13.20 =
* Third re-review fixes (concurrency and REST hardening):
* The passkey per-user limit is now enforced in a single atomic INSERT ... SELECT statement, so two simultaneous registrations can never both exceed the maximum — the cap no longer depends on a post-insert rollback. The per-user registration lock is released only by its owner (compare-and-delete), so a stale lock cannot be freed out from under the request that stole it.
* The shared rate/attempt counter now FAILS CLOSED on a database error (a read or write failure blocks the guarded action instead of silently allowing it), and gained an atomic "reserve one slot under a cap" primitive for callers that need a strict quota.
* The REST re-open for the anonymous passkey-login routes now clears ONLY a genuine 401 "authentication required" restriction; a 403 from a WAF, IP gate, maintenance mode or capability check is preserved even on the plugin's own routes.

= 0.13.19 =
* Second re-review fixes (concurrency, 2FA and distribution build):
* Distribution build: WooCommerce's global WC() (and the wc_*/WC_* family) is excluded from prefixing, so the build no longer rewrites a function_exists('WC') check or emits a global WC() alias that forwards to a non-existent prefixed function (which could mis-detect WooCommerce or fatal another plugin). The final-artifact check now also fails on any mis-prefixed WooCommerce symbol or any generated host-function alias.
* A passkey login that did NOT perform user verification is treated as a weaker login: with the site's 2FA active it is now sent to the 2FA challenge BEFORE the auth cookie is issued (previously the 2FA "verified" mark was merely withheld afterwards).
* The post-first-factor 2FA attempt counter now uses an atomic fixed-window count (shared with the login rate limiter), passkey registration takes a short per-user lock so the per-user limit holds strictly under concurrency, and the REST auth-error allowlist clears only the plugin's own authentication errors.

= 0.13.18 =
* Re-review resubmission. No code change from 0.13.17: the end-to-end matrix (clean-WordPress activation, passkey register/login, user-verification-gated 2FA, single-use / per-user limit / rate limit, coexistence with another plugin that bundles web-auth, and the two-factor integration) was run on a real install and passed. The procedure is recorded in docs/E2E-TESTING.md.

= 0.13.17 =
* Re-review fixes: make the namespace-prefixed distribution build actually boot, plus concurrency and configuration hardening.
* The prefixed bundled libraries now load correctly — the build generates the class map BEFORE scoping (and no longer regenerates it afterwards, which had dropped the prefixed classes), so the plugin no longer mistakes WebAuthn for "missing". WordPress-core and third-party-plugin symbols are no longer prefixed by mistake. A final-artifact check (bin/verify-dist.php) guards against a regression, and the bundled third-party licence notices are kept in the ZIP.
* A direct passkey login satisfies the site's 2FA only when the authenticator performed user verification (biometric/PIN); possession alone no longer counts as the second factor.
* The WebAuthn user handle is minted under an atomic database lock, the per-user passkey-limit rollback is deterministic under concurrent registrations, and the per-IP login rate counter is an atomic fixed-window count — closing read-modify-write races.
* reCAPTCHA fail-open vs fail-closed on a Google outage is now an explicit setting (Settings -> Rapls Passkey), not just a filter.

= 0.13.16 =
* Build hardening for distribution (no runtime behaviour change): the bundled libraries (web-auth/webauthn-lib, Symfony, Brick, spomky-labs, ParagonIE) are now rewritten into the plugin-private RaplsPasskey\Vendor\ namespace at build time with PHP-Scoper, so another plugin bundling a different version of the same library can no longer collide with this one. The WebAuthn availability checks were switched to a `::class` reference so the prefixing is deterministic. Committed source stays unscoped; only the distributed ZIP is prefixed.

= 0.13.15 =
* Documentation: clarified that sharing an RP ID / Related Origins aligns the WebAuthn protocol across domains but does not by itself make a passkey usable on another site — that also needs shared users and a shared credential store (Multisite or shared tables). Independent installs use a passkey per domain. No behaviour change.

= 0.13.14 =
* Related Origin Requests (cross-domain passkeys) now work end-to-end: a site can use a shared relying-party ID that is not its own registrable domain when its origin is one of the authorized related origins, and the ceremony verifier accepts assertions from those origins. Filter: rapls_passkey/related_origins (Pro's "related origins" setting wires it). Requires real cross-domain WebAuthn testing to validate for your domains.

= 0.13.13 =
* The WooCommerce "My account" passkey endpoint's rewrite rule is now cleared and regenerated across a deactivate/reactivate cycle, so the tab no longer 404s if the rewrite rules were flushed while the plugin was inactive.

= 0.13.12 =
* The login and passkey-management shortcodes/blocks now scope their controls to each instance instead of shared DOM ids, so more than one of them (or a shortcode alongside the WooCommerce account integration) can appear on the same page and all of them work.
* The registration policy filter (rapls_passkey/registration_policy) now also receives a context array (owner_id, actor_id, context) so a policy can be scoped to the real owner rather than the current user, which differs during admin enrolment.

= 0.13.11 =
* The "Export Personal Data" tool now pages through the audit log instead of stopping at the first 1000 rows, so a data-subject export is complete no matter how many events a user has.

= 0.13.10 =
* The per-user passkey limit is now enforced atomically: after storing a new credential the count is re-checked and the insert is rolled back if two simultaneous registrations both slipped past the pre-insert check, so the configured maximum can never be exceeded.

= 0.13.9 =
* More hardening from a follow-up review:
* Passkey registration now binds the new credential to the user the ceremony was actually built for (a constant-time check of the verified userHandle against the re-resolved owner), so a caller allowed to enrol on behalf of others cannot request options for one user and then save the credential to another.
* A user's WebAuthn user handle is now created atomically on first use, so two simultaneous first registrations can no longer mint two different handles for the same account.
* reCAPTCHA can now be set to fail closed when Google cannot be reached (it still fails open by default so an outage does not lock everyone out). Filter: rapls_passkey/recaptcha_fail_open.

= 0.13.8 =
* The relying-party ID is now validated against the full Mozilla Public Suffix List (bundled in data/public_suffix_list.dat), so a public suffix the previous short denylist missed — github.io, appspot.com, co.id, com.ar and the like — is correctly rejected as an RP ID. Wildcard and exception rules are honoured. If the list file is unavailable the matcher falls back to the previous heuristic, so validation never hard-fails. Filter: rapls_passkey_rp_id_public_suffixes (adds further suffixes).

= 0.13.7 =
* Hardening from a follow-up review (concurrency and multi-factor assurance):
* The signature counter is now advanced with an optimistic compare-and-set, and the login is refused if it does not commit — so a replayed assertion cannot slip past the counter check in a race, and a database write failure no longer signs anyone in on stale state. Counter-less authenticators (which legitimately report 0) are unaffected.
* Login options accept a raise-only "uv=required" request so a caller (such as Pro's step-up) can require user verification for a multi-factor login; it can only strengthen, never weaken, the site's setting. Filter: rapls_passkey/allow_uv_elevation.

= 0.13.6 =
* From a follow-up static-analysis review:
* The WebAuthn verifier now accepts both the Site Address (home) and WordPress Address (site) origins, matching the REST same-origin gate, so a split-URL install whose login screen runs on a different origin verifies correctly.
* Registration options now isolate a corrupt stored credential per row (like login options already did), so one unreadable record can no longer stop a user from enrolling a replacement passkey.
* The REST login allowlist now only clears the known "REST is limited to logged-in users" authentication errors on its routes, so an unrelated block (WAF, IP gate, maintenance) another plugin returns is preserved. Filter: rapls_passkey/rest_clearable_error_codes.
* Moved the older change history into changelog.txt to keep readme.txt within the WordPress.org size guideline.


= 0.13.5 =
* From a follow-up static-analysis review:
* Same-origin checks on the login/registration routes now compare the full origin (scheme, host and port), so http vs https or a different port is no longer treated as the same site.
* The relying-party ID can no longer be set to a public suffix (e.g. "com" or "co.jp"); such a value is rejected and the site host is used instead. Filter: rapls_passkey_rp_id_public_suffixes.
* The REST allowlist that keeps passkey login reachable under "logged-in only" security plugins now matches its routes on path-segment boundaries, so an unrelated route that merely contains the string is not affected.
* Alternative (magic-link / recovery-code) logins now fail closed when an active 2FA plugin cannot report a user's second-factor status: rather than letting the weaker login through, it is refused and the user is asked to use their passkey or password. A passkey login is unaffected.
* An internal exception reason is never returned to an anonymous REST client, even with WP_DEBUG on; it is written to the server log only.
* Tested up to WordPress 7.0.2.

= 0.13.4 =
* From a static-analysis review: challenge consumption now fails closed on a database error (a query error can no longer leave a challenge reusable — the worst case is a single retryable login).
* Set a real Plugin URI, and excluded stray editor/tooling folders (.claude) from the distributed ZIP.

= 0.13.3 =
* Added an "Rapls Passkey Pro" panel on the settings screen describing what Pro adds. It is shown only when Pro is not installed, only on this screen (not a site-wide nag), and every line is translatable; the link is filterable with rapls_passkey/pro_url and the whole panel with rapls_passkey/show_upsell.
* Review compliance: the one inline style block on the profile screen is now attached with wp_add_inline_style instead of being printed inline.
* Completed the bundled Japanese translation.

= 0.13.2 =
* From a deeper code review:
* Security (defense in depth): the REST allowlist that keeps passkey login working under "REST for logged-in users only" security plugins is now scoped to the anonymous login routes, so it no longer clears the cookie-nonce error on the authenticated credential-management routes (register, delete, rename, suspend).
* Hardening: /login/options now throttles username-bearing lookups per IP to blunt scripted enumeration of which accounts hold passkeys; usernameless autofill is unaffected. Filter: rapls_passkey/login_options_max.
* reCAPTCHA verification now requires the action and score fields (a token minted for another action, or a scoreless token, no longer passes).
* Challenge consumption is now atomic, so two concurrent verifications cannot reuse one challenge.
* credential_id is stored as varchar(512): a non-resident authenticator whose credential id encodes to more than 255 characters is no longer truncated (which would have broken later logins). The table upgrades automatically.
* The last-administrator guard now accounts for multisite super admins; can_enforce_for_user() now includes the last-admin check so it is safe to use on its own.
* Site Health and the setup wizard now agree on which hosts count as local (loopback only; browsers do not exempt .local/.test).
* The login/manage blocks gained an editor panel for the redirect URL and button label (the attributes existed but could not be set).
* Multisite uninstall now cleans every site's tables, options and transients, not just the current one; both plugins also sweep their leftover transients.
* WP-CLI passkey removal now records the audit event against the credential's owner.
* The profile heading reads "Registered passkeys" when an administrator views another user; the users-column count no longer mis-pluralises.

= 0.13.1 =
* From a code review of 0.10.0-0.13.0:
* The setup-check notice no longer appears on a site that upgraded with passkeys already registered — a site that is plainly working has been through setup, it just predates the wizard.
* uninstall.php now also removes the setup-check option.

= 0.13.0 =
* Added a first-run setup check. Activating a passkey plugin and landing on a settings screen leaves the two questions that actually decide whether passkeys will work unanswered: is the site served over HTTPS (browsers refuse WebAuthn otherwise), and what relying-party ID will the credentials be bound to — get that wrong later and every registered passkey stops matching. The wizard answers both, notes any security plugin it is coexisting with, and walks the administrator through registering their own passkey, since an administrator who has not tried it cannot tell whether it works for anyone else. It appears once and is reachable afterwards from "Setup check" on the settings screen.

= 0.12.0 =
* Passkeys can now be suspended instead of deleted. A device that is at the repair shop or left at the office is not gone for good, but until now the only way to stop it signing in was to destroy the credential and re-run the whole registration ceremony later. A suspended passkey is refused at login and is not offered to the browser, but survives to be resumed. Users manage their own from the profile screen and the [rapls_passkey_register] shortcode; an administrator can suspend anyone's. A suspended passkey does not count as "having a passkey", so enforcement and the disabled password login cannot strand a user with nothing to sign in with.
* Added a site-wide passkey list under Users -> Passkeys: every passkey on the site, searchable by owner or name, showing the authenticator, when it was registered, when it was last used (or never), and its status — with suspend and delete in place. Answering "who still has a passkey on the laptop we just lost?" previously meant opening user profiles one at a time.
* Administrators can register a passkey on another user's behalf, for handing over a pre-configured security key or setting someone up in person. Off unless the site turns it on (Rapls Passkey Pro -> Administrator enrolment), gated on the edit_user capability for that specific user, notified to the account's owner by email, and recorded in the audit log. Sites without Pro can enable it with the rapls_passkey/allow_admin_enrolment filter.
* The credential table gained an `active` column; the schema upgrade runs automatically on the next admin page load.

= 0.11.0 =
* Passkeys can now be renamed after registration, from the profile screen and the [rapls_passkey_register] shortcode. The name was previously fixed at registration time, which left a user with two "iCloud Keychain" entries no way to tell which one to revoke after losing a device. Renaming is owner-only and is recorded in the audit log.

= 0.10.0 =
* Two-factor plugins now also cover the logins that are weaker than a passkey. Wordfence Login Security, Two-Factor and the like enforce their second factor only inside the wp-login.php password chain; the email magic link and the recovery-code login (Pro) set the auth cookie without entering it, so they were a way around the site's 2FA. Those logins now stop at a two-factor challenge screen and are completed only after the site's own 2FA plugin verifies the code — Wordfence's own check is used, not a re-implementation of it, so a Wordfence recovery code works there as well. A passkey sign-in (including the QR cross-device flow and passkey sign-up) is itself phishing-resistant MFA and is never challenged.
* The challenge only ever appears for users who have actually configured a second factor, and RAPLS_PASSKEY_BYPASS lifts it along with the rest of the enforcement, so it cannot lock anyone out. It can be switched off under Settings -> Session security, or per login via the rapls_passkey/require_second_factor filter; adapters for other 2FA plugins can be registered with rapls_passkey/second_factor_providers.

= 0.9.7 =
* Fixed the login rate limit being far too aggressive: only failed passkey assertions now count toward the limit. The /login/options request (which the browser legitimately makes several times per page for autofill / conditional UI) is no longer counted, so a small limit such as 3 no longer triggers "Too many attempts" before you even sign in. A successful login still clears the counter, and once the limit is reached the lockout lasts the configured window.
* Added a "Reset to defaults" button on the settings screen (CSP-safe confirmation, nonce-protected).

= 0.9.6 =
* A successful passkey login no longer counts toward the "Too many attempts" limit: the per-IP counter is cleared on a successful sign-in, so only genuine failures accumulate.
* The login attempt limit and the lockout time are now configurable on the settings screen (Login rate limit), with the rapls_passkey_login_rate_max / rapls_passkey_login_rate_window filters. Setting the attempt limit to 0 disables it. Defaults are unchanged (30 attempts per 300 seconds).
* Added a "Session security" setting to optionally allow administrators to use "remember me" on a passkey login (off by default; administrators stay non-persistent for safety unless you turn it on). Also adjustable via the rapls_passkey_admin_remember_allowed filter.

= 0.9.5 =
* Follow-up review fixes:
* The /login/options endpoint now rejects requests with no Origin and no Referer (a new strict gate); /login/verify stays lenient, since WebAuthn binds the origin there.
* AuthSession re-applies the "administrators are never persistent" rule after the rapls_passkey/login_remember filter, so the filter cannot re-grant a persistent cookie to an admin.
* Replaced remaining direct mb_substr() calls with a Str::substr() helper that falls back to substr() when mbstring is absent.

= 0.9.4 =
* From an external review. Hardening, no change to normal use:
* Passkey login now honours the "remember me" checkbox (default off) instead of always issuing a persistent session, and administrators never get a persistent cookie. All sign-in paths now go through one chokepoint (AuthSession) so the login policy is applied consistently.
* Admin removal of another user's passkey now checks the per-user edit_user capability for that specific user, not the blanket edit_users.
* A signature-counter regression on assertion (a possible cloned authenticator) is now recorded in the audit log and exposed via the rapls_passkey/counter_mismatch action.
* The rapls_passkey_rp_id filter value is validated (must be the host or a registrable parent) and falls back to the host if misconfigured, preventing a site-wide passkey breakage.
* The per-user passkey limit is re-checked immediately before storing (closes a registration race), and mb_substr() now has a substr() fallback.
* readme wording clarified for cross-device sign-in and two-factor coexistence.

= 0.9.3 =
* The stored reCAPTCHA secret key is now encrypted at rest (versioned, tagged ciphertext via libsodium, with an OpenSSL AES-256-GCM fallback) instead of plaintext. Existing keys keep working and are re-encrypted on the next save. Settings export decrypts secrets so a configuration stays portable between sites.

= 0.9.2 =
* Hardening: the audit-log CSV export now neutralises spreadsheet formula injection (cells beginning with =, +, -, @ are prefixed with an apostrophe), since a user-chosen login could otherwise be executed as a formula when the CSV is opened in Excel/Sheets.

= 0.9.1 =
* Hardening (from a security review): added a rapls_passkey/allow_login veto filter, consulted before a passkey/alternative-method login sets the auth cookie, so integrations that block users via the core authenticate filter can apply the same block to passkey logins. Expanded uninstall cleanup to also remove the plugin's per-user meta. No functional change for normal use.

= 0.9.0 =
* Internationalization: all source strings are now in English, with Japanese provided as a bundled translation (languages/rapls-passkey-ja.po/.mo). The text domain remains rapls-passkey, so any locale can be translated. No functional change; the Japanese UI is unchanged for ja sites.

= 0.8.0 =
* Added a passkey education snippet: a concise "What is a passkey?" explainer (a JavaScript-free disclosure) on the profile screen, the login screen, and the login/management shortcodes. Use the rapls_passkey_learn_more_url filter to point the "Learn more" link at your own help page.

= 0.7.0 =
* Added LifterLMS and BuddyPress login-form integrations (a passkey button on each plugin's login form, only when that plugin is active). Forms that use the standard login_form action (such as bbPress and LearnDash) are already covered by the login-screen passkey button (and can be added via the rapls_passkey_login_form_hooks filter).

= 0.6.0 =
* Added an "Adoption" dashboard widget. See at a glance the number of registered passkeys, how many users have a passkey and their share of all users, and the last 30 days of activity (logins / new registrations), plus links to the users list and settings (administrators only).

= 0.5.0 =
* Added `wp rapls-passkey stats` (site-wide adoption: total passkeys and users with a passkey). Together with the existing `list` / `remove` commands, this helps automate operations and audits.

= 0.4.0 =
* Authenticator (provider) names: added an "Authenticator" column to the passkey list (profile screen and the [rapls_passkey_register] shortcode), showing the provider derived from the AAGUID — iCloud Keychain, Google Password Manager, Windows Hello, 1Password, YubiKey, and more. The provider name is also included in the registration notification email. The mapping is extensible/overridable via the rapls_passkey/authenticator_names filter (shows "Unknown" where the provider is hidden).

= 0.3.0 =
* Security notification emails: notify the user about passkey registration and removal, and about passkey sign-ins from a new device (can be disabled in settings; individually controllable via filters).
* Privacy (GDPR): integrates passkeys and the audit log with WordPress's built-in personal-data export/erase, and purges related data when a user is deleted.
* Added a "Passkey" column to the Users list (count / last used / not registered), and an adoption summary (rate / total) on the settings screen.
* Extended login-form integrations: a passkey button on the login forms of Ultimate Member / MemberPress / Easy Digital Downloads / Theme My Login (only when each plugin is active; individually controllable via filters).
* Added advanced WebAuthn settings: timeout, user verification (required/preferred/discouraged), and authenticator type (platform/cross-platform), adjustable in settings and via filters.
* Added extension hooks for the registration policy (rapls_passkey/registration_policy, rapls_passkey/attestation_conveyance) for the Pro authenticator policy or custom attestation verification.
* Post-login passkey prompt: right after a password login, suggest creating a passkey on the spot (only for users without one, once per interval, can be disabled in settings).
* Added a "Passkey" tab to the WooCommerce "My account" page so members can register and remove their own passkeys (only when WooCommerce is active).
* CSV export of the audit log (download from the settings screen; UTF-8 with BOM for Excel).
* Added the registration core that powers Pro passkey sign-up (creation options for not-yet-created users).
* Automatic passkey creation (Conditional Create): on supported browsers (Safari 18+ / Chrome 136+, etc.) a passkey is created automatically right after a password login with no dialog; falls back to the prompt screen when unsupported.
* Site Health integration: self-checks for HTTPS, the WebAuthn library, the database tables, and RP ID / security-plugin coexistence under Tools → Site Health (Status tab checks plus an Info tab panel/export).
* Added WebAuthn UI hints. Tell supported browsers whether to suggest this device, another device (QR), or a security key first. Also supports usernameless passkey login (sign in with just the button).
* Plugin Check compliance: the post-login prompt script is loaded via wp_enqueue_script, direct DB query annotations were tidied (table names come from the prefix and are safe), and a direct-access guard was added to dev-only files (tests/ and bin/).

= 0.2.0 =
* Front-end embedding via shortcodes and Gutenberg blocks (login / passkey management).
* Configurable per-user passkey registration limit. Administrators can remove other users' passkeys.
* Works alongside two-factor plugins (Automattic Two-Factor / WP 2FA): a passkey login can be treated as a strong-authenticated session. Exact compatibility depends on the 2FA plugin and its policy, because passkey login sets the session directly rather than passing through the password authenticate chain.
* Keeps working even where a security plugin restricts the REST API to logged-in users, by allowing only the passkey endpoints.
* Does not break Content-Security-Policy: injects no custom CSP header and uses no inline event handlers.
* Added the rapls_passkey_rp_id / rapls_passkey_rp_name filters for multisite (shared RP ID).

= 0.1.0 =
* Initial scaffold: plugin bootstrap, credential table, and dependency checks.
* Passkey registration and login (same-device, cross-device, and autofill).
* WP-CLI management/recovery commands and an emergency bypass constant.
* Settings screen, reCAPTCHA v3 for password logins, and an audit log.
* Detects and coexists with Wordfence / SiteGuard WP Plugin / CloudSecure WP Security, etc.
