From 41d145dde10ad3d44b4783b2529b7224f0460a97 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 28 Oct 2026 14:03:50 +0100 Subject: [PATCH] docs(phase-19.6): add security threat verification --- .../19.6-SECURITY.md | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 .planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-SECURITY.md diff --git a/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-SECURITY.md b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-SECURITY.md new file mode 100644 index 00000000..84b7aa34 --- /dev/null +++ b/.planning/phases/19.6-two-factor-auth-and-device-flow-in-sm-user-plugin/19.6-SECURITY.md @@ -0,0 +1,124 @@ +--- +phase: "19.6" +slug: "two-factor-auth-and-device-flow-in-sm-user-plugin" +status: verified +# threats_open = count of OPEN threats at or above workflow.security_block_on severity (the blocking gate) +threats_open: 0 +asvs_level: 1 +created: "2026-10-28" +--- + +# Phase 19.6 — Security + +> Per-phase security contract: threat register, accepted risks, and audit trail. + +Implementation lives in sibling repos: **U** = sm-user-plugin (`fonoteka.go/plugins/golem15/user`), **M** = `sm-mfa-plugin`, **S** = `sm-sociallogin-plugin`, **D** = `sm-demo-app/vue-demo-app`; **F** = this repo. The detailed per-threat evidence (mutation checks, log-call sweep, D-14 passkey rules, D-18 hardening) is in `19.6-SECURITY-REVIEW.md`; this file is the register and sign-off. + +--- + +## Trust Boundaries + +| Boundary | Description | Data Crossing | +|----------|-------------|---------------| +| first-factor login → token mint | a correct password must not yield a token while a second factor is pending | credentials, JWT | +| sm-user-plugin → other plugins | gates, admin reset service and listeners run M's code inside U's requests and transactions | user ids, transaction handle | +| unauthenticated device → devices/initiate, devices/status | anyone can create sessions and poll with a token | device token, short code, device name | +| signed-in user → devices/authorize, list, revoke | a bearer JWT binds or ends device sessions | device session ids, jti | +| JWT refresh → device revocation | a refreshed device token must stay revocable | jti lineage | +| holder of a password → challenge routes | unauthenticated clients present challenge tokens and guessed codes | challenge token, TOTP/email/recovery codes | +| signed-in user → management routes | a JWT changes the user's own factors | TOTP secrets, recovery codes, credentials | +| plugin → database / mailer | secrets at rest; one-time codes leave by email | hashed tokens, encrypted secret, bcrypt codes | +| browser authenticator / frontend origin → WebAuthn routes | attestations and assertions bound to server-side ceremony state, RP ID and origins | WebAuthn ceremony data | +| anonymous client → passwordless routes | a passkey alone yields a token | assertion, JWT | +| social provider callback → signIn | a social identity must not bypass an enabled second factor | provider identity, JWT | +| administrator → Users screen, settings | privileged reset of a user's factors; global 2FA policy | settings values | +| demo frontend → API | server-generated SVG rendered in the browser | QR SVG | +| test results → phase verdict; releases → app pins | a skipped test must not pass; fixes must reach every pin | gate output, git refs | + +--- + +## Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation | Status | +|-----------|----------|-----------|----------|-------------|------------|--------| +| T-19.6-01 | Elevation of privilege | login gate consultation | high | mitigate | U `controllers/login_gates.go` `CheckLoginGates`, called from `Login` before restore/mint; gate error → opaque 500; `TestLoginGates`, `TestLoginGateOrder`, `TestLoginGateSmoke/gate_error` | closed | +| T-19.6-02 | Tampering | soft-delete restore before second factor | medium | mitigate | restore after the gates; `TestLoginGateOrder/challenged_soft_deleted_stays_deleted` (U), `TestGate/deactivated_user_is_challenged_and_stays_deactivated` (M) | closed | +| T-19.6-03 | Spoofing | trusted devices surviving a password change | medium | mitigate | `AccessRevokedEvent` in the write transaction (U); M `classes/admin.go` `OnAccessRevoked`; `TestPasswordChangedSources`, `TestRevocation` | closed | +| T-19.6-04 | Elevation of privilege | Reset 2FA record action | medium | mitigate | U `classes/two_factor_admin.go` `PermissionResetTwoFactor` registered in `admin_permissions.go`, no default role; `TestResetTwoFactorAction`, `TestAdminReset` | closed | +| T-19.6-05 | Tampering | short-code guessing | medium | mitigate | U `classes/device_auth.go` `NewDeviceShortCode` (crypto/rand, 31^8); guarded authorize UPDATE; `TestDeviceCodes`, `TestDeviceAuthorize` (mutation-checked) | closed | +| T-19.6-06 | Spoofing | device-code phishing | medium | accept | D-12c, see AR-01 | closed | +| T-19.6-07 | Elevation of privilege | device hand-over race | high | mitigate | U `controllers/devices.go:282,300` guarded `status = 'confirmed'` UPDATE; `TestDeviceHandOverConcurrent` (mutation-checked) | closed | +| T-19.6-08 | Elevation of privilege | revoked device keeps refreshing | high | mitigate | U `classes/device_auth.go` `HandOverDeviceJTI`; revoke blacklists jti; `TestDeviceRevokeAfterRefresh`; `blacklist_grace` fork see AR-02 | closed | +| T-19.6-09 | Information disclosure | device tokens at rest and in URLs | medium | mitigate | SHA-256 only; F `modules/surf/accesslog.go` logs no path; `TestDeviceTokensAtRest`; proxy logging see AR-05 | closed | +| T-19.6-10 | Denial of service | initiate filling the table | low | mitigate | user-api bucket, bounded cleanup (≤500); `TestDeviceInitiate/cleanup_is_bounded` | closed | +| T-19.6-11 | Tampering | attacker-chosen device_name | low | mitigate | ≤255 runes, 422; `TestDeviceInitiate/device_name` | closed | +| T-19.6-12 | Tampering | core plugin contract | medium | mitigate | additive only; gate `--userplugin`, `--fonoteka` | closed | +| T-19.6-13 | Spoofing | TOTP/email brute force | high | mitigate | M `classes/challenge.go` `FailureCapReached`, 2fa-verify bucket, 5 attempts; `TestFailureCap`, `TestRoutesAndBuckets` | closed | +| T-19.6-14 | Tampering | parallel verifies | high | mitigate | M `classes/challenge.go:96,119` `completed_at IS NULL AND attempts < max_attempts`; `TestChallengeLifecycle/one_completion_when_factors_race` (mutation-checked) | closed | +| T-19.6-15 | Spoofing | TOTP replay | medium | mitigate | M `classes/methods.go` `AcceptTOTPCounter`; `TestTOTP/replay` | closed | +| T-19.6-16 | Spoofing | email downgrade of TOTP-only user | high | mitigate | M `classes/email.go` `SendEmailCode` gated on enabled email method; `TestEmailCode/gating` | closed | +| T-19.6-17 | Denial of service | mail bombing | medium | mitigate | M `classes/email.go:83` `email_sends < 3`; `TestEmailCode/sends_replace_and_cap` (mutation-checked) | closed | +| T-19.6-18 | Information disclosure | secrets at rest | high | mitigate | SHA-256 tokens, M `models/two_factor_method.go` `Secret lagoon.Encrypted`, bcrypt codes; `TestTokensAtRest` | closed | +| T-19.6-19 | Information disclosure | logs and error strings | medium | mitigate | log-call sweep (SECURITY-REVIEW "Log calls"); `TestEmailCode/never_logged` | closed | +| T-19.6-20 | Spoofing | stolen trusted-device token | medium | mitigate | M `classes/trusted.go`, `classes/gate.go`; `TestTrustedDevice`, `TestRevocation` | closed | +| T-19.6-21 | Elevation of privilege | management routes on other users' rows | high | mitigate | JWT-scoped queries, digit `{id}`; `TestRoutesAndBuckets`, `TestWebAuthn/delete_scoped_to_the_owner` | closed | +| T-19.6-22 | Information disclosure | recovery codes after creation | medium | mitigate | shown once, count route needs password; `TestRecovery/first_set_only_when_created`, `count_route` | closed | +| T-19.6-23 | Denial of service | bcrypt CPU per wrong code | low | mitigate | M `classes/recovery.go` `NormalizeRecoveryCode` (≤32 chars); `TestRecoveryCodeFormat` | closed | +| T-19.6-24 | Tampering | settings values | low | mitigate | M `models/settings.go` `BeforeValidate` allow-lists; defect fixed in M `71d8f27`, released v0.1.1; `TestSettings`, `TestPHPUser005` | closed | +| T-19.6-25 | Spoofing | client-chosen TOTP secret at confirm | low | accept | PHP parity, see AR-03 | closed | +| T-19.6-26 | Spoofing | client-supplied WebAuthn challenge | high | mitigate | server-side state in cooler, single-use `Pull`; `TestWebAuthnShapes/registration_state`, `TestWebAuthn/registration` | closed | +| T-19.6-27 | Elevation of privilege | passkey login with second-factor-only credential (D-14) | high | mitigate | M `classes/passwordless.go` `ValidatePasskeyLogin` with UV, `passwordless` column check; `TestPasswordless` (mutation-checked); D-14 table in SECURITY-REVIEW | closed | +| T-19.6-28 | Spoofing | cloned authenticator | medium | mitigate | `CloneWarning` refusal (M `classes/webauthn.go:506`, `classes/passwordless.go:110`); `TestWebAuthn/assertions` | closed | +| T-19.6-29 | Spoofing | RP ID / origin misconfiguration | medium | mitigate | M `classes/config.go` `loadWebAuthnConfig`; `TestWebAuthnShapes`, `TestPasswordless/refused_foreign_origin` | closed | +| T-19.6-30 | Elevation of privilege | social sign-in bypassing 2FA (D-09) | high | mitigate | S `classes/flow.go:343` `CheckLoginGates` with `LoginSourceSocial` before FinishLogin; `TestSocialGate`, `TestEveryLoginPathIsGated/social_sign_in` | closed | +| T-19.6-31 | Elevation of privilege | passkey login of banned/suspended/inactive account | high | mitigate | M `classes/passwordless.go:126` `RejectIfThrottled` + activation check; `TestPasswordless/refused_banned`, `refused_suspended`, `refused_not_activated` | closed | +| T-19.6-32 | Tampering | remote operations (plan 03 pushes) | medium | mitigate | process control: fetch + range check before every push, no force, no deploy (19.6-03-SUMMARY "Remote operations") | closed | +| T-19.6-33 | Tampering | demo rendering QR SVG | low | mitigate | D `app/lib/twoFactor.ts` `svgDataUrl` (img data URL, no `v-html`); `twoFactor.test.ts` | closed | +| T-19.6-34 | Information disclosure | user handle from numeric id (A4) | low | accept | see AR-04 | closed | +| T-19.6-35 | Denial of service | ceremony state in memory cooler, multi-instance (A11) | low | accept | README; fails closed; see AR-06 | closed | +| T-19.6-36 | Tampering | check-phase19.6.sh verdict | medium | mitigate | F `scripts/check-phase19.6.sh --self-test` (fail/skip/zero-match/missing-name detectors) | closed | +| T-19.6-37 | Tampering | post-release fixes not reaching apps | medium | mitigate | `71d8f27` is an ancestor of M tag v0.1.1; pinned by sm-demo-app `9166a1e`, sm-bm-app `fe4e292` | closed | +| T-19.6-38 | Elevation of privilege | token mint path bypassing 2FA | high | mitigate | M `login_paths_test.go` `TestEveryLoginPathIsGated` (every mint site listed); activate-by-code residual = F-1, see AR-07 | closed | +| T-19.6-SC | Tampering | Go module installs (plans 01–04) | high | mitigate | M `go.mod`: pquerna/otp v1.5.0, boombuler/barcode v1.1.0, go-webauthn v0.18.2 (approved, `.planning/notes/2026-10-21-mfa-otp-webauthn.md`); `go list -deps` of M has no `gopkg.in/yaml.v3`; fonoteka.go `go.mod`/`go.sum` have no go-webauthn | closed | + +*Status: open · closed · open — below high threshold (non-blocking)* +*Severity: critical > high > medium > low — only open threats at or above workflow.security_block_on count toward threats_open* +*Disposition: mitigate (implementation required) · accept (documented risk) · transfer (third-party)* + +--- + +## Accepted Risks Log + +| Risk ID | Threat Ref | Rationale | Accepted By | Date | +|---------|------------|-----------|-------------|------| +| AR-01 | T-19.6-06 | Device-code phishing: D-12c keeps PHP's authorize-with-JWT rule; the device list shows name and IP and revoke signs the device out at once; the frontend must show device name and address before confirming. | user (D-12c, 19.6-CONTEXT) | 2026-10-26 | +| AR-02 | T-19.6-08 | `blacklist_grace` > 0 lets two concurrent refreshes fork; only one jti is followed, so revoke misses the other until it expires. Default grace is 0; a password change ends both. | plan 01 (Pitfall 15) | 2026-10-26 | +| AR-03 | T-19.6-25 | Client-chosen TOTP secret at confirm (PHP parity): own account only, base32 and 16–64 chars validated, a current code from the secret required. | plan 02 threat model | 2026-10-26 | +| AR-04 | T-19.6-34 | PHP-compatible 8-byte user handle reveals only the internal user id to the user's own authenticator; keeps imported passkeys mappable. | plan 03 threat model (A4) | 2026-10-26 | +| AR-05 | T-19.6-09 | Reverse proxies may log `devices/status/{token}`; the token lives five minutes and is single use (README). | plan 01 threat model | 2026-10-26 | +| AR-06 | T-19.6-35 | With the memory cooler a WebAuthn ceremony must stay on one instance; multi-instance apps use the Redis cooler (README); ceremonies fail closed otherwise. | plan 03 threat model (A11) | 2026-10-26 | +| AR-07 | T-19.6-38 (F-1) | activate-by-code mints a token without the second factor only when `require_activation: false` and the user enabled 2FA before activating; the mailed activation code stands in for the second factor once. Low impact, non-default config. Deferred; pinned by `TestEveryLoginPathIsGated/mint_sites`; todo `.planning/todos/pending/2026-10-26-activate-by-code-second-factor.md`. | user (plan 04 Task 5, option c) | 2026-10-26 | + +*Accepted risks do not resurface in future audit runs.* + +--- + +## Security Audit Trail + +| Audit Date | Threats Total | Closed | Open | Run By | +|------------|---------------|--------|------|--------| +| 2026-10-26 | 42 | 42 | 0 | plan 04 security review (`19.6-SECURITY-REVIEW.md`; mutation checks, `check-phase19.6.sh --all` with Docker) | +| 2026-10-28 | 42 | 42 | 0 | /msd-secure-phase (L1 grep verification of cited mitigations in U, M, S, D, F; short-circuit: register authored at plan time, ASVS 1) | + +Threat count: T-19.6-01..38 plus four T-19.6-SC rows (plans 01–04), merged into one row above. + +--- + +## Sign-Off + +- [x] All threats have a disposition (mitigate / accept / transfer) +- [x] Accepted risks documented in Accepted Risks Log +- [x] `threats_open: 0` confirmed +- [x] `status: verified` set in frontmatter + +**Approval:** verified 2026-10-28