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.