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",
},