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/PLAN.md b/installer/PLAN.md index fd10c38..76f005f 100644 --- a/installer/PLAN.md +++ b/installer/PLAN.md @@ -1,6 +1,9 @@ # Runic Gateway Installer — plan -Status: **Phase 0 in progress.** No installer code exists yet. This document is the design of record; +Status: **Phase 0 all but complete** — every prerequisite in another repo has landed, and the +installer repo now publishes the bundle manifest, so *what* the installer will install is already +released and composed ahead of the binary that installs it. No installer code exists yet; `0.4` +(`INSTALL.md`) is the remaining item, then Phase 1. This document is the design of record; it supersedes the informal overview it grew out of, which described a ServUO integration that does not match how `servuo-plugins` actually ships (see [Corrections](#corrections-to-the-original-overview)). @@ -8,9 +11,9 @@ not match how `servuo-plugins` actually ships (see | Phase 0 item | State | |---|---| | 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`) | 🟨 In review — [link#24](https://gitea.whitlocktech.com/RunicGateway/link/pulls/24) | -| 0.3 Bundle CI in the installer repo | ⬜ Not started — **next**; both components it composes now exist | -| 0.4 This file + `INSTALL.md` | 🟦 This file exists; `INSTALL.md` waits on the shape settling | +| 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 | 🟨 In review — [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` | +| 0.4 This file + `INSTALL.md` | 🟦 This file exists; `INSTALL.md` is **next** — the shape has now settled | | — 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)) | --- @@ -298,6 +301,39 @@ Repo work that must land before an installer can exist. two gates → publish `bundle.json`), the nightly cron, and the dispatch step appended to each component's release workflow. This must exist before Phase 1 is useful, since the installer resolves what to install *from* the bundle. + + As built ([installer#3](https://gitea.whitlocktech.com/RunicGateway/installer/pulls/3), + [link#25](https://gitea.whitlocktech.com/RunicGateway/link/pulls/25), + [servuo-plugins#9](https://gitea.whitlocktech.com/RunicGateway/servuo-plugins/pulls/9)) — + `installer/.gitea/workflows/bundle.yml`, with the decisions §7 had left open: + + - **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. + - **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 + `sidecar/src/main.rs` at the tag the release was built from works uniformly, needs no execution + of a downloaded artifact, and does not provision a throwaway config whose auth token would then + be sitting in a CI log. A constant that has moved or been renamed is a hard failure — treating + "could not read" as "matches" is exactly how a mismatched pair would ship. + - **Gate 2 records the hash CI computed itself**, after verifying the download against the + publishing repo's `SHA256SUMS`. It also asserts the reverse direction — an asset with *no* + `SHA256SUMS` entry — because `sha256sum -c` silently passes over a file the sums file does not + mention, which would put an unverified artifact in the bundle. + - **Release metadata is read anonymously**, on purpose: those are exactly the requests the + shipped installer makes on a host with no Gitea credentials, so a repo flipped to private + fails CI here instead of on an operator's machine. + - **An unrecognized asset name is a hard failure.** link's binaries are mapped onto platform keys + by suffix; adding a target (aarch64, macOS) to its release workflow therefore reddens this job + rather than silently omitting the new binary from every bundle. + - **A run that changes nothing writes nothing** — the comparison excludes `bundle` and + `generated`, which are metadata about the run. Without that the nightly cron would commit a + dated duplicate of the same matrix every morning. + + The workflow's compose steps were run against the live releases before merge, producing the + first bundle (`2026.08.04`: link `v1.1.0` + overlay `v0.1.1`, protocol 3), which is committed so + the manifest exists ahead of the binary that reads it. 4. **`docs`: this file, plus `docs/installer/INSTALL.md`** (the operator-facing guide) once the shape is settled. @@ -471,15 +507,33 @@ resolving "latest", CI publishes a small manifest naming an exact, checked combi ```json { - "bundle": "2026.08.01", + "schema": 1, + "bundle": "2026.08.04", + "generated": "2026-08-04T16:07:13Z", "protocol": 3, - "link": { "version": "0.3.0", "sha256": "a91f..." }, - "overlay": { "version": "0.1.0", "commit": "968b526", "sha256": "7c3e..." } + "link": { + "repo": "RunicGateway/link", "tag": "v1.1.0", "version": "1.1.0", "protocol": 3, + "assets": { + "linux-x86_64": { "name": "uo-link-sidecar-linux-x86_64", "url": "…", "sha256": "27d491ef…" }, + "windows-x86_64": { "name": "uo-link-sidecar-windows-x86_64.exe", "url": "…", "sha256": "fbefd886…" } + } + }, + "overlay": { + "repo": "RunicGateway/servuo-plugins", "tag": "v0.1.1", "version": "0.1.1", + "commit": "3a52abb…", "protocol": 3, + "servuo": { "min_version": "57.4", "patches_verified_against": "57.4" }, + "asset": { "name": "runicgateway-overlay-0.1.1.tar.gz", "url": "…", "sha256": "75dc6d6c…" } + } } ``` +Note `link.assets` is a **map keyed by platform**, not the single `sha256` this section originally +sketched: link publishes a Linux binary and a Windows `.exe`, and the installer runs on both, so one +hash could only ever have described one of them. `schema` versions this document's shape and is +independent of `protocol` and of either component's release version — all three move separately. + The installer fetches the current bundle at run time; `--bundle ` pins an older one for a -reproducible install. Because the bundle is data, **a new `link` release regenerates ~20 lines of +reproducible install. Because the bundle is data, **a new `link` release regenerates ~30 lines of JSON and leaves the installer binary untouched** — operators do not re-download the installer to pick up a sidecar patch, and the installer does not accumulate releases whose code is byte-identical. @@ -487,19 +541,53 @@ Two gates run at compose time, both cheap and both worth it: 1. The sidecar's `PROTOCOL_VERSION` must equal the overlay manifest's declared protocol version. This is the check that catches an `edge`/`main` protocol mismatch before it reaches an operator. + The two halves are read from different places because they *are* different: the overlay's from + `manifest.json` inside the tarball (the only statement of it that exists — §7.0), the sidecar's + from `sidecar/src/main.rs` at the release tag (see Phase 0 item 3 for why not from the binary). 2. Every referenced asset must exist and its SHA256 must match the publishing repo's `SHA256SUMS`. + The hash recorded in the bundle is the one CI computed from the asset it downloaded, *after* that + check — and the installer verifies every download against it. These artifacts are deliberately + unsigned (§3), so the checksum is the whole trust anchor; a hash copied from a file nobody + verified would make the chain decorative. + +#### 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): + +``` +bundles/current.json → …/RunicGateway/installer/raw/branch/main/bundles/current.json +bundles/bundle-.json → …/raw/branch/main/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. + +**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`. ### 7.2 What triggers a bundle | Trigger | Why | |---|---| -| `link` publishes a release | Its release job `POST`s to the installer repo's workflow-dispatch endpoint as its final step. `link/.gitea/workflows/release.yml` already declares `workflow_dispatch: {}` and already holds a `write:repository` token | -| `servuo-plugins` publishes a release | Same. Phase 0 item 1 gave it the release workflow; the dispatch step is marked as a TODO in that workflow's header and lands with the bundle CI it would call (item 3) — a step that `404`s on every release is worse than no step | +| `link` publishes a release | Its release job `POST`s to the installer repo's workflow-dispatch endpoint as its final step | +| `servuo-plugins` publishes a release | Same. Phase 0 item 1 gave it the release workflow; the dispatch step was left as a marked TODO until there was something to dispatch, and landed with the bundle CI it calls (item 3) — a step that `404`s on every release is worse than no step | | Nightly cron on the installer repo | Recomputes from whatever the latest releases actually are, so a missed or failed dispatch self-heals instead of silently pinning operators to a stale sidecar | `repository_dispatch` is deliberately avoided — support for it is uncertain on this Gitea version, whereas dispatching an existing `workflow_dispatch` workflow via the API works today. +**A failed dispatch is a warning, never a failed release.** By the time that step runs the component +release is published and correct; failing the job would misreport it. This also keeps the dispatch +from becoming a new hard credential requirement — `REGISTRY_TOKEN` having write on the installer +repo is a nicety, and without it the nightly cron picks the release up anyway. A dropped dispatch +costs latency, not correctness, which is the whole reason the cron exists. + ### 7.3 Stale-overlay handling: dispatch, don't wait Each component **self-releases on merge to its own `main`**, using the same conventional-commit @@ -507,6 +595,12 @@ engine. Note that "updated since the last release" must mean *releasable* commit `RELEASE=false` when nothing but `docs:`/`chore:` has landed, so a docs typo correctly does **not** cut an overlay release, and the bundle keeps using the existing one. +The compose job's copy of that rule additionally **excludes merge commits**, whose subject is +`Merge pull request ''`. Without that, every squash-free merge of a `feat:` branch +would be counted twice, and worse, a merge of a `docs:` branch whose *title* happens to quote a +`fix:` would be read as releasable — re-dispatching, every night, a release workflow that correctly +declines to run. + So by the time the installer's CI looks, the release normally already exists. If it finds `servuo-plugins` main ahead of its latest release *with* releasable commits, it: