diff --git a/CONTEXT-MAP.md b/CONTEXT-MAP.md index 28b283de0..f90bbe61a 100644 --- a/CONTEXT-MAP.md +++ b/CONTEXT-MAP.md @@ -15,6 +15,7 @@ PiFinder is a multi-process Raspberry Pi finder/plate-solver. These contexts eac - [Display](./docs/ax/display/CONTEXT.md) — how panels turn rendered pixels into light: each panel's brightness axes, the dimming policy (knee curve), and the camera-as-photometer bench rig that measures panel flux. Runtime policy + bench tooling. - [NixOS](./docs/ax/nixos/CONTEXT.md) — how a NixOS PiFinder is built, published, and updated over the air: the Attic cache, the stable/beta/unstable channels, and the on-device upgrade flow. Cross-cutting infrastructure, not a runtime slice. - [Bring-up](./docs/ax/bringup/CONTEXT.md) — first power-on validation of a freshly assembled board: the checks a builder runs at the bench and which of them can be machine-verified. Bench tooling, not a runtime slice. +- [Network](./docs/ax/network/CONTEXT.md) — the device's WiFi identity and connections: AP vs Client mode, first-boot AP provisioning (random SSID + WPA2 on new units, [ADR 0034](./docs/adr/0034-ap-provisioning-ssid-tokens-encrypted-default.md)), client network credentials, and getting another device connected (Connect WiFi screen, web Network page). ## Relationships @@ -40,6 +41,9 @@ PiFinder is a multi-process Raspberry Pi finder/plate-solver. These contexts eac - **Bring-up → UI**: bring-up reuses the display drivers, fonts and layout helpers, but **not** `UIModule`, `MenuManager` or the menu tree — it draws its own frames and never joins the navigation stack. It reads the keypad as **switches** at their **matrix positions**, below the layer where UI's logical **keys**, `ALT_*` chords and `LNG_*` long presses are formed. - **Bring-up ↛ `hardware_detect`**: unlike `main.py` and `splash.py`, bring-up does **not** derive its panel from the BQ25895 probe. Doing so would make a dead charger indistinguishable from a dead screen on exactly the boards it exists to diagnose. +- **Network → UI**: the **Connect WiFi screen** renders the AP's identity — SSID, **AP passphrase**, **WiFi QR code** — from the Network context. On a **new unit** (encrypted by default, ADR 0034) it is the primary join path, not a convenience view, so it must work on every panel. +- **Network → NixOS**: the migration converts **client networks** into NetworkManager keyfiles (`nixos_migration_wifi.py`); AP provisioning has no NixOS equivalent yet, so the encrypted-by-default AP posture must survive migration deliberately, not by luck (ADR 0034). + Companion architecture docs live next to each `CONTEXT.md`: - [`docs/ax/nixos.md`](./docs/ax/nixos.md) - [`docs/ax/catalog.md`](./docs/ax/catalog.md) diff --git a/docs/adr/0034-ap-provisioning-ssid-tokens-encrypted-default.md b/docs/adr/0034-ap-provisioning-ssid-tokens-encrypted-default.md new file mode 100644 index 000000000..ddcd65860 --- /dev/null +++ b/docs/adr/0034-ap-provisioning-ssid-tokens-encrypted-default.md @@ -0,0 +1,39 @@ +# AP provisioning via SSID tokens: new units ship encrypted (EN 18031) + +Fresh images ship their access point pre-configured as `ssid=PiFinder-CHANGEME-ENCRYPTME`. First boot consumes the tokens: `CHANGEME` becomes a random `PiFinder-XXXXX`, `ENCRYPTME` becomes WPA2 with a passphrase generated on that unit and shown nowhere but its own screen. Consuming a token strips it from the SSID. A software upgrade never provisions — units already in the field keep whatever AP they have, open or not. + +## Why the default flipped + +Issue #179 asked for exactly this in 2024: encrypted AP, random per-unit password. Review on PR #311 talked it down to opt-in, for a reason that was and is real: if the UI fails to start, an encrypted AP is one nobody can join, and the fallback is reading a 20-character password off a 1.5-inch screen — mrosseel called it a support nightmare. The QR code came out of that same thread (brickbots) as the mitigation, and the PR settled on "random name by default, encryption if you ask for it". + +EN 18031 — the EU cybersecurity baseline backing the Radio Equipment Directive — takes the choice away for units placed on the EU market: an open-by-default radio configuration with no per-unit credentials is no longer shippable. So the 2025 preference lost to a 2025 regulation, and the objection converts into a mitigation bill rather than a veto: the Connect WiFi screen (QR join) and the recovery reset are now load-bearing, and both get validated on real hardware, on every panel type, before release. + +## Why tokens in the SSID + +A per-unit secret cannot be baked into a shared image — every builder flashes the same published file, so anything the image contains is by definition not unique. Whatever the mechanism, credentials must be *generated* at first boot, which needs a durable marker for "not yet generated". + +The SSID itself is that marker. It lives in the exact file being provisioned, so it survives image flashing by construction and cannot drift out of sync with the thing it describes; there is no sidecar state file to lose or contradict. It is also self-describing in failure: a `PiFinder-CHANGEME` showing up in a phone's WiFi list tells you precisely which step never ran. + +Stripping the token on consumption is what makes the boundary work: "tokens present" and "work to do" become the same predicate, provisioning is idempotent, and a field-upgraded unit (no tokens) is untouched with no version arithmetic at all. The #311 draft never stripped `ENCRYPTME` — under this default that means every new unit rewrites its config on every boot, which is why token stripping is the first defect the refresh fixes rather than a nice-to-have. + +## Considered options + +**A fixed default passphrase in the image** — a universal default credential is the exact thing the regulation exists to forbid. + +**Generate credentials at image build time** — gives every copy of a published image the same "unique" secret. The same failure, one step removed. + +**Force-encrypt field units on upgrade** — would break every saved phone connection in the installed base, unannounced, for devices the placed-on-market rule was not aimed at. The token mechanism gets us the honest split for free: fresh image provisions, existing config is left alone. + +**A sidecar "provisioned" flag file** — duplicate state that can disagree with the config it describes, and one more thing a partial flash or manual edit can strand. + +**NetworkManager-based provisioning** — not the Debian runtime today; hostapd's config is the source of truth. Noted for the NixOS future below rather than adopted early. + +## Consequences + +**The Connect WiFi screen is the primary join path.** A new unit's user cannot reach the web interface without it, so it must render and scan on every panel (128×128, 176×176, 320×240) and its QR payload gets machine-verified in tests. + +**The recovery reset must restore an AP that stays open.** If the recovery configuration carried `ENCRYPTME`, the next boot would re-encrypt and lock the user out of the unit they just recovered — so it restores an open AP (random name via `CHANGEME` is fine) and the user re-encrypts from the web interface. Recovery requires physical access; whether that posture fully satisfies EN 18031 is confirmed as part of the refresh, and recorded here when it is. + +**The NixOS migration must not undo this.** `nixos_migration_wifi.py` carries client networks into NetworkManager keyfiles; it has no AP-provisioning equivalent, so without deliberate work a migrated new unit could come back with a different AP posture than it shipped with. Tracked in the refresh plan. + +**Deliberately not settled here**, expected to resolve during the refresh: whether the web interface may disable encryption on a provisioned unit (leaning no); whether the two tokens collapse into one (leaning no — `ENCRYPTME` alone is the opt-in path for field units); whether first boot should surface the Connect WiFi screen unprompted (#179 asked for the credentials to be reachable from the main screens). diff --git a/docs/ax/network/CONTEXT.md b/docs/ax/network/CONTEXT.md new file mode 100644 index 000000000..e55588656 --- /dev/null +++ b/docs/ax/network/CONTEXT.md @@ -0,0 +1,64 @@ +# Network (WiFi & Connectivity) + +The Network context owns the device's WiFi identity and connections: whether the PiFinder broadcasts its own network or joins someone else's, how a fresh unit acquires a unique name and credentials at first boot, and how a phone or laptop gets connected to it. It is the vocabulary for the network half of `sys_utils`, the Connect WiFi screen, and the web interface's Network page. + +## Language + +### Modes + +**WiFi mode**: +Which of two roles the device's radio plays: **AP** (the PiFinder broadcasts its own network) or **Client** (it joins an existing one). Exactly one is active at a time. +_Avoid_: "hotspot mode" (say AP), "station mode" (say Client). + +**Access Point (AP)**: +The network the PiFinder itself broadcasts, named by its **SSID**. The hostapd configuration is the single source of truth for its identity: SSID, encryption, AP passphrase, country code. +_Avoid_: "hotspot". + +**Client network**: +A saved network the PiFinder can join in Client mode (SSID plus credentials). The user manages the list from the web interface. +_Avoid_: "saved WiFi"; "home network" (a client network need not be at home). + +### Provisioning + +**Provisioning token**: +A marker embedded in a fresh image's AP SSID that first boot consumes: `CHANGEME` (generate a random SSID) and `ENCRYPTME` (enable WPA2 with a generated passphrase). Consuming a token strips it from the SSID — a token present at boot means work to do; no tokens means the unit is provisioned. Fresh images ship with both. +_Avoid_: "magic SSID"; leaving a token in the SSID after acting on it (that is the bug, not the design). + +**New unit** vs **field-upgraded unit**: +The compliance boundary (EN 18031, see [ADR 0034](../../adr/0034-ap-provisioning-ssid-tokens-encrypted-default.md)). A **new unit** first-boots a fresh image, finds provisioning tokens, and provisions itself: random SSID, WPA2, unique AP passphrase. A **field-upgraded unit** took a software update over an existing configuration; its AP is left exactly as it was — an upgrade never provisions and never force-encrypts. +_Avoid_: reasoning from software version (the predicate is "tokens present", not "release N or later"). + +**AP passphrase**: +The WPA2 credential for an encrypted AP, generated on the unit itself at provisioning and shown nowhere but its own screen. User-facing surfaces display it as "Password". +_Avoid_: "PSK", "key" (implementation words); any shared or image-baked default value (per-unit uniqueness is the point). + +**Country code**: +The regulatory domain the AP radio operates under (ISO 3166 two-letter code), set from the web interface. + +### Connecting + +**Connect WiFi screen**: +The on-device screen that gets another device onto the PiFinder: in AP mode a **WiFi QR code** or the SSID and AP passphrase in the clear; in Client mode the name of the network the PiFinder joined. For a new unit this is the primary join path, not a convenience view. +_Avoid_: "WiFi password screen" (it shows more than a password — see Flagged ambiguities). + +**WiFi QR code**: +A QR encoding of the AP's SSID, security type, and AP passphrase in the standard `WIFI:` join format, which phone cameras turn into a one-tap connection. +_Avoid_: describing it as a link or URL (it is a credential payload, not a web address). + +**Recovery reset**: +The documented physical-access procedure (console, or SSH over a network cable) that restores the AP to an open default so the user can get back in — the way into a unit whose encrypted AP cannot be joined because the screen cannot be read. The restored configuration must stay open until the user re-encrypts; deliberately not possible remotely. +_Avoid_: "factory reset" (nothing else is reset). + +## Flagged ambiguities + +- One screen, four names: the menu says "Connect WiFi", the module is `wifi_password.py`, the help folder is `wifi_connect`, the title bar says "WIFI". Canonical: **Connect WiFi screen**. The refresh should rename the module to match (`wifi_connect.py`). +- "Password" vs "passphrase": hostapd and the code say passphrase; screens and user docs say password. Canonical in code and internal docs: **AP passphrase**. User-facing text stays "password". + +## Example dialogue + +Dev: A user upgraded to the new release and their AP is still open. Bug? +Expert: No — field-upgraded unit. An upgrade must not touch their AP. Only a new unit provisions, and only because its image still carries the tokens. +Dev: And if they reflash that same device? +Expert: Then it first-boots a fresh image, finds `CHANGEME-ENCRYPTME` in the SSID, and comes up as a new unit: random SSID, WPA2, unique passphrase. Reflashing is how an old device becomes a new unit. +Dev: Screen is dead, AP is encrypted, they never wrote the passphrase down. Now what? +Expert: Recovery reset. Physical access, restore the open default, join it, re-encrypt from the web interface. If the recovery config carried `ENCRYPTME`, the next boot would lock them out again — which is why it must not. diff --git a/secure-wifi-refresh-plan.md b/secure-wifi-refresh-plan.md new file mode 100644 index 000000000..61ff9b831 --- /dev/null +++ b/secure-wifi-refresh-plan.md @@ -0,0 +1,102 @@ +# PR #311 "More secure WiFi configuration" — release-readiness plan + +*Prepared 2026-08-24; revised the same day for EN 18031 (EU RED cybersecurity): new units must ship with an encrypted AP and a per-unit random password. PR: #311 (branch `secure-wifi`, Jens Scheidtmann), implements #179. The vocabulary for this plan is defined in [docs/ax/network/CONTEXT.md](docs/ax/network/CONTEXT.md); the shipped-posture decision is [ADR 0034](docs/adr/0034-ap-provisioning-ssid-tokens-encrypted-default.md).* + +## Where the PR stands + +- Last commit May 2025; 386 commits behind main; GitHub shows CONFLICTING. +- Feature set as built and hardware-tested by the author (10-point checklist, April 2025): + - **Boot-time AP provisioning via SSID tokens**: `CHANGEME` in the hostapd SSID → replaced with `PiFinder-XXXXX` (5 random chars); `ENCRYPTME` → WPA2 enabled with a generated 20-char passphrase (`xxxxx-xxxxx-xxxxx-xxxxx`). The PR shipped `ssid=PiFinder-CHANGEME` — random name by default, encryption opt-in. **The refresh changes this default** (see Decisions: EN 18031 requires new units to ship encrypted). + - **"Connect WiFi" screen** under Start (new `ui/wifi_password.py`, 307 lines): QR-code view (scannable WiFi join code), plain-password view (color-coded chars), client-mode view showing the connected SSID; marking menu with a jump to WiFi Mode; help screens (`help/wifi_connect/1-3.png` + `.xcf`). + - **Web Network page**: encryption checkbox (when AP is open), AP password field with length validation (8–63), WiFi country field validated against ISO 3166, error display. + - `main.py` runs `Network.configure_accesspoint()` at startup; `sys_utils_fake` mirrors the API for dev. + - Docs edits to `user_guide.rst`, `quick_start.rst`, `build_guide.rst`. +- Review history that shaped it: brickbots rejected "recommend ethernet" and suggested the QR code; mrosseel flagged the support burden of forced random passwords → the PR went opt-in. Only a COMMENTED review; never approved. + +## What moved under it on main (semantic drift — the real work; textual conflicts are small) + +1. **Web server migrated Bottle → Flask/Jinja2 + waitress.** `views/network.tpl` is now `views/network.html`, `request.forms` → `request.form`, rendering via `app.jinja_env`. The PR's server.py + template changes must be **ported, not merged**. +2. **Docs restructured** (rev4 docs plan): WiFi + web-interface prose moved from `user_guide.rst` to **`connectivity.rst`**. The PR's user_guide edits must be rewritten against the new page. `PiFinderAP` is named ~6× in connectivity.rst and once in user_guide.rst. +3. **Three display geometries now** (128×128 OLED/ST7789-128, 176×176 rev4 SSD1333, 320×240 ST7789; titlebar heights 17/20/22). `wifi_password.py` hardcodes 128px geometry (8px char cells, 16-char password wrap, fixed 16px line steps, `len(ssid) > 14` breaks). Help PNGs are fine — base.py normalizes 128×128 help art onto any panel. +4. **i18n**: a `zh` locale now exists (PR predates it). The PR's hand-merged `.po`/`.mo` churn (all conflicting) should be discarded and regenerated via `nox -s babel`; new strings need de/es/fr/zh translations. +5. **Unchanged, so the PR's approach still lands**: hostapd/wpa_supplicant stack in sys_utils (no NetworkManager on the Debian runtime), the Start menu, `__help_name__` help convention, marking-menu `menu_jump`/`label` conventions. Conflict hunks: 1 in sys_utils.py (append position), 3 in server.py (all in the rewritten web layer), 1 import block in menu_structure.py. +6. **NixOS migration touches WiFi config**: `nixos_migration_wifi.py` converts `wpa_supplicant.conf` into NetworkManager keyfiles when a device migrates to NixOS. Client networks the PR writes carry forward fine; the AP provisioning story (runtime hostapd.conf edits) has **no** NixOS equivalent — ADR 0034 records this seam so the migration doesn't silently change a unit's AP posture. + +## Defects found reading the diff (fix during refresh) + +1. **`ENCRYPTME` is never stripped from the SSID** → `configure_accesspoint` rewrites the config + backup on every boot, and if used without `CHANGEME` the token is broadcast in the SSID forever. Strip tokens after provisioning; make boot a no-op when nothing to do. **Ship-blocking under the EN 18031 posture**: every new unit now ships with `ENCRYPTME` active, so every new unit exercises this path on every boot. +2. **Broken test**: `test_generate_five` asserts a dash at index 12; the dashes in `xxxxx-xxxxx-xxxxx-xxxxx` are at 5/11/17. Fails whenever the module imports (it will on CI Linux). Also typos ("aftger thrid"). +3. **Password-field sentinel leak**: the form pre-fills `get_ap_pwd()`, which returns `""` for open APs — submitting can set the sentinel as the passphrase, and prefilled passwords land in served HTML. Don't prefill; handle the sentinel/None explicitly. +4. **Lint/style**: bare `except:` in `set_ap_wifi_country` (E722), commented-out debug lines, f-string logging; must pass current `nox -s lint` / `type_hints`. +5. **Passphrase staged in world-readable `/tmp/hostapd.conf`** before the sudo copy. Use a 0600 temp file (or sudo tee). +6. Template typo "Acess Point WiFi Name"; web error strings not localized (match main's current Flask i18n convention). +7. A stray `debug()` was added in server.py's port-8080 path — dies with the Flask port; don't reintroduce. +8. QR image is LANCZOS-resized (`resize(..., 1)`); use explicit NEAREST — antialiasing softens QR modules and hurts scanning. +9. **Docs bugs**: invalid `.. stop::` directive (docs build will complain); the "Reset Access Point" recovery section references `pi_config_files/hostapd-open.conf` **which the PR never adds** (add it — critical-path under the new posture, and it must not carry `ENCRYPTME`: a recovered unit has to stay open until the user re-encrypts, see ADR 0034), and a `switch-ap.sh` script name to verify; user_guide claims the AP "will be encrypted using WPA2" — now true for fresh images under the EN 18031 posture, but still wrong for field-upgraded units, so the docs must distinguish the two. +10. **Drive-bys to drop**: `help/object_details/2.xcf`, the quick_start "Observation Session Checklist" (fine idea, separate PR). + +## Decisions to settle (grill-with-docs input) + +- **Shipped security posture — SETTLED (2026-08-24)**: for **EN 18031 compliance (EU RED cybersecurity regulations), new units must ship encrypted with a per-unit random WiFi password**. The default `pi_config_files/hostapd.conf` ships with both tokens active (`ssid=PiFinder-CHANGEME-ENCRYPTME`), so first boot provisions a random SSID **and** WPA2 with a generated passphrase. This reverses the PR's shipped default. Recorded as ADR 0034. + - **New vs field-upgraded units**: tokens exist only in fresh images, so a software upgrade never force-encrypts an existing unit's AP — field units keep their config, new units placed on the market start encrypted. This distinction is what makes the token design load-bearing. + - **Consequences**: the Connect WiFi QR screen and the documented recovery path stop being conveniences and become the mandatory first-boot UX. mrosseel's support-lockout concern is now *mitigated, not avoided* — a unit whose UI fails to start presents an encrypted AP nobody can join, so the recovery procedure and its bench validation move onto the critical path. +- **Token vocabulary under the new posture**: keep both tokens (`ENCRYPTME` alone lets a legacy/open unit opt in; `CHANGEME` alone is no longer a shippable default) or collapse to one? Recommend keeping both, documented as such — grill fodder. +- **May the web UI disable encryption?** Recommend no "disable" affordance on new units (keeps the compliance posture simple); legacy open APs keep the opt-in encrypt checkbox. Grill fodder. +- **Recovery-vs-compliance check**: the documented reset-to-open procedure requires physical access (SSH over ethernet / console + manual file copy). Confirm that's an acceptable posture under EN 18031 and record the outcome in ADR 0034. +- **First-boot discoverability**: with encryption mandatory, should first boot surface the Connect WiFi screen (or a hint) rather than relying on the user finding Start > Connect WiFi? Issue #179 originally asked for the password to be reachable from the main screens. Grill fodder. +- **Vocabulary/naming**: settled in [docs/ax/network/CONTEXT.md](docs/ax/network/CONTEXT.md) — the screen is the **Connect WiFi screen**; rename the module `wifi_connect.py` during the refresh. +- Whether the web page also needs a "regenerate password" affordance. Under the encrypted-by-default posture this gets more attractive (password rotation without SSH), but it's not required to ship — recommend deciding at the grill, defaulting to follow-up. +- Country entry: free-text with validation (as coded) vs dropdown — the author deliberately chose an entry box; keep unless grilling says otherwise. +- **Where provisioning runs**: `main.py` startup (as coded) vs the service unit. Keep, recorded in ADR 0034's scope (needs passwordless sudo, which the pifinder user has). +- **PR mechanics**: we can't push to Jens's fork branch, so refresh = new branch off main + superseding PR crediting Jens (`Co-authored-by`), after a courtesy comment on #311. + +## Workstreams + +### WS1 — Network bounded context + ADR *(landing via the docs PR that adds this plan)* +- `docs/ax/network/CONTEXT.md`: the Network glossary — WiFi mode, Access Point, provisioning token, new unit vs field-upgraded unit, AP passphrase, Connect WiFi screen, WiFi QR code, recovery reset. +- ADR 0034: "AP provisioning via SSID tokens: new units ship encrypted (EN 18031)" — the compliance trail for the posture decision, the #179/#311 history it supersedes, the token rationale, and the NixOS seam. +- CONTEXT-MAP.md: Network context + relationships (Network → UI, Network → NixOS). +- Grilling continues against these documents; open questions above are marked as such in ADR 0034. +- Side-note found while surveying: CONTEXT-MAP.md references `docs/ax/nixos/CONTEXT.md` + `docs/ax/nixos.md`, but neither exists on main — pre-existing inconsistency, worth a separate tiny fix. +- The architecture companion (`docs/ax/network.md`: provisioning flow, restart semantics, real-vs-fake seam) arrives with the code PR, once the code it describes exists on main. + +### WS2 — Mechanical refresh +New branch off main; merge `origin/pr-311`; **change the shipped default to `ssid=PiFinder-CHANGEME-ENCRYPTME`** in `pi_config_files/hostapd.conf`; port the web layer to Flask/Jinja (network.html: encryption checkbox for legacy open APs, password + country fields, inline errors; `network_update` in Flask idiom); drop drive-bys; discard locale churn; add `qrcode` (verify a py3.9-compatible pin) + `types-qrcode`; get `nox -s lint type_hints smoke_tests unit_tests` green. + +### WS3 — Correctness + hardening +The defect list above. Refactor the hostapd/wpa paths to injectable constants so provisioning logic is unit-testable with temp files. + +### WS4 — Multi-resolution UI +Derive geometry from `display_class.resX/resY` + font metrics. Verify with screenshots of QR view, password view, long-SSID case at 128×128 and 320×240 (and the 176×176 layout if it can be driven off-hardware); machine-verify the QR by decoding the screenshot (pyzbar) and comparing the `WIFI:S:...;T:WPA;P:...;` payload — cheap, strong check. Prefer `image_util.make_red()` for the QR bitmap rather than qrcode's own fill colors, matching how help images are recolored. + +### WS5 — i18n +`nox -s babel` extract/update/compile; translate the new strings for de/es/fr/zh (scope to tracked .po diffs). + +### WS6 — User docs +- `connectivity.rst`: rewrite the AP story around the new first-boot reality — fresh images come up as `PiFinder-XXXXX`, WPA2, unique password, and **the QR screen (Start > Connect WiFi) is how you join** (`PiFinderAP` → `PiFinder-XXXXX` everywhere); web Network page fields; a section for field-upgraded units (still open unless they opt in via the encrypt checkbox or `ENCRYPTME`); AP reset/recovery appendix promoted to critical-path. +- `quick_start.rst`: the first-connection flow must now lead with the Connect WiFi QR screen — a new user cannot reach the web interface without it. +- `build_guide.rst`: bench-time WiFi setup section (port the PR's; the security rationale now stands on EN 18031 rather than the earlier over-claimed framing; keep proportionate per brickbots' feedback). +- Mention EN 18031 as the reason for the change in the release notes so field users understand why new images behave differently. + +### WS7 — Tests +Unit (`@pytest.mark.unit`): provisioning idempotence, token stripping, passphrase generation, password-length + country validation — today none of the AP functions have any coverage. Web (`@pytest.mark.web` / Selenium): extend `test_web_network.py` for the encryption checkbox, error rendering, and country field. Fix the dash-index test. + +### WS8 — Hardware validation + ship +- **First-boot validation on a fresh image is the headline test**: flash, boot, confirm the AP comes up as `PiFinder-XXXXX` with WPA2 and a unique passphrase, join via the QR screen with a phone, reach the web interface — with no other setup steps. This is every new builder's first experience; it must be bulletproof. +- **Upgrade validation**: apply the release to a unit with an existing (open or customized) AP config and confirm it is left untouched. +- Re-run Jens's 10-point checklist on real hardware, on **both** rev3 OLED and rev4 LCD; phone-scan the QR on both panels (red-on-black on the LCD is the open risk). +- Walk the documented recovery procedure end-to-end on hardware (it's now the only way back into a failed encrypted unit). +- Comment on #311, open the superseding PR against main crediting Jens, request brickbots/mrosseel review. + +## Sequencing & effort + +WS1 → WS2 → (WS3 + WS4 + WS5 in parallel) → WS6 + WS7 → WS8. +Rough effort: WS1 ~half-day, WS2 ~half-day, WS3+WS4 ~a day, WS5 small, WS6 ~a day, WS7 ~half-day. Hardware validation is the long pole and the only step that needs a physical unit. + +## Risks + +- **Lockout is now on the mainline path**: with new units encrypted by default, a unit whose UI fails to start presents an AP nobody can join. Mitigations: the recovery procedure (documented, hardware-walked in WS8) and making the first-boot path bulletproof. This was the maintainers' original objection to default encryption — EN 18031 overrides the preference, so the mitigation quality is what earns the merge. +- **Compliance scope**: this work implements the technical posture and ADR 0034 records EN 18031 as the driver; the broader conformity-assessment paperwork is out of scope here. +- **NixOS/Bookworm futures** (#499, NixOS channels) may replace runtime hostapd editing; contained by documenting the seam in ADR 0034, not blocked on it — but the encrypted-by-default posture must survive any migration. +- **Fork courtesy**: Jens may prefer to update #311 himself — ask before superseding. (Worth telling him the posture he originally wanted — encryption on by default — is now the requirement; EN 18031 vindicates his instinct.) +- **QR scan reliability on the LCD** — validate early in WS4/WS8, before docs promise it; under the new posture the QR screen is the primary join path, not a convenience.