feat(reader): channel tagging (clone defense) + structural filter for phantom scans
Two reader-hardening changes born from the park-buzi phantom-scan investigation
(empty pre-opening site, exit reader pushing sun-decoded garbage codes).
1. CHANNEL TAGGING — closes the printed-card-clone hole. The DT-008 push is
channel-blind (one opaque cardid from either engine) and SubscriptionFlow
matched by value only, so printing an RF card's UID (often written on the
card face, e.g. 86A158) as a barcode cloned the card. Now:
- Vendor tool sets output prefixes (QRCode "Q:", Card "K:"; server env
overrides READER_QR_PREFIX / READER_CARD_PREFIX).
- routes/qr-reader.ts strips the prefix and tags the read's confirmed
channel (DeviceReadEvent.channel optical|rf; kind qr|card). Enrollment
capture stores the BARE value. READ log lines carry ch=… (permanent
phantom attribution).
- SubscriptionFlow.match requires channel agreement: an optical decode may
not claim an rf credential (and vice versa) — refused + signed
sub.refused.channelMismatch anomaly (a clone attempt is a fraud signal).
- Unprefixed reads keep the legacy untagged shape and match as before, so
enforcement only bites where prefixes are deployed. Deploy server FIRST,
then set prefixes in the vendor tool.
2. STRUCTURAL FILTER — phantom decodes out of the signed feed (operator-
requested, reverses the earlier "record every probe" position — red
"who is exiting?" rows for NOBODY train the operator to ignore the feed).
read-dispatch.ts drops a no-match reader value that cannot possibly be a
credential we issue (no ticket Luhn shape, no SUB-/SUBSESS- prefix, not
confirmed-RF, not a plate) to UNSIGNED device_events telemetry
(unrecognizedRead:true). Deliberately WIDE plausibility: forged ticket
shapes, unknown physical cards, unknown SUB- codes all still sign the
normal refusal anomaly; enrolled credentials match before the filter and
can never be hidden. Works for legacy unprefixed reads too — the feed
cleans up on deploy, before any vendor-tool change.
Wiki: dingtian-dt008-reader.md records the clone hole + fix, the filter (as a
recorded position reversal), and the two device-side settings now part of the
credential contract (output prefixes + Card Input format, moving 6H→8H at the
next vendor-tool session; both live ON the device — re-apply after any
factory reset/swap).
Tests: qr-reader-channel.test.ts (prefix split, route tagging, bare-value
capture), subscription-channel.test.ts (channel agreement matrix + anomaly),
read-dispatch-filter.test.ts (filter boundary: phantoms dropped, probes kept,
enrolled never hidden). Suite 278 green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user