feat(reader): channel tagging (clone defense) + structural filter for phantom scans
Two reader-hardening changes born from the park-buzi phantom-scan investigation
(empty pre-opening site, exit reader pushing sun-decoded garbage codes).
1. CHANNEL TAGGING — closes the printed-card-clone hole. The DT-008 push is
channel-blind (one opaque cardid from either engine) and SubscriptionFlow
matched by value only, so printing an RF card's UID (often written on the
card face, e.g. 86A158) as a barcode cloned the card. Now:
- Vendor tool sets output prefixes (QRCode "Q:", Card "K:"; server env
overrides READER_QR_PREFIX / READER_CARD_PREFIX).
- routes/qr-reader.ts strips the prefix and tags the read's confirmed
channel (DeviceReadEvent.channel optical|rf; kind qr|card). Enrollment
capture stores the BARE value. READ log lines carry ch=… (permanent
phantom attribution).
- SubscriptionFlow.match requires channel agreement: an optical decode may
not claim an rf credential (and vice versa) — refused + signed
sub.refused.channelMismatch anomaly (a clone attempt is a fraud signal).
- Unprefixed reads keep the legacy untagged shape and match as before, so
enforcement only bites where prefixes are deployed. Deploy server FIRST,
then set prefixes in the vendor tool.
2. STRUCTURAL FILTER — phantom decodes out of the signed feed (operator-
requested, reverses the earlier "record every probe" position — red
"who is exiting?" rows for NOBODY train the operator to ignore the feed).
read-dispatch.ts drops a no-match reader value that cannot possibly be a
credential we issue (no ticket Luhn shape, no SUB-/SUBSESS- prefix, not
confirmed-RF, not a plate) to UNSIGNED device_events telemetry
(unrecognizedRead:true). Deliberately WIDE plausibility: forged ticket
shapes, unknown physical cards, unknown SUB- codes all still sign the
normal refusal anomaly; enrolled credentials match before the filter and
can never be hidden. Works for legacy unprefixed reads too — the feed
cleans up on deploy, before any vendor-tool change.
Wiki: dingtian-dt008-reader.md records the clone hole + fix, the filter (as a
recorded position reversal), and the two device-side settings now part of the
credential contract (output prefixes + Card Input format, moving 6H→8H at the
next vendor-tool session; both live ON the device — re-apply after any
factory reset/swap).
Tests: qr-reader-channel.test.ts (prefix split, route tagging, bare-value
capture), subscription-channel.test.ts (channel agreement matrix + anomaly),
read-dispatch-filter.test.ts (filter boundary: phantoms dropped, probes kept,
enrolled never hidden). Suite 278 green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -120,6 +120,32 @@ 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`.
|
||||
|
||||
## Channel tagging — output prefixes close the printed-card-clone hole (2026-07-04)
|
||||
|
||||
The push carries ONE opaque `cardid` whether the **optical** engine decoded a QR/barcode or the
|
||||
**RF** engine read a card — the server couldn't tell. And `SubscriptionFlow.match` matched by
|
||||
**value only** (the stored `rf`/`qr` kind was a label). Consequence: printing a card's UID (often
|
||||
written on the card face, e.g. `86A158`) as a barcode and holding it up **cloned the RF card** —
|
||||
the optical decode matched the RF credential and opened the barrier. In-threat-model and cheap.
|
||||
|
||||
**Fix (both sides):**
|
||||
- **Reader (vendor tool, both units):** set `QRCode Output Prefix` = `Q:` and `Card Output
|
||||
Prefix` = `K:` (env-overridable server-side: `READER_QR_PREFIX` / `READER_CARD_PREFIX`).
|
||||
- **Server:** `routes/qr-reader.ts` strips the prefix and tags the read's confirmed channel
|
||||
(`DeviceReadEvent.channel: "optical"|"rf"`; kind `qr`/`card`). Enrollment capture stores the
|
||||
**bare** value. `SubscriptionFlow.match` then requires **channel agreement**: an optical read
|
||||
may not claim an `rf` credential (and vice versa) — a mismatch is refused AND signs a
|
||||
`sub.refused.channelMismatch` **anomaly** (a clone attempt is a fraud signal). An **unprefixed**
|
||||
read keeps the legacy untagged shape and matches as before — enforcement only bites where
|
||||
prefixes are deployed, so an unconfigured reader never breaks.
|
||||
- Bonus: every `READ` log line now carries `ch=optical|rf`, which permanently attributes any
|
||||
future phantom (see below) to its engine.
|
||||
|
||||
⚠️ **Two device-side settings are now part of the credential contract** (they live ON the reader,
|
||||
not in our DB — re-apply after any factory reset/swap): the two **output prefixes** (must match
|
||||
the server's expected `Q:`/`K:`), and **`Card Input format` (currently `6H`)** — it defines the
|
||||
UID shape we enroll; changing it later silently orphans every enrolled card.
|
||||
|
||||
## ⚠️ Phantom optical decodes from sunlight patterns (park-buzi, 2026-07-04)
|
||||
|
||||
With the site EMPTY (pre-opening, verified live + by snapshot), the **exit reader
|
||||
@@ -135,11 +161,20 @@ no checksum — any high-contrast stripe pattern of the right proportions "decod
|
||||
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:
|
||||
**Impact: noise, not risk.** Every phantom was REFUSED fail-closed; a phantom can never match a
|
||||
ticket ([[ticket-encoding]] ids are 11-digit + Luhn, so a 6-digit read has nothing to match).
|
||||
|
||||
**Feed filter (2026-07-04 — supersedes the earlier "do not filter" position).** Initially each
|
||||
phantom signed an `exit.refused.noSession` anomaly (#52–58 in the feed) and the position was to
|
||||
keep it that way. The operator overruled it, correctly: red "who is trying to exit?" rows for
|
||||
NOBODY train the operator to ignore the signed feed — alarm fatigue is the adversary's friend.
|
||||
`read-dispatch.ts` now drops a no-match value that **cannot possibly be a credential we issue** to
|
||||
UNSIGNED telemetry (`device_events`, `unrecognizedRead:true` — still auditable). "Possibly ours"
|
||||
is deliberately WIDE and everything in it still signs the normal refusal anomaly: Luhn-valid
|
||||
ticket shapes (a forged ticket is a real probe), `SUB-`/`SUBSESS-` prefixes, ANY read on a
|
||||
confirmed-RF channel (a physical card is a real event, never sun noise), and plates (never
|
||||
shape-filtered). The filter also works pre-prefix (legacy untagged reads). Still fix at the
|
||||
source too:
|
||||
|
||||
**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
|
||||
|
||||
+25
@@ -2247,3 +2247,28 @@ flip a signed config_change, tickets stamped presenceBypassed, radar-bypass cool
|
||||
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.
|
||||
|
||||
## [2026-07-04] update | Reader channel tagging: printed-card-clone hole closed
|
||||
|
||||
Investigating the phantom scans surfaced a real vulnerability: the DT-008 push is channel-blind
|
||||
and SubscriptionFlow.match matched by value only, so printing an RF card's UID (written on the
|
||||
card face) as a barcode cloned the card. Fixed with channel tagging: vendor-tool output prefixes
|
||||
(Q:/K:) → routes/qr-reader.ts strips + tags DeviceReadEvent.channel (optical|rf) → match requires
|
||||
channel agreement, refusing a mismatch + signing a sub.refused.channelMismatch anomaly (a clone
|
||||
attempt is a fraud signal). Untagged (unprefixed) reads keep legacy behavior — enforcement only
|
||||
bites where prefixes are deployed. Enrollment capture stores bare values. Recorded on
|
||||
[[dingtian-dt008-reader]] incl. the two device-side settings now part of the credential contract
|
||||
(prefixes + Card Input format 6H — re-apply after factory reset). 14 new tests; suite 272 green.
|
||||
|
||||
## [2026-07-04] update | Structural read filter: phantom scans out of the signed feed
|
||||
|
||||
Operator-requested reversal of the earlier "do not filter" position (recorded as superseded on
|
||||
[[dingtian-dt008-reader]]): phantom decodes were signing exit.refused.noSession anomalies — red
|
||||
rows for nobody, training the operator to ignore the feed. read-dispatch.ts now drops a no-match
|
||||
reader value that cannot possibly be ours (no ticket Luhn shape, no SUB-/SUBSESS- prefix, not
|
||||
confirmed-RF, not a plate) to unsigned device_events telemetry (unrecognizedRead:true). The
|
||||
plausibility rule is deliberately wide so every real probe (forged ticket shape, unknown physical
|
||||
card, unknown SUB- code) still signs the normal anomaly; enrolled credentials match before the
|
||||
filter and can never be hidden by it. Works for legacy unprefixed reads too, so the feed cleans up
|
||||
before the vendor-tool visit. 6 new tests; suite 278 green. Also this session: reader channel
|
||||
tagging (clone defense) — see the prior entry.
|
||||
|
||||
Reference in New Issue
Block a user