diff --git a/apps/server/src/device-events.ts b/apps/server/src/device-events.ts index cfbacf8..51255cc 100644 --- a/apps/server/src/device-events.ts +++ b/apps/server/src/device-events.ts @@ -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 } diff --git a/apps/server/src/read-dispatch-filter.test.ts b/apps/server/src/read-dispatch-filter.test.ts new file mode 100644 index 0000000..f3a1c01 --- /dev/null +++ b/apps/server/src/read-dispatch-filter.test.ts @@ -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 + }); +}); diff --git a/apps/server/src/read-dispatch.ts b/apps/server/src/read-dispatch.ts index 9beebf9..03dd856 100644 --- a/apps/server/src/read-dispatch.ts +++ b/apps/server/src/read-dispatch.ts @@ -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; } diff --git a/apps/server/src/routes/qr-reader-channel.test.ts b/apps/server/src/routes/qr-reader-channel.test.ts new file mode 100644 index 0000000..9d84c15 --- /dev/null +++ b/apps/server/src/routes/qr-reader-channel.test.ts @@ -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; +let capture: CredentialCapture; +let seen: DeviceReadEvent[]; + +/** Dispatcher stub: records the event the route built, always rejects. */ +const fakeDispatcher = { + dispatch: async (e: DeviceReadEvent): Promise => { + 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); + }); +}); diff --git a/apps/server/src/routes/qr-reader.ts b/apps/server/src/routes/qr-reader.ts index 8962a0d..b5f14a3 100644 --- a/apps/server/src/routes/qr-reader.ts +++ b/apps/server/src/routes/qr-reader.ts @@ -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}`); } } } diff --git a/apps/server/src/subscription-channel.test.ts b/apps/server/src/subscription-channel.test.ts new file mode 100644 index 0000000..0ea0d3f --- /dev/null +++ b/apps/server/src/subscription-channel.test.ts @@ -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); + }); +}); diff --git a/apps/server/src/subscription-flow.ts b/apps/server/src/subscription-flow.ts index 405ce69..e777583 100644 --- a/apps/server/src/subscription-flow.ts +++ b/apps/server/src/subscription-flow.ts @@ -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. diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index a608ad9..4c5168a 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -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", }, diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 864c4cb..01239b9 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -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", }, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 98f31d2..e925c4d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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 = { "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", }; diff --git a/wiki/entities/dingtian-dt008-reader.md b/wiki/entities/dingtian-dt008-reader.md index 35116d2..b82bfa8 100644 --- a/wiki/entities/dingtian-dt008-reader.md +++ b/wiki/entities/dingtian-dt008-reader.md @@ -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 diff --git a/wiki/log.md b/wiki/log.md index b06302f..6d743b2 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -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.