feat(reader): channel tagging (clone defense) + structural filter for phantom scans
Build desktop / desktop (push) Successful in 4m12s
Build & push images / images (push) Successful in 2m52s
CI / check (push) Successful in 42s

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:
2026-07-04 19:34:48 +02:00
parent 35e593ab63
commit 43c1f45e29
12 changed files with 515 additions and 15 deletions
+7
View File
@@ -24,6 +24,13 @@ export interface DeviceReadEvent {
readonly deviceId: string; // devices id of the reader/scanner/camera
readonly value: string; // the ticket id / plate / card number
readonly kind: "ticket" | "plate" | "qr" | "card";
/** The CONFIRMED physical channel the value arrived on, when the reader tags it
* (the DT-008 output prefixes — see routes/qr-reader.ts). `optical` = decoded by
* the barcode/QR engine; `rf` = read from a card/chip. Undefined = legacy reader
* with no prefixes configured (channel unknown — flows must not assume). Lets the
* subscription match refuse an OPTICAL decode claiming an RF credential (a printed
* copy of a card's UID must not clone the card). */
readonly channel?: "optical" | "rf";
readonly at: string; // ISO-8601
}
@@ -0,0 +1,103 @@
import { randomUUID } from "node:crypto";
import { beforeEach, describe, expect, it } from "vitest";
import { devices, deviceEvents as deviceEventsTable, ledgerEvents, subscriptionCredentials, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { ReadDispatcher } from "./read-dispatch.js";
import { ExitFlow } from "./exit-flow.js";
import { SubscriptionFlow } from "./subscription-flow.js";
import type { DeviceReadEvent } from "./device-events.js";
import { makeLog, silentLogger } from "./test-helpers.js";
// STRUCTURAL FILTER at the dispatcher (2026-07-04): a reader value that matched
// nothing AND can't possibly be a credential we issued (no ticket Luhn shape, no
// SUB-/SUBSESS- prefix, not a confirmed-RF read) is refused with UNSIGNED telemetry
// instead of reaching the exit flow and signing a noSession anomaly. Born from the
// park-buzi phantom optical decodes: red "who is exiting?" rows for NOBODY train the
// operator to ignore the signed feed. Anything plausibly ours STILL signs normally.
let db: Db;
let dispatcher: ReadDispatcher;
const READER = "reader-exit";
beforeEach(() => {
({ db } = createTestDb());
db.insert(devices).values({
id: "ctl-exit",
category: "access",
driverId: "stub-access",
config: { relays: [{ relay: 1, direction: "exit" }] },
enabled: true,
}).run();
db.insert(devices).values({
id: READER,
category: "reader",
driverId: "dingtian-qr-reader",
config: { serial: "H05MA5B0", direction: "exit" },
enabled: true,
}).run();
const log = makeLog(db);
dispatcher = new ReadDispatcher(db, new ExitFlow(db, log, silentLogger()), new SubscriptionFlow(db, log, silentLogger()), silentLogger());
});
function read(value: string, opts: { kind?: DeviceReadEvent["kind"]; channel?: DeviceReadEvent["channel"] } = {}): DeviceReadEvent {
return {
driverId: "dingtian-qr-reader",
deviceId: READER,
value,
kind: opts.kind ?? "qr",
...(opts.channel ? { channel: opts.channel } : {}),
at: new Date().toISOString(),
};
}
const ledger = () => db.select().from(ledgerEvents).all();
const unrecognized = () =>
db.select().from(deviceEventsTable).all()
.map((r) => r.detail as { unrecognizedRead?: boolean; value?: string })
.filter((d) => d.unrecognizedRead === true);
describe("read-dispatch structural filter", () => {
it("phantom 6-digit optical decode → refused, telemetry only, NOTHING signed", async () => {
const out = await dispatcher.dispatch(read("999459", { channel: "optical" }));
expect(out.accepted).toBe(false);
expect(out.reason).toMatch(/unrecognized/);
expect(ledger()).toHaveLength(0); // the whole point: no red row in the feed
expect(unrecognized()).toHaveLength(1);
expect(unrecognized()[0].value).toBe("999459");
});
it("legacy untagged garbage ('C') → filtered too (works before prefixes are deployed)", async () => {
const out = await dispatcher.dispatch(read("C"));
expect(out.accepted).toBe(false);
expect(ledger()).toHaveLength(0);
expect(unrecognized()).toHaveLength(1);
});
it("Luhn-valid unknown ticket → NOT filtered: the exit flow signs the noSession anomaly", async () => {
const out = await dispatcher.dispatch(read("00000000000")); // valid shape, no session
expect(out.accepted).toBe(false);
expect(unrecognized()).toHaveLength(0);
const anomalies = ledger().filter((r) => r.type === "anomaly");
expect(anomalies.length).toBeGreaterThan(0); // a real probe stays in the signed feed
});
it("unknown card on a CONFIRMED RF channel → NOT filtered (a physical card is a real event)", async () => {
await dispatcher.dispatch(read("1A86A158", { kind: "card", channel: "rf" }));
expect(unrecognized()).toHaveLength(0);
expect(ledger().filter((r) => r.type === "anomaly").length).toBeGreaterThan(0);
});
it("unknown SUB- code → NOT filtered (our own prefix = an interesting probe)", async () => {
await dispatcher.dispatch(read("SUB-DOESNOTEXIST", { channel: "optical" }));
expect(unrecognized()).toHaveLength(0);
expect(ledger().filter((r) => r.type === "anomaly").length).toBeGreaterThan(0);
});
it("an ENROLLED credential is matched BEFORE the filter (never hidden by it)", async () => {
// A card UID that would fail every shape test — enrolled, so it must still match.
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: "sub-1", kind: "rf", value: "999459" }).run();
await dispatcher.dispatch(read("999459")); // legacy untagged read of it
expect(unrecognized()).toHaveLength(0); // reached the subscription flow, not the filter
});
});
+68 -1
View File
@@ -1,7 +1,9 @@
import { devices, eq, type Db } from "@parking/db";
import { randomUUID } from "node:crypto";
import { devices, deviceEvents as deviceEventsTable, eq, type Db } from "@parking/db";
import type { FastifyBaseLogger } from "fastify";
import type { DeviceReadEvent, ReadOutcome } from "./device-events.js";
import type { ExitFlow } from "./exit-flow.js";
import { validateTicketCode } from "./entry-flow.js";
import type { SubscriptionFlow } from "./subscription-flow.js";
import { relayForDevice } from "./device-resolve.js";
@@ -17,6 +19,22 @@ import { relayForDevice } from "./device-resolve.js";
// it opens that exact barrier. An "entry" reader drives the entry side, an "exit"
// reader the exit side; "both" defers to the flow's own inference (subscription:
// session state; transient: exit).
//
// STRUCTURAL FILTER (2026-07-04, operator-requested). The DT-008's scan engine
// false-decodes sunlight stripe patterns into short garbage codes (phantom reads —
// see wiki/entities/dingtian-dt008-reader.md), and each one was reaching the exit
// flow and signing an exit.refused.noSession anomaly: red "who is trying to exit?"
// rows for NOBODY, training the operator to ignore the feed (alarm fatigue is the
// adversary's friend). So a reader value that matched nothing AND cannot possibly be
// a credential we issued is dropped to UNSIGNED telemetry (device_events, still
// auditable) instead of the signed ledger. "Possibly ours" stays deliberately wide —
// any of these still reaches the flows and signs the normal refusal anomaly:
// - a Luhn-valid ticket shape (validateTicketCode — a forged/expired ticket is a
// real probe),
// - our issued-code prefixes (SUB- / SUBSESS-),
// - ANY read on a CONFIRMED RF channel (a physically present card, enrolled or
// not, is a real event — RF is never sun noise),
// - plates (different population; never shape-filtered here).
export class ReadDispatcher {
readonly #db: Db;
@@ -45,6 +63,20 @@ export class ReadDispatcher {
if (sub) {
return this.#subscription.run(resolved, e, sub);
}
// Matched nothing — if the value can't even BE one of ours, it's scanner noise
// (phantom optical decode): refuse with unsigned telemetry, keep the signed feed
// for events that involve an actual credential or an actual card.
if ((e.kind === "qr" || e.kind === "card" || e.kind === "ticket") && !plausibleCredential(e)) {
this.#recordUnrecognized(e);
this.#logger.info(`read filtered (not a credential shape): '${e.value}' from ${e.deviceId}${e.channel ? ` ch=${e.channel}` : ""}`);
return {
accepted: false,
direction: resolved.direction === "entry" ? "entry" : "exit",
reason: "unrecognized code (no credential shape — telemetry only)",
};
}
// Not a subscription → transient ticket exit. An ENTRY reader can't produce a
// transient exit (transient entry is the button flow, not a reader), so reject+log
// rather than treat an entry scan as an exit.
@@ -53,4 +85,39 @@ export class ReadDispatcher {
}
return this.#exit.handleAt(resolved, e);
}
/** Unsigned telemetry for a filtered read — auditable in device_events, out of the
* signed feed. Mirrors the entry flow's suppressed-press pattern. */
#recordUnrecognized(e: DeviceReadEvent): void {
try {
this.#db
.insert(deviceEventsTable)
.values({
id: randomUUID(),
deviceId: e.deviceId,
category: "reader",
kind: "read",
detail: {
unrecognizedRead: true,
value: e.value,
readKind: e.kind,
...(e.channel ? { channel: e.channel } : {}),
reason: "no credential shape (phantom decode / garbage scan)",
},
occurredAt: e.at,
})
.run();
} catch (err) {
this.#logger.error(`unrecognized-read telemetry insert failed: ${(err as Error).message}`);
}
}
}
/** Could this reader value possibly be a credential WE issued (or a real card)?
* Deliberately WIDE — only shapes that can't be anything of ours are filtered. */
function plausibleCredential(e: DeviceReadEvent): boolean {
if (e.channel === "rf") return true; // a physically present card — never sun noise
if (validateTicketCode(e.value)) return true; // ticket shape (10–14 digits + Luhn)
if (/^SUB(SESS)?-/.test(e.value)) return true; // our subscription QR / window-slip ids
return false;
}
@@ -0,0 +1,101 @@
import Fastify from "fastify";
import { beforeEach, afterEach, describe, expect, it } from "vitest";
import { devices, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { qrReaderRoutes, splitChannel } from "./qr-reader.js";
import { CredentialCapture } from "../credential-capture.js";
import type { DeviceReadEvent, ReadOutcome } from "../device-events.js";
import type { ReadDispatcher } from "../read-dispatch.js";
// CHANNEL TAGGING (2026-07-04): the DT-008's "QRCode Output Prefix" / "Card Output
// Prefix" (vendor tool) mark which engine produced a push — Q: = optical, K: = RF.
// The route strips the prefix, tags the read's confirmed channel, and enrollment
// capture stores the BARE value. Unprefixed reads stay the legacy untagged shape so
// an unconfigured reader keeps working. These tests pin the route-side contract;
// the match-side enforcement is pinned in ../subscription-channel.test.ts.
const SERIAL = "H05MA5B0";
const READER_ID = "reader-exit";
let db: Db;
let app: ReturnType<typeof Fastify>;
let capture: CredentialCapture;
let seen: DeviceReadEvent[];
/** Dispatcher stub: records the event the route built, always rejects. */
const fakeDispatcher = {
dispatch: async (e: DeviceReadEvent): Promise<ReadOutcome> => {
seen.push(e);
return { accepted: false, reason: "test" };
},
} as unknown as ReadDispatcher;
beforeEach(async () => {
({ db } = createTestDb());
db.insert(devices).values({
id: READER_ID,
category: "reader",
driverId: "dingtian-qr-reader",
config: { serial: SERIAL },
enabled: true,
}).run();
seen = [];
capture = new CredentialCapture();
app = Fastify({ logger: false });
await qrReaderRoutes(app as never, db, fakeDispatcher, capture);
});
afterEach(async () => {
await app.close();
});
const scan = (cardid: string) =>
app.inject({ method: "GET", url: `/qa/mcardsea.php?cardid=${encodeURIComponent(cardid)}&cjihao=${SERIAL}&mjihao=1&status=10` });
describe("splitChannel", () => {
it("K: prefix → bare value, kind card, channel rf", () => {
expect(splitChannel("K:86A158")).toEqual({ value: "86A158", kind: "card", channel: "rf" });
});
it("Q: prefix → bare value, kind qr, channel optical", () => {
expect(splitChannel("Q:12345678901")).toEqual({ value: "12345678901", kind: "qr", channel: "optical" });
});
it("no prefix → value untouched, legacy untagged qr", () => {
expect(splitChannel("86A158")).toEqual({ value: "86A158", kind: "qr" });
});
});
describe("qr-reader route channel tagging", () => {
it("card-prefixed push dispatches a stripped, rf-tagged read", async () => {
const res = await scan("K:86A158");
expect(res.statusCode).toBe(200);
expect(seen).toHaveLength(1);
expect(seen[0]).toMatchObject({ value: "86A158", kind: "card", channel: "rf", deviceId: READER_ID });
});
it("qr-prefixed push dispatches a stripped, optical-tagged read", async () => {
await scan("Q:00000000000");
expect(seen[0]).toMatchObject({ value: "00000000000", kind: "qr", channel: "optical" });
});
it("unprefixed push stays legacy: kind qr, no channel", async () => {
await scan("86A158");
expect(seen[0]).toMatchObject({ value: "86A158", kind: "qr" });
expect(seen[0].channel).toBeUndefined();
});
it("a bare prefix (empty value after strip) dispatches nothing", async () => {
await scan("K:");
expect(seen).toHaveLength(0);
});
it("enrollment capture stores the BARE value, not the prefixed one", async () => {
capture.arm(READER_ID);
const res = await scan("K:86A158");
expect(seen).toHaveLength(0); // intercepted — never dispatched to the access flow
const state = capture.state();
expect(state.status).toBe("captured");
if (state.status === "captured") expect(state.value).toBe("86A158");
// Beeps "ok" so the operator knows the card was read.
expect(res.json().data[0].status).toBe(1);
});
});
+42 -9
View File
@@ -27,6 +27,36 @@ interface ReaderQuery {
time?: string;
}
// ── CHANNEL TAGGING (2026-07-04) ────────────────────────────────────────────────
// The DT-008 push carries one opaque `cardid` whether its OPTICAL engine decoded a
// QR/barcode or its RF engine read a card — the server can't tell them apart. That
// enabled a cheap clone: print a card's UID (often written on the card face) as a
// barcode and the optical decode matches the RF credential. Fix: the vendor tool's
// "QRCode Output Prefix" / "Card Output Prefix" are set to the markers below on every
// reader; the route strips the prefix and tags the read's confirmed channel, and the
// subscription match refuses a channel-mismatched credential. A read with NO prefix
// stays the legacy untagged shape (kind "qr", channel undefined) so an unconfigured
// reader keeps working — the enforcement only bites where prefixes are deployed.
// ⚠️ Prefixes must MATCH the vendor tool; also FREEZE "Card Input format" (6H) — that
// setting defines the UID shape we enroll. See wiki/entities/dingtian-dt008-reader.md.
const QR_CHANNEL_PREFIX = process.env.READER_QR_PREFIX ?? "Q:";
const CARD_CHANNEL_PREFIX = process.env.READER_CARD_PREFIX ?? "K:";
/** Split a raw pushed `cardid` into its bare value + confirmed channel (if prefixed). */
export function splitChannel(raw: string): {
value: string;
kind: "qr" | "card";
channel?: "optical" | "rf";
} {
if (CARD_CHANNEL_PREFIX.length > 0 && raw.startsWith(CARD_CHANNEL_PREFIX)) {
return { value: raw.slice(CARD_CHANNEL_PREFIX.length), kind: "card", channel: "rf" };
}
if (QR_CHANNEL_PREFIX.length > 0 && raw.startsWith(QR_CHANNEL_PREFIX)) {
return { value: raw.slice(QR_CHANNEL_PREFIX.length), kind: "qr", channel: "optical" };
}
return { value: raw, kind: "qr" }; // legacy: unprefixed reader, channel unknown
}
export async function qrReaderRoutes(
app: FastifyInstance,
db: Db,
@@ -55,6 +85,7 @@ export async function qrReaderRoutes(
// See wiki/sources/qrcode-sdk.md, entities/dingtian-dt008-reader.md.
reply.header("connection", "close");
const cardid = (q.cardid ?? "").trim();
const scan = splitChannel(cardid); // bare value + confirmed channel (if prefixed)
const mjihao = q.mjihao != null ? Number(q.mjihao) : 0;
const serial = (q.cjihao ?? "").trim();
@@ -65,35 +96,37 @@ export async function qrReaderRoutes(
const deviceId = matchedRowId ?? serial;
let accepted = false;
if (cardid) {
if (scan.value) {
// ENROLLMENT INTERCEPT: if THIS reader is armed for credential capture, grab the
// value for the subscription form and do NOT run the access flow (we must not
// open a barrier for a card being enrolled). Single-shot — capture auto-disarms.
// Reads from the OTHER reader are untouched and dispatch normally below.
if (capture.tryConsume(deviceId, cardid)) {
app.log.info(`CAPTURE serial=${serial || "?"} device=${matchedRowId ? matchedRowId.slice(0, 8) : "?"} value=${cardid}`);
// Captured BARE (prefix stripped) so enrolled values match future stripped reads.
if (capture.tryConsume(deviceId, scan.value)) {
app.log.info(`CAPTURE serial=${serial || "?"} device=${matchedRowId ? matchedRowId.slice(0, 8) : "?"} value=${scan.value}${scan.channel ? ` ch=${scan.channel}` : ""}`);
accepted = true; // beep "ok" so the operator knows the card was read
} else {
const read: DeviceReadEvent = {
driverId: "dingtian-qr-reader",
deviceId,
value: cardid,
kind: "qr",
value: scan.value,
kind: scan.kind,
...(scan.channel ? { channel: scan.channel } : {}),
at: new Date().toISOString(),
};
try {
const outcome = await dispatcher.dispatch(read);
accepted = outcome.accepted;
// Per-read diagnostic: which reader (serial) sent it, which configured device
// it mapped to, and the verdict — so a barrier/serial mismatch is visible in
// the logs (e.g. an entry-side scan resolving to the exit relay).
// it mapped to, the confirmed channel (if prefixed), and the verdict — so a
// barrier/serial mismatch or a channel anomaly is visible in the logs.
app.log.info(
`READ serial=${serial || "?"} → device=${matchedRowId ? matchedRowId.slice(0, 8) : "UNASSIGNED"} ` +
`card=${cardid} verdict=${accepted ? "ACCEPT" : "REJECT"}${outcome.direction ? ` dir=${outcome.direction}` : ""}` +
`card=${scan.value}${scan.channel ? ` ch=${scan.channel}` : ""} verdict=${accepted ? "ACCEPT" : "REJECT"}${outcome.direction ? ` dir=${outcome.direction}` : ""}` +
`${accepted ? "" : ` reason="${outcome.reason ?? "?"}"`}`,
);
} catch (err) {
app.log.error(`QR dispatch failed for ${cardid}: ${(err as Error).message}`);
app.log.error(`QR dispatch failed for ${scan.value}: ${(err as Error).message}`);
}
}
}
@@ -0,0 +1,88 @@
import { randomUUID } from "node:crypto";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ledgerEvents, subscriptionCredentials, type Db } from "@parking/db";
import { createTestDb } from "@parking/db/testing";
import { SubscriptionFlow } from "./subscription-flow.js";
import type { DeviceReadEvent } from "./device-events.js";
import { makeLog, silentLogger } from "./test-helpers.js";
// CHANNEL AGREEMENT in SubscriptionFlow.match (2026-07-04): when the reader CONFIRMED
// the physical channel (DT-008 output prefixes → DeviceReadEvent.channel), the
// credential kind must agree. An OPTICAL decode claiming an RF credential is the
// cheap clone (print the card's UID as a barcode) — refused + ONE signed anomaly.
// Legacy untagged reads (channel undefined) match as before, so readers without
// prefixes keep working.
let db: Db;
let flow: SubscriptionFlow;
const SUB = "sub-1";
const CARD_UID = "86A158";
const QR_CODE = "SUB-TESTQR";
beforeEach(() => {
({ db } = createTestDb());
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: SUB, kind: "rf", value: CARD_UID }).run();
db.insert(subscriptionCredentials).values({ id: randomUUID(), subscriptionId: SUB, kind: "qr", value: QR_CODE }).run();
flow = new SubscriptionFlow(db, makeLog(db), silentLogger());
});
function read(value: string, opts: { kind?: DeviceReadEvent["kind"]; channel?: DeviceReadEvent["channel"] } = {}): DeviceReadEvent {
return {
driverId: "dingtian-qr-reader",
deviceId: "reader-1",
value,
kind: opts.kind ?? "qr",
...(opts.channel ? { channel: opts.channel } : {}),
at: new Date().toISOString(),
};
}
const anomalies = () =>
db.select().from(ledgerEvents).all().filter((r) => r.type === "anomaly");
describe("subscription match — credential channel agreement", () => {
it("OPTICAL read of an RF card's UID → no match + signed channelMismatch anomaly (the clone)", async () => {
const m = flow.match(read(CARD_UID, { kind: "qr", channel: "optical" }));
expect(m).toBeNull();
await vi.waitFor(() => expect(anomalies()).toHaveLength(1)); // append is fire-and-forget
expect(anomalies()[0].identity).toBe(SUB);
expect(anomalies()[0].payload).toMatchObject({
reasonCode: "sub.refused.channelMismatch",
channelMismatch: true,
credentialKind: "rf",
channel: "optical",
value: CARD_UID,
});
});
it("RF read of the same card → matches (via card), nothing signed", () => {
const m = flow.match(read(CARD_UID, { kind: "card", channel: "rf" }));
expect(m).toMatchObject({ subscriptionId: SUB, via: "card" });
expect(anomalies()).toHaveLength(0);
});
it("legacy untagged read of the card → still matches (unprefixed readers keep working)", () => {
const m = flow.match(read(CARD_UID)); // kind qr, channel undefined — today's shape
expect(m).toMatchObject({ subscriptionId: SUB, via: "card" });
expect(anomalies()).toHaveLength(0);
});
it("OPTICAL read of a QR credential → matches (the legit path)", () => {
const m = flow.match(read(QR_CODE, { kind: "qr", channel: "optical" }));
expect(m).toMatchObject({ subscriptionId: SUB, via: "qr" });
});
it("RF read claiming a QR credential → refused symmetrically (mis-encoded clone tag)", async () => {
const m = flow.match(read(QR_CODE, { kind: "card", channel: "rf" }));
expect(m).toBeNull();
await vi.waitFor(() => expect(anomalies()).toHaveLength(1));
expect(anomalies()[0].payload).toMatchObject({ credentialKind: "qr", channel: "rf" });
});
it("unknown value → plain no-match, no anomaly (a phantom/typo is not a clone attempt)", () => {
const m = flow.match(read("999459", { kind: "qr", channel: "optical" }));
expect(m).toBeNull();
expect(anomalies()).toHaveLength(0);
});
});
+34
View File
@@ -77,6 +77,40 @@ export class SubscriptionFlow {
.where(eq(subscriptionCredentials.value, e.value))
.get();
if (cred) {
// CHANNEL AGREEMENT (clone defense, 2026-07-04). When the reader CONFIRMED the
// physical channel (DT-008 output prefixes), the credential kind must agree: an
// OPTICAL decode may not claim an RF credential — otherwise printing a card's
// UID (often written on the card face) as a barcode clones the card. Symmetric
// for an RF read claiming a QR credential (a mis-encoded clone tag). A legacy
// untagged read (channel undefined) matches as before — enforcement only bites
// where prefixes are deployed. The attempt itself is a fraud signal → signed
// anomaly, then treated as no-match (the flows refuse it as unknown).
const mismatch =
(e.channel === "optical" && cred.kind === "rf") ||
(e.channel === "rf" && cred.kind === "qr");
if (mismatch) {
this.#logger.warn(
`credential channel mismatch: ${cred.kind} credential '${e.value}' presented via ${e.channel} (sub ${cred.subscriptionId}) — possible clone`,
);
void this.#log
.append({
type: "anomaly",
identity: cred.subscriptionId,
payload: {
...reasonPayload("sub.refused.channelMismatch", {
credentialKind: cred.kind,
channel: e.channel === "optical" ? "optical" : "rf",
}),
channelMismatch: true,
credentialKind: cred.kind,
channel: e.channel,
value: e.value,
deviceId: e.deviceId,
},
})
.catch((err) => this.#logger.error(`channel-mismatch anomaly append failed: ${(err as Error).message}`));
return null;
}
return { subscriptionId: cred.subscriptionId, carKey: e.value, via: cred.kind === "qr" ? "qr" : "card" };
}
// Plate binding: a read plate that matches a subscription's bound plate is an identity.
+1
View File
@@ -299,6 +299,7 @@ export const en: Catalog = {
"sub.refused.noSession": "Subscription exit with no open session (already out / never entered)",
"sub.refused.atCapacity": "Subscription refused — at capacity ({{inUse}}/{{max}} cars in)",
"sub.refused.unpaidWindow": "Exit refused — out-of-window charge unpaid ({{amount}} {{currency}}); pay at the booth",
"sub.refused.channelMismatch": "Credential refused — a {{credentialKind}} credential arrived via the {{channel}} channel (possible cloned credential)",
"void.ticketCancelled": "Ticket cancelled — {{reason}}",
"setup.relayTest": "Relay test — admin {{operator}} pulsed relay {{relay}} on controller {{controller}} from Setup",
},
+1
View File
@@ -302,6 +302,7 @@ export const sq = {
"sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)",
"sub.refused.atCapacity": "Abonimi u refuzua — në kapacitet ({{inUse}}/{{max}} makina brenda)",
"sub.refused.unpaidWindow": "Dalja u refuzua — detyrim jashtë orarit i papaguar ({{amount}} {{currency}}); paguaje në kabinë",
"sub.refused.channelMismatch": "Kredenciali u refuzua — kredencial {{credentialKind}} i paraqitur në kanalin {{channel}} (kredencial i mundshëm i klonuar)",
"void.ticketCancelled": "Bileta u anulua — {{reason}}",
"setup.relayTest": "Test releje — admini {{operator}} aktivizoi relenë {{relay}} te kontrolluesi {{controller}} nga Konfigurimi",
},
+5
View File
@@ -405,6 +405,10 @@ export const REASON_CODES = [
// a subscriber owes an out-of-window (early-entry / late-exit) transient charge and
// hasn't paid it — exit is gated until they settle (the tariff-bridge gate).
"sub.refused.unpaidWindow",
// a credential value arrived on the WRONG physical channel (e.g. an RF card's UID
// presented as a printed barcode — a cloned-credential attempt). Channel comes from
// the reader's output prefixes; see wiki/entities/dingtian-dt008-reader.md.
"sub.refused.channelMismatch",
// a wrongly-printed transient ticket cancelled by the operator (signed void event).
"void.ticketCancelled",
// an admin fired a barrier relay from Setup to test the wiring. The physical open is
@@ -443,6 +447,7 @@ export const REASON_EN: Record<ReasonCode, string> = {
"sub.refused.noSession": "subscription exit with no open session (already out / never entered)",
"sub.refused.atCapacity": "subscription refused — at capacity ({inUse}/{max} cars in)",
"sub.refused.unpaidWindow": "exit refused — out-of-window charge unpaid ({amount} {currency} owed); pay at the booth",
"sub.refused.channelMismatch": "credential refused — a {credentialKind} credential arrived via the {channel} channel (possible cloned credential)",
"void.ticketCancelled": "ticket cancelled — {reason}",
"setup.relayTest": "relay test — admin {operator} pulsed relay {relay} on controller {controller} from Setup",
};
+40 -5
View File
@@ -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
View File
@@ -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.