diff --git a/android/TRUSTED_DEVICES_APP_HANDOFF.md b/android/TRUSTED_DEVICES_APP_HANDOFF.md new file mode 100644 index 0000000..81ef644 --- /dev/null +++ b/android/TRUSTED_DEVICES_APP_HANDOFF.md @@ -0,0 +1,126 @@ +# Handoff — Trusted Devices & MFA: remaining work + +> Written 2026-07-22 at the end of the backend/web implementation session, for a +> fresh session to continue. Canonical design: `docs/website/TRUSTED_DEVICES_MFA.md`. +> App-side plan: `docs/android/PLAN.md §4.1.1`. This doc is the "what's left + how". + +## 1. Status at handoff + +**Done, in review, and live-smoke-tested** (real MariaDB + AVD): +- Backend (schema, session service, web + mobile login, self-service + admin + endpoints, invalidation), web client UI, admin front-end UI, OpenAPI spec, + 33 server tests — all in **website PR #93** (branch `feature/trusted-devices-mfa`). +- Docs (BACKEND_DESIGN §3/§4/§6, TRUSTED_DEVICES_MFA.md, PLAN §4.1.1) — **docs PR #32** + (branch `docs/trusted-devices-mfa`). +- Live smoke test passed end-to-end: trusted-device TOTP-skip (web + mobile + `X-Trust-Token`), recovery-code login (single-use), cap 409, admin MFA reset, + audit logging, and a real 2FA login through the Android app on an emulator. + +**Not done — the two remaining items below.** + +## 2. Remaining item A — merge gate (no code) + +- **website#93** and **docs#32** need CI green + review, then merge to `main`. +- CI (`.gitea/workflows/pr-checks.yml`) runs server tests, client build, bot install. +- Nothing to build here; just get them reviewed/merged. The Android work should land + **after** #93 merges so the app builds against the merged contract. + +## 3. Remaining item B — Android app implementation (the real work) + +The backend is fully ready and additive; the existing app is unaffected (verified). +The app just needs to *consume* the new endpoints. Own PR in the **`android-app`** +repo, branch `feature/trusted-devices-mfa`. Package id `com.runicgateway.app`. + +### 3.1 Scope (from PLAN §4.1.1) +1. **Login/TOTP screen:** on the existing `401 { totpRequired }` step, add + - a **"Trust this device"** checkbox, and + - a **"use a recovery code instead"** toggle (send `recoveryCode` instead of `code`). +2. **Trust token storage:** when a login response carries `trustToken`, store it in + **EncryptedSharedPreferences** (same secure store as the bearer tokens — never + plain prefs/logs). On subsequent logins send it as the **`X-Trust-Token`** header + so the server skips the TOTP prompt. +3. **Cap handling:** a login response with `{ trustLimitReached: true, devices }` + means show the device list and prompt the user to revoke one + (`DELETE /auth/me/trusted-devices/:id`) then retry trusting. +4. **Account screens:** + - **Trusted Devices**: list (`GET /auth/me/trusted-devices`), revoke one, untrust + all; "trust this device" (`POST /auth/me/trusted-devices` → returns `trustToken` + for native — store it). + - **Recovery Codes**: show the one-time batch returned by TOTP enable; remaining + count (`GET …/recovery-codes/status`); regenerate (`POST …/recovery-codes/generate`, + password step-up) with a show-once display + copy/share. +5. **Invalidation:** on logout / dead-refresh sign-out / Settings→Server switch, + **clear the stored `trustToken`** alongside the bearer tokens. (A server-side + password change/reset or TOTP disable already revokes it.) +6. **Tests:** JVM `:app:testDebugUnitTest` — DTO decode for the new fields, and + repository logic (trust-token persist/clear, recoveryCode vs code branch). + +### 3.2 Exact API contract the app consumes + +`POST /auth/mobile/login` — body `{ username, password, code?, recoveryCode?, trustDevice?, device_name? }`, optional header `X-Trust-Token: ` +- `200` → `{ accessToken, refreshToken, expiresIn, user:{id,username,role}, trustToken?, trustLimitReached?, devices? }` + - `trustToken` present only when `trustDevice:true` was accepted (store it). + - `trustLimitReached:true` + `devices[]` when at the cap (login still succeeded). +- `401` → `{ totpRequired:true, message }` (missing/invalid 2nd factor) — reveal the + code field (existing behavior); or generic `{ message }` for bad credentials. +- A valid `X-Trust-Token` bound to the user makes a code unnecessary → straight `200`. + +Self-service (Bearer access token): +- `GET /auth/me/trusted-devices` → `[{ id, platform, deviceName, userAgent, createdAt, lastUsedAt, expiresAt }]` +- `POST /auth/me/trusted-devices` body `{ deviceName? }` → `{ trusted:true, trustToken }` (native) | `409 { error:'trusted_device_limit', devices }` +- `DELETE /auth/me/trusted-devices/:id` → `{ revoked:boolean }` +- `DELETE /auth/me/trusted-devices` → `{ revoked:number }` +- `GET /auth/me/account/recovery-codes/status` → `{ remaining:number }` +- `POST /auth/me/account/recovery-codes/generate` body `{ currentPassword? }` → `{ recoveryCodes:[string] }` +- `POST /auth/me/account/totp/enable` body `{ code }` → `{ totp_enabled:true, recoveryCodes:[string] }` (codes shown once) + +The OpenAPI spec (`website/server/swagger/swagger-output.json`, schemas `TrustedDevice`, +`TrustDeviceResult`, `TrustedDeviceLimit`, `RecoveryCodes`) is the source of truth. + +### 3.3 Where it likely goes in the app +The app already handles `totpRequired` (reveals a code field — verified live), so the +auth surface exists. Extend the existing auth Retrofit API + repository + login +ViewModel/screen, add a secure `trustToken` accessor to the encrypted token store, +and add two account screens. Explore `android-app/app/src/main/java/com/runicgateway/app/` +(auth/data/core modules) at the start — don't assume file names. + +## 4. Environment & how-to (verified this session) + +- **DB:** Docker container `uomm-db`, MariaDB on host port **3307**, db `uomysticmoon`, + user `uomm` (password: `docker exec uomm-db printenv MARIADB_PASSWORD`). +- **Run the server:** `cd website/server && node src/server.js` (uses `.env`, already + points at 127.0.0.1:3307). It ensures schema + seeds on boot. For cap testing set + `MAX_TRUSTED_DEVICES=2`; recovery count via `RECOVERY_CODE_COUNT`. + - Pre-existing noise: `uo-link-socket … Unsupported state` decrypt errors are an old + encrypted `uo_link_config` row, unrelated — ignore. +- **Server tests:** `cd website/server && DB_HOST=127.0.0.1 DB_PORT=59999 node --test` + (dead port by design; models stubbed). Client: `cd website/client && npm test`. +- **TOTP codes for manual testing:** from `website/server`, + `node -e "process.stdout.write(require('speakeasy').totp({secret:'',encoding:'base32'}))"`. +- **Android build:** JDK 21; `cd android-app && ./gradlew :app:assembleDebug -Pksp.incremental=false`. + Unit tests: `./gradlew :app:testDebugUnitTest -Pksp.incremental=false`. +- **Emulator:** SDK at `~/AppData/Local/Android/Sdk`; AVDs `Medium_Phone_API_36.1`, + `s22_ultra`. `adb install -r -g app/build/outputs/apk/debug/app-debug.apk`. +- **⚠ Cleartext HTTP gotcha (dev only):** the app blocks plain HTTP to `10.0.2.2` + (`CLEARTEXT communication … not permitted`). Cleartext to `127.0.0.1` **is** allowed, + so for local testing run `adb reverse tcp:3000 tcp:3000` and set the app's server URL + to `http://127.0.0.1:3000` (its default). Production uses HTTPS via the proxy — not a + code issue. (Consider whether v1 wants a `network_security_config` dev exception; not + required for the feature.) + +## 5. Conventions (CLAUDE.md) + +- Branch from up-to-date `main`; commit/push as **`wtclaude`** using the token at + `C:\Users\colby\.gitea_token_claude` via `http.extraHeader` (never inline in URL). +- Conventional Commits; end commit messages with the `Co-Authored-By: Claude …` + + `Claude-Session:` trailers. PR template requires ticking the **AI-assisted** box + (tool: Claude Code) and the license box. +- Use `mcp__gitea__*` for PRs/issues. GPL-3.0-or-later. + +## 6. First moves for the fresh session +1. Check whether website#93 / docs#32 have merged (`mcp__gitea__pull_request_read`). +2. In `android-app`: sync `main`, `git checkout -b feature/trusted-devices-mfa`. +3. Explore the app's auth module; implement §3 against the §3.2 contract. +4. Test with the local server + emulator via the §4 cleartext workaround. +5. Open a single `android-app` PR; update `docs/android/PLAN.md` if the app design + deviates from §4.1.1. diff --git a/installer/INSTALL.md b/installer/INSTALL.md index 65a198c..d9b0654 100644 --- a/installer/INSTALL.md +++ b/installer/INSTALL.md @@ -648,7 +648,7 @@ Throughout: `` is your ServUO root, and **the shard is stopped**. ### A1. Fetch the bundle (so you install a checked pair) ```bash -curl -s https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/main/bundles/current.json +curl -s https://gitea.whitlocktech.com/RunicGateway/installer/raw/branch/bundles/current.json ``` It names the sidecar tag, the overlay tag, their agreed `protocol`, and the SHA256 of every asset. diff --git a/installer/PLAN.md b/installer/PLAN.md index 2b068c0..aa09c83 100644 --- a/installer/PLAN.md +++ b/installer/PLAN.md @@ -35,7 +35,7 @@ described a ServUO integration that does not match how `servuo-plugins` actually |---|---| | 0.1 `servuo-plugins` release workflow | ✅ Merged — [servuo-plugins#7](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/7) + [#8](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/8); first overlay release is [`v0.1.1`](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/releases/tag/v0.1.1) | | 0.2 `link` installable (data paths + `--print-config`) | ✅ Merged — [link#24](https://gitea.whitlocktech.com/RunicGateway/link/pulls/24) (docs half [docs#84](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/84)); released as [`v1.1.0`](https://gitea.whitlocktech.com/RunicGateway/link/releases/tag/v1.1.0) | -| 0.3 Bundle CI in the installer repo | ✅ Merged — [installer#3](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/3), plus the dispatch step in each component ([link#25](https://gitea.whitlocktech.com/RunicGateway/link/pulls/25), [servuo-plugins#9](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/9)). First bundle: [`2026.08.04`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/main/bundles/current.json) | +| 0.3 Bundle CI in the installer repo | ✅ Merged — [installer#3](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/3), plus the dispatch step in each component ([link#25](https://gitea.whitlocktech.com/RunicGateway/link/pulls/25), [servuo-plugins#9](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/9)). First bundle: [`2026.08.04`](https://gitea.whitlocktech.com/RunicGateway/installer/src/branch/bundles/current.json) | | 0.4 This file + `INSTALL.md` | ✅ Merged — [docs#87](https://gitea.whitlocktech.com/RunicGateway/docs/pulls/87). [`INSTALL.md`](INSTALL.md) is the operator guide, written before the binary because it *is* the specification of the run | | — Repo bootstrap (governance + CI) | ✅ [`RunicGateway/installer`](https://gitea.whitlocktech.com/RunicGateway/installer) created; workflows merged ([installer#1](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/1), [#2](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/2)) | @@ -433,7 +433,9 @@ Repo work that must land before an installer can exist. - **Bundles are committed to the installer repo, not published as releases** — see §7.1 for where and why. That was the one genuinely open question here, and the deciding factor is that - this repo's *own* releases are the installer binaries. + this repo's *own* releases are the installer binaries. They were committed to `main` until + 2026-08-05, when the first run that actually had a bundle to write found `main` protected; + they now live on a `bundles` branch (§7.1). - **Gate 1 reads the sidecar's protocol from source at the release tag**, not from the binary. `--print-config` (Phase 0.2) would answer authoritatively, but only for releases from `v1.1.0` onward, and `--bundle ` has to be able to recompose a bundle from an older pair. Reading @@ -1200,24 +1202,44 @@ Two gates run at compose time, both cheap and both worth it: #### Where bundles are published -Committed to the installer repo under `bundles/`, so the installer's fetch is a plain anonymous -`GET` against a public repo — the shard host has no Gitea credentials (§1): +Committed to the installer repo, on a **`bundles` branch of their own** and at its root, so the +installer's fetch is a plain anonymous `GET` against a public repo — the shard host has no Gitea +credentials (§1): ``` -bundles/current.json → …/RunicGateway/installer/raw/branch/main/bundles/current.json -bundles/bundle-.json → …/raw/branch/main/bundles/bundle-2026.08.04.json (--bundle) +current.json → …/RunicGateway/installer/raw/branch/bundles/current.json +bundle-.json → …/raw/branch/bundles/bundle-2026.08.04.json (--bundle) ``` Every bundle is kept forever, so `--bundle` stays reproducible. Tags are UTC dates; a second bundle on the same day — a sidecar release in the morning and an overlay release in the afternoon is the normal way that happens — becomes `2026.08.04.2`, so one tag always names exactly one matrix. +**A branch, not `main`, and that correction cost a day.** This section originally said bundles were +committed to `main` and "needs no new branch-protection exception: `release.yml`'s version-bump +commit already requires the CI user to be able to push to `main`". Both halves were wrong. `main` is +protected and declines a push from CI (`pre-receive hook declined`), and `release.yml` had never +pushed anything — its bump step has never executed in any repo carrying it, because an **empty +template expression written literally in one of its comments** makes the runner fail to build the +step and skip it *without failing the job*. The tags exist because Gitea's release API creates one +when it publishes. So the assumption that a working push path already existed was never tested by +anything. + +Publishing to a branch of its own keeps every property the original choice was for — a reviewable +diff, a git history of the compat matrix, plain anonymous raw URLs, no credentials on the shard host +— and needs no exception at all. The alternative, whitelisting a scheduled job for pushes to the +default branch, buys nothing this does not. + **Not one Gitea release per bundle**, which was the obvious alternative. This repo's own releases are the installer *binaries*, and `/releases/latest` returns whichever release is newest regardless of kind — interleaving bundle releases would make "latest" intermittently resolve to a release -carrying no installer binary. Committing also yields a reviewable diff and a git history of the -compat matrix, and needs no new branch-protection exception: `release.yml`'s version-bump commit -already requires the CI user to be able to push to `main`. +carrying no installer binary. + +**The release workflows tag and never write to a branch**, for the same reason and settled at the +same time (org lead, 2026-08-05): the tag *is* the version, as `servuo-plugins` has always done it. +The version is still written into `Cargo.toml` before building — so a released binary self-reports +correctly — but is no longer committed back, and the next version is computed from the newest tag. +A first release must not depend on a write to a protected branch. ### 7.2 What triggers a bundle