diff --git a/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-RESEARCH.md b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-RESEARCH.md new file mode 100644 index 00000000..56d16a0c --- /dev/null +++ b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-RESEARCH.md @@ -0,0 +1,1055 @@ +# Phase 19.6: Two-factor auth and device flow in sm-user-plugin - Research + +**Researched:** 2026-10-21 +**Domain:** Second factors (TOTP, WebAuthn/passkeys, email codes, recovery codes, trusted devices) in a new shared plugin `sm-mfa-plugin`, a login-gate seam and a QR/short-code device authorization flow in `sm-user-plugin` +**Confidence:** HIGH for the PHP contract, the Go seams and the library facts (all read or measured this session). MEDIUM for browser-side WebAuthn behaviour, which only a real authenticator can confirm. + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +#### Placement and libraries +- **D-01:** **2FA lives in a separate plugin**: repo `sm-mfa-plugin`, Go plugin `golem15.mfa`, which `Require()`s the user plugin. This follows the 19.5 sociallogin and 19.4 oauthserver pattern. Apps without 2FA carry no WebAuthn/CBOR/COSE dependencies, no extra tables and no `/2fa` routes. The repo layout and commit order follow `.planning/notes/core-plugins-own-repos.md`. — **Reversibility:** costly — apps' `summer.yaml`, submodules and settings reference the plugin once it ships. +- **D-02:** **Login-gate seam in sm-user-plugin.** This is an additive registry of gates. `Login` consults them after `CheckAndRecordLogin` succeeds (`controllers/api_controller.go:94-101`) and before `mintFor` (`:115`). + - A gate may return a challenge payload. `Login` then answers HTTP 200 `{two_factor_required:true, challenge_token, available_methods[], expires_at}` (PHP `ApiController.php:55-76`) instead of a token. + - With no gate registered, login behaves exactly as today. + - The mfa plugin registers the gate and mounts the challenge-completion routes, which mint the token through the user plugin's exported helpers. + - sociallogin's `oauth-complete` consults the same gate (D-09). + - JWT refresh never consults the gate, as in PHP. +- **D-03:** The **device authorization flow stays in sm-user-plugin**. It is small and dependency-free, and its paths are already under `/_user/api/v1`. +- **D-04:** **Libraries** (a decision note under `.planning/notes/` names each with its pinned version before code lands; `.planning/research/STACK.md` is updated): + - TOTP: `github.com/pquerna/otp`. Issuer is the app name, the account is the user's email, the window is ±1 step, and digits and period stay at the defaults as in PHP. + - WebAuthn: `github.com/go-webauthn/webauthn`. The RP ID is the app URL's host and the RP name is the app name, as in PHP. + - QR: the **researcher picks** a library that can emit SVG, so the TOTP setup contract `{secret, qr_code_svg, qr_code_url}` stays. + +#### Contract: verbatim vs hardened +- **D-05:** **Route paths, request fields and response shapes follow PHP** (`routes.php:303-336`, `:372-453`; `TwoFactorApiController.php`): + - Challenge group: `/_user/api/v1/2fa/{verify,recovery,email/send,webauthn/options,webauthn/verify}`, unauthenticated, in the **`2fa-verify` bucket (10/min per IP)**. + - Management group: `jwt.auth`, with `status`, `totp/setup|confirm`, `DELETE totp`, `email/enable`, `DELETE email`, `webauthn/register/options`, `webauthn/register`, `DELETE webauthn/{id}`, `GET recovery-codes?password=`, `POST recovery-codes/regenerate`, `DELETE trusted-devices/{id}` and `DELETE trusted-devices`. + - The method list and the trusted-device list stay inside `status`. + - Challenge: TTL 5 min, `max_attempts` 5, and every verify increments the counter. Recovery codes are always accepted through `/recovery`. + - Recovery codes: 10 per set, format `XXXXX-XXXXX` over the alphabet `23456789ABCDEFGHJKMNPQRSTUVWXYZ`, stored hashed, and listing returns only the remaining count. + - Email code: 6 digits stored hashed, template `two_factor_code` with vars `name`, `code`, `expiry_minutes`, registered with `-en` variants as the user plugin's mail templates are. + - Events: the PHP names (`golem15.user.2fa.challenged|failed|recovery_used|verified|enabled|disabled|webauthn.registered|webauthn.removed|recovery_regenerated`), then the user login event after verify. +- **D-06:** **Tokens at rest are SHA-256 hashes**: challenge tokens, trusted-device tokens and device-auth session tokens (the same `sha256Hex` scheme as wristband and API tokens). Clients see no difference. A future Winter import hashes the plaintext PHP rows on the way in. The TOTP secret is encrypted (`lagoon.Encrypted`). Recovery codes and email codes keep a slow hash; research picks bcrypt or argon2id to match the user plugin's password hashing. +- **D-07:** **Revocation on security events goes beyond PHP**: + - Any password change revokes all trusted devices: API change, reset or admin set. PHP only does this for a CMS password change. + - Disabling the user's last 2FA method revokes all trusted devices. + - Regenerating recovery codes invalidates the old set, as in PHP. + + The user plugin fires the password-change hook (additive), and mfa listens. +- **D-08:** **WebAuthn JSON uses go-webauthn's standard shapes**: base64url `PublicKeyCredentialCreationOptions`, `PublicKeyCredentialRequestOptions` and the attestation/assertion responses that `navigator.credentials` and `@simplewebauthn/browser` produce. The paths stay PHP's. This is a **deliberate, recorded deviation** from lbuchs' plain-base64 layout, acceptable because no client implements it yet. Attestation is `none`, the timeout is 60 s, and user verification is `preferred` for second-factor use. — **Reversibility:** costly — clients built against the shape would break. +- **D-09:** **Social login and 2FA.** A backend setting (D-12) decides whether `oauth-complete` goes through the challenge. The **default is skip, as PHP does**. When enabled, sociallogin's `oauth-complete` consults the login gate and returns the same `two_factor_required` shape instead of `{token,user,…}`. The SPA callback page must handle that shape before an app turns the setting on. +- **D-10:** **Trusted devices**: off by default (`trusted_device_enabled=false`), 30 days. With `trust_device` on a successful verify, the response adds `trusted_device_token` (PHP `TwoFactorApiController.php:578-588`), and the client sends it back in the login body. The CMS cookie variant (`g15_trusted_device`) is not ported, since there is no CMS. + +#### Device flow (sm-user-plugin) +- **D-11:** **PHP's custom QR plus short-code flow is kept** (not RFC 8628), so QuestStream's `useDeviceAuth.ts` keeps working: + - Routes inside the `user-api` group: `POST devices/initiate`, `GET devices/status/{token}`, `POST devices/authorize` (JWT, accepts `short_code` or `token`), `GET devices` and `DELETE devices/{id}`. + - A 64-char token and an `XXXX-XXXX` short code (recovery alphabet), with a 5 min TTL. + - `auth_url` is a **configurable frontend URL template** (default `{frontend_url}/device/scan/{token}`), because Go has no CMS page. + - The model is `DeviceAuthSession`, with PHP's status enum `pending|confirmed|used|expired|revoked`. +- **D-12a:** **The polling device gets a one-time JWT.** On the first poll after confirmation, `devices/status/{token}` returns PHP's `{status:'confirmed', user_id}` plus `token` and `user`. An atomic `confirmed→used` transition guarantees that only one poll receives it, and later polls see `used`. PHP's API never issued a token (only its CMS component logged the device in), so this is an additive extension without which the API flow cannot sign anything in. +- **D-12b:** **Revoking a device session blacklists the issued token's JTI**, and its refresh lineage, on the existing JWT blacklist. The session row stores the JTI. A revoked TV or desktop is signed out at once. +- **D-12c:** **Authorizing needs only a valid JWT, as in PHP.** The authorizing user already passed login and 2FA, and the device binds to them. The frontend shows the device name and IP before confirming. There is no 2FA re-check. + +#### Modes, settings and admin (mfa) +- **D-12:** **A backend Settings screen in mfa** (`pact.HasSettings`) holds PHP's keys and defaults, which config supplies as defaults: + - `two_factor_mode`: `disabled|optional|enforced`, default `optional` + - `two_factor_available_methods`: default `[totp,email]` + - `two_factor_enforce_groups` + - `two_factor_email_code_ttl`: 10 + - `two_factor_challenge_ttl`: 5 + - `two_factor_passwordless_login`: default off + - `trusted_device_enabled`: off + - `trusted_device_ttl_days`: 30 + - **new** social-login challenge toggle: default off/skip + + Changes apply without a restart. +- **D-13:** **The child-account exemption is dropped.** The Go user plugin has no child-account concept, so only `mode=disabled` exempts. Per-group exemptions may come much later; see deferred. +- **D-14:** **Passwordless passkey login ships as API routes now**: `/_user/api/v1/2fa/webauthn/passwordless/options` and `.../verify` (exact paths are the planner's call within the `/2fa` prefix and the `2fa-verify` bucket). They issue a JWT directly. Rules: + - Only **discoverable credentials registered with user verification `required`** may be used. Second-factor-only credentials are rejected. + - A successful passkey login satisfies 2FA, so no second challenge follows. + - It is gated by `two_factor_passwordless_login`, off by default. + - It still enforces activated/not-banned and the login event. + + It gets an explicit security review. — **Reversibility:** costly — once users register passkeys, removing the route strands them. +- **D-15:** **Admin "Reset 2FA" on the Users screen**, behind a new backend permission. It disables all methods, deletes the WebAuthn credentials and recovery codes, revokes trusted devices and fires `golem15.user.2fa.disabled`. It is additive: a cabana action contributed by mfa to the user plugin's Users screen. The mechanism (a cabana extension hook vs a new action) is researched, and if the user plugin needs a seam, it is additive. + +#### Tables +- **D-16:** The six PHP tables are ported with PHP's names and columns (`golem15_user_two_factor_methods`, `_two_factor_challenges`, `_two_factor_recovery_codes`, `_webauthn_credentials`, `_trusted_devices` in mfa; `golem15_user_device_auth_sessions` in sm-user-plugin), adjusted for D-06 hashing and the D-12b JTI column. The names stay so a later Winter import is a near-straight copy. + +#### Adoption +- **D-17:** **sm-demo-app and sm-bm-app** mount mfa in this phase, and both get the device flow through the user plugin pin bump. fonoteka.go does not mount mfa. Its guard test `fonoteka/phase07_coverage_test.go:79` (`TestNoDeferredRoutesResolve`) fails once `/devices` routes resolve, so it is updated in the fonoteka pointer-bump commit. Commit order: sm-user-plugin → sm-mfa-plugin and sociallogin pushed → app pointer bumps as separate commits. Push, tag and repo creation need the user's go-ahead. + +### Claude's Discretion +- Go identifiers for the gate interface, registry and payloads. +- The QR library (research, D-04). +- Slow-hash choice for codes (D-06). +- Exact passwordless route names inside `/2fa` (D-14). +- How the admin reset action plugs into the Users screen (D-15). +- Whether 19.6 lands before or after 19.5's sociallogin release. If before, the D-09 gate call in sociallogin is added when sociallogin exists. + +### Deferred Ideas (OUT OF SCOPE) +- Per-group 2FA exemptions (settings on user groups), replacing PHP's child-account exemption. Long term. +- Notification mails on 2FA enabled/disabled or a new trusted device (not in PHP). +- Winter import of the 2FA and device tables (Phase 30 `import:winter`, numbered 20 when this was written, hashing plaintext tokens per D-06). + + + +## Phase Requirements + +No REQUIREMENTS.md IDs are mapped to this phase (TBD). The CONTEXT decisions and the four roadmap success criteria are the source items. + +| ID | Description | Research Support | +|----|-------------|------------------| +| SC-1 | TOTP, WebAuthn, email codes, recovery codes, trusted devices against the PHP contract; WebAuthn decision note first | §Q1 libraries (pins, size, vulns), §Q4 PHP contract tables, Pitfalls 5-12, Code Examples 3-6 | +| SC-2 | Login with a second factor returns the challenge, rate-limited by `2fa-verify` | §Q5 gate placement, Pattern 1, Code Example 1, Pitfall 1 | +| SC-3 | Device authorization on `DeviceAuthSession` | §Q4 device table, Pattern 4, Code Example 7, Pitfalls 13-16, §Q7 refresh lineage | +| SC-4 | Six models + migrations, security review, unit tests last | §Q4 tables, §Q6 extra WebAuthn columns, Validation Architecture, Security Domain | +| D-01 | Separate `sm-mfa-plugin` | §Recommended Project Structure (copy of the sociallogin layout) | +| D-02 | Login-gate registry | Pattern 1, Code Example 1, Pitfall 1 (throttle reset), Pitfall 2 (body already consumed) | +| D-04 | Libraries, QR pick | §Q1, §Q2, Package Legitimacy Audit | +| D-05 | Contract | §Q4 (verbatim messages and shapes), Pitfall 4 (PHP attempt off-by-one) | +| D-06 | Hashing, slow hash pick | §Q3 (bcrypt via `bouncer.HashPassword`, measured), Pitfall 9 | +| D-07 | Revocation | §Q5 password-write sites, Pattern 5, Pitfall 3 (rehash is not a change) | +| D-08 | Standard WebAuthn JSON | §Q6 envelopes, Pitfall 6 (server-side ceremony state), Pitfall 7 (RP ID and origins) | +| D-09 | Social login gate | §Q5 (token is minted at callback time), Pattern 1 | +| D-10 | Trusted devices | §Q4, Pitfall 2 | +| D-11/D-12a/b/c | Device flow | §Q4, §Q7, Pattern 4, Code Example 7, Pitfalls 13-16 | +| D-12 | Settings screen | §Q8 (no `checkboxlist`/`radio` in cabana settings) | +| D-13 | No child exemption | §Q4 (`isEnabledForUser`) | +| D-14 | Passwordless | §Q6, Code Example 6, Security Domain | +| D-15 | Admin reset | §Q9 (cabana has no cross-plugin action hook; user-plugin slot recommended) | +| D-16 | Tables | §Q4 (PHP columns verbatim), §Q6 (columns go-webauthn needs) | +| D-17 | Adoption | §Q10 | + + +## Project Constraints (from CLAUDE.md) + +From `summercms.go/CLAUDE.md`, the meta-repo `CLAUDE.md` and the user's global instructions. These carry the weight of locked decisions. + +- **Standard library first. A new dependency needs a research doc or a phase/decision note naming it** (`.planning/research/STACK.md` holds the decided set). pquerna/otp, go-webauthn and the QR library need `.planning/notes/-mfa-otp-webauthn.md` **before** they land in any `go.mod` (D-04). +- YAML is parsed with `goccy/go-yaml`, never `gopkg.in/yaml.v3`. go-webauthn's go.mod lists `go.yaml.in/yaml/v3` (indirect, test-only); it is not in the import graph of the packages the plugin uses (measured, §Q1). +- **Core plugins must not get breaking changes** (global and meta CLAUDE.md, `core-plugins-own-repos.md`): every sm-user-plugin change is additive. Routes, payloads, tables, migration IDs and config keys stay. +- **Framework code never names a consuming application.** Plugin READMEs say "the application". +- A change to exported API, config keys, CLI commands or dependencies updates the module's `README.md` (and `docs/` pages for framework modules) **in the same commit**. Plugin READMEs follow the standard structure (H1, summary, import line, `**Name:**`, Overview, Features, Usage, API reference, Configuration, Dependencies, Testing). New plugin needs a beach-themed `**Name:**` mnemonic. +- `go vet ./...` and `go test ./...` green at every commit. Docker tests skip under `-short`; a container-starting `TestMain` calls `flag.Parse()` and checks `testing.Short()`. If the Docker socket is denied, run through `sudo -u golem`. +- **Lean planning:** few large plans; present the plan count with one-line scopes and wait for confirmation before writing PLAN.md files. **Unit tests are the last plan.** +- Update `STATE.md` only through `msd_run query state.*`. Mirror the phase in g15office. +- **No co-author tags.** One logical change per commit; planning docs and code separate. Stage by explicit path, never `git add -A`. Other sessions edit concurrently (STATE.md, state.json, cabana). +- Shared plugins: commit and push the plugin first, then the pointer bump in the app as a separate commit. Never stage files inside a submodule from the app repo. Use `ssu` for submodule management. +- Don't push, tag, create repos or deploy without the user's go-ahead. A pushed release tag of the **framework** gets a summercms.io changelog post the same day (this phase needs no framework release under the recommended design, §Q9). + +## Summary + +The phase splits cleanly. **sm-user-plugin** (currently `v0.1.5`, all three relevant app checkouts at `35cf166`) gets five additive seams: a login-gate registry consulted in `Login`, a login event (Go has none today), a password-changed event fired from the three password-write paths, a `reset_two_factor` record-action slot on the Users screen, and the device authorization flow (one migration, one model, five routes, plus a JTI hand-over in `Refresh`). Everything else lives in the new **sm-mfa-plugin**, built on the sociallogin layout (`go.work` replacing the framework and the user plugin, a testcontainers `freshDB` harness, a singleton settings row, `pact.HasSettings`). + +The libraries are settled and measured. `github.com/pquerna/otp v1.5.0` (Apache-2.0) and `github.com/go-webauthn/webauthn v0.18.2` (BSD-3-Clause, published 2026-09-19) compile together with **`github.com/boombuler/barcode v1.1.0`** for the QR code. boombuler is already a requirement of pquerna/otp, so picking it adds no module, and a 25-line SVG serializer over its module matrix produced a code that an independent decoder read back byte-for-byte. Linked into a copy of the real sm-demo-app build, the three libraries add **2,269,184 bytes (+2.16 MiB)** to a stripped binary (51,802,375 → 54,071,559) and nine linked modules. They also bump `go-viper/mapstructure/v2` from v2.4.0 to v2.5.0 in the app graph. `govulncheck v1.9.0` found no reachable vulnerabilities. For the slow hash, use **bcrypt through `bouncer.HashPassword`/`CheckPassword`** at `golem15.user.password.bcrypt_cost` (default 10): it matches the user plugin, and ten compares measured 631 ms. + +Research found nine things the CONTEXT did not anticipate. Each needs a plan task, and three need user confirmation: + +1. **There is no refresh lineage.** `bouncer.Refresh` mints a fresh `jti` and blacklists the old one. Nothing links the two, so blacklisting the stored JTI (D-12b) misses a device that has refreshed once. Fix: the user plugin's `Refresh` handler moves the session row's JTI forward (`UPDATE … SET jwt_jti = new WHERE jwt_jti = old`). +2. **Go has no login event.** D-05 and D-14 both require "the login event"; `classes/events.go` declares none. An additive `classes.LoginEvent` is needed. +3. **The password step resets the login throttle before the second factor runs** (`CheckAndRecordLogin(…, true)` zeroes `attempts`). Each new challenge then gets a fresh 5 attempts, so the `2fa-verify` bucket is the only brute-force limit. A per-user failure cap in mfa is recommended (Security Domain). +4. **cabana cannot add a record action to another plugin's controller.** Record actions must be named in the owner's `config_form.yaml`, and their permissions are validated at boot. D-15 therefore needs a user-plugin slot and a permission code the user plugin registers. +5. **go-webauthn must persist more than the PHP columns hold** (backup-eligible/state flags, UV-initialized, AAGUID, attestation format), and it refuses a login whose BE flag changed. PHP stores PEM public keys and go-webauthn needs COSE, so PHP credentials are not importable as-is. +6. **WebAuthn origins:** the RP ID must be a registrable suffix of the *frontend* origin, and IP-address hosts are refused by browsers. The demo's `url: http://127.0.0.1:8097` cannot run a ceremony; split-domain SPAs need an RP ID and origin override. +7. **PHP registration trusts a client-echoed challenge** (`webauthnRegister` takes `challenge` from the body). The Go port keeps ceremony state server-side (cooler `Pull`), and keeps `challenge` only as a lookup key. +8. **sociallogin mints its token at callback time**, not in `oauth-complete`. The D-09 gate goes into `signIn`, and the completion payload carries the challenge instead of a token. +9. **PHP never shows the first recovery-code set** (`ensureRecoveryCodes` discards the plaintext on first enable). Keep it, or return the codes additively? (Open Question 1.) + +**Primary recommendation:** Pin pquerna/otp v1.5.0, go-webauthn v0.18.2 and boombuler/barcode v1.1.0 in a decision note. Add the five user-plugin seams first in one additive release. Then build mfa with every state change as a single guarded SQL `UPDATE` (attempts, completion, recovery consume, device confirm/used) and every ceremony state server-side. + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Password check, throttle, gate consultation | API / Backend (sm-user-plugin `Login`) | — | The gate must run before any token exists | +| Challenge creation, method verification, token mint after verify | API / Backend (mfa) | Database (challenge row, guarded UPDATEs) | Attempts and one-time completion are enforced in SQL | +| TOTP secret storage | Database (`lagoon.Encrypted`) | API | Encrypted at rest (D-06) | +| WebAuthn ceremony (options → assertion) | Browser (`navigator.credentials`) | API (go-webauthn verify), Cache (ceremony state via `cooler.Pull`) | The authenticator runs in the browser; verification and state stay server-side | +| Email code delivery | API (mfa) → `postcard.Mailer` | — | Code hashed on the challenge row | +| Trusted-device token | Browser/Client (stores opaque token) | Database (SHA-256 hash, expiry) | Token is a bearer secret; only its hash is stored | +| Device QR/short code display | Browser/Client (new device) | API (initiate/status) | The new device has no session | +| Device authorization | Browser/Client (signed-in phone) | API (authorize, JWT) | The authorizer's JWT binds the device | +| Device session revocation | API (user plugin) | Database (JWT blacklist) | Revocation must reach every server instance | +| 2FA settings | Database (singleton row) | Config defaults | Changes without restart (D-12) | +| Admin reset | API (cabana record action in user plugin slot) | mfa (implementation via published service) | YAML-declared actions belong to the controller owner | + +## Answers to the research questions + +### Q1. Libraries: versions, import graph, size, vulnerabilities + +**Pins** [VERIFIED: `go list -m -json @latest` against proxy.golang.org, this session] +- `github.com/pquerna/otp v1.5.0`, published 2024-12-31T23:15:27Z, `go 1.12`, requires `github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc`. 2,963 stars, Apache-2.0, last push 2025-08-07, not archived. [VERIFIED: pquerna/otp@v1.5.0/go.mod; `gh api repos/pquerna/otp`] +- `github.com/go-webauthn/webauthn v0.18.2`, published 2026-09-19T12:32:05Z, `go 1.26.0`. 1,351 stars, BSD-3-Clause, last push 2026-10-20, not archived. [VERIFIED: go.mod read; `gh api`] +- `github.com/boombuler/barcode v1.1.0`, published 2025-07-22T22:10:49Z. 1,563 stars, MIT, last push 2026-10-18. [VERIFIED] + +**go-webauthn v0.18.2 direct requirements** [VERIFIED: go-webauthn@v0.18.2/go.mod]: +``` +github.com/fxamacker/cbor/v2 v2.9.4 +github.com/go-viper/mapstructure/v2 v2.5.0 +github.com/go-webauthn/x v0.3.1 +github.com/golang-jwt/jwt/v5 v5.3.1 +github.com/google/go-tpm v0.9.8 +github.com/google/uuid v1.6.0 +github.com/stretchr/testify v1.12.1 +github.com/tinylib/msgp v1.6.4 +go.uber.org/mock v0.6.0 +``` +`golang-jwt/jwt/v5 v5.3.1`, `google/uuid v1.6.0` and `golang.org/x/crypto v0.57.0` are already in the framework and app graphs at the same versions. `go-viper/mapstructure/v2` is at `v2.4.0 // indirect` in summercms.go, sm-demo-app and sm-bm-app, so minimal version selection bumps it to v2.5.0. [VERIFIED: grep of the three go.mod files plus `go version -m` on the probe binary showing `github.com/go-viper/mapstructure/v2 v2.5.0`] + +**Binary size and linked modules** [VERIFIED: measured this session]. Method: a scratch `go.work` uses every sm-demo-app module plus a probe module that drives `totp.Generate`, `totp.Validate`, `qr.Encode`, `webauthn.New`, `BeginRegistration`, `BeginLogin`, `FinishRegistration`, `FinishLogin`, `FinishPasskeyLogin` and `protocol.ParseCredentialCreationResponseBody`. An `-overlay` file wires it into the demo `main` package, and both binaries are built with `-trimpath -ldflags="-s -w"`. No repository file was touched. +- Base 51,802,375 bytes → with libraries 54,071,559 bytes: **+2,269,184 bytes (+2.16 MiB)**. +- Newly linked modules (`go version -m` diff): `boombuler/barcode v1.1.0`, `fxamacker/cbor/v2 v2.9.4`, `go-webauthn/webauthn v0.18.2`, `go-webauthn/x v0.3.1`, `google/go-tpm v0.9.8`, `philhofer/fwd v1.2.0`, `pquerna/otp v1.5.0`, `tinylib/msgp v1.6.4`, `x448/float16 v0.8.4`. +- This is the cost D-01 keeps out of apps that don't mount mfa. fonoteka.go and the other apps never link these modules. + +**Vulnerabilities** [VERIFIED: `govulncheck@v1.9.0 -show verbose` on the probe module]: "Your code is affected by 0 vulnerabilities." One module-level advisory, GO-2026-5932 (`golang.org/x/crypto/openpgp` unmaintained), is in a package nothing imports. x/crypto v0.57.0 is already the framework's version. + +**TOTP URL format** [VERIFIED: probe run]: `otpauth://totp/Acme:a@b.c?algorithm=SHA1&digits=6&issuer=Acme&period=30&secret=NJWU5QTBSX7QGLWY7ZQ34EGBCZ6XOY4G`. pquerna's default secret is 20 bytes (32 base32 chars). PHP's Google2FA default is 16 base32 chars [ASSUMED]. Clients treat both as opaque. + +### Q2. QR code library (D-04, researcher's pick) + +**Pick: `github.com/boombuler/barcode/qr` v1.1.0 plus a 25-line SVG serializer in mfa.** +- It is already a requirement of pquerna/otp (for `Key.Image()`), so the module is in mfa's graph either way. Picking it adds no module, no maintainer and no supply-chain surface. +- The hard parts of QR are mode selection, Reed-Solomon ECC and masking, and the library does all of them. The plugin only writes `` rectangles for dark modules, which is not cryptographic or protocol code. +- Proven this session: a TOTP URL encoded at level M, scaled and decoded by the independent `piglig/go-qr` decoder, returned the original URL exactly (`decoded equals URL: true`). The SVG was 17,203 bytes for a 45-module code. [VERIFIED: scratch run] + +**Alternative (if the user reads D-04 literally as "the library itself emits SVG"):** `github.com/piglig/go-qr v1.1.0` (2026-05-29, MIT, zero runtime deps, `(*QrCode).ToSVGBytes`). It has 67 stars, one maintainer, and its go.mod carries `gopkg.in/yaml.v3` as a test-only indirect. Prefer boombuler; Assumption A1 records the choice. + +PHP renders with Bacon `RendererStyle(200)` + `SvgImageBackEnd`. The Go SVG should declare `width="200" height="200"` with a `viewBox` so it renders at the same size [VERIFIED: TotpMethod.php:42-47 `new RendererStyle(200)`, `new SvgImageBackEnd()`]. + +### Q3. Slow hash for recovery and email codes (D-06) + +**bcrypt through `bouncer.HashPassword(cost, plain)` / `bouncer.CheckPassword(hash, plain)`** at `golem15.user.password.bcrypt_cost` (default `10`, user plugin `config/config.yaml`: `password:` → `bcrypt_cost: 10`). This is exactly the user plugin's password hashing [VERIFIED: summercms.go/modules/bouncer/password.go:6-19; user plugin api_controller.go:102-110]. No new dependency; argon2id would add `golang.org/x/crypto/argon2` usage the framework does not have. +- Cost: 10 compares at cost 10 took **631 ms** on this machine [VERIFIED: measured]. A recovery verify compares against every unused code (up to 10), so a wrong code costs about 0.63 s of CPU. With the `2fa-verify` bucket at 10/min per IP that is acceptable. Never hash at a cost above the configured one, and cap the recovery-code input length (a 72-byte bcrypt limit applies). +- Regenerating 10 codes is 10 hashes (~0.6 s). Do it inside one transaction (delete old + insert new). + +### Q4. The PHP contract to reproduce + +Source: `/media/nvme/dev/jz/wavepath.org/plugins/golem15/user`, files read this session. + +**Rate limiter** [VERIFIED: routes.php:16-18]: +```php +RateLimiter::for('2fa-verify', function (Request $request) { + return Limit::perMinute(10)->by($request->ip()); +}); +``` + +**Challenge group** [VERIFIED: routes.php:303-337]: `['prefix' => '/_user/api/v1/2fa', 'middleware' => ['throttle:2fa-verify', 'bindings']]` with `Route::post('verify'…)`, `'recovery'`, `'email/send'`, `'webauthn/options'`, `'webauthn/verify'`. +**Management group** [VERIFIED: routes.php:372-453]: `['prefix' => '/_user/api/v1/2fa', 'middleware' => ['jwt.auth', 'bindings']]` with `Route::get('status')`, `post('totp/setup')`, `post('totp/confirm')`, `delete('totp')`, `post('email/enable')`, `delete('email')`, `post('webauthn/register/options')`, `post('webauthn/register')`, `delete('webauthn/{id}')->where('id', '[0-9]+')`, `get('recovery-codes')`, `post('recovery-codes/regenerate')`, `delete('trusted-devices/{id}')->where('id', '[0-9]+')`, `delete('trusted-devices')`. The management group has **no throttle** in PHP. +**Device routes** [VERIFIED: routes.php:191-225] inside the `'/_user/api/v1'` group with `'throttle:user-api'`: `get('devices')`, `delete('devices/{id}')->where('id', '[0-9]+')`, `post('devices/authorize')`, `post('devices/initiate')`, `get('devices/status/{token}')`. Authentication happens inside the controller. + +**Login challenge** [VERIFIED: ApiController.php:55-76]: +```php +if ($twoFactorService->isEnabledForUser($userModel)) { + $trustedDeviceToken = $request->get('trusted_device_token'); + if ($trustedDeviceToken && $twoFactorService->isTrustedDeviceEnabled() + && $twoFactorService->checkTrustedDeviceToken($userModel->id, $trustedDeviceToken)) { + event('golem15.user.login', [$userModel]); + return response()->json(['token' => $token, 'user' => $userModel->getApiArray()]); + } + JWTAuth::invalidate($token); + $challenge = $twoFactorService->createChallenge( + $userModel, $request->ip(), $request->userAgent() + ); + return response()->json([ + 'two_factor_required' => true, + 'challenge_token' => $challenge->token, + 'available_methods' => $twoFactorService->getEnabledMethods($userModel), + 'expires_at' => $challenge->expires_at->toIso8601String(), + ]); +} +``` +`expires_at` uses `toIso8601String()`, which matches `wire.Time` (`2006-01-02T15:04:05+00:00`) [VERIFIED: summercms.go/modules/wire/response.go:32-44]. + +**Who is challenged** [VERIFIED: TwoFactorService.php:33-47, 54-69, 74-77]: `isEnabledForUser` is false when `two_factor_mode` is `'disabled'`, otherwise true when any `golem15_user_two_factor_methods` row has `is_enabled = true`. `getEnabledMethods` plucks those methods and drops `webauthn` when no enabled credential exists. `available_methods` in the challenge is the user's enabled methods, **not** filtered by the global `two_factor_available_methods`. +**"Enforced" is informational only**: `isEnforcedForUser` (:95-109) is used only by `getStatus` (`'enforced' =>`, :586). PHP never refuses a login for a user without 2FA in enforced mode. [VERIFIED: grep of the plugin; only TwoFactorService.php:586 calls it] + +**verifyChallenge** [VERIFIED: TwoFactorService.php:134-205]: find a valid challenge → `incrementAttempts()` → `if ($challenge->isExhausted())` fire `golem15.user.2fa.failed` and fail → recovery: `TwoFactorRecoveryCode::verifyAndConsume` + `golem15.user.2fa.recovery_used` with the remaining count; other methods: handler `verify`, `touchLastUsed` → on failure `golem15.user.2fa.failed` → `markCompleted()` → `JWTAuth::fromUser($user)` → `event('golem15.user.login', [$user])` → `Event::fire('golem15.user.2fa.verified', [$user, $method])` → `['token' => $jwtToken, 'user' => $user->getApiArray()]`. +**Challenge validity** [VERIFIED: TwoFactorChallenge.php:105-133]: not found, `isExpired()`, `isCompleted()` or `isExhausted()` (`$this->attempts >= $this->max_attempts`) → null. `generate()` calls `cleanupExpired($user->id)` first (deletes expired, uncompleted rows) and uses `Str::random(64)`. + +**Challenge endpoints** [VERIFIED: TwoFactorApiController.php] + +| Endpoint | Validation (Laravel) | Success | Failure | +|---|---|---|---| +| `POST 2fa/verify` (:37-66) | `challenge_token` `required\|string\|size:64`, `method` `required\|string\|in:totp,email`, `code` `required\|string\|min:6\|max:6` | 200 `{token, user}` (+ `trusted_device_token`) | 422 `{error, errors}`; 401 `{"error":"Invalid verification code.","two_factor_failed":true}` | +| `POST 2fa/recovery` (:74-102) | `challenge_token` `required\|string\|size:64`, `code` `required\|string` | 200 `{token, user}` (+ `trusted_device_token`) | 422; 401 `{"error":"Invalid recovery code.","two_factor_failed":true}` | +| `POST 2fa/email/send` (:110-138) | `challenge_token` `required\|string\|size:64` | 200 `{"message":"Verification code sent.","email":}` | 422 `{error}` (no `errors`); 401 `{"error":"Invalid or expired challenge."}` / `{"error":"Invalid challenge."}` | +| `POST 2fa/webauthn/options` (:146-176) | `challenge_token` `required\|string\|size:64` | 200 options (stores challenge in `code`, `method = webauthn`) | 422; 401 as above | +| `POST 2fa/webauthn/verify` (:184-218) | `challenge_token` `required\|string\|size:64`, `assertion` `required\|array` | 200 `{token, user}` (+ `trusted_device_token`) | 401 `{"error":"Invalid or expired challenge. Request WebAuthn options first."}`; 401 `{"error":"WebAuthn verification failed.","two_factor_failed":true}` | + +`withTrustedDevice` (:578-588) adds `trusted_device_token` only when `$request->get('trust_device')` is truthy **and** `isTrustedDeviceEnabled()`. + +**Management endpoints** [VERIFIED: TwoFactorApiController.php:229-568]. Every one catches auth failure as 401 `{"error":"Unauthorized"}`. + +| Endpoint | Success body | Errors | +|---|---|---| +| `GET status` | `getStatus($user)` | — | +| `POST totp/setup` | `{secret, qr_code_svg, qr_code_url}` | 403 `{"error":"TOTP is not available."}` | +| `POST totp/confirm` (`secret` `required\|string`, `code` `required\|string\|min:6\|max:6`) | `{"message":"TOTP enabled successfully.","status":…}` | 422 validation; 422 `{"error":"Invalid verification code."}` | +| `DELETE totp` | `{"message":"TOTP disabled.","status":…}` | — | +| `POST email/enable` | `{"message":"Email 2FA enabled.","status":…}` | 403 `{"error":"Email 2FA is not available."}` | +| `DELETE email` | `{"message":"Email 2FA disabled.","status":…}` | — | +| `POST webauthn/register/options` | options | 403 `{"error":"WebAuthn is not available."}` | +| `POST webauthn/register` (`attestation` array, `challenge` string, `name` `nullable\|string\|max:255`) | `{"message":"Security key registered successfully.","credential":{"id","name" (default "Security Key"),"created_at" ISO-8601},"status":…}` | 422 `{"error":"Registration failed: " . $e->getMessage()}` | +| `DELETE webauthn/{id}` | `{"message":"Security key removed.","status":…}` | 404 `{"error":"Credential not found."}` | +| `GET recovery-codes?password=` | `{"recovery_codes_remaining":N,"message":"Recovery codes cannot be viewed after creation. Regenerate to get new codes."}` | 403 `{"error":"Password confirmation required."}` | +| `POST recovery-codes/regenerate` (`password`) | `{"recovery_codes":[…],"message":"Save these codes securely. They will not be shown again."}` | 403 as above | +| `DELETE trusted-devices/{id}` | `{"message":"Device revoked.","status":…}` | 404 `{"error":"Device not found."}` | +| `DELETE trusted-devices` | `{"message":"All trusted devices revoked.","status":…}` | — | + +**Status shape** [VERIFIED: TwoFactorService.php:584-593]: +```php +return [ + 'enabled' => $this->isEnabledForUser($user), + 'enforced' => $this->isEnforcedForUser($user), + 'methods' => $methods->pluck('method')->toArray(), + 'webauthn_credentials' => $webauthnCredentials->toArray(), + 'recovery_codes_remaining' => $recoveryCodesRemaining, + 'available_methods' => UserSettings::get('two_factor_available_methods', ['totp', 'email']), + 'trusted_device_enabled' => $this->isTrustedDeviceEnabled(), + 'trusted_devices' => $trustedDevices, +]; +``` +`webauthn_credentials` items: `'id'`, `'name' => $cred->name ?? 'Security Key'`, `'last_used_at'`, `'created_at'`, both via `toIso8601String()` (:559-564). `trusted_devices` items (only when enabled, `trusted_until > now()`, ordered `last_used_at desc`): `'id'`, `'device_name' => $d->device_name ?? 'Unknown Device'`, `'ip_address'`, `'last_used_at'`, `'trusted_until'`, `'created_at'` as raw Carbon values (:569-582). Raw Carbon serializes through `jsonSerialize`, as `2026-10-21T12:00:00.000000Z` [ASSUMED: Carbon 2 `toJSON` format]. That differs from `+00:00`, so a byte-parity test must pin it. + +**Recovery codes** [VERIFIED: TwoFactorRecoveryCode.php:49 `const CODE_CHARACTERS = '23456789ABCDEFGHJKMNPQRSTUVWXYZ';`, :59-87 generate, :96-112 verify]: +- `generateForUser` deletes all existing codes first, then builds `$part1 . '-' . $part2` of 5 random chars each and stores `Hash::make($plaintext)`. +- `verifyAndConsume` normalizes with `strtoupper(str_replace(' ', '', $code))` (the hyphen is kept), then loops unused codes with `Hash::check`. +- `ensureRecoveryCodes` (TwoFactorService.php:599-608) generates a set on the first enable but **returns nothing**. TOTP confirm, email enable and WebAuthn register never show the plaintext (Open Question 1). + +**Method enable/disable** [VERIFIED: TwoFactorService.php:228-294, 322-356]: +- `confirmTotp` takes the client-sent `secret`, verifies with window 1 and upserts `{is_enabled:true, secret}`. +- `disableMethod` disables the row, and for webauthn also all credentials. When no method remains, it **deletes recovery codes and trusted devices**. +- `removeWebAuthnCredential` deletes one credential. With none left it disables the method and fires `golem15.user.2fa.disabled`, then runs the same no-method cleanup. + +**TOTP** [VERIFIED: TotpMethod.php:34-54, 60-72]: issuer `config('app.name', 'Golem15')`, holder `$user->email`, `verifyKey($method->secret, $code, 1)`. TOTP verify requires an enabled `totp` row with a secret. There is **no replay protection**. + +**Email method** [VERIFIED: EmailMethod.php:52-68, 73-76, 81-98]: +- `sendCode` stores `Hash::make` of `str_pad((string) random_int(0, 999999), 6, '0', STR_PAD_LEFT)` on the challenge (`method = email`). It mails `golem15.user::mail.two_factor_code` with `'name' => $user->name, 'code' => $code, 'expiry_minutes' => $ttl` (`two_factor_email_code_ttl`, default 10) and returns the masked email. +- The mask is the first char, `*` × (len-2), the last char, `@domain`; a local part of ≤2 chars gives first char + `***`. +- **`sendCode` and `verify` never check that the user enabled the email method**, and the code expires only with the challenge (5 min), not after `two_factor_email_code_ttl`. + +**Mail template** [VERIFIED: views/mail/two_factor_code.htm]: `subject = "Your verification code"`; the body says `Hi {{ name }},` / `Your two-factor authentication code is:` / `**{{ code }}**` / `This code expires in {{ expiry_minutes }} minutes.` / `If you didn't request this code, please secure your account immediately by changing your password.` + +**Trusted devices** [VERIFIED: TrustedDevice.php:36-56, 87-124; TwoFactorService.php:483-530]: +- `createForUser` stores the `Str::random(64)` token, `device_name` from `parseDeviceName($userAgent)` (`"{$browser} on {$os}"`, e.g. `Chrome on Windows`), the IP, and `trusted_until = now + ttlDays`. +- `checkTrustedDeviceToken` requires `findValidToken` (`trusted_until > now`) and a matching `user_id`, then updates `last_used_at` and `ip_address`. + +**Settings** [VERIFIED: models/Settings.php:40-42, 56-63]: constants `TWO_FACTOR_DISABLED = 'disabled'`, `TWO_FACTOR_OPTIONAL = 'optional'`, `TWO_FACTOR_ENFORCED = 'enforced'`. Defaults: `config('golem15.user::twoFactorMode', self::TWO_FACTOR_DISABLED)`, `twoFactorAvailableMethods ['totp', 'email']`, `twoFactorEmailCodeTtl 10`, `twoFactorChallengeTtl 5`, `twoFactorEnforceGroups []`, `twoFactorPasswordlessLogin false`, `trustedDeviceEnabled false`, `trustedDeviceTtlDays 30`. **PHP's default mode is `disabled`**; D-12 locks `optional` for mfa, which makes sense because mounting mfa is the opt-in. Group enforcement compares `$user->groups->pluck('code')` (:107-108); Go has `classes.UserGroupCodes(ctx, db, userID)`. + +**Device flow** [VERIFIED: ApiController.php:1175-1347; DeviceAuthSession.php:86-90]: +- Status constants: `const STATUS_PENDING = 'pending'; const STATUS_CONFIRMED = 'confirmed'; const STATUS_USED = 'used'; const STATUS_EXPIRED = 'expired'; const STATUS_REVOKED = 'revoked';` +- `initiate` (:1277-1308), unauthenticated. It creates a row with `'token' => Str::random(64)`, a unique short code, `'user_id' => null`, status pending, `expires_at` now+5 min, `device_ip`, `device_user_agent` and `device_name` from `$request->input('device_name')`. Response: `{"success":true,"data":{"token","short_code","auth_url","expires_at" (toIso8601String)}}`. +- `status/{token}` (:1315-1347): + - unknown → 404 `{"error":"Session not found"}`; + - expired → `markAsExpired()` and **flat** `{"success":true,"status":"expired","message":"Authorization session has expired"}`; + - otherwise `{"success":true,"data":{"status":…}}`, plus `user_id` and `"message":"Device authorized - complete login"` when confirmed. +- `authorize` (:1233-1270): + - 401 `{"error":"Unauthorized"}`; + - with `short_code`: strip `-`, length ≠ 8 → 422 `{"error":"Please enter a valid 8-character code"}`, then `findValidShortCode` (uppercases, removes spaces, re-adds the hyphen); otherwise with `token`: `findValidToken`; + - nothing found → 400 `{"error":"Invalid or expired code"}`; + - success: user_id set only if null, `confirm($request->ip())`, then `{"success":true,"message":"Device authorized successfully"}`. +- `listDevices` (:1175-1202): statuses confirmed|used, ordered `last_activity_at desc`, items `{id, device_name, device_ip, authorized_at, last_activity, is_current}` → `{"success":true,"devices":[…]}`. +- `revokeDevice` (:1208-1227): 404 `{"error":"Device not found"}` or `{"success":true,"message":"Device revoked successfully"}`. +- Short code [VERIFIED: ApiController.php private `generateUniqueShortCode`, DeviceAuthSession.php:142-160]: 8 chars from `'23456789ABCDEFGHJKMNPQRSTUVWXYZ'`, formatted `XXXX-XXXX`. +- `auth_url` was `url("/device/scan/{$this->token}")` (DeviceAuthSession.php:388-391). +- **Client** [VERIFIED: vue-queststream-app/composables/useDeviceAuth.ts:55-125]: reads `response.data || response` for both initiate and status, stops polling on `status === 'expired'` or `'confirmed'`, and polls every `2000` ms. + +**Tables (PHP columns, verbatim)** [VERIFIED: the migrations under updates/, read this session] +- `golem15_user_two_factor_challenges` (v3.0.0/create_two_factor_challenges_table.php:13-34): `increments('id')`, `integer('user_id')->unsigned()`, `string('token', 64)->unique()`, `string('method', 20)->nullable()`, `string('code', 255)->nullable()`, `tinyInteger('attempts')->unsigned()->default(0)`, `tinyInteger('max_attempts')->unsigned()->default(5)`, `string('ip_address', 45)->nullable()`, `text('user_agent')->nullable()`, `timestamp('expires_at')`, `timestamp('completed_at')->nullable()`, `timestamps()`, `index('token')`, `index(['user_id', 'expires_at'])`, FK `user_id` → `users.id` `onDelete('cascade')`. +- `golem15_user_two_factor_methods` (create_two_factor_methods_table.php:13-31): `user_id`, `string('method', 20)`, `boolean('is_enabled')->default(true)`, `text('secret')->nullable()`, `json('metadata')->nullable()`, `timestamp('last_used_at')->nullable()`, `timestamps()`, `unique(['user_id', 'method'])`, `index(['user_id', 'is_enabled'])`, FK cascade. +- `golem15_user_two_factor_recovery_codes` (create_two_factor_recovery_codes_table.php:13-27): `user_id`, `string('code', 255)`, `timestamp('used_at')->nullable()`, `timestamp('created_at')->nullable()` (no `updated_at`), `index(['user_id', 'used_at'])`, FK cascade. +- `golem15_user_webauthn_credentials` (create_webauthn_credentials_table.php:13-33): `user_id`, `text('credential_id')`, `text('public_key')`, `string('attestation_type')->nullable()`, `json('transports')->nullable()`, `integer('sign_count')->unsigned()->default(0)`, `string('name')->nullable()`, `boolean('is_enabled')->default(true)`, `timestamp('last_used_at')->nullable()`, `timestamps()`, `index('user_id')`, FK cascade. +- `golem15_user_trusted_devices` (v3.1.0/create_trusted_devices_table.php:13-31): `user_id`, `string('token', 64)->unique()`, `string('device_name')->nullable()`, `string('ip_address', 45)->nullable()`, `timestamp('trusted_until')`, `timestamp('last_used_at')->nullable()`, `timestamps()`, `index('user_id')`, `index('token')`, FK cascade. +- `golem15_user_device_auth_sessions`: + - v2.1.0 (create_device_auth_sessions_table.php:18-46): `string('token', 64)->unique()->index()`, `integer('user_id')->unsigned()->index()`, `enum('status', ['pending', 'confirmed', 'used', 'expired', 'revoked'])->default('pending')->index()`, `timestamp('expires_at')->index()`, `string('device_ip')->nullable()`, `text('device_user_agent')->nullable()`, `string('device_name')->nullable()`, `string('auth_ip')->nullable()`, `timestamp('confirmed_at')->nullable()`, `timestamp('used_at')->nullable()`, `string('session_id')->nullable()`, `timestamp('last_activity_at')->nullable()`, `timestamps()`, FK cascade; + - v2.3.1:16 `string('short_code', 9)->nullable()->unique()->after('token')`; + - v2.3.2 makes `user_id` nullable; + - v2.7.1:19 `dateTime('expires_at')->nullable()->change()`. + +All three token columns are 64 characters wide, and a SHA-256 hex digest is exactly 64 characters, so D-06 hashing fits the PHP column widths unchanged. + +### Q5. Go code to extend (sm-user-plugin v0.1.5) + +**Where the plugin lives.** There is **no `../sm-user-plugin` sibling directory**. The plugin is checked out as a submodule in each app. `fonoteka.go/plugins/golem15/user`, `sm-demo-app/plugins/golem15/user` and `sm-bm-app/plugins/golem15/user` are all at `35cf166` (= tag `v0.1.5` = `origin/master`). `sm-grzybyfunkcjonalne-app` and `sm-summercmsio-app` are still at `24b16b1` (v0.1.1). sociallogin's `go.work` replaces the user plugin with `../fonoteka.go/plugins/golem15/user`, so work there and push from there (19.5 precedent). [VERIFIED: git log/describe this session] + +**Login** [VERIFIED: controllers/api_controller.go:47-127]: +1. `readFields(r)` (the body is consumed here); +2. `LookupByEmail`, `RejectIfThrottled`, `bouncer.CheckPassword`; +3. soft-deleted user → undelete + reactivate mail (:87-93); +4. `classes.CheckAndRecordLogin(ctx, gdb, user, ip, true)` (:94-101); +5. bcrypt rehash (:102-110), `TouchLastSeen` (:112-114), `mintFor` (:115), `apiArray` (:120), `writeJSON(w, http.StatusOK, map[string]any{"token": token, "user": payload})` (:125). + +D-02 places the gate between step 4 and the mint. +- `CheckAndRecordLogin(…, true)` runs `Updates(map[string]any{"attempts": 0, "is_suspended": false, …})` [VERIFIED: classes/throttle.go:107-140]. It resets the throttle **before** the second factor (Pitfall 1). + +**Other token mints** [VERIFIED: grep + read]: +- `ActivateByCode` (:425) mints only for a not-yet-activated account (`IsAlreadyActivated` → error page), which cannot have 2FA yet. +- `Register` (:703) is for a new user. +- fonoteka's onboarding controller calls `IssueToken` but fonoteka does not mount mfa. +- sociallogin `signIn` and `linkIdentity` (flow.go:155, :315). +- cabana's `account.go`/`auth.go` are the backend admin login. Backend-admin 2FA is not in scope. + +**Exported helpers** [VERIFIED: registration.go:99-191, api_controller.go:877-899]: +- `FinishLogin(ctx, app, db, user, ip) error`: restore soft-deleted + reactivate mail, `last_ip_address`, `TouchLastSeen`; "checks no password, throttle, activation or ban and fires no event". +- `IssueToken(app, user, r) (string, error)`, `APIArray(ctx, app, user) (map[string]any, error)`, `RefreshTTL(app) time.Duration`, `RemoteIP(r) string`. +- `mintFor` discards the JTI: `token, _, err := bouncer.Mint(secret, strconv.FormatUint(uint64(user.ID), 10), issuer, ttl)`. +- `bouncer.Mint` returns `(string, string, error)`, and "The returned jti is the token's own jti claim" [VERIFIED: bouncer/mint.go:29-33]. + +**Events** [VERIFIED: classes/events.go:9-70]: `CreatedEventName = "user.Created"`, `DeletingEventName = "user.Deleting"`, `DeactivatingEventName = "user.Deactivating"`. Types `GetApiArrayEvent`, `RegisterEvent`, `CreatedEvent`, `DeletingEvent`, `DeactivatingEvent`. **No login event, no password event.** The festival bus is typed: `app.Events.Listen[*T](owner, fn)` / `app.Events.Fire(ctx, &T{…})` keyed by `reflect.TypeFor[T]()` [VERIFIED: summercms.go/modules/festival/bus.go:43-84]. + +**Password-write sites (D-07 hook points)** [VERIFIED: grep of `HashPassword(` + read]: +- `ResetPassword`: `api_controller.go:327-343`, `Updates(map[string]any{"password": hashed, "tokens_valid_after": now, "email_verified_at": now})`. +- `ChangePassword`: `:580-597`, `"password": hashed, "must_change_password": false, "has_self_set_password": true, "tokens_valid_after": cutoff`. +- Admin update: `users_admin_controller.go:238-247`, `changed, err := c.storePassword(ctx, user)` → `user.TokensValidAfter = &now`, inside cabana's write (use `requestDB(ctx, c.db)` for the transaction). +- **Not a change:** the bcrypt rehash on login (`api_controller.go:106-109`) and `RegisterUser` (registration.go:63). + +**Throttle bucket shape** [VERIFIED: plugin.go:150-171]: the `"user-api"` bucket is `Max: 120, Decay: time.Minute`, keyed `"u:"+sub` or `surf.ClientIP(r, trusted)`. Bucket names are global and unique (`surf: bucket %q already registered by %s`, surf/limiter.go:85), so mfa owns `"2fa-verify"`. + +**Routes** [VERIFIED: routes.go:17-37]: everything is in `r.Group("/_user/api/v1", surf.Use("throttle:user-api"), …)`. `g.Where("id", "[0-9]+")` follows `g.Delete("/tokens/{id}", …)`, and `Where` "attaches … to the last route" (surf/router.go:279-298; Pitfall 17). + +**Mail** [VERIFIED: plugin.go:189-206; classes/mail.go:12-18; postcard/templates.go:115-170, 197-199]: +- Templates are `"golem15.user::mail.activate"` / `"…-en"` pairs. `MailTemplate(base, locale)` appends `-en` only for `en`; every other locale stays on the unsuffixed (Polish) template. +- postcard requires the template name to start with the owning plugin's ID (`template %s is not owned by %s`). Layout aliases are per plugin (`c.aliases[pluginID]`), and registering an existing full layout name fails (`duplicate layout`). +- mfa therefore registers `golem15.mfa::mail.two_factor_code` and `golem15.mfa::mail.two_factor_code-en`, and uses the default layout or ships its own. It cannot reuse the user plugin's `user` alias. +- Template syntax is Go `text/template` (`{{ .Name }}` in restore-en.htm). The user plugin passes both cases (`"name": name, "Name": name`, api_controller.go:924-929). + +**sociallogin** [VERIFIED: sm-sociallogin-plugin/classes/flow.go:128-160, 312-325, codes.go:35-40, controllers/complete.go:17-35, controllers/helpers.go:148-169]: +- The token is minted in `signIn` (`usercontrollers.FinishLogin` → `usercontrollers.IssueToken`) at **callback** time and stored in `CompletionPayload{Token, User, Action, ReturnTo}`. `Complete` only pulls the code and calls `writeSession`. +- The D-09 gate therefore belongs in `signIn` (shared by the callback login and `RegisterComplete`), not in `Complete`. The completion payload gets an additive `Challenge map[string]any` field, and `Complete` writes it as a plain 200 JSON when set. +- `linkIdentity` (`action=link`) issues a fresh token for an already-signed-in user, so it must not be gated. + +### Q6. go-webauthn: what the port needs + +**API used** [VERIFIED: go-webauthn@v0.18.2 webauthn/{types.go,registration.go,login.go,registration_opt.go,login_opt.go,types_session.go,credential.go}, protocol/{options.go,authenticator.go,assertion.go,credential.go}] +- `webauthn.New(&webauthn.Config{RPID, RPDisplayName, RPOrigins, AttestationPreference, Timeouts: webauthn.TimeoutsConfig{Login, Registration webauthn.TimeoutConfig{Enforce, Timeout}}})`. +- Second factor: `BeginRegistration(user, opts…)` / `CreateCredential(user, session, parsed)`, then `BeginLogin(user, opts…)` / `ValidateLogin(user, session, parsed)`. +- Passwordless: `BeginDiscoverableLogin(opts…)` / `ValidatePasskeyLogin(handler, session, parsed) (User, *Credential, error)`, with `type DiscoverableUserHandler func(rawID, userHandle []byte) (user User, err error)`. +- The request body is PHP's envelope (`{challenge_token, assertion}`), not a bare credential. Parse the inner object with `protocol.ParseCredentialCreationResponseBytes(data)` / `protocol.ParseCredentialRequestResponseBytes(data)` and use `CreateCredential` / `ValidateLogin` / `ValidatePasskeyLogin`, not the `*http.Request` variants. +- Options: `WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired)`, `WithExclusions(…)`, `WithConveyancePreference(protocol.PreferNoAttestation)`, `WithExtensions(WithExtensionCredProps())`, and `WithAllowedCredentials(…)` / `WithUserVerification(protocol.VerificationRequired)` (constants: `ResidentKeyRequirementRequired = "required"`, `VerificationRequired = "required"`, `PreferNoAttestation ConveyancePreference = none`, protocol/authenticator.go:153-211, protocol/options.go:183). +- JSON envelopes: `protocol.CredentialCreation` = `{"publicKey": PublicKeyCredentialCreationOptions, "mediation"?}`, `protocol.CredentialAssertion` = `{"publicKey": …}` (protocol/options.go:8-21). +- `webauthn.User` interface: `WebAuthnID() []byte` (≤64 bytes), `WebAuthnName() string`, `WebAuthnDisplayName() string`, `WebAuthnCredentials() []Credential` (types.go:368-400). +- `SessionData` fields: `Challenge`, `RelyingPartyID`, `Origin`, `UserID`, `AllowedCredentialIDs`, `Expires`, `UserVerification`, `Extensions`, `CredParams`, `Mediation`, `AuthorizeUVInitialization` (types_session.go:66-80). The package doc says it "MUST be definitively anchored to a user's active session", with bytes the RP serialized and kept integrity-protected (doc.go:122-138). + +**The credential record needs more than PHP's columns** [VERIFIED: credential.go:105-150, 440-470; login.go:402-408; doc.go:98-120] +- `Credential` holds `ID`, `PublicKey` (COSE), `AttestationType`, `AttestationFormat`, `Transport`, `Flags{UserPresent, UserVerified (latched uvInitialized), BackupEligible, BackupState}`, `Authenticator{AAGUID, SignCount, CloneWarning, Attachment}`, `Attestation`, `Extensions{RK *bool, …}`. +- The doc comment says the record "must be written back on every successful FinishLogin / ValidateLogin". +- Login fails when `credential.Flags.BackupEligible != parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible()`, so the flags must be stored. +- Recommended columns, additive to the PHP table: + - `credential_data TEXT NULL`: the JSON of `webauthn.Credential`, the library's supported opaque shape, which round-trips every field; + - `passwordless BOOLEAN NOT NULL DEFAULT FALSE` (D-14); + - a unique index on `credential_id`, needed for the passwordless lookup by raw ID. +- Keep `credential_id` (base64url of the raw ID), `public_key` (base64url COSE), `attestation_type`, `transports`, `sign_count`, `name`, `is_enabled` and `last_used_at` filled for display, queries and the import. +- The explicit-column alternative the library doc prefers is the planner's call (Assumption A3). + +**PHP credentials are not portable as-is** [VERIFIED: wavepath vendor/lbuchs/webauthn/src/WebAuthn.php:408 `$data->credentialPublicKey = $attestationObject->getAuthenticatorData()->getPublicKeyPem();`; WebAuthnMethod.php:170-179 stores `base64_encode($data->credentialPublicKey)`]. PHP stores a PEM key, while go-webauthn verifies with the COSE key. The Phase 30 import will need a PEM→COSE conversion or must drop WebAuthn rows. PHP's user handle is `\hex2bin(str_pad(dechex($user->id), 16, '0', STR_PAD_LEFT))` (WebAuthnMethod.php:127), the 8-byte big-endian user ID. Using the same handle in Go keeps imported passkeys mappable (Assumption A4). + +**Ceremony state storage** (Pitfall 6): +- Login 2FA: store only the base64url challenge in the challenge row's `code` column (≤255, as PHP does, `method = webauthn`). At verify, rebuild `SessionData{Challenge, RelyingPartyID, UserID, AllowedCredentialIDs (from DB), UserVerification: preferred, Expires}`. The row is server-side, tied to the challenge token and expiring, so it qualifies as "anchored". +- Registration and passwordless have no challenge row. Store `SessionData` JSON in `cooler` under `mfa:webauthn:::` with a 120 s TTL and consume it with `cooler.Cache.Pull` (atomic, one use) [VERIFIED: summercms.go/modules/cooler/cooler.go:83-87 `Pull … of two concurrent callers at most one gets found=true`]. The client echoes `challenge` (PHP's field), which is used only as the lookup key. As with sociallogin, the memory driver works on one instance only. + +**Envelopes (D-08: standard inner shapes, PHP outer keys)**: +- `register/options` and `webauthn/options` answer `{"options": , "challenge": ""}`. PHP's WebAuthnMethod returned `['options' => …, 'challenge' => …]` (:139-142, :217-220). +- `register` takes `{attestation: , challenge, name?}`. +- `webauthn/verify` takes `{challenge_token, assertion: , trust_device?}`. +- The passwordless pair takes `{}` → `{options, challenge}` and `{challenge, assertion}`. +- `@simplewebauthn/browser` needs `options.publicKey` as its `optionsJSON` [ASSUMED]. + +**RP ID and origins** (Pitfall 7): +- The RP ID must equal, or be a registrable suffix of, the page origin running `navigator.credentials`, which is the SPA, not the API. +- IP-address hosts are not valid RP IDs in browsers [CITED: lists.w3.org/Archives/Public/public-webauthn/2017May/0250.html; ory.sh/docs/troubleshooting/passkeys-webauthn-security-error]. +- Default `RPID` = `app.url` host (D-04). Add config overrides `golem15.mfa.webauthn.rp_id` and `golem15.mfa.webauthn.origins` (default: the `app.url` origin plus a configured `frontend_url` origin). +- sm-demo-app's `config/app.yaml` has `url: http://127.0.0.1:8097` [VERIFIED], so WebAuthn there needs `localhost` or a real host. + +### Q7. Refresh lineage for D-12b (gap) + +[VERIFIED: bouncer/refresh.go:92-155] +- `refreshAudience` verifies the old token, then `next, _, err := MintAudience(secret, sub, issuerURL, ttl, audience)`, a fresh token with a **new random jti** and `iat = now`. It then `bl.Add(…, jti, expiresAt, time.Now().Add(grace))` for the **old** jti. +- No claim links the new token to the old one. A device that refreshed once carries a JTI the session row never saw, so blacklisting the stored JTI does nothing. + +Fix, all in sm-user-plugin and additive: +- After `bouncer.Refresh` succeeds in `controllers.Refresh` (api_controller.go:179-213), read the old JTI from `raw` (`jwt.NewParser().ParseUnverified` is safe here because `Refresh` just verified the signature). +- Read the new JTI from `next` via `bouncer.VerifyClaims`. +- Run `UPDATE golem15_user_device_auth_sessions SET jwt_jti = ?, last_activity_at = NOW() WHERE jwt_jti = ? AND status = 'used'`. + +The only refresh path in any app is this handler. `bouncer.CookieRefreshHandler` exists but no app mounts it [VERIFIED: grep]. With `golem15.user.jwt.blacklist_grace > 0` (default `0`), two concurrent refreshes can fork a lineage, and only one fork is tracked (Pitfall 15). + +Revoke then does `bl.Add(ctx, row.JwtJti, now + refreshTTL + jwtTTL + 1 min, now)`, which covers the refresh window as `refreshAudience` does. A password change already kills device tokens through `tokens_valid_after`. + +### Q8. Settings screen field types (D-12) + +cabana settings forms accept the controller form types minus `widget`, `partial`, `fileupload` and `permissioneditor` [VERIFIED: cabana/settings.go:98-103; form_schema.go:23-30 lists `"text", "textarea", "number", "checkbox", "switch", "dropdown", …, "password", "taglist"`]. There is **no `checkboxlist` and no `radio`**, both of which PHP used (fields.yaml:90-160). Mapping: + +| PHP key | PHP type | Go field | +|---|---|---| +| `two_factor_mode` | radio | `dropdown` with static options `disabled`, `optional`, `enforced` | +| `two_factor_available_methods` | checkboxlist | `taglist` `mode: array` on a JSON column, plus `BeforeValidate` allow-list `totp,email,webauthn` (taglist mode array needs an `sql.Scanner`+`driver.Valuer` column, cabana/field_taglist.go:124-139) | +| `two_factor_enforce_groups` | checkboxlist | `taglist` `mode: array` + `BeforeValidate` against existing group codes | +| `two_factor_email_code_ttl`, `two_factor_challenge_ttl`, `trusted_device_ttl_days` | number | `number` | +| `two_factor_passwordless_login`, `trusted_device_enabled`, new social toggle | switch | `switch` | + +Follow sm-sociallogin-plugin's settings (singleton row id 1 seeded by the migration, `LoadSettings(ctx, db)` per request, nullable columns where NULL = config default, `Fillable()`, `Rules()`, `BeforeValidate`) [VERIFIED: sm-sociallogin-plugin/models/settings.go, updates/202610200001_create_golem15_sociallogin_settings.go, admin_settings.go]. + +### Q9. Admin "Reset 2FA" mechanism (D-15) + +[VERIFIED: cabana/extension.go:89-107, actions.go:236-252, registry.go:343, pact/capabilities.go:343-376; user plugin controllers/users/config_form.yaml] +- A record action is offered and routable only if the owner's `config_form.yaml` names it (`recordActionOf` checks `cc.Form.recordActions`). The Users screen has `recordActions: [activate, unban, unsuspend]`. +- Every YAML name must be registered by the controller, or boot fails with `recordActions: unsupported action %s`. +- Record-action permissions are validated at boot: `cabana: %s references unknown permission %s`. +- cabana has no capability through which plugin B adds actions to plugin A's controller. + +**Recommended: a user-plugin slot (additive, no framework release).** +1. The user plugin registers a new permission `golem15.users.reset_two_factor` (no default role, like `manage_privileged_groups`). It must live in the user plugin because a code only mfa registers would fail boot in apps without mfa. +2. It adds `reset_two_factor` to `recordActions` in `controllers/users/config_form.yaml` and registers the action with `Danger: true`. `Applies` looks up a published `classes.TwoFactorAdmin` service and returns false when none exists (no mfa), so the button never shows there. `Run` delegates to the service inside cabana's transaction (`cabana.TxFromContext`). +3. mfa publishes the service with `app.Publish[userclasses.TwoFactorAdmin](…)`. It implements `HasTwoFactor(ctx, tx, userID) (bool, error)` and `Reset(ctx, tx, user) error`: disable all methods, delete credentials and recovery codes, delete trusted devices, fire the disabled event. + +The rejected alternative is a framework capability (`pact` + cabana) to contribute actions to another plugin's controller. It is generic, but needs a framework tag, docs and a bump of every consumer. + +### Q10. Adoption + +- **sm-demo-app**: submodule `plugins/golem15/mfa`, `go.work use`, `summer.yaml` entry `- id: golem15.mfa` / `module: git.golem15.com/golem15/sm-mfa-plugin` (after `golem15.user`), `summer build`. `boot_test.go` boots every plugin **without a database**, so mfa's Boot must not need a DB or cache (19.5 Pitfall 8). Configure the WebAuthn RP for local use (Q6). +- **sm-bm-app**: same mount. The SPA is embedded and same-origin (`config/golem15.sociallogin.yaml:10` says so), so the RP ID default works on a real host. bm's frontend has no 2FA UI; with default `optional` users cannot enable 2FA without one, so login behaviour is unchanged (Open Question 3). +- **fonoteka.go**: no mfa. Bump the user-plugin pointer and drop `"/devices"` from `banned` in `TestNoDeferredRoutesResolve` [VERIFIED: fonoteka/phase07_coverage_test.go:79-112, list `"pin-login"`, `"verify-pin"`, `"verify-family-member-pin"`, `"/devices"`, `"2fa"`, `"/api/user/batch"`, `"/_user/activate/"`]. Keep `"2fa"` because fonoteka still must not resolve `/2fa`. No other test in fonoteka, demo, bm or the framework references `/devices` or `2fa` [VERIFIED: grep]. +- grzybyfunkcjonalne and summercmsio stay at v0.1.1; nothing forces them. + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| `github.com/pquerna/otp` (`totp`, `hotp`) | v1.5.0 | TOTP generate/validate, otpauth URL | Locked by D-04; Google-Authenticator-compatible defaults (SHA1, 6 digits, 30 s) [VERIFIED] | +| `github.com/go-webauthn/webauthn` (`webauthn`, `protocol`) | v0.18.2 | WebAuthn registration, assertion, passkeys | Locked by D-04; standard JSON shapes (D-08) [VERIFIED] | +| `github.com/boombuler/barcode` (`qr`) | v1.1.0 | QR matrix for the TOTP URL | Already required by pquerna/otp; zero new modules (§Q2) [VERIFIED] | +| `bouncer.HashPassword` / `CheckPassword` (bcrypt) | framework | Recovery and email code hashes | Matches the user plugin (D-06, §Q3) [VERIFIED] | +| `lagoon.Encrypted` | framework | TOTP secret at rest | D-06 [VERIFIED: lagoon/encrypted.go:32-103] | +| `cooler.Cache.Pull` | framework ≥ v0.3.1 | One-time WebAuthn ceremony state | Atomic consume (§Q6) [VERIFIED] | +| stdlib `crypto/rand`, `crypto/sha256`, `crypto/subtle`, `encoding/base64` | Go 1.27 | Tokens, hashes, constant-time compares | Standard library first | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| `postcard.Mailer` | framework | Email code mail | `app.Lookup[postcard.Mailer]()` as the user plugin does | +| `lagoon.Validate` | framework | Laravel-style rules (`size`, `in`, `min`, `max` are supported) | Request validation with PHP error shape | +| `wire.Time` | framework | `+00:00` timestamps | `expires_at`, credential dates | +| testcontainers Postgres | v0.44.0 | DB tests | Copy sociallogin `testmain_test.go` / `harness_test.go` | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| boombuler + own SVG writer | `piglig/go-qr` v1.1.0 (`ToSVGBytes`) | Native SVG, but a new 67-star single-maintainer module | +| bcrypt | argon2id (`x/crypto/argon2`) | Not what the user plugin uses; D-06 asks to match | +| `credential_data` JSON column | explicit columns per `Credential` field | The library doc prefers explicit columns; JSON survives library field additions with less migration work | +| user-plugin action slot (D-15) | framework cross-plugin action capability | Generic, but needs a framework release | + +**Installation** (inside `sm-mfa-plugin`, only after the decision note exists and the human package check passes): +```bash +go get github.com/pquerna/otp@v1.5.0 github.com/go-webauthn/webauthn@v0.18.2 github.com/boombuler/barcode@v1.1.0 +go mod tidy +``` + +## Package Legitimacy Audit + +The `package-legitimacy` seam supports only npm, PyPI and crates (`Usage: msd-tools package-legitimacy check --ecosystem `), so it cannot check Go modules [VERIFIED: ran it this session]. The 19.4 and 19.5 precedent applies: Go modules are [ASSUMED] for legitimacy, and the planner gates the first `go get` behind a `checkpoint:human-verify` task. Signals gathered by hand: + +| Package | Registry | Age | Popularity | Source Repo | Verdict | Disposition | +|---------|----------|-----|-----------|-------------|---------|-------------| +| `github.com/pquerna/otp` | proxy.golang.org | v1.5.0 2024-12-31, repo ~10 yrs [ASSUMED age] | 2,963 stars | github.com/pquerna/otp (Apache-2.0) | n/a (seam unsupported) | Approved pending human check | +| `github.com/go-webauthn/webauthn` | proxy.golang.org | v0.18.2 2026-09-19 | 1,351 stars, pushed 2026-10-20 | github.com/go-webauthn/webauthn (BSD-3) | n/a | Approved pending human check | +| `github.com/boombuler/barcode` | proxy.golang.org | v1.1.0 2025-07-22 | 1,563 stars | github.com/boombuler/barcode (MIT) | n/a | Approved pending human check (already transitive via otp) | +| `github.com/piglig/go-qr` | proxy.golang.org | v1.1.0 2026-05-29 | 67 stars | github.com/piglig/go-qr (MIT) | n/a | Alternative only, not recommended | + +**Packages removed due to [SLOP] verdict:** none. +**Packages flagged as suspicious [SUS]:** none. All three recommended modules are [ASSUMED] for legitimacy (the seam cannot run on Go), so the planner adds one `checkpoint:human-verify` before the first `go get`. + +## Architecture Patterns + +### System Architecture Diagram + +``` + SPA (password login) SPA / new device + │ POST /_user/api/v1/login │ POST devices/initiate ──► row(pending, token#, short_code, 5 min) + ▼ │ GET devices/status/{token} (poll 2 s) + user plugin Login │ │ pending / expired / used / revoked + lookup → ban check → bcrypt → CheckAndRecordLogin │ │ confirmed ─► atomic confirmed→used ─► mint JWT (jti→row) ─► {data:{status:confirmed,user_id,token,user}} + │ │ + ▼ signed-in phone ─► POST devices/authorize {short_code|token} (JWT) + LoginGates.Check(attempt{user, source, fields}) └► atomic pending→confirmed (user_id, auth_ip) + │ no gate / nil │ challenge user: GET devices / DELETE devices/{id} ─► blacklist row.jwt_jti + ▼ ▼ + mint JWT → {token,user} 200 {two_factor_required, challenge_token, available_methods, expires_at} + │ + mfa challenge routes (throttle:2fa-verify, 10/min/IP) + ┌───────────────┬───────────────┬──────────────────┬──────────────────────┐ + │ verify totp │ email/send → │ recovery │ webauthn/options → │ + │ (replay-safe) │ mail code# │ (bcrypt, consume)│ code=challenge; │ + │ │ verify email │ │ webauthn/verify │ + └───────┬───────┴───────┬───────┴────────┬─────────┴──────────┬───────────┘ + └─ atomic attempts++ (guarded) ──┴─ verify ─ atomic completed_at ─► FinishLogin + IssueToken + LoginEvent + (+ trusted_device_token if asked & enabled) + sociallogin callback → signIn → LoginGates.Check(source=social) → completion code carries {token,…} or {challenge} + refresh → bouncer.Refresh → device row jwt_jti old→new + password change/reset/admin set → PasswordChangedEvent → mfa deletes trusted devices + admin Users preview → reset_two_factor (slot) → TwoFactorAdmin.Reset (mfa) +``` + +### Recommended Project Structure +``` +sm-mfa-plugin/ # git.golem15.com/golem15/sm-mfa-plugin, plugin golem15.mfa +├── plugin.go # ID, Requires golem15.user, Register/Boot (publish gate + TwoFactorAdmin), Buckets "2fa-verify" +├── routes.go # /_user/api/v1/2fa challenge group (throttle:2fa-verify) + management group (jwt.auth) +├── admin_settings.go, admin_permissions.go +├── config.go, config/config.yaml # defaults: mode optional, methods [totp,email], ttl, rp overrides, issuer +├── classes/ # gate, challenge service, totp, email, recovery, trusted, webauthn wrapper, qr svg, events +├── controllers/ # challenge.go, management.go, passwordless.go +├── models/ # TwoFactorMethod, TwoFactorChallenge, TwoFactorRecoveryCode, TrustedDevice, WebAuthnCredential, Settings +├── models/settings/fields.yaml +├── updates/ # 5 table migrations + settings migration +├── views/mail/two_factor_code.htm, two_factor_code-en.htm +├── lang/{en,pl}/lang.yaml +├── go.mod, go.work # replace framework ../summercms.go, user plugin ../fonoteka.go/plugins/golem15/user +└── *_test.go # testmain + harness copied from sm-sociallogin-plugin + +sm-user-plugin (additions) +├── classes/login_gate.go # LoginGate, LoginAttempt, LoginGates registry +├── classes/events.go # + LoginEvent, PasswordChangedEvent, TwoFactorAdmin interface +├── controllers/devices.go # initiate, status, authorize, list, revoke +├── models/device_auth_session.go +├── updates/2026102X0001_create_device_auth_sessions.go +└── controllers/users/config_form.yaml # + reset_two_factor +``` + +### Pattern 1: Login-gate registry (D-02) +**What:** The user plugin publishes a `*classes.LoginGates` registry in `Register`, and mfa adds its gate in `Boot`, since party runs every Register before every Boot in `Requires` order. `Login` calls `gates.Check(ctx, attempt)` after `CheckAndRecordLogin` succeeds. A non-nil challenge writes 200 JSON and stops. An error writes the opaque 500 and **never** falls through to minting. No registry (unit tests that skip Register) behaves as no gate. +**When:** password `Login`, sociallogin `signIn`. Not for refresh, register, activate-by-code or link. +**Example:** Code Example 1. + +### Pattern 2: Every state change is one guarded UPDATE +**What:** Increment attempts with `UPDATE … SET attempts = attempts + 1 WHERE id = ? AND completed_at IS NULL AND expires_at > NOW() AND attempts < max_attempts`. Treat `RowsAffected == 0` as exhausted or invalid, and verify only after a successful increment. Complete with `… SET completed_at = NOW() WHERE id = ? AND completed_at IS NULL` (one token per challenge). Consume a recovery code with `… SET used_at = NOW() WHERE id = ? AND used_at IS NULL`. Confirm a device with `… WHERE status = 'pending' AND user_id IS NULL AND expires_at > NOW()`. Hand over the device token with `… SET status = 'used' WHERE status = 'confirmed'`. +**Why:** PHP does read-modify-write (`$this->attempts++; save()`), so N parallel verifies each see the old counter, and two verifies can both complete and mint. + +### Pattern 3: Ceremony state server-side, client echo only as key +**What:** Covered in §Q6. A login-2FA challenge goes in `code`. Registration and passwordless state go in `cooler` + `Pull`. Never accept a client-supplied challenge as the expected value (PHP `webauthnRegister` did). + +### Pattern 4: Device session state machine +`pending → confirmed` (authorize), `confirmed → used` (first poll, JWT minted, jti stored), `used → revoked` (DELETE, jti blacklisted), and `pending|confirmed → expired` (lookup after `expires_at`). Never move `used` or `revoked` to `expired`; PHP's `deviceAuthStatus` checks `isExpired()` before the status and would. The device list shows `confirmed|used`, `is_current` is `row.jwt_jti == bearer jti`, and `last_activity_at` advances on refresh. + +### Pattern 5: Security events as typed festival events +`PasswordChangedEvent{User, Tx, Source}` fires from reset, change and admin set. Fire it inside the same transaction as the password write and let a listener error roll the write back, as `DeactivatingEvent` does. mfa listens and deletes trusted devices. mfa's own events are typed structs with `…EventName` constants holding the PHP names (`golem15.user.2fa.challenged`, …), mirroring `CreatedEventName = "user.Created"`. + +### Anti-Patterns to Avoid +- **Gate error ⇒ login anyway.** Fail closed (500). +- **Reading the login body twice.** `Login` already consumed it; pass `fields` in the attempt (Pitfall 2). +- **Filtering `available_methods` by the global list.** PHP doesn't, and it could lock users out. +- **One `Where` for several `{id}` routes.** surf attaches it to the last route only. +- **Logging `RetrieveError`-style library detail or any token/code.** Log types, never values. + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| TOTP (HOTP truncation, base32, skew) | own HMAC/truncation | `pquerna/otp/totp`, `hotp.ValidateCustom` per counter | RFC 6238 edge cases, constant-time compare inside | +| WebAuthn (CBOR, COSE, attestation, flags, counters) | own parser | `go-webauthn` | Huge spec surface; BE/BS/UV rules, origin/top-origin checks | +| QR encoding (ECC, masks) | own encoder | `boombuler/barcode/qr` | Only the SVG serialization is ours | +| Slow hash | own KDF | `bouncer.HashPassword` | Same as passwords | +| One-time ceremony state | ad-hoc map | `cooler.Cache.Pull` | Atomic, TTL, Redis-capable | +| Token hashing | — | `sha256` hex (`classes.HashAPIToken` in the user plugin) | Same scheme as API tokens (D-06) | +| Rate limiting | own counters for IP limits | surf bucket `2fa-verify` | Framework limiter | + +**Key insight:** the security of this phase lives in state transitions (attempt counters, one-time use, revocation lineage), not in crypto primitives. Libraries cover the crypto; the plan must make every transition a single guarded SQL statement. + +## Common Pitfalls + +### Pitfall 1: The password step resets the throttle before the second factor +**What goes wrong:** `CheckAndRecordLogin(…, true)` zeroes `attempts` and clears suspension, and every `/login` makes a fresh challenge with 5 attempts. An attacker holding the password brute-forces TOTP at the `2fa-verify` rate per IP, without limit across IPs. Three of 10⁶ codes are valid per window. +**How to avoid:** add a per-user second-factor failure cap in mfa (for example ≥10 failed verifications in 15 min → refuse new challenges and verifies with the generic 401 until the window passes; counted from challenge rows in the DB, so it is multi-instance safe). Keep failed challenge rows for the window instead of PHP's eager `cleanupExpired`. Values are [ASSUMED] (A6). +**Warning signs:** a test that loops login→verify(wrong) never gets refused. + +### Pitfall 2: Trusted-device token must come from the already-read body +`Login` calls `readFields(r)` first. The gate needs `trusted_device_token` from those `fields`, so `LoginAttempt` carries them. The social path (top-level redirect) has no body, so trusted devices never skip the social challenge (A7). + +### Pitfall 3: A bcrypt rehash is not a password change +`Login` rewrites `password` when `NeedsRehash` (api_controller.go:106-109). It must not fire `PasswordChangedEvent`, or every cost bump would wipe trusted devices. + +### Pitfall 4: PHP's attempts are off by one +PHP increments, then refuses when `attempts >= max_attempts`, so the 5th call never verifies: four real tries. The guarded UPDATE in Pattern 2 gives five. The response shape is identical either way; recommend five and record the deviation (A5). + +### Pitfall 5: TOTP code replay +pquerna's `Validate` accepts the same code again within the ±1 window. Validate per counter (`counter-1, counter, counter+1` with `hotp.ValidateCustom`), store the accepted counter in `golem15_user_two_factor_methods.metadata` (`{"last_counter": N}`), and refuse `counter <= last_counter`. This needs no schema change. + +### Pitfall 6: Trusting the client's WebAuthn challenge +PHP `webauthnRegister` builds the expected challenge from `$request->get('challenge')`. Go must load `SessionData` server-side (Pattern 3). go-webauthn's `SessionData.Extensions` encoding changes across releases, so cached sessions are drained on upgrade. With a 120 s TTL that happens on its own. + +### Pitfall 7: RP ID vs SPA origin; IP hosts +See §Q6. Symptom: `SecurityError` in the browser on `create()`/`get()`. Configure `rp_id`/`origins` per app. + +### Pitfall 8: Stored credential flags +Without persisting BE/BS/UV, `ValidateLogin` fails after the first login, or BE-change detection is lost. Write the updated `Credential` back after every successful assertion (sign count, flags, clone warning). + +### Pitfall 9: bcrypt CPU per recovery attempt +Up to 10 compares (~0.63 s) per wrong recovery code. Keep the bucket, normalize input (uppercase, strip spaces, length cap) before hashing, and stop at the first match. + +### Pitfall 10: Email method downgrade and mail bombing +PHP lets any challenge request an email code even when the user enabled only TOTP, and `email/send` is unlimited within the bucket. Recommend: `email/send` and `verify method=email` only when the user's email method is enabled (otherwise the same 401 `{"error":"Invalid or expired challenge."}`), and at most 3 sends per challenge. This is hardening beyond PHP (A8). + +### Pitfall 11: Email code lifetime vs mail text +PHP mails `expiry_minutes = two_factor_email_code_ttl` (10), but the code dies with the challenge (5). Either cap the mail value at the remaining challenge TTL, or store a code expiry. Recommend `min(email_ttl, remaining challenge time)` in the mail (A9). + +### Pitfall 12: Initial recovery codes are never shown in PHP +See Open Question 1. If the Go port adds `recovery_codes` to the TOTP-confirm/email-enable/register response on first creation, that is an additive key. + +### Pitfall 13: `used` sessions downgraded to `expired` +PHP `deviceAuthStatus` marks any session past `expires_at` as expired, including `used` ones, which removes active devices from the list and breaks revoke. Expire only `pending|confirmed`. + +### Pitfall 14: `device_name` is attacker-controlled and unbounded +`initiate` is unauthenticated, and the column is VARCHAR(255). Validate `nullable|string|max:255` (or truncate), and have the frontend show `device_ip` and the parsed UA next to the name (D-12c phishing mitigation). + +### Pitfall 15: Refresh forks with `blacklist_grace` +Covered in §Q7. With grace > 0, two refreshes of the same token both succeed, and only one new JTI is tracked. Document it; the default grace is `0`. + +### Pitfall 16: Pending-session table growth +Unauthenticated `initiate` inserts rows at up to 120/min/IP. Delete stale `pending|expired` rows older than an hour on each initiate (bounded `DELETE`), or sweep them. + +### Pitfall 17: surf `Where` binds to the last route +`g.Delete("/devices/{id}", …); g.Where("id", "[0-9]+")` must be adjacent. Likewise for `webauthn/{id}` and `trusted-devices/{id}` in mfa. + +### Pitfall 18: postcard ownership and layouts +Covered in §Q5. mfa's templates must be `golem15.mfa::mail.*`, and the `user` layout alias is not visible to mfa. + +### Pitfall 19: Settings type gaps +Covered in §Q8. Don't write `checkboxlist`/`radio` in fields.yaml; cabana boot fails on unknown types. + +### Pitfall 20: Boot without DB in sm-demo-app +`boot_test.go` boots all plugins without a database. mfa Boot only reads config, publishes the gate, the TwoFactorAdmin service and the event listeners, and looks up DB and cache per request. + +### Pitfall 21: Soft-deleted account restored by the password step +`Login` undeletes a soft-deleted account and mails "reactivate" (:87-93) before the gate, so a password-only attacker can restore a deactivated account without passing 2FA. D-02 locks the gate placement after this step. Note it in the security review, or move the undelete after the gate if the user agrees (A10). + +## Code Examples + +### 1. Login-gate registry and wiring (identifiers proposed, Claude's discretion) +```go +// sm-user-plugin/classes/login_gate.go +type LoginAttempt struct { + User *models.User + Source string // "password" or "social" + Fields map[string]any // parsed body (password login), carries trusted_device_token + Request *http.Request +} + +type LoginGate interface { + // Challenge returns nil to allow the sign-in, or the JSON body that + // replaces the token response (HTTP 200). + Challenge(ctx context.Context, a LoginAttempt) (map[string]any, error) +} + +type LoginGates struct { + mu sync.RWMutex + gates []LoginGate +} + +func (g *LoginGates) Add(gate LoginGate) { g.mu.Lock(); g.gates = append(g.gates, gate); g.mu.Unlock() } + +func (g *LoginGates) Check(ctx context.Context, a LoginAttempt) (map[string]any, error) { + if g == nil { + return nil, nil + } + g.mu.RLock() + gates := slices.Clone(g.gates) + g.mu.RUnlock() + for _, gate := range gates { + body, err := gate.Challenge(ctx, a) + if err != nil || body != nil { + return body, err // fail closed on error + } + } + return nil, nil +} + +// controllers/api_controller.go Login, after CheckAndRecordLogin(…, true) succeeds, before mintFor: +if gates, ok := app.Lookup[*classes.LoginGates](); ok { + body, err := gates.Check(ctx, classes.LoginAttempt{User: user, Source: "password", Fields: fields, Request: r}) + if err != nil { + writeOpaque500(w) + return + } + if body != nil { + writeJSON(w, http.StatusOK, body) + return + } +} +``` + +### 2. Guarded attempt increment (Pattern 2) +```go +res := tx.Exec(`UPDATE golem15_user_two_factor_challenges + SET attempts = attempts + 1, updated_at = NOW() + WHERE token = ? AND completed_at IS NULL AND expires_at > NOW() AND attempts < max_attempts`, + sha256Hex(challengeToken)) +if res.Error != nil { return errServer } +if res.RowsAffected == 0 { return errInvalidChallenge } // 401 {"error":"Invalid verification code.","two_factor_failed":true} +// … verify the factor … +res = tx.Exec(`UPDATE golem15_user_two_factor_challenges SET completed_at = NOW() + WHERE id = ? AND completed_at IS NULL`, ch.ID) +if res.RowsAffected != 1 { return errInvalidChallenge } // lost a race: never mint twice +``` + +### 3. TOTP verify with replay protection +```go +// Source: pquerna/otp@v1.5.0 totp/totp.go:101-133 (ValidateCustom loops counters with hotp.ValidateCustom) +opts := hotp.ValidateOpts{Digits: otp.DigitsSix, Algorithm: otp.AlgorithmSHA1} +now := uint64(time.Now().Unix() / 30) +for _, c := range []uint64{now - 1, now, now + 1} { + ok, err := hotp.ValidateCustom(code, c, secret, opts) + if err != nil { return false, err } + if ok { + if c <= meta.LastCounter { return false, nil } // replay + meta.LastCounter = c // persist in metadata JSON, same tx as completion + return true, nil + } +} +return false, nil +``` + +### 4. QR as SVG (verified round-trip this session) +```go +// Source: scratch probe; boombuler/barcode v1.1.0 qr.Encode +func qrSVG(text string, size int) (string, error) { + code, err := qr.Encode(text, qr.M, qr.Auto) + if err != nil { return "", err } + b, n, q := code.Bounds(), code.Bounds().Dx(), 4 + var p strings.Builder + for y := 0; y < n; y++ { + for x := 0; x < n; x++ { + if r, _, _, _ := code.At(b.Min.X+x, b.Min.Y+y).RGBA(); r == 0 { + fmt.Fprintf(&p, "M%d %dh1v1h-1z", x+q, y+q) + } + } + } + w := n + 2*q + return fmt.Sprintf(``, + size, size, w, w, p.String()), nil +} +``` + +### 5. WebAuthn instance per request (settings and config change without restart) +```go +w, err := webauthn.New(&webauthn.Config{ + RPID: rpID, // golem15.mfa.webauthn.rp_id, default app.url host + RPDisplayName: appName, // app.name + RPOrigins: origins, // app.url origin + frontend origin(s) + AttestationPreference: protocol.PreferNoAttestation, + Timeouts: webauthn.TimeoutsConfig{ + Login: webauthn.TimeoutConfig{Enforce: true, Timeout: 60 * time.Second}, + Registration: webauthn.TimeoutConfig{Enforce: true, Timeout: 60 * time.Second}, + }, +}) +``` + +### 6. Passwordless verify (D-14) +```go +// Source: go-webauthn@v0.18.2 webauthn/login.go:276 ValidatePasskeyLogin +raw, found, err := cache.Pull(ctx, "mfa:webauthn:passwordless:-:"+challenge) +if err != nil || !found { /* 401 generic */ } +var sess webauthn.SessionData // created by BeginDiscoverableLogin(webauthn.WithUserVerification(protocol.VerificationRequired)) +_ = json.Unmarshal(raw, &sess) +parsed, err := protocol.ParseCredentialRequestResponseBytes(assertionJSON) +user, cred, err := w.ValidatePasskeyLogin(func(rawID, handle []byte) (webauthn.User, error) { + row, err := credentialByRawID(ctx, db, rawID) // unique index on credential_id + if err != nil || row == nil || !row.IsEnabled || !row.Passwordless { return nil, errNotPasskey } + u, err := loadUser(ctx, db, row.UserID) + if err != nil || !bytes.Equal(handle, u.WebAuthnID()) { return nil, errNotPasskey } + return u, nil +}, sess, parsed) +// then: not soft-deleted, activated, RejectIfThrottled; write back cred; FinishLogin; IssueToken; LoginEvent +``` + +### 7. Device poll hand-over (D-12a/b) +```go +token, jti, err := bouncer.Mint(secret, strconv.FormatUint(uint64(row.UserID), 10), issuer, ttl) +if err != nil { /* 500 */ } +res := db.Exec(`UPDATE golem15_user_device_auth_sessions + SET status = 'used', used_at = NOW(), last_activity_at = NOW(), jwt_jti = ?, updated_at = NOW() + WHERE id = ? AND status = 'confirmed' AND expires_at > NOW()`, jti, row.ID) +if res.RowsAffected != 1 { + // another poll won: answer the current status without a token; the minted token was never sent +} +// 200 {"success":true,"data":{"status":"confirmed","user_id":…,"message":"Device authorized - complete login","token":…,"user":…}} +``` + +### 8. Refresh hands the JTI forward (§Q7) +```go +next, err := bouncer.Refresh(secret, raw, refreshTTL(app), bl, blacklistGrace(app), requestURL(r)) +if err == nil { + oldJTI := unverifiedJTI(raw) // signature already checked by Refresh + if _, _, _, newJTI, err := bouncer.VerifyClaims(next, secret); err == nil && oldJTI != "" { + _ = gdb.Exec(`UPDATE golem15_user_device_auth_sessions SET jwt_jti = ?, last_activity_at = NOW() + WHERE jwt_jti = ? AND status = 'used'`, newJTI, oldJTI).Error // log on error, never fail the refresh + } +} +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| lbuchs plain-base64 options, PEM keys | WebAuthn L3 JSON (base64url), COSE keys, BE/BS flags | WebAuthn L3; go-webauthn v0.11+ BE checks [ASSUMED version] | D-08 deviation; PHP credentials not importable as-is | +| Second-factor-only security keys | Discoverable passkeys with UV | Industry 2023-2025 [ASSUMED] | D-14 passwordless with `required` RK + UV | +| Cookie-based remember-device | Opaque bearer token returned to SPA | PHP already had `createTrustedDeviceToken` | D-10 | + +**Deprecated/outdated:** go-webauthn stored the attestation *format* in `AttestationType` in older releases. `Credential.UnmarshalJSON` migrates such records (credential.go:189-212). Write `AttestationFormat` explicitly. + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | boombuler + own 25-line SVG writer satisfies D-04's "library that can emit SVG" | §Q2 | User wanted native SVG output: swap to piglig/go-qr (one new module) | +| A2 | Carbon serializes raw dates as `…T…:…:….000000Z` in `status.trusted_devices` | §Q4 | Byte-parity test pins the wrong format | +| A3 | `credential_data` JSON column instead of explicit per-field columns | §Q6 | Planner prefers explicit columns: more migration columns, same behaviour | +| A4 | PHP-compatible 8-byte big-endian user ID as WebAuthn user handle | §Q6 | Reveals the numeric user ID to authenticators; random handles need a lookup column | +| A5 | Five real verification attempts (PHP gives four) | Pitfall 4 | Minor UX parity difference | +| A6 | Per-user 2FA failure cap (10 per 15 min) | Pitfall 1 | Without it, password-holders can brute-force TOTP across IPs | +| A7 | Social sign-in through the challenge ignores trusted devices | Pitfall 2 | Users with trusted devices still get challenged on social login when the toggle is on | +| A8 | Email code only for users who enabled email; ≤3 sends per challenge | Pitfall 10 | Diverges from PHP's permissive fallback | +| A9 | Mail `expiry_minutes` capped at the remaining challenge time | Pitfall 11 | Mail text says 10 min while the code dies at 5 | +| A10 | Undelete-before-2FA left as is (D-02 placement) | Pitfall 21 | A password-only attacker can restore a deactivated account | +| A11 | Registration and passwordless ceremony state in `cooler` (memory driver single-instance) | §Q6 | Multi-instance apps without Redis fail ceremonies spanning instances | +| A12 | `@simplewebauthn/browser` consumes `options.publicKey` | §Q6 | Frontend wiring detail only | +| A13 | Go modules' legitimacy (seam cannot check Go) | Package Legitimacy Audit | Mitigated by the human check | + +## Open Questions + +1. **Show the first recovery-code set?** + - What we know: PHP generates codes on first enable and never returns them (TwoFactorService.php:599-608). The user only sees codes after `regenerate`. + - What's unclear: whether the Go port should add `recovery_codes` to the first-enable responses (additive key) or keep PHP's behaviour. + - Recommendation: add `recovery_codes` only when a new set was created by that call. It is additive and the safer UX. Needs user confirmation. +2. **Server-side "enforced" mode?** + - What we know: PHP only reports `enforced` in `status`. + - Recommendation: keep parity (frontend nags); a server-side block would be a new contract. +3. **Do bm and demo need a 2FA UI this phase?** + - What we know: D-17 mounts mfa; there is no frontend work listed. With no UI, users cannot enable 2FA, so nothing changes. + - Recommendation: API-only in this phase. The demo's Vue app could get a minimal TOTP page later. +4. **Hardening items A6, A8, A9, A10** need a yes/no from the user, since they are security-relevant deviations from PHP (Security Domain lists them). +5. **Release order vs 19.5:** 19.5 is complete (sociallogin v0.1.2 at `9fd58db` requires user plugin v0.1.5), so the D-09 gate call can land in sociallogin in this phase. + +## Environment Availability + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| Go | all | ✓ | go1.27.2 | — | +| Docker | testcontainers Postgres tests | ✓ | 29.9.1 (also via `sudo -u golem`) | `-short` runs skip them | +| Postgres (local) | dev only | ✓ | accepting on /run/postgresql:5432 | testcontainers | +| Redis (local) | cooler Redis tests | ✓ | PONG | memory driver / miniredis | +| Node / npm | bm frontend tests (if touched) | ✓ | v22.23.3 / 12.2.1 | — | +| proxy.golang.org | `go get` | ✓ | — | — | +| `govulncheck` | dependency audit | via `go run golang.org/x/vuln/cmd/govulncheck@v1.9.0` | v1.9.0 | — | +| A WebAuthn authenticator + browser on a non-IP origin | WebAuthn UAT | ✗ in CI | — | Unit tests with go-webauthn virtual-authenticator data; human UAT | + +**Missing dependencies with no fallback:** none for automated work. A real browser ceremony is human UAT. + +## Validation Architecture + +### Test Framework +| Property | Value | +|----------|-------| +| Framework | Go `testing` (go1.27.2) + `httptest`; testcontainers-go v0.44.0 Postgres (DB tests skip under `-short`) | +| Config file | none. New `sm-mfa-plugin/testmain_test.go` + `harness_test.go` copied from `sm-sociallogin-plugin` (`bootSocialWith` → boots `golem15.user` + plugin, memory cooler) | +| Quick run command | `cd sm-mfa-plugin && go test -short ./...`; `cd fonoteka.go/plugins/golem15/user && go test -short ./...` | +| Full suite command | `cd fonoteka.go/plugins/golem15/user && go vet ./... && go test ./...`; `cd sm-mfa-plugin && go vet ./... && go test ./...`; `cd sm-sociallogin-plugin && go test ./...`; `cd sm-demo-app && go test ./...`; `cd sm-bm-app && go test ./...`; `cd fonoteka.go && go test ./...` | + +### Phase Decisions → Test Map +| Item | Behavior | Test Type | Automated Command | File Exists? | +|------|----------|-----------|-------------------|-------------| +| D-02 | no gate → login unchanged; gate challenge → 200 shape, no token; gate error → 500, no token | unit (user plugin) | `go test ./... -run TestLoginGate` | ❌ Wave 0 | +| D-02/SC-2 | 2FA user login → `{two_factor_required, challenge_token(64), available_methods, expires_at +00:00}`; `2fa-verify` 11th req/min → 429 | integration | mfa `-run 'TestLoginChallenge\|TestBucket2faVerify'` | ❌ | +| D-05 | verify/recovery/email/send/webauthn verify: every PHP error body verbatim; attempts 5 then refused; parallel verifies → one token | integration | mfa `-run 'TestChallenge'` | ❌ | +| D-05 | TOTP setup → `{secret, qr_code_svg, qr_code_url}` (SVG decodes to URL); confirm/disable; replay refused | unit + integration | mfa `-run 'TestTOTP\|TestQRSVG'` | ❌ | +| D-05 | recovery: 10 codes, format/alphabet regex, hashed, consume once (concurrent), regenerate invalidates, remaining count | integration | mfa `-run TestRecovery` | ❌ | +| D-05 | email: 6 digits hashed, template `golem15.mfa::mail.two_factor_code[-en]` vars, masked email | integration (mail capture) | mfa `-run TestEmailCode` | ❌ | +| D-06 | challenge/trusted/device tokens stored as 64-hex sha256; TOTP secret ciphertext ≠ secret | integration | `-run TestTokensAtRest` (mfa + user plugin) | ❌ | +| D-07 | change, reset, admin set → trusted devices gone; rehash does not; last method disabled → gone | integration | user plugin `-run TestPasswordChangedEvent`; mfa `-run TestRevocation` | ❌ | +| D-08/D-14 | WebAuthn registration + assertion with recorded authenticator fixtures; BE flag persisted; passwordless refuses second-factor-only and UV-less credentials; setting off → refused | unit + integration | mfa `-run 'TestWebAuthn\|TestPasswordless'` | ❌ | +| D-09 | toggle off → `oauth-complete` unchanged; on → challenge shape, no token; link not gated | integration | sociallogin `-run TestSocialGate` | ❌ | +| D-10 | `trust_device` + enabled → `trusted_device_token`; login with it skips challenge; expired/other user's token → challenge | integration | mfa `-run TestTrustedDevice` | ❌ | +| D-11/D-12a | initiate/status/authorize/list/revoke PHP shapes (flat expired body); first poll gets token once (concurrent polls); used never becomes expired | integration | user plugin `-run TestDevice` | ❌ | +| D-12b | revoke → device JWT 401; after a refresh the new token is also 401 | integration | user plugin `-run TestDeviceRevokeAfterRefresh` | ❌ | +| D-12 | settings row overrides config on next request; taglist allow-list | integration | mfa `-run TestSettings` | ❌ | +| D-13 | mode disabled → no challenge even with methods | integration | mfa `-run TestModeDisabled` | ❌ | +| D-15 | reset action absent without mfa (Applies false); with mfa resets all and fires disabled; permission required | integration (admin harness) | user plugin `-run TestResetTwoFactorSlot`; mfa `-run TestAdminReset` | ❌ | +| D-16 | migrations up/down; columns and indexes as PHP + additions | integration | `-run TestMigrations` | ❌ | +| D-17 | demo boots with mfa (no DB); fonoteka guard passes with `/devices`, still bans `2fa` | app tests | `cd sm-demo-app && go test -run TestBootAllPlugins`; `cd fonoteka.go && go test ./plugins/golem15/fonoteka -run TestNoDeferredRoutesResolve` | ⚠ update existing | +| PHP map | `tests/security/AuthenticationTest.php:149` (USER-005 settings type safety) → typed mode setting test | unit | mfa `-run TestPHPUser005` | ❌ | + +### Sampling Rate +- **Per task commit:** `go vet ./...` + `go test -short ./...` of the touched repo. +- **Per plan merge:** full suites (Docker) of every touched repo. +- **Phase gate:** a `scripts/check-phase19.6.sh` in the 19.5 style. It runs the stages go/userplugin (with an additive-exports check against `v0.1.5` and a migrations-only-added check)/mfa (no `gopkg.in/yaml.v3` in `go list -deps .`)/sociallogin/demo/bm/fonoteka/docker/docs (decision note names the three pins). It must pass before `/msd-verify-work`. Real-browser WebAuthn is human UAT. + +### Wave 0 Gaps +- [ ] `sm-mfa-plugin/testmain_test.go`, `harness_test.go` (copy of sociallogin's, booting `golem15.user` + `golem15.mfa`) +- [ ] WebAuthn fixtures: recorded attestation/assertion JSON for a known RP ID/origin (go-webauthn's own test vectors or a virtual authenticator in the test) +- [ ] Clock injection for TOTP tests (pass `time.Time` into the verifier) +- [ ] user-plugin harness helper for device-flow tests (existing `session_test.go` `insertUser`/`loginToken`) + +## Security Domain + +`security_enforcement` is absent from `.planning/config.json`, so it is treated as enabled. Target ASVS L1 plus the explicit D-14 review. + +### Applicable ASVS Categories + +| ASVS Category | Applies | Standard Control | +|---------------|---------|-----------------| +| V2 Authentication | yes | TOTP (RFC 6238 via pquerna, replay-safe), WebAuthn (go-webauthn, UV rules), bcrypt-hashed recovery and email codes, per-IP bucket + per-user failure cap, guarded attempt counter | +| V3 Session Management | yes | Challenge token 64 chars, SHA-256 at rest, 5 min, one completion. Device JWT one-time hand-over, JTI tracked through refresh, blacklist on revoke. `tokens_valid_after` on password change | +| V4 Access Control | yes | Management routes `jwt.auth`. Every row query scoped by `user_id`. Admin reset behind `golem15.users.reset_two_factor` | +| V5 Input Validation | yes | `lagoon.Validate` with PHP rules. Base32 check on the TOTP `secret`. Length caps (`device_name`, recovery input, WebAuthn JSON body size) | +| V6 Cryptography | yes | Libraries only. `crypto/rand` tokens. `subtle.ConstantTimeCompare` where comparing secrets. `lagoon.Encrypted` for TOTP secret | +| V7 Errors and Logging | yes | Generic 401s. Never log codes, tokens, secrets or assertions. `Registration failed:` with a fixed reason, not library internals. The access log never logs paths (surf/accesslog.go:51-58), but reverse proxies may log `devices/status/{token}` | + +### Known Threat Patterns + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|---------------------| +| TOTP brute force across challenges (Pitfall 1) | Spoofing | `2fa-verify` 10/min/IP + per-user failure cap | +| Parallel verify races (attempt bypass, double mint) | Tampering | Guarded UPDATEs (Pattern 2) | +| TOTP replay within window | Spoofing | Last-counter tracking (Pitfall 5) | +| WebAuthn challenge supplied by client | Spoofing | Server-side `SessionData` (Pattern 3) | +| Passkey login with a second-factor-only credential | Elevation | `passwordless` column + UV required + discoverable only (D-14) | +| Credential cloning | Spoofing | Sign-count / `CloneWarning` persisted; reject or flag | +| Device-code phishing (attacker sends victim a QR to authorize) | Spoofing | D-12c accepted; frontend shows device name, IP and parsed UA. Short TTL. Revoke list | +| Short-code guessing to bind victims' pending devices | Tampering | 31⁸ space, 5 min TTL, user-api bucket. Atomic `pending`-only confirm with `user_id IS NULL` | +| Revoked device keeps refreshing | Elevation | JTI follows refresh (§Q7) | +| Trusted-device token theft | Spoofing | SHA-256 at rest, 30 d expiry, revoke on password change and last-method disable, per-user listing | +| Email downgrade of TOTP users | Spoofing | Email only when enabled (A8) | +| Mail bombing via `email/send` | DoS | Bucket + per-challenge send cap | +| Admin reset abuse | Elevation | New permission, Danger confirm, cabana audit log (`cabana: admin record action`) | +| Restoring a deactivated account with the password only | Tampering | Pitfall 21 / A10 decision | + +## Sources + +### Primary (HIGH confidence) +- PHP reference, read this session: `/media/nvme/dev/jz/wavepath.org/plugins/golem15/user/{routes.php, controllers/ApiController.php, controllers/TwoFactorApiController.php, classes/twofactor/*.php, models/{TwoFactorChallenge,TwoFactorMethod,TwoFactorRecoveryCode,TrustedDevice,WebAuthnCredential,DeviceAuthSession,Settings}.php, models/settings/fields.yaml, views/mail/two_factor_code.htm, updates/v2.1.0-v3.1.0, components/TwoFactor.php, components/DeviceAuth.php, tests/security/AuthenticationTest.php}`, `vendor/lbuchs/webauthn/src/WebAuthn.php` +- Clients: `vue-queststream-app/composables/useDeviceAuth.ts`, `vue-wavepath-app/src/types/auth.ts:157-162`, `vue-fonoteka-app/app/stores/auth.ts:328` +- Go, read this session: sm-user-plugin v0.1.5 (`plugin.go`, `routes.go`, `controllers/{api_controller,registration,users_admin_controller}.go`, `controllers/users/config_form.yaml`, `classes/{events,throttle,mail,oauth_providers,api_tokens}.go`, `updates/*`); sm-sociallogin-plugin (`plugin.go`, `routes.go`, `admin_settings.go`, `models/settings.go`, `classes/{flow,codes}.go`, `controllers/{complete,helpers}.go`); summercms.go modules `bouncer/{mint,refresh,jwt,password}.go`, `cabana/{extension,actions,registry,settings,form_schema,field_taglist}.go`, `pact/capabilities.go`, `postcard/templates.go`, `festival/bus.go`, `cooler/cooler.go`, `surf/{limiter,router,accesslog}.go`, `wire/response.go`, `lagoon/encrypted.go`; `fonoteka/phase07_coverage_test.go` +- Module sources: `go-webauthn/webauthn@v0.18.2` (`webauthn/*.go`, `protocol/*.go`, `doc.go`, `example_passkey_test.go`), `pquerna/otp@v1.5.0` (`totp/totp.go`, `hotp/hotp.go`, `go.mod`), `boombuler/barcode@v1.1.0`, `piglig/go-qr@v1.1.0` +- Measurements: proxy.golang.org `go list -m -json`, `gh api repos/*`, probe builds (size, `go version -m`), `govulncheck@v1.9.0`, QR round-trip, bcrypt timing + +### Secondary (MEDIUM confidence) +- [W3C public-webauthn: restrict to valid domains](https://lists.w3.org/Archives/Public/public-webauthn/2017May/0250.html) +- [Ory: passkeys WebAuthn SecurityError](https://ory.sh/docs/troubleshooting/passkeys-webauthn-security-error) +- [piglig/go-qr repository](https://github.com/piglig/go-qr) + +### Tertiary (LOW confidence) +- Carbon `toJSON` microsecond format, `@simplewebauthn/browser` option shape, Google2FA default secret length (training knowledge, marked [ASSUMED]) + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH. Versions from the proxy, size and graph measured, vulns scanned, QR proven by round-trip. +- Architecture: HIGH. Every seam traced to Go code read this session; the refresh-lineage and cabana-action gaps verified in source. +- Pitfalls: HIGH for PHP behaviour and races (read in source), MEDIUM for browser WebAuthn origin behaviour (cited, not executed). + +**Research date:** 2026-10-21 +**Valid until:** 2026-11-20 (go-webauthn releases often and is pre-1.0; re-check the pin before `go get` if later) diff --git a/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-VALIDATION.md b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-VALIDATION.md new file mode 100644 index 00000000..c088fff9 --- /dev/null +++ b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-VALIDATION.md @@ -0,0 +1,77 @@ +--- +phase: "19.6" +slug: "two-factor-auth-and-device-flow-in-sm-user-plugin" +# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6) +# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117) +status: draft +nyquist_compliant: false +wave_0_complete: false +created: "2026-10-21" +--- + +# Phase 19.6 — Validation Strategy + +> Per-phase validation contract for feedback sampling during execution. + +--- + +## Test Infrastructure + +| Property | Value | +|----------|-------| +| **Framework** | Go `testing` + `httptest`; testcontainers-go Postgres (DB tests skip under `-short`) | +| **Config file** | none — Wave 0 adds `sm-mfa-plugin/testmain_test.go` and `harness_test.go` (copied from `sm-sociallogin-plugin`) | +| **Quick run command** | `go -C ../sm-mfa-plugin test -short ./...` and `go -C ../fonoteka.go test -short ./plugins/golem15/user/...` | +| **Full suite command** | `scripts/check-phase19.6.sh` (go, userplugin, mfa, sociallogin, demo, bm, fonoteka, docker, docs stages) | +| **Estimated runtime** | ~180 seconds (full, with Docker) | + +--- + +## Sampling Rate + +- **After every task commit:** `go vet ./...` + `go test -short ./...` of the touched repo +- **After every plan wave:** full suites (Docker) of every touched repo +- **Before `/msd-verify-work`:** `scripts/check-phase19.6.sh` must be green +- **Max feedback latency:** 60 seconds for the short loop + +--- + +## Per-Task Verification Map + +Decision → test map is in `19.6-RESEARCH.md` § Validation Architecture; the planner assigns task IDs. + +| Task ID | Plan | Wave | Requirement | Threat Ref | Secure Behavior | Test Type | Automated Command | File Exists | Status | +|---------|------|------|-------------|------------|-----------------|-----------|-------------------|-------------|--------| +| 19.6-XX-XX | TBD | TBD | D-XX | T-19.6-XX | per RESEARCH map | unit/integration | per RESEARCH map | ❌ W0 | ⬜ pending | + +*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky* + +--- + +## Wave 0 Requirements + +- [ ] `sm-mfa-plugin/testmain_test.go`, `harness_test.go` — boot `golem15.user` + `golem15.mfa` +- [ ] WebAuthn fixtures — recorded attestation/assertion JSON or a virtual authenticator +- [ ] Clock injection for TOTP tests +- [ ] user-plugin harness helper for device-flow tests + +--- + +## Manual-Only Verifications + +| Behavior | Requirement | Why Manual | Test Instructions | +|----------|-------------|------------|-------------------| +| WebAuthn registration and login in a real browser | D-08 / SC-1 | Browser authenticator ceremony cannot run in `go test` | Register a passkey on the demo instance over HTTPS with a hostname RP ID, then sign in with it | + +--- + +## Validation Sign-Off + +- [ ] All tasks have `` verify or Wave 0 dependencies +- [ ] Sampling continuity: no 3 consecutive tasks without automated verify +- [ ] Wave 0 covers all MISSING references +- [ ] No watch-mode flags +- [ ] Feedback latency < 60s +- [ ] `nyquist_compliant: true` set in frontmatter + +**Approval:** pending