From 35e593ab63e52fe87a0d084e8b3252294aab44e2 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 4 Jul 2026 19:00:49 +0200 Subject: [PATCH] docs(wiki): DT-008 phantom-scan diagnosis + backfill bypass/relay-test concept pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent wiki updates bundled (all docs): 1. dingtian-dt008-reader.md: phantom optical decodes on the park-buzi EXIT reader (empty pre-opening site, low-sun afternoons). Chain of evidence: READ log lines carry the reader's own serial (H05MA5B0) → physical device, not a network source; snapshot shows nobody present; code shapes are the giveaway (6-digit numerics = checksum-less Interleaved 2-of-5, lone "C" = Code39/Codabar artifact) → 1D engine decoding sun-made stripe patterns (striped arm, fence shadows, glare). No fraud exposure (11-digit Luhn ids can't match); noise only. Fix on the entity page: vendor-tool symbology cut to QR+Code128 + min decode length, BOTH readers; config lives ON the device → re-apply after any factory reset/swap. Deliberately NOT filtering impossible codes server-side — probe recording is the anomaly path's job. 2. Backfilled two shipped-but-undocumented features (six code files already linked the first page as if it existed): - concepts/entry-presence-bypass.md — admin drops a FAULTY presence signal (granular radar/camera by decision, not a master switch); every flip is a signed config_change; persists till off; tickets stamped presenceBypassed; radar-bypass cooldown tradeoff; "the admin is not the adversary, but trusted never means invisible". - concepts/setup-relay-test.md — admin-only commissioning pulse, signed barrier_open_command BEFORE the fire so a test open never reads as the out-of-band-open fraud signal; saved controllers/declared relays only; radarAlert lamps excluded; pulseOpen only. Cross-linked from operator-issued-entry.md, cataloged in index.md, logged. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V --- wiki/concepts/entry-presence-bypass.md | 65 ++++++++++++++++++++++++++ wiki/concepts/operator-issued-entry.md | 7 ++- wiki/concepts/setup-relay-test.md | 48 +++++++++++++++++++ wiki/entities/dingtian-dt008-reader.md | 29 +++++++++++- wiki/index.md | 6 ++- wiki/log.md | 24 ++++++++++ 6 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 wiki/concepts/entry-presence-bypass.md create mode 100644 wiki/concepts/setup-relay-test.md diff --git a/wiki/concepts/entry-presence-bypass.md b/wiki/concepts/entry-presence-bypass.md new file mode 100644 index 0000000..627091b --- /dev/null +++ b/wiki/concepts/entry-presence-bypass.md @@ -0,0 +1,65 @@ +--- +type: concept +tags: [parking, entry, admin, devices, threat-model, presence] +sources: [] +updated: 2026-07-04 +status: settled +--- + +# Entry presence-gate bypass (faulty radar / faulty camera) + +The transient entry gates require a **real vehicle** before a ticket can mint: the radar/loop +presence input and the camera vehicle-detection ([[entry-double-press]] for the physical button, +[[operator-issued-entry]] for the booth button). When one of those devices is **faulty** — a dead +radar, a camera that stopped pushing ([[g3h-anpr-push-gotchas|it happens]]) — the gate blocks +**legitimate** transient entry until support fixes the hardware. This feature lets the **admin** +drop a specific signal as a requirement until then. Built 2026-07-02 (migration `0020`). + +## The principle: the admin is NOT the adversary + +The [[threat-model]] adversary is the **booth operator**; the admin is the trusted party. So the +admin *may* weaken an anti-fraud gate — but weakening it stays **attributed and auditable** +(signed), because "trusted" never means "invisible" in this system. Compare +[[operator-issued-entry]], where the untrusted role gets capability + a red flag on every use; +here the trusted role gets a switch + a signed record of flipping it. + +## Decisions (2026-07-02) + +- **Granular, not a master switch.** One flag per signal: a faulty camera drops only the camera + check (radar still gates); a faulty radar drops only radar. Chosen over a single "bypass all" + toggle so a single broken device never silently disables the *other*, still-working gate. +- **Persists until turned OFF, and every flip is signed.** No auto-expiry (support visits are + unpredictable); instead each enable/disable appends a **`config_change`** ledger event + (`setting: entryPresenceBypass.`, `value`, `prev`, `operator`) — the append-only chain + records *who weakened the gate and when*, and the OFF transition too. A no-op write (same value) + signs nothing. + +## What a bypass changes + +- **Physical button** (`EntryFlow.#suppressReason`): camera bypassed → the camera press-gate is + skipped; radar bypassed → the presence-loop check is skipped and the press falls through to the + **cooldown backstop**. ⚠️ A dead loop can't re-arm one-car-one-ticket, so with radar bypassed + and **no `entryCooldownSec` configured there is no anti-double-press left** — the admin's + accepted tradeoff while bypassed (set a cooldown when bypassing radar). +- **Operator button** (`issueForOperator`): the bypassed signal is dropped as a requirement; a + refusal only happens when a **still-required** signal fails to confirm. +- **Auditability on every ticket:** a ticket minted under a bypass carries + `presenceBypassed: ["radar"|"camera", ...]` on its signed `vehicle_entry` (and refusal anomalies + record it too) — so reconciliation can always tell which entries happened under a weakened gate. +- **Booth lights** ([[booth-console]]): a bypassed signal renders as satisfied, so the operator's + button-enable logic matches the server's. + +## As-built + +- **Schema:** `site_config.bypass_presence_radar` / `bypass_presence_camera` (booleans, default + off = the normal both-required gate), migration `0020_entry_presence_bypass.sql`. Read **live** + per decision — a toggle needs no restart. +- **Endpoint:** `PUT /api/site-config/presence-bypass` (`routes/site.ts`), admin-only + (`site:update`), signs one `config_change` per actually-changed signal, 400 on non-boolean/empty. +- **UI:** SetupWizard "presence gate" panel (two checkboxes + an amber "active" warning) after the + controller section; `config_change` events render amber in the booth feed. +- **Tests:** `entry-presence-bypass.test.ts` (gate decisions under each combination), + `presence-bypass-route.test.ts` (RBAC, signing, no-op, validation), and + `entry-press-gate.test.ts` (camera bypass on the physical press). +- Fixing this surfaced a latent bug: `firstRelayByDirection` never attached `presenceInput`, so + the operator-issue radar check had always read "presence loop unavailable" (fixed 2026-07-02). diff --git a/wiki/concepts/operator-issued-entry.md b/wiki/concepts/operator-issued-entry.md index 85f474c..bac2a8f 100644 --- a/wiki/concepts/operator-issued-entry.md +++ b/wiki/concepts/operator-issued-entry.md @@ -2,7 +2,7 @@ type: concept tags: [parking, booth, entry, threat-model, anpr, presence] sources: [] -updated: 2026-07-01 +updated: 2026-07-04 status: settled --- @@ -33,6 +33,11 @@ with phantom tickets, and (crucially) it guarantees the entry snapshot captures what [[plate-reconciliation]] reads at exit. **No presence loop configured → the feature is unavailable** at that site (we require both; no weaker camera-only fallback). +> **Bypass (2026-07-02):** when one of the two devices is FAULTY, the admin can drop that signal +> as a requirement via [[entry-presence-bypass]] (granular, signed `config_change`, persists till +> turned off). The gate then requires only the still-working signal; tickets minted under a bypass +> carry `presenceBypassed` on the signed entry, and refusal anomalies record it too. + **Enforced on BOTH sides.** The UI only enables the entry [[booth-console|BarrierLight]] as a clickable issue-control when `radar.entry && lanes.entry` (both true) and the operator holds `session:create`. The **server re-checks** current presence (`LaneStatus.snapshot().entry === true` AND the entry relay's diff --git a/wiki/concepts/setup-relay-test.md b/wiki/concepts/setup-relay-test.md new file mode 100644 index 0000000..b8cf024 --- /dev/null +++ b/wiki/concepts/setup-relay-test.md @@ -0,0 +1,48 @@ +--- +type: concept +tags: [parking, setup, devices, integrity, admin] +sources: [] +updated: 2026-07-04 +status: settled +--- + +# Setup relay test (signed, admin-only barrier pulse) + +Commissioning a lane needs a way to prove **wiring**: does relay N on this controller actually +lift *this* barrier? Before this feature (built 2026-07-01) the only way was to fake a credential +or short an input — both of which pollute the flows they exercise. Now the Setup controller +section offers a per-relay **Test** button that pulses the relay directly. + +## The design constraint: a test open must be SIGNED + +The core anti-fraud rule ([[append-only-event-chain]]) is that **a physical barrier open with no +matching signed command is THE fraud signal**. An unsigned test pulse would therefore read as +fraud in any reconciliation of controller logs vs. ledger. So the test **signs a +`barrier_open_command` BEFORE the pulse fires** — same ordering invariant as every real open: + +- `source: "manual"` (a deliberate human action, same as an operator barrier open), + `identity: relay-test::`, +- payload: `reasonPayload("setup.relayTest", { operator, relay, controller })` + `relayTest: true` + so reconciliation and the feed can tell a test from an intervention. +- No `EventLog` available (boot ordering) → the endpoint refuses (503) rather than fire unsigned. + +## Guardrails + +- **Admin-only** (`site:update`) + CSRF — the operator (the [[threat-model]] adversary) cannot + pulse barriers from Setup. +- **Saved controllers only**, and only relays the saved config **declares** (unknown relay → 400, + unknown controller → 404, nothing signed on any refusal). No free-form "pulse anything" seam. +- **`radarAlert` (lamp) relays are excluded** in the UI — they are aux outputs, not barriers + ([[button-light-indicator]]); the test drives `pulseOpen` only, so [[barrier-not-a-door]] holds + (intent-only; the barrier firmware owns the close). +- UI confirms before firing (`Test` → confirm dialog → pulse), per-relay buttons in the + controller assignment row. + +## As-built + +- Endpoint `POST /api/setup/test-relay` (`routes/setup.ts`) — validates, signs, then + `registry.create(...)` → `pulseOpen(relay)`. Feature-detects `pulseOpen` on the built device. +- UI: `RelayTester` in `SetupWizard.tsx` (access category only). Reason code `setup.relayTest` + in `@parking/shared` + both web catalogs ([[i18n]]). +- Tests: `setup-relay-test.test.ts` — RBAC 403, CSRF 403, signed command on success, 400/404 + refusals sign nothing, bad relay value. diff --git a/wiki/entities/dingtian-dt008-reader.md b/wiki/entities/dingtian-dt008-reader.md index 5a318bc..35116d2 100644 --- a/wiki/entities/dingtian-dt008-reader.md +++ b/wiki/entities/dingtian-dt008-reader.md @@ -2,7 +2,7 @@ type: entity tags: [parking, hardware, readers, qr, dingtian] sources: [dingtian-dt008] -updated: 2026-06-28 +updated: 2026-07-04 status: open --- @@ -120,6 +120,33 @@ host in the **vendor tool**; assign + enter its serial + bind it here. - `output` is replied as `0` (Access). Confirm on hardware whether the reader needs `1`/`2` (WG26/34) to drive its access line, vs. `0`. +## ⚠️ Phantom optical decodes from sunlight patterns (park-buzi, 2026-07-04) + +With the site EMPTY (pre-opening, verified live + by snapshot), the **exit reader +(`cjihao=H05MA5B0`) pushed spontaneous scans** at random afternoon times (observed 16:38–18:14, +low-western-sun hours): `cardid` values like `997492`, `389861`, `024358`, `192793` — and once a +lone **`C`**. Server logs (`READ serial=` lines) confirm the pushes carry the reader's own serial +and resolve to its assigned row, so this is **the physical reader decoding, not a network source**. + +**Diagnosis:** the scan engine ships with many 1D symbologies enabled, some with weak/no checksums. +Six-digit all-numeric strings are the signature of **Interleaved 2-of-5** (even-length, digits-only, +no checksum — any high-contrast stripe pattern of the right proportions "decodes"); a lone `C` is a +**Code39/Codabar** artifact (Codabar start/stop chars are A–D). Low sun creates exactly such +patterns at a gate: the striped barrier arm, fence/railing shadows sweeping as the sun moves, glare +bands. RFID noise would instead give repeating UID-shaped values. + +**Impact: noise, not risk.** Every phantom was REFUSED fail-closed (`exit.refused.noSession`, a +signed anomaly — #52–58 in the feed); a phantom can never match a ticket ([[ticket-encoding]] ids +are 11-digit + Luhn, so a 6-digit read has nothing to match). Do NOT filter "impossible" codes +server-side — recording every probe of an exit reader is what the anomaly path is for; fix at the +source instead: + +**Fix (vendor tool, per reader — config lives ON THE DEVICE, not in our DB):** disable every +symbology except **QR + Code128** (all our credentials); if offered, set **minimum decode length +≥ 10** and require checksums. Apply to BOTH readers. ⚠️ A factory reset or a swapped unit silently +re-enables the phantom symbologies — re-apply after any reset/replacement. Physical fallback if any +noise survives: hood/visor the window, tilt it down, avoid facing the striped arm. + ## ⚠️ Reply MUST set `Connection: close` (verified on hardware) The reader sends `Connection: keep-alive` but **only acts on the verdict (beep/output) once the TCP diff --git a/wiki/index.md b/wiki/index.md index 1a82db9..79c8e48 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -80,8 +80,10 @@ Counts: 4 sources · 19 entities · 47 concepts · 7 decision records. - [[challenge-response-auth]] — asymmetric nonce scheme for the ESP32 (auth + anti-replay). - [[entry-exit-readers]] — two populations, two integration paths; both can share a relay. - [[entry-exit-points]] — pool-of-spaces model (no lane); per-relay direction, reader→relay binding, camera snapshots. -- [[entry-double-press]] — one car = one ticket: per-relay presence gate (loop OR radar) preferred, cooldown fallback; suppressed press = telemetry. -- [[button-light-indicator]] — entry button lamp on a spare relay: radar × camera 3-state (blink/solid/off); aux-output; fails OFF. +- [[entry-double-press]] — one car = one ticket: per-relay presence gate (loop OR radar) preferred, cooldown fallback (a real backstop since 2026-07-04); camera press-gate + post-hoc duplicate-plate anomaly (2026-07-04). +- [[entry-presence-bypass]] — admin drops a FAULTY presence signal (radar/camera, granular) until support fixes it; every flip is a signed config_change; tickets minted under bypass are stamped (2026-07-02). +- [[setup-relay-test]] — admin-only per-relay Test button in Setup; signs a barrier_open_command BEFORE the pulse so a test open never reads as fraud (2026-07-01). +- [[button-light-indicator]] — entry button lamp on a spare relay: radar × camera 3-state (blink/solid/off); aux-output; fails OFF. The press gate enforces its SOLID state since 2026-07-04. - [[uhppote-vs-esp32]] — comparison: detection vs. prevention. ## Concepts — business domain diff --git a/wiki/log.md b/wiki/log.md index 68c5aa5..b06302f 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2223,3 +2223,27 @@ along the way: camera-vetoed re-arm (defer re-arm until the lane flips free) — silence-timeout "free" never fires inside a queue, so it would suppress every queued car after the first. Proper preventive fix noted open: a pass-through sensor (`passedInput`). 13 new tests (`entry-press-gate.test.ts`, `entry-duplicate-plate.test.ts`); suite 258 green. + +## [2026-07-04] update | Exit reader phantom scans traced to optical 1D decodes (sun patterns) + +Pre-opening park-buzi, empty site: the exit [[dingtian-dt008-reader]] pushed spontaneous 6-digit +numeric scans (+ one lone "C") at low-sun afternoon hours; all refused fail-closed as +exit.refused.noSession. Server READ logs confirmed the reader's own serial (H05MA5B0) → the +physical device decoding, not a network source; a live snapshot confirmed nobody present. +Diagnosis: default-enabled weak-checksum 1D symbologies (I2of5 6-digit signature; "C" = +Code39/Codabar artifact) decoding sun-made stripe patterns (striped barrier arm, fence shadows, +glare). No fraud exposure (11-digit Luhn ids can't match a 6-digit read). Fix recorded on the +entity page: vendor-tool symbology cut to QR+Code128 + min length, BOTH readers, re-apply after +any factory reset (config lives on the device). Deliberately NOT filtering impossible codes +server-side — probe recording is the anomaly path's job. + +## [2026-07-04] update | Backfilled missing concept pages: entry-presence-bypass + setup-relay-test + +Two shipped features (2026-07-01/02) had no wiki pages — worse, six code files and +[[entry-double-press]] already linked [[entry-presence-bypass]] as if it existed. Written now: +[[entry-presence-bypass]] (admin drops a faulty radar/camera signal, granular by decision, every +flip a signed config_change, tickets stamped presenceBypassed, radar-bypass cooldown tradeoff, +"the admin is not the adversary" threat-model nuance) and [[setup-relay-test]] (admin-only +commissioning pulse, signed barrier_open_command BEFORE the fire so a test open never reads as the +out-of-band-open fraud signal, saved-controllers-only, radarAlert lamps excluded). Cross-linked +from [[operator-issued-entry]] (bypass note) and cataloged in index.md.