PageSourceSearch

https://verifythecard.com/js/transport-msr.js

js verifythecard.com collected 2026-10-02 00:34:37 UTC 9,641 bytes, 260 lines download raw bytes

1/**
2 * transport-msr.js — verifythecard.com
3 *
4 * Magstripe transport — listens for the HID-keyboard burst from a USB MSR,
5 * parses the tracks via magstripe-parser.js, and emits a presence-only
6 * event in the same family as the iso7816-* event types.
7 *
8 * This is NOT a chip protocol — there is no SELECT, no INTERNAL AUTHENTICATE,
9 * no cryptographic exchange. A magstripe swipe carries static data that
10 * anyone with a $15 reader can copy. The manifest event explicitly says so
11 * via cryptographicAnchor: false.
12 *
13 * Why we still support it: many cards in the wild are still magstripe-only
14 * (legacy bank cards, gift cards, hotel keys, employee badges, transit
15 * cards). Recording "this card was swiped at this moment" is useful as a
16 * presence event even though it can't prove identity cryptographically.
17 *
18 * UX flow (consumed by chip-bind.html):
19 *   1. User clicks "Listen for swipe"
20 *   2. listenOnce() resolves with a captured swipe (or rejects on timeout)
21 *   3. magstripe-parser.js parses the buffer
22 *   4. Event emitted in the same shape as iso7816-* events for uniformity
23 *
24 * Detection mechanism:
25 *   Most USB MSRs operate as HID-keyboard devices. When you swipe a card,
26 *   they "type" the parsed track data into whatever input field has focus —
27 *   very fast, usually under 100ms total. The burst starts with '%' or ';'
28 *   and ends with '?'. We detect this pattern in a focused hidden input.
29 */
30
31(function (global) {
32  'use strict';
33
34  // Configuration — tune these if your MSR behaves differently.
35  const SWIPE_TIMEOUT_MS    = 30000;   // give up listening after 30s
36  const BURST_QUIET_MS      = 120;     // 120ms of no keystrokes = burst complete
37  const MIN_BURST_LEN       = 20;      // any "burst" shorter than this is keyboard typing, ignore
38  const VALID_START_CHARS   = ['%', ';'];
39
40  class MsrTransport {
41    constructor() {
42      this.name = 'msr-hid';
43      this._buffer = '';
44      this._listening = false;
45      this._resolve = null;
46      this._reject  = null;
47      this._timeoutId = null;
48      this._quietId = null;
49      this._captureHost = null;
50      this._onKey = null;
51    }
52
53    /**
54     * Listen for exactly one magstripe swipe.
55     * @returns {Promise<{ raw: string, capturedAt: string, durationMs: number }>}
56     */
57    listenOnce() {
58      if (this._listening) {
59        return Promise.reject(new Error('Already listening for a swipe.'));
60      }
61
62      return new Promise((resolve, reject) => {
63        this._listening = true;
64        this._resolve   = resolve;
65        this._reject    = reject;
66        this._buffer    = '';
67        const startTs   = performance.now();
68
69        // Listen at the document level — modern Chrome treats off-screen
70        // inputs unreliably for focus, so a hidden capture-host alone is
71        // fragile (keystrokes arrive but the listener on the unfocused
72        // host never fires). Document-level keydown fires regardless of
73        // focus target, which is the load-bearing change here.
74        //
75        // We still mount a hidden input on-screen-but-invisible as a
76        // courtesy keystroke sink so swipe characters don't accidentally
77        // land in any other form field during the listen window.
78        const host = document.createElement('input');
79        host.type = 'text';
80        host.autocomplete = 'off';
81        host.spellcheck = false;
82        host.style.cssText =
83          'position:fixed;left:50%;top:0;width:1px;height:1px;opacity:0;pointer-events:none;';
84        host.setAttribute('aria-hidden', 'true');
85        document.body.appendChild(host);
86        try { host.focus({ preventScroll: true }); } catch (_) { host.focus(); }
87        this._captureHost = host;
88
89        this._onKey = (e) => {
90          // Try to keep focus on our sink so swipe chars don't leak into
91          // visible form fields, but don't fail if the browser refuses.
92          try {
93            if (document.activeElement !== host) host.focus({ preventScroll: true });
94          } catch (_) {}
95
96          // Detect what character was emitted. MSR readers send literal
97          // ASCII characters via keydown; the field separator and end
98          // marker arrive as plain chars.
99          let ch = e.key;
100          if (ch === 'Enter') ch = '\n';   // some readers terminate with Enter
101          if (ch && ch.length === 1) {
102            this._buffer += ch;
103            // Prevent the keystroke from typing into a visible field or
104            // triggering page shortcuts during the swipe burst.
105            e.preventDefault();
106          }
107
108          // Reset the burst-end quiet timer on every captured keystroke.
109          if (this._quietId) clearTimeout(this._quietId);
110          this._quietId = setTimeout(
111            () => this._finishBurst(startTs),
112            BURST_QUIET_MS
113          );
114        };
115
116        // ★ Document-level capture-phase listener — fires regardless of
117        //   which element has focus. This replaces the previous
118        //   host-input listener that relied on programmatic focus
119        //   succeeding (which off-screen inputs can't guarantee).
120        document.addEventListener('keydown', this._onKey, true);
121
122        // Overall timeout — if no valid burst arrives within SWIPE_TIMEOUT_MS,
123        // give up cleanly.
124        this._timeoutId = setTimeout(() => {
125          this._teardown();
126          reject(new Error(`No swipe detected within ${SWIPE_TIMEOUT_MS / 1000}s.`));
127        }, SWIPE_TIMEOUT_MS);
128      });
129    }
130
131    _finishBurst(startTs) {
132      const buf = this._buffer;
133      if (!buf || buf.length < MIN_BURST_LEN) {
134        // Probably just keyboard noise — keep listening, don't resolve.
135        // But reset the buffer in case the user accidentally typed something.
136        this._buffer = '';
137        return;
138      }
139      // Verify the burst starts with a valid track marker
140      if (!VALID_START_CHARS.some(c => buf.includes(c))) {
141        this._buffer = '';
142        return;
143      }
144
145      const durationMs = Math.round(performance.now() - startTs);
146      const capturedAt = new Date().toISOString();
147      const raw        = buf;
148
149      // Capture the resolve callback BEFORE teardown nulls it.
150      const resolve = this._resolve;
151      this._teardown();
152      if (resolve) resolve({ raw, capturedAt, durationMs });
153    }
154
155    /**
156     * Cancel an active listen.
157     */
158    cancel() {
159      if (!this._listening) return;
160      // Capture the reject callback BEFORE teardown nulls it.
161      const reject = this._reject;
162      this._teardown();
163      if (reject) reject(new Error('Listen cancelled.'));
164    }
165
166    _teardown() {
167      this._listening = false;
168      if (this._timeoutId) { clearTimeout(this._timeoutId); this._timeoutId = null; }
169      if (this._quietId)   { clearTimeout(this._quietId);   this._quietId   = null; }
170      // Remove the document-level keydown capture listener.
171      if (this._onKey) {
172        try { document.removeEventListener('keydown', this._onKey, true); } catch (_) {}
173      }
174      if (this._captureHost) {
175        try {
176          this._captureHost.remove();
177        } catch (_) {}
178        this._captureHost = null;
179      }
180      this._resolve = null;
181      this._reject  = null;
182      this._onKey   = null;
183    }
184
185    /**
186     * Run the full magstripe protocol: listen for one swipe + parse + emit
187     * event. Returns an event in the same shape as ChipProtocol.run() so
188     * chip-bind.html renders both flavors with the same code path.
189     */
190    async run() {
191      const t0 = performance.now();
192      let swipe;
193      try {
194        swipe = await this.listenOnce();
195      } catch (e) {
196        return {
197          ok: false,
198          eventType: 'magstripe-error',
199          transport: this.name,
200          cardClass: null,
201          cardLabel: null,
202          aid: '',
203          atr: '',
204          selectResponse: null,
205          authResponse: null,
206          capturedAt: new Date().toISOString(),
207          durationMs: Math.round(performance.now() - t0),
208          errors: [e.message],
209          cryptographicAnchor: false,
210        };
211      }
212
213      // Parse
214      const parsed = await global.MagstripeParser.parse(swipe.raw);
215      if (!parsed.ok) {
216        return {
217          ok: false,
218          eventType: 'magstripe-error',
219          transport: this.name,
220          cardClass: null,
221          cardLabel: null,
222          aid: '',
223          atr: '',
224          selectResponse: null,
225          authResponse: null,
226          capturedAt: swipe.capturedAt,
227          durationMs: swipe.durationMs,
228          errors: [parsed.reason],
229          cryptographicAnchor: false,
230        };
231      }
232
233      // Successful parse — emit a presence event
234      return {
235        ok: true,
236        eventType: 'magstripe-swipe',
237        transport: this.name,
238        cardClass: 'magstripe',          // NEW class — distinct from 'federal' and 'emv'
239        cardLabel: parsed.brand,
240        aid: '',                         // magstripe has no AID
241        atr: '',                         // magstripe has no ATR
242        selectResponse: null,
243        authResponse: null,
244        // Magstripe-specific fields:
245        panHash:  parsed.panHash,
246        panLast4: parsed.last4,
247        panBin:   parsed.bin,
248        expiry:   parsed.expiry,         // { month, year } or null
249        trackUsed: parsed.track,
250        luhnValid: parsed.luhnValid,
251        capturedAt: swipe.capturedAt,
252        durationMs: swipe.durationMs,
253        errors:    [],
254        cryptographicAnchor: false,      // explicit honest label
255      };
256    }
257  }
258
259  global.MsrTransport = MsrTransport;
260})(typeof window !== 'undefined' ? window : globalThis);

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.