docs(19.6): phase verification report (human_needed)
This commit is contained in:
@@ -0,0 +1,307 @@
|
||||
---
|
||||
phase: 19.6-two-factor-auth-and-device-flow-in-sm-user-plugin
|
||||
verified: 2026-10-26T15:58:30Z
|
||||
status: human_needed
|
||||
score: 45/45 must-haves verified
|
||||
covered_files:
|
||||
- .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-01-PLAN.md
|
||||
- .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-01-SUMMARY.md
|
||||
- .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-02-PLAN.md
|
||||
- .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-02-SUMMARY.md
|
||||
- .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-03-PLAN.md
|
||||
- .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-03-SUMMARY.md
|
||||
- .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-04-PLAN.md
|
||||
- .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-04-SUMMARY.md
|
||||
- scripts/check-phase19.6.sh
|
||||
covered_digest: "v2:sha256:0f825ccda8084c2d5ee75b6cdf0337c23e2682ba80413cdf44a5a640f3690320"
|
||||
# Files outside this repository are pinned by commit instead of digest (all equal to the release tags on the remotes):
|
||||
# sm-user-plugin 07196a4 = v0.1.13 (checkout fonoteka.go/plugins/golem15/user), sm-mfa-plugin 463c750 = v0.1.2,
|
||||
# sm-sociallogin-plugin ecfb8a7 = v0.1.5, vue-demo-app 44a5229 (origin master), sm-demo-app 8fcdbdd (origin master;
|
||||
# gitlinks and go.mod requires at the three tags), sm-bm-app HEAD c665073 (gitlinks at the three tags via the local,
|
||||
# UNPUSHED bumps 56a79ab, 1c1db04, 604b06c; origin/master ebb2266 still pins user 3b22134, mfa 33ad164, sociallogin
|
||||
# b752177), fonoteka.go e36577a (user gitlink 07196a4, unpushed by design).
|
||||
behavior_unverified: 0
|
||||
overrides_applied: 0
|
||||
human_verification:
|
||||
- test: "Run a real-browser WebAuthn ceremony against an instance with webauthn in two_factor_available_methods, served over HTTPS with a hostname RP ID: register a security key (POST 2fa/webauthn/register/options, navigator.credentials.create, POST 2fa/webauthn/register), sign out, sign in with the password and complete the challenge through 2fa/webauthn/options and 2fa/webauthn/verify. With two_factor_passwordless_login on, register a passkey (passwordless true) and sign in through 2fa/webauthn/passwordless/options and /verify."
|
||||
expected: "Registration answers {message, credential:{id,name,created_at}, status} (plus recovery_codes on the first set); the challenge completes with {token, user}; the passkey sign-in issues a token with no second challenge; a second-factor-only key is refused for passwordless with 401 WebAuthn verification failed."
|
||||
why_human: "Server-side ceremonies are proven with a software authenticator (real ECDSA P-256 and CBOR, TestWebAuthn, TestPasswordless), but no test runs a browser's navigator.credentials JSON (credProps, transports, base64url fields) against the D-08 shapes, or a real RP ID and origin. No client exists yet: the demo has no WebAuthn screens and its config/golem15.mfa.yaml leaves webauthn out of the available methods, so the 19.6-VALIDATION.md manual row ('on the demo instance') needs another client page."
|
||||
- test: "On the demo (vue-demo-app served by sm-demo-app at the v0.1.13 / v0.1.2 / v0.1.5 pins): sign in, open /account/security, enable TOTP by scanning the QR code with a real authenticator app and entering a code, keep the recovery codes shown once, sign out, sign in again and answer the challenge with an app code; repeat with a recovery code; then disable TOTP."
|
||||
expected: "The QR renders as an image and scans; the recovery codes appear once after confirm; the login page switches to the code step on two_factor_required and stores the token after verify or recovery; a used recovery code is refused the second time; after disable the login issues a token directly."
|
||||
why_human: "User-facing flow and look (D-20). Covered only by twoFactor.test.ts helpers and the Go route tests; SUMMARY 03 and 04 record the browser run as NOT RUN."
|
||||
- test: "Review the judgment-tier prohibition verdicts in the 'Prohibitions' section of this report (non-authoritative LLM-judge verdicts, 19 items across plans 01-04)."
|
||||
expected: "Confirm or dispute each verdict; all are recorded as no violation found."
|
||||
why_human: "Judgment-tier prohibitions never pass silently (ADR-550 D4)."
|
||||
---
|
||||
|
||||
# Phase 19.6: Two-factor auth and device flow in sm-user-plugin Verification Report
|
||||
|
||||
**Phase Goal:** `sm-user-plugin` has the PHP user plugin's second factors and device login: TOTP, WebAuthn, email codes, recovery codes and trusted devices, a challenge step during login, and QR/code device authorization. Per CONTEXT D-01 the second factors live in the new `sm-mfa-plugin`; per D-03 the device flow stays in sm-user-plugin.
|
||||
**Verified:** 2026-10-26T15:58:30Z
|
||||
**Status:** human_needed
|
||||
**Re-verification:** No, initial verification
|
||||
|
||||
**MVP-mode note:** ROADMAP marks the phase `mode: mvp`, but the goal is not a user story. Every plan records the decision not to invent one (as in 19.5), so this report runs standard goal-backward verification against SC-1..SC-4, D-01..D-20 (with D-12a/b/c) and the 41 plan must-haves. No "User Flow Coverage" table is produced; the user-facing demo flow is human item 2.
|
||||
|
||||
**Release state, checked in the repositories.** sm-user-plugin `v0.1.13` = 07196a4, sm-mfa-plugin `v0.1.2` = 463c750 and sm-sociallogin-plugin `v0.1.5` = ecfb8a7 exist on the remotes, and each remote `master` equals its tag. Each checkout is clean at that commit. sm-demo-app `origin/master` = 8fcdbdd: its gitlinks are the three tag commits, and go.mod requires v0.1.13, v0.1.2 and v0.1.5. sm-bm-app's HEAD carries the same gitlinks, but the bumps (56a79ab, 1c1db04, 604b06c) sit among 39 unpushed commits, so `origin/master` ebb2266 still pins user 3b22134 (v0.1.12), mfa 33ad164 (v0.1.0) and sociallogin b752177 (v0.1.4). The orchestrator says this is intentional, and I count it as a recorded decision, not a gap. **A deploy from bm's origin therefore lacks 71d8f27 (an untouched settings form disabled every method) and every review fix, CR-01 included.** fonoteka.go e36577a bumps the user gitlink to 07196a4 and is never pushed, by design.
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
Abbreviations: **U** sm-user-plugin, **M** sm-mfa-plugin, **S** sm-sociallogin-plugin, **D** sm-demo-app/vue-demo-app, **B** sm-bm-app, **A** fonoteka.go. "Gate" means my own run of `PHASE196_REQUIRE_DOCKER=1 TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.6.sh --all` at the released pins. It exited 0, and all 11 stages passed.
|
||||
|
||||
**Roadmap success criteria**
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| SC-1 | TOTP setup/confirm/disable, WebAuthn registration and login challenges, email codes, recovery codes and trusted devices work against the PHP contract; the WebAuthn dependency has a decision note before code lands | VERIFIED (browser interop: human item 1) | M `routes.go` mounts PHP's 5 challenge routes and all 13 management routes, compared one by one with PHP `routes.php:303-336,372-453`. Note `24d9d2a` (09:49) lands before M's first go.mod commit `5c6ad2f` (11:05) and the go-webauthn require `2662252` (12:32). Gate `--mfa` and `--docker`: TestTOTP, TestQRSVG, TestRecovery, TestEmailCode, TestTrustedDevice, TestWebAuthn, TestPasswordless and TestPHPErrorBodies all PASS. The WebAuthn tests use a software authenticator with real ECDSA and CBOR (`webauthn_authenticator_test.go`). |
|
||||
| SC-2 | Login with a second factor returns the unauthenticated challenge step, rate-limited by `2fa-verify` | VERIFIED | U `controllers/api_controller.go:139-157` consults the gates and answers the gate body. M `classes/gate.go` returns `{two_factor_required, challenge_token, available_methods, expires_at}`. M `buckets.go` sets 10/min per client IP, and `routes.go` puts the challenge group under `throttle:2fa-verify`. TestGate, TestLoginGateSmoke and TestRoutesAndBuckets (11th request 429) PASS. |
|
||||
| SC-3 | `devices/initiate`, `devices/status/{token}`, `devices/authorize`, device list and revoke work on `DeviceAuthSession` | VERIFIED | U `routes.go` mounts the 5 routes in the user-api group. U `models/device_auth_session.go` and migration `202610260001_create_device_auth_sessions` match PHP's columns (v2.1.0-v2.7.1) plus `jwt_jti`. TestDeviceInitiate, TestDeviceStatus, TestDeviceAuthorize, TestDeviceHandOverConcurrent, TestDeviceListAndRevoke, TestDeviceRevokeAfterRefresh and TestDeviceFlowSmoke PASS in the gate. I also ran TestDeviceRevokeRacesRefresh and TestDeviceAccessRevocation by name: both PASS. |
|
||||
| SC-4 | Six models and migrations ported; a security review; unit tests in the last plan | VERIFIED | M `models/` has TwoFactorMethod, TwoFactorChallenge, TwoFactorRecoveryCode, TrustedDevice and WebAuthnCredential; U has DeviceAuthSession. Migrations M `202610220001..0006` and U `202610260001` carry PHP's table and column names (compared with PHP `updates/v3.0.0`, `v3.1.0`, `v2.1.0`). `19.6-SECURITY-REVIEW.md` maps T-19.6-01..38 with a D-14 section. Plan 04 delivered 41 named tests; the gate runs 15+1 U, 21 M and 2 S Postgres tests plus the Docker-free names. |
|
||||
|
||||
**Plan 01 (sm-user-plugin seams and device flow)**
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1.1 | D-04 decision note with the three pins, the measurements and the human check; STACK rows; no go.mod of U, framework or A requires the libraries | VERIFIED | The note names each pin once. STACK.md references the note 3 times. `grep -c` gives 0 in U go.mod and framework go.mod. `go list -m all` in A shows no webauthn, pquerna or boombuler module. |
|
||||
| 1.2 | D-02: registry published in Register; Login consults it after CheckAndRecordLogin and the rehash, before the restore and the mint; a gate error answers the opaque 500; refresh never asks | VERIFIED | `api_controller.go:112-157`. `Refresh` has no CheckLoginGates call. TestLoginGates (also with `-race`) and TestLoginGateOrder PASS. |
|
||||
| 1.3 | D-18 A10: a challenged soft-deleted account stays deleted, with no reactivate mail | VERIFIED | The restore at `:158-164` runs after the gate return. TestLoginGateOrder/challenged_soft_deleted_stays_deleted PASS (gate). |
|
||||
| 1.4 | LoginEvent fires once after the mint, none on a challenge; FireLoginEvent and CheckLoginGates are exported | VERIFIED | `:179`. TestLoginGateSmoke no_gate and challenge PASS. |
|
||||
| 1.5 | D-07: the password-change hook fires in the write transaction for reset, change and admin set; a listener error rolls back; the rehash fires none | VERIFIED (recorded deviation) | Built on the existing `AccessRevokedEvent` with `PasswordChanged()` instead of a new `PasswordChangedEvent` (01-SUMMARY deviation 1). TestPasswordChangedSources PASS. |
|
||||
| 1.6 | D-15: permission `golem15.users.reset_two_factor`, Danger action, offered only with a published TwoFactorAdmin, Run in cabana's transaction | VERIFIED | `users_admin_controller.go:820-847` (`Lookup[classes.TwoFactorAdmin]`, plus the WR-04 privileged guard). `config_form.yaml:17`. TestResetTwoFactorAction PASS. |
|
||||
| 1.7 | D-03/D-11/D-16: 5 routes, PHP columns, SHA-256 token, XXXX-XXXX code, 5-minute TTL, status enum, `jwt_jti`, configurable auth_url | VERIFIED (two recorded deviations) | Migration ID `202610260001` instead of the planned `202610210002` (04-SUMMARY deviation 5). The auth_url default is now `{frontend_url}/device/scan/{short_code}` instead of D-11's `{token}` (WR-01, released in v0.1.13, user decision in DISPOSITION). Authorize accepts a short code in `token`, so QuestStream's `useDeviceAuth.ts`/`useProfile.ts` keep working. |
|
||||
| 1.8 | D-12a: the first poll after confirm gets `{status:'confirmed', user_id, message, token, user}` through one guarded UPDATE; concurrent polls give one winner; used and revoked never expire | VERIFIED | `devices.go:298-300` (`WHERE status='confirmed' AND expires_at > NOW()`). `ExpiredAt` exempts used and revoked. TestDeviceHandOverConcurrent (50 polls) PASS. Since WR-01 the hand-over also requires the initiating User-Agent. |
|
||||
| 1.9 | D-12b: revoke blacklists the jti; refresh follows the jti | VERIFIED | `DeviceDestroy` runs `UPDATE … RETURNING jwt_jti` and calls `Lookup[bouncer.BlacklistStore]` (`devices.go:534-552`). `Refresh` calls `FollowDeviceJTI` and fails closed (`api_controller.go:316-333`, CR-01). TestDeviceRevokeAfterRefresh PASS (gate); TestDeviceRevokeRacesRefresh PASS (my run). |
|
||||
| 1.10 | D-12c: authorize needs only a valid bearer JWT; one guarded pending-to-confirmed UPDATE; no 2FA re-check | VERIFIED | `devices.go:412-414`. TestDeviceAuthorize PASS. |
|
||||
| 1.11 | D-17: every change to U is additive; S and A compile | VERIFIED | Gate additive check: "no exported declaration removed or changed … since 50da92c (63 declarations added; 1 allowed migration)". A builds in the fonoteka stage. S and M build with `GOWORK=off` at their own requires (exit 0, my run). |
|
||||
|
||||
**Plan 02 (sm-mfa-plugin core)**
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 2.1 | D-01: module `git.golem15.com/golem15/sm-mfa-plugin`, plugin `golem15.mfa` requires `golem15.user`; Boot needs no database | VERIFIED | `plugin.go:72-89`: Boot only adds the gate, publishes TwoFactorAdmin and listens. Gate `--mfa` checks the pins. |
|
||||
| 2.2 | SC-2: challenge body and the 2fa-verify bucket | VERIFIED | Same evidence as SC-2. |
|
||||
| 2.3 | D-05: PHP 401 and 422 bodies; TTL 5, 5 attempts, one completion | VERIFIED | `classes/challenge.go:96,119,137` (guarded `attempts < max_attempts`, `completed_at IS NULL`). TestChallengeLifecycle (20 parallel verifies, one token) and TestPHPErrorBodies PASS. Plus WR-02 (TestChallengeAfterRevocation) and WR-07 (TestCompletionFailureSpendsNothing), both PASS in my run. |
|
||||
| 2.4 | D-04/D-05: TOTP setup `{secret, qr_code_svg, qr_code_url}`, ±1 step, encrypted secret, replay refused, disable | VERIFIED | `controllers/management.go:78-80`, `classes/totp.go`, `classes/qr.go`. TestTOTP (replay), TestQRSVG and TestTokensAtRest PASS. |
|
||||
| 2.5 | D-05/D-19: recovery codes, 10 × `XXXXX-XXXXX`, bcrypt, single use, count only, regenerate, first set returned | VERIFIED | `classes/recovery.go:19-26,136`. TestRecovery and TestRecoveryCodeFormat PASS. |
|
||||
| 2.6 | D-05/D-18 A8 and A9: 6-digit bcrypt email code, template `golem15.mfa::mail.two_factor_code` (and -en), gating, 3 sends, `expiry_minutes` = min | VERIFIED | `classes/email.go:22,102`, `mail.go:22-23`. TestEmailCode and TestEmailExpiryMinutes PASS. Code lifetime is bound to the challenge, as PHP's `EmailMethod::verify` is (IN-02 is PHP parity). |
|
||||
| 2.7 | D-18 A6: 10 failures in 15 minutes per user refuse new challenges and verifies | VERIFIED | `gate.go` uses `FailureCapReached` (`challenge.go:218`). TestFailureCap PASS. |
|
||||
| 2.8 | D-06: tokens as SHA-256, TOTP secret encrypted, codes bcrypt, nothing logged | VERIFIED | TestTokensAtRest PASS. My log sweep found no token, code or secret attribute in M's log calls. |
|
||||
| 2.9 | D-07/D-10: trusted devices off by default, 30 days, revoked on a password change and when the last method goes, revoke routes | VERIFIED | TestTrustedDevice, TestRevocation and TestTrustedSettingsSmoke PASS. |
|
||||
| 2.10 | D-12/D-13/D-09: Settings screen with PHP keys and defaults, applied per request, only `disabled` exempts | VERIFIED | `models/settings.go`, `classes/config.go`. No child exemption anywhere in M (grep). TestSettings (including form_round_trip, fix 71d8f27) and TestPHPUser005 PASS. |
|
||||
| 2.11 | D-15: TwoFactorAdmin published; reset disables methods, deletes codes and devices, fires disabled | VERIFIED | `plugin.go:82`. TestAdminReset PASS. |
|
||||
| 2.12 | D-16: four tables plus settings with PHP names and columns (+ `email_sends`) | VERIFIED | DDL compared with PHP v3.0.0 and v3.1.0: same columns, token as VARCHAR(64). TestMigrations PASS. |
|
||||
|
||||
**Plan 03 (WebAuthn, passkeys, social gate, demo, release)**
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 3.1 | D-04/D-08: go-webauthn v0.18.2 only in M; standard base64url shapes; attestation none, 60 s, UV preferred | VERIFIED (browser: human item 1) | TestWebAuthnShapes and TestWebAuthnCeremonySmoke PASS. A's module graph has no go-webauthn. |
|
||||
| 3.2 | D-05/D-06: WebAuthn state server-side, consumed once; PHP register, verify and remove bodies | VERIFIED | `cache.Pull` in `classes/webauthn.go:318` and `classes/passwordless.go:69`. TestWebAuthn and TestWebAuthnShapes/registration_state PASS. |
|
||||
| 3.3 | D-16: `golem15_user_webauthn_credentials` with PHP columns + `credential_data`, `passwordless`, unique `credential_id` | VERIFIED | Migration `202610220006`. TestMigrations PASS. |
|
||||
| 3.4 | D-14: passwordless routes in the bucket, 403 while off, passkeys only, UV required, activation, ban and suspension enforced, Source passkey, no second challenge | VERIFIED | SECURITY-REVIEW "D-14" table. TestPasswordless and TestPasswordlessSmoke PASS. The WR-05 partial (passkeys demoted on a password change) is pinned by TestPasswordless/password_change_demotes_passkeys. |
|
||||
| 3.5 | D-09: S consults the gate in signIn (Source social), skipped by default, the challenge body on both completions, refusals and errors fail closed, link never gated | VERIFIED | S `classes/flow.go:343-359`. TestSocialGate PASS (gate). WR-08 TestChallengedSignInFiresOnCompletion PASS (my run). |
|
||||
| 3.6 | D-20: the demo sets up TOTP with QR and confirm, shows recovery codes once, disables, and answers the login challenge with TOTP or a recovery code; bm stays API-only | VERIFIED (code and unit; browser: human item 2) | D `pages/account/security.vue` uses `useTwoFactor` (`totp/setup`, `confirm`, `DELETE totp`) and renders `<img :src="svgDataUrl(...)">`. `login.vue` and `useDemoAuth.ts` call `2fa/verify` and `2fa/recovery`. The gate's demo-web stage requires `twoFactor.test.ts`. B `frontend/src` has no 2FA code (grep). |
|
||||
| 3.7 | D-17: release order, D and B mount mfa and bump pointers in separate commits, A bumps user and updates TestNoDeferredRoutesResolve without mounting mfa | VERIFIED (B unpushed, recorded) | Remote tags and heads as in "Release state". D `summer.yaml:40` and `plugins.gen.go:12,44`; B `summer.yaml:8` and `plugins.gen.go:9,21`. A `phase07_coverage_test.go:97-100` drops `/devices` and keeps `2fa`. A's `summer.yaml` has no mfa. |
|
||||
| 3.8 | D-01: A has no /2fa route, no 2FA table, no second-factor module | VERIFIED | Gate `--fonoteka` and my own `go list -m all`. |
|
||||
|
||||
**Plan 04 (unit tests, security review, gate, post-release fixes)**
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 4.1 | SC-4: unit tests for every U seam, every M decision and S's gate, on Postgres where needed | VERIFIED | Gate `--docker`: 15 U root, 1 U updates, 21 M and 2 S tests by name. The Docker-free names pass in their stages. |
|
||||
| 4.2 | D-02/D-18 A10 table tests | VERIFIED | TestLoginGates (`-race`) and TestLoginGateOrder PASS. |
|
||||
| 4.3 | Device-flow table tests | VERIFIED | The 8 device tests and TestDeviceAuthSessionsMigration PASS. |
|
||||
| 4.4 | M tests pin every PHP body and decision | VERIFIED | TestPHPErrorBodies compares error and 422 bodies byte for byte. Success bodies are compared by key set because Go sorts keys (recorded observation). |
|
||||
| 4.5 | D-08/D-14 tests and the review's D-14 section | VERIFIED | `19.6-SECURITY-REVIEW.md` "D-14" maps each rule to a subtest. |
|
||||
| 4.6 | TestEveryLoginPathIsGated and TestSocialGate | VERIFIED | `login_paths_test.go:29-52` lists the mint sites; ActivateByCode points to F-1 (deferred by the user). PASS. |
|
||||
| 4.7 | PHP map: TestPHPMethodMap fails on missing, unknown or extra methods; test_user_005 maps to TestPHPUser005 | VERIFIED | Both run in the `--mfa` stage: PASS. |
|
||||
| 4.8 | The security review maps every T-19.6 threat to a test or an accepted disposition | VERIFIED | T-19.6-01..38 plus 4 SC rows; F-1 deferred with a corrected reason. |
|
||||
| 4.9 | The gate fails closed (fail, skip, zero tests, missing names); `--self-test`; docker NOT RUN is fatal with REQUIRE_DOCKER | VERIFIED | `--self-test` PASS in my run. My `--all` with `PHASE196_REQUIRE_DOCKER=1`: exit 0. |
|
||||
| 4.10 | Every post-release production fix is in every pin that consumes it, or tracked | VERIFIED | 71d8f27 is in v0.1.1 and later. The review fixes are in v0.1.13, v0.1.2 and v0.1.5, and the gitlinks of D, B (local HEAD) and A equal those tag commits. B's push is held, and WR-05 and F-1 are deferred, all by user decision. |
|
||||
|
||||
**Score:** 45/45 truths verified (0 present, behavior-unverified). Every behavior-dependent truth (guarded UPDATE races, revoke versus refresh, restore ordering, transaction rollbacks) has a named passing test. The race tests were mutation-checked per the security review.
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
| Artifact | Status | Details |
|
||||
|----------|--------|---------|
|
||||
| `.planning/notes/2026-10-21-mfa-otp-webauthn.md` | VERIFIED | Committed in 24d9d2a before any require |
|
||||
| U `classes/login_gate.go` | VERIFIED | `type LoginGate interface`, a registry with RWMutex and clone |
|
||||
| U `controllers/devices.go` | VERIFIED | 573 lines; `DeviceStatus`, hand-over, authorize, destroy |
|
||||
| U `updates/202610210002_create_device_auth_sessions.go` | VERIFIED (renamed) | Exists as `202610260001_create_device_auth_sessions.go` (deviation 04-5). The verify.artifacts tool reports the planned path missing. |
|
||||
| U `login_gate_smoke_test.go` | VERIFIED | Run by name in the gate |
|
||||
| M `plugin.go`, `classes/challenge.go`, `classes/qr.go`, `buckets.go`, `tracer_smoke_test.go` | VERIFIED | verify.artifacts 5/5 |
|
||||
| M `classes/webauthn.go`, `classes/passwordless.go`, `updates/202610220006_*` | VERIFIED | |
|
||||
| S `classes/flow.go` | VERIFIED | `CheckLoginGates` |
|
||||
| D `vue-demo-app/app/pages/account/security.vue` | VERIFIED | The tool flags the missing literal `totp/setup`. The page calls `useTwoFactor().setupTotp()`, which posts to `/_user/api/v1/2fa/totp/setup` (`composables/useTwoFactor.ts`). Wired. |
|
||||
| D `mfa_test.go` (TestMFAMounted) | VERIFIED | PASS in `--demo` |
|
||||
| `scripts/check-phase19.6.sh` | VERIFIED | My `--all` run exited 0 |
|
||||
| M `login_paths_test.go`, `testdata/PHP_METHOD_MAP.md`, `19.6-SECURITY-REVIEW.md` | VERIFIED | verify.artifacts 4/4 |
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
The `verify.key-links` tool rejects paths outside this repository, so I checked every link by hand with grep.
|
||||
|
||||
| From | To | Via | Status |
|
||||
|------|----|-----|--------|
|
||||
| U `api_controller.go` | `classes/login_gate.go` | `CheckLoginGates(` in Login (1 call) | WIRED |
|
||||
| U `api_controller.go` | `jwt_jti` | `classes.FollowDeviceJTI` in Refresh (`HandOverDeviceJTI` wraps it) | WIRED |
|
||||
| U `devices.go` | `bouncer.BlacklistStore` | `app.Lookup[bouncer.BlacklistStore]()` `:547` | WIRED |
|
||||
| U `users_admin_controller.go` | `TwoFactorAdmin` | `app.Lookup[classes.TwoFactorAdmin]()` `:843` | WIRED |
|
||||
| M `plugin.go` | U `LoginGates` | `Lookup[*userclasses.LoginGates]` then `gates.Add(&classes.Gate{})`; Boot fails if the registry is absent | WIRED |
|
||||
| M `classes/login.go` | U helpers | `FinishLogin`, `IssueToken`, `APIArray`, `FireLoginEvent` | WIRED |
|
||||
| M `plugin.go` | U events | `Listen[*userclasses.AccessRevokedEvent]` (the plan named PasswordChangedEvent; deviation 01-1) | WIRED |
|
||||
| M `routes.go` | M `buckets.go` | `surf.Use("throttle:2fa-verify")` | WIRED |
|
||||
| M `classes/webauthn.go` | `cooler` | `cache.Pull` | WIRED |
|
||||
| S `classes/flow.go` | U `login_gates.go` | `Source: userclasses.LoginSourceSocial` | WIRED |
|
||||
| D `useDemoAuth.ts` | `/_user/api/v1/2fa/verify` | `$fetch` | WIRED |
|
||||
| D `summer.yaml` | M | `golem15.mfa`, `plugins.gen.go` | WIRED |
|
||||
| M | D `plugins/golem15/mfa` | gitlink 463c750 = v0.1.2 | WIRED |
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data | Source | Real data | Status |
|
||||
|----------|------|--------|-----------|--------|
|
||||
| Login challenge body | `challenge_token`, `available_methods`, `expires_at` | `CreateChallenge` inserts a row; `EnabledMethods` queries `golem15_user_two_factor_methods` | yes | FLOWING |
|
||||
| `2fa/status` | methods, trusted devices, WebAuthn credentials, remaining count | `classes/methods.go` status queries | yes | FLOWING |
|
||||
| Device status hand-over | `token`, `user` | `mintWithJTI` + `apiArray` after the guarded UPDATE on `golem15_user_device_auth_sessions` | yes | FLOWING |
|
||||
| Demo security page | `state`, `setup.qr_code_svg` | `useTwoFactor` → `2fa/status`, `totp/setup` | yes | FLOWING |
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| Whole phase gate at the released pins, Docker required | `PHASE196_REQUIRE_DOCKER=1 TESTCONTAINERS_HOST_OVERRIDE=127.0.0.1 bash scripts/check-phase19.6.sh --all` | exit 0; self-test, go, userplugin (5 + 1 `-race` + 1, additive check), mfa (11), sociallogin (9), demo (3), demo-web, bm (2), fonoteka, docs, docker (15 + 1 U, 21 M, 2 S) | PASS |
|
||||
| Review-fix regression tests outside the gate (U) | `go test -count=1 -run '^(TestDeviceRevokeRacesRefresh\|TestDeviceAccessRevocation)$' ./plugins/golem15/user` (fonoteka.go) | both PASS | PASS |
|
||||
| Review-fix regression tests outside the gate (M, S) | `go test -run '^(TestChallengeAfterRevocation\|TestPasswordConfirmationThrottle\|TestCompletionFailureSpendsNothing)$' .` in M; `-run '^TestChallengedSignInFiresOnCompletion$'` in S | all PASS (4 + 2 + 2 subtests) | PASS |
|
||||
| Demo module-mode build at the tags | `GOWORK=off GOFLAGS=-mod=readonly go build -trimpath .` (sm-demo-app) | ok; resolves user v0.1.13, mfa v0.1.2, sociallogin v0.1.5 | PASS |
|
||||
| M and S build at their own go.mod requires (user v0.1.12) | `GOWORK=off go build ./...` | exit 0 both | PASS |
|
||||
| A carries no second-factor module | `go list -m all \| grep -E 'webauthn\|pquerna\|boombuler'` (fonoteka.go) | no match | PASS |
|
||||
|
||||
### Probe Execution
|
||||
|
||||
No `scripts/*/tests/probe-*.sh` is declared or exists for this phase. The phase gate (above) is the runnable check.
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| ID | Source Plan | Status | Evidence |
|
||||
|----|-------------|--------|----------|
|
||||
| SC-1 | 02, 03 | SATISFIED (browser interop human) | Truths SC-1, 2.4-2.6, 2.9, 3.1-3.3 |
|
||||
| SC-2 | 01, 02, 03 | SATISFIED | SC-2, 1.2, 2.2 |
|
||||
| SC-3 | 01 | SATISFIED | SC-3, 1.7-1.10 |
|
||||
| SC-4 | 01-04 | SATISFIED | SC-4, 4.1-4.9 |
|
||||
| D-01 | 02, 03 | SATISFIED | 2.1, 3.8 |
|
||||
| D-02 | 01, 03, 04 | SATISFIED | 1.2, 1.4 |
|
||||
| D-03 | 01 | SATISFIED | 1.7 |
|
||||
| D-04 | 01, 02, 03 | SATISFIED | 1.1, 2.4, 3.1 (boombuler/barcode with its own SVG writer) |
|
||||
| D-05 | 02, 03, 04 | SATISFIED | 2.3-2.6, 3.2 (mail template now `golem15.mfa::`, as the plugin moved) |
|
||||
| D-06 | 01, 02, 03, 04 | SATISFIED | 2.8, TestDeviceTokensAtRest |
|
||||
| D-07 | 01, 02, 04 | SATISFIED | 1.5, 2.9 |
|
||||
| D-08 | 03, 04 | SATISFIED (browser human) | 3.1 |
|
||||
| D-09 | 02, 03, 04 | SATISFIED | 3.5, 2.10 |
|
||||
| D-10 | 02, 03, 04 | SATISFIED | 2.9 |
|
||||
| D-11 | 01, 04 | SATISFIED (default auth_url changed by WR-01) | 1.7 |
|
||||
| D-12 | 02, 04 | SATISFIED | 2.10 |
|
||||
| D-12a | 01, 04 | SATISFIED | 1.8 |
|
||||
| D-12b | 01, 04 | SATISFIED | 1.9 |
|
||||
| D-12c | 01, 04 | SATISFIED | 1.10 |
|
||||
| D-13 | 02, 04 | SATISFIED | 2.10 |
|
||||
| D-14 | 03, 04 | SATISFIED | 3.4 |
|
||||
| D-15 | 01, 02, 04 | SATISFIED | 1.6, 2.11 |
|
||||
| D-16 | 01, 02, 03, 04 | SATISFIED | 2.12, 3.3, SC-4 |
|
||||
| D-17 | 01, 03, 04 | SATISFIED (B push held) | 1.11, 3.7, 4.10 |
|
||||
| D-18 | 01, 02, 04 | SATISFIED | 1.3, 2.6, 2.7 |
|
||||
| D-19 | 02, 03, 04 | SATISFIED | 2.5 |
|
||||
| D-20 | 03 | SATISFIED (browser human) | 3.6 |
|
||||
|
||||
REQUIREMENTS.md maps no IDs to this phase ("Requirements: TBD"), so no requirement is orphaned. Every ID in CONTEXT is claimed by at least one plan.
|
||||
|
||||
### Decision Coverage
|
||||
|
||||
`check.decision-coverage-verify`: 23/23 trackable CONTEXT decisions honored by the shipped artifacts (not_honored: none). Non-blocking gate.
|
||||
|
||||
### Test Quality Audit
|
||||
|
||||
| Area | Active | Skipped | Circular | Assertion level | Verdict |
|
||||
|------|--------|---------|----------|-----------------|---------|
|
||||
| U gate, device and password tests | 15 + 1 Docker, 7 Docker-free | 0 in the gate run; `t.Skip` only under `-short`, and the gate counts a skip as a failure | no | behavioral (races, transactions, bodies) | OK |
|
||||
| M decision tests | 21 Docker, 11 Docker-free | 0 | no; PHP bodies are literals from PHP sources, PHP_METHOD_MAP from PHP's test file | value and behavioral | OK |
|
||||
| S gate tests | 2 Docker + WR-08 test | 0 | no | behavioral | OK |
|
||||
| D `twoFactor.test.ts` | in the demo-web vitest run | 0 | no | value (`svgDataUrl` has no `<`) | OK |
|
||||
|
||||
Disabled tests on requirements: 0. Circular patterns: 0. Insufficient assertions: 0. **Warning:** the six regression tests the review round added (TestDeviceRevokeRacesRefresh, TestDeviceAccessRevocation, TestChallengeAfterRevocation, TestPasswordConfirmationThrottle, TestCompletionFailureSpendsNothing, TestChallengedSignInFiresOnCompletion) are not in the gate's required-name lists. They pass (my run), but `check-phase19.6.sh` would not catch a later regression of CR-01, WR-02, WR-03, WR-06, WR-07 or WR-08.
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
I scanned the 137 files the phase touched (U `50da92c..07196a4`, all of M, S `v0.1.3..v0.1.5`):
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| (none) | - | TBD / FIXME / XXX | - | No debt marker. `XXXXX-XXXXX` format strings excluded. |
|
||||
| (none) | - | TODO / HACK / placeholder | - | None |
|
||||
| M, U log calls | - | secrets in logs | - | Only ids and `err` attributes (matches the review's "Log calls" sweep) |
|
||||
| sm-bm-app | - | release pins only local | ℹ️ Info | origin runs mfa v0.1.0 and user v0.1.12, which lack 71d8f27 and the review fixes, until bm is pushed (recorded decision) |
|
||||
| A `go.mod` | 11 | requires sm-user-plugin v0.1.10 | ℹ️ Info | resolved through `replace`; open item in deferred-items.md |
|
||||
| M, S `go.mod` | 6 | require sm-user-plugin v0.1.12 | ℹ️ Info | `GOWORK=off` builds pass; the apps select v0.1.13 by MVS |
|
||||
| `19.6-REVIEW.md` IN-01..IN-10 | - | info findings | ℹ️ Info | Open outside the fix scope the user chose. None contradicts an SC; IN-02 matches PHP's behaviour. |
|
||||
|
||||
### Recorded deferrals (user decisions, not gaps)
|
||||
|
||||
- **WR-05** (factor changes need only a session): partial mitigation in M 463c750; the full fix needs an API contract change. Todo `2026-10-26-mfa-factor-changes-need-reauth.md` (g15office krt3vrts). It does not contradict SC-1, because PHP's contract has the same exposure.
|
||||
- **F-1** (activate-by-code mints without the second factor when `require_activation: false`): todo `2026-10-26-activate-by-code-second-factor.md`. It needs a non-default configuration. SC-2 holds for the login path it names, and TestEveryLoginPathIsGated pins the mint site with its pointer.
|
||||
- **sm-bm-app push**: held because its unpushed range carries another session's commits.
|
||||
|
||||
### Prohibitions (judgment-tier, non-authoritative LLM-judge verdicts)
|
||||
|
||||
| Plan | Prohibition | Verdict |
|
||||
|------|-------------|---------|
|
||||
| 01 | No JWT, device token, short code, challenge token or password in a log line or error string | No violation found (log sweep of U `devices.go` and `api_controller.go`) |
|
||||
| 01 | sm-user-plugin changes additive only | No violation found (gate additive check against `50da92c`; the WR-01 default change concerns a key this phase introduced) |
|
||||
| 01 | No go.mod of U, framework or A requires the three libraries | No violation found |
|
||||
| 01 | Nothing pushed or tagged in plan 01 | No violation found (process; SUMMARY-attested) |
|
||||
| 01 | Framework code and READMEs name no consuming application | No violation found (gate docs: 503 files checked) |
|
||||
| 02 | No token, code, secret or password logged | No violation found |
|
||||
| 02 | goccy only; no `gopkg.in/yaml.v3` | No violation found (gate `--mfa` deps; grep) |
|
||||
| 02 | No notification mail, per-group exemption, Winter import or child exemption | No violation found (grep) |
|
||||
| 02 | Nothing pushed in plan 02 | No violation found (process) |
|
||||
| 02 | U unchanged by plan 02 except for defect fixes | No violation found (SUMMARY-attested) |
|
||||
| 03 | No assertion, attestation, challenge, token or library error logged or echoed | No violation found |
|
||||
| 03 | QR SVG rendered as an image data URL, never `v-html` | No violation found (`security.vue:130`; the existing `v-html` uses are in unrelated deck components) |
|
||||
| 03 | No remote operation before the go-ahead; no force-push; A never pushed; no deploy | No violation found (A is 38 commits ahead of origin) |
|
||||
| 03 | A does not mount mfa; B's frontend has no 2FA UI | No violation found |
|
||||
| 03 | The stale `../vue-demo-app` is not used | No violation found (commit 44a5229 lives in `sm-demo-app/vue-demo-app`, origin golem15/vue-demo-app) |
|
||||
| 04 | No production change except defect fixes | No violation found (71d8f27 only) |
|
||||
| 04 | No real secrets, authenticators or mail servers in tests | No violation found (virtual authenticator, capturing mailer) |
|
||||
| 04 | A skipped Docker test never counts as passed | No violation found (detector self-test PASS) |
|
||||
| 04 | Pushes and tags only as authorised; nothing force-pushed or deployed | No violation found (process; SUMMARY-attested) |
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
#### 1. Real-browser WebAuthn ceremony
|
||||
|
||||
**Test:** On an instance with `webauthn` in `two_factor_available_methods`, served over HTTPS with a hostname RP ID, register a security key and complete a login challenge with it (`2fa/webauthn/register/options` → `navigator.credentials.create` → `2fa/webauthn/register`; then `2fa/webauthn/options` → `navigator.credentials.get` → `2fa/webauthn/verify`). With `two_factor_passwordless_login` on, register a passkey and sign in through `2fa/webauthn/passwordless/options` and `/verify`.
|
||||
**Expected:** PHP's register body, `{token, user}` on verify, a passkey token with no second challenge, and a second-factor-only key refused for passwordless.
|
||||
**Why human:** The software-authenticator tests prove the server; only a browser proves the D-08 JSON shapes and the origin and RP ID configuration end to end. The demo cannot run this as configured (no WebAuthn screens, webauthn not offered), so a small client page is needed.
|
||||
|
||||
#### 2. Demo TOTP flow in a browser (D-20)
|
||||
|
||||
**Test:** Sign in on the demo, enable TOTP from the QR with an authenticator app, keep the recovery codes, sign out, answer the challenge with an app code and then with a recovery code, then disable TOTP.
|
||||
**Expected:** The QR scans, the recovery codes show once, the challenge step completes with either code, a used recovery code is refused, and after disable login is direct.
|
||||
**Why human:** User-facing look and flow; plans 03 and 04 record the browser run as NOT RUN.
|
||||
|
||||
#### 3. Judgment-tier prohibitions
|
||||
|
||||
**Test:** Review the 19 verdicts in "Prohibitions" above.
|
||||
**Expected:** Confirm or dispute each.
|
||||
**Why human:** ADR-550 D4: judgment-tier items never pass silently.
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
No gaps. Every roadmap criterion, every CONTEXT decision and every plan must-have is backed by code at the released tags and by named tests that pass in my own run of the phase gate with Docker required, plus the six review-fix regression tests I ran separately. The deviations from plan wording (AccessRevokedEvent for D-07, migration ID `202610260001`, the auth_url default changed by WR-01) are recorded in the SUMMARYs or the review disposition and keep each decision's intent. Before `passed`, three human items remain: a real-browser WebAuthn ceremony, the demo's TOTP flow in a browser, and the prohibition verdicts. Worth knowing beyond those: sm-bm-app's origin still runs the pre-review plugin versions until its held push, and the gate does not yet require the review round's regression tests by name.
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-10-26T15:58:30Z_
|
||||
_Verifier: Claude (msd-verifier)_
|
||||
Reference in New Issue
Block a user