From 9f2b287fad247c6b70b0f6ac40a86b8df56a80fd Mon Sep 17 00:00:00 2001 From: rspruel <214661994+rspruel@users.noreply.github.com> Date: Mon, 20 Jul 2026 20:17:27 -0400 Subject: [PATCH 1/3] Update smartcard.js to support multiple smart-cards --- core/output/smartcard.js | 255 ++++++++++++++++++++++++++++----------- 1 file changed, 185 insertions(+), 70 deletions(-) diff --git a/core/output/smartcard.js b/core/output/smartcard.js index 386db8794..8ffc837ba 100644 --- a/core/output/smartcard.js +++ b/core/output/smartcard.js @@ -15,7 +15,7 @@ const fromHex = (data = "") => { return new Uint8Array(data.match(/.{1,2}/g).map((b) => parseInt(b, 16))); }; -// smartcard relay packets +// smartcard relay packet constants const REQUEST_STATUS = 0x01; const REQUEST_POWER_ON = 0x02; const REQUEST_POWER_OFF = 0x03; @@ -25,6 +25,14 @@ const REQUEST_INITIALIZE = 0x06; const RESPONSE_ACK = 0x80; const RESPONSE_ERROR = 0x81; +// Protocol version marker — outside the command/response byte range (0x01-0x06, 0x80-0x81) +// so v1 packets are unambiguously distinguishable from v0 legacy packets. +// v1 layout: [0x10][command (1)][reader_id (1)][U32 length (4)][payload] +// v0 layout: [command (1)][U16 length (2)][payload] (reader_id=0 implied) +const PROTOCOL_VERSION_V1 = 0x10; + +const MAX_READER_LANES = 8; + const commandToString = (command) => { return { [REQUEST_STATUS]: "REQUEST_STATUS", @@ -38,41 +46,66 @@ const commandToString = (command) => { }[command] || `0x${command.toString(16).toUpperCase()}`; }; -const createRelayPacket = (command, payload = new Uint8Array(0)) => { +// createRelayPacket: smartcard.js only sends responses (RESPONSE_ACK / RESPONSE_ERROR). +const createRelayPacket = (command, readerId, payload = new Uint8Array(0)) => { if (command !== RESPONSE_ACK && command !== RESPONSE_ERROR) { throw new Error("invalid_relay_response"); } - - const packet = new Uint8Array(3 + payload.length); - packet[0] = command; - packet[1] = (payload.length >> 8) & 0xff; - packet[2] = payload.length & 0xff; - packet.set(payload, 3); + const packet = new Uint8Array(7 + payload.length); + packet[0] = PROTOCOL_VERSION_V1; + packet[1] = command; + packet[2] = readerId; + const len = payload.length; + packet[3] = (len >>> 24) & 0xff; + packet[4] = (len >>> 16) & 0xff; + packet[5] = (len >>> 8) & 0xff; + packet[6] = len & 0xff; + packet.set(payload, 7); return packet; }; +// parseRelayPacket: the bridge (or a legacy client) sends requests. +// Returns { command, readerId, payload }. const parseRelayPacket = (data) => { - if (data.length < 3) { - throw new Error("invalid_relay_packet"); + if (!data || data.length < 1) { + throw new Error("relay_packet_empty"); } + if (data[0] === PROTOCOL_VERSION_V1) { + // v1: [0x10][command][reader_id][U32 length][payload] + if (data.length < 7) { + throw new Error("relay_packet_v1_too_short"); + } + const command = data[1]; + const readerId = data[2]; + const payloadLength = ((data[3] << 24) | (data[4] << 16) | (data[5] << 8) | data[6]) >>> 0; + if (data.length < 7 + payloadLength) { + throw new Error("relay_packet_v1_incomplete"); + } + return { command, readerId, payload: data.slice(7, 7 + payloadLength) }; + } + + // v0 legacy: [command][U16 length][payload], reader_id=0 implied + if (data.length < 3) { + throw new Error("relay_packet_v0_too_short"); + } const command = data[0]; const payloadLength = (data[1] << 8) | data[2]; - if (data.length < 3 + payloadLength) { - throw new Error("invalid_relay_packet"); + throw new Error("relay_packet_v0_incomplete"); } - - return { - command, - payload: data.slice(3, 3 + payloadLength), - }; + return { command, readerId: 0, payload: data.slice(3, 3 + payloadLength) }; }; const KASM_SMARTCARD_EXTENSION_ID = "cjkohjfgidilbllbjkdhpoeonjanpomo"; +// SmartcardSession is bound to one specific reader name. +// readerId is the lane index (0..N-1) used for self-healing reader discovery. +// readerName=null means the lane is empty; refresh() will attempt discovery. class SmartcardSession { - constructor() { + constructor(readerName, readerId = 0) { + this.readerName = readerName; + this.readerId = readerId; this.context = null; this.cardAtr = null; this.cardHandle = null; @@ -82,65 +115,62 @@ class SmartcardSession { } async refresh() { - // skip check if we have refreshed recently + // skip if recently refreshed or mid-transmit if (this.lastRefreshAt && Date.now() - this.lastRefreshAt < 1000) { return; } - - // skip check if we have sent data recently if (this.lastTransmitAt && Date.now() - this.lastTransmitAt < 1000) { return; } - // query state let refreshContext = null; try { refreshContext = await this._establishContext(); - this.readers = await this._listReaders(refreshContext); - if (this.readers.length == 0) { - throw new Error("no_readers"); + // If no reader is bound yet (initializeSessions ran too early), try to bind now. + if (!this.readerName) { + const readers = await this._listReaders(refreshContext); + if (readers.length > this.readerId) { + this.readerName = readers[this.readerId]; + Log.Info(`smartcard: reader[${this.readerId}] late-bound to "${this.readerName}"`); + } } - this.cardAtr = await this._getStatusChange(refreshContext, this.readers[0]).then(({ atr }) => atr); + if (this.readerName) { + this.cardAtr = await this._getStatusChange(refreshContext, this.readerName) + .then(({ atr }) => atr); + } else { + this.cardAtr = null; + } } catch (error) { + // A failed status query (bad context, unknown-reader name, native host + // down, macOS SCARD_STATE_EMPTY quirk) must not look identical to a + // genuinely empty reader — log it so "no card" can be told apart from + // "status query failed". + Log.Warn( + `smartcard: reader[${this.readerId}] "${this.readerName || "(unbound)"}" ` + + `status refresh failed: ${error.message}` + ); this.context = null; - this.readers = []; this.cardAtr = null; this.cardHandle = null; this.activeProtocol = null; } - // update web ui - const smartcardStatus = { - isExtensionEnabled: !!refreshContext, - isReaderConnected: this.readers.length > 0, - isCardPresent: !!this.cardAtr, - }; - - if (WebUtil.isInsideKasmVDI()) { - window.parent.postMessage({ - action: "smartcard_status", - value: smartcardStatus, - }, "*"); - } - - Log.Debug(`smartcard.refresh: ${JSON.stringify(smartcardStatus, null, 2)}`); - - // clean up this.lastRefreshAt = Date.now(); if (refreshContext) { - await this._releaseContext(refreshContext); + await this._releaseContext(refreshContext).catch(() => {}); } } async powerOn() { + if (!this.readerName) throw new Error("no_reader_bound"); this.context = this.context || (await this._establishContext()); if (!this.cardHandle || !this.activeProtocol) { - const { cardHandle, activeProtocol } = await this._connect(this.context, this.readers[0]); + const { cardHandle, activeProtocol } = await this._connect(this.context, this.readerName); this.cardHandle = cardHandle; this.activeProtocol = activeProtocol; } @@ -182,7 +212,6 @@ class SmartcardSession { return await this._callExtension("release_context", context).then(([status]) => status); } - async _listReaders(context) { return await this._callExtension("list_readers", context).then(([status, readers]) => { return Array.isArray(readers) ? readers : readers.split(",").filter(Boolean); @@ -260,59 +289,145 @@ class SmartcardSession { } } +// Discover the current reader list and build a session Map: +// lane i → the i-th reader from list_readers, capped at MAX_READER_LANES. +// Empty lanes (no reader bound) are left out of the map; lane 0 always exists for compat. +const initializeSessions = async () => { + const sessions = new Map(); + const probe = new SmartcardSession(null); + let readers = []; + + try { + const ctx = await probe._establishContext(); + try { + readers = await probe._listReaders(ctx); + } finally { + await probe._releaseContext(ctx).catch(() => {}); + } + } catch (err) { + Log.Warn(`smartcard: reader discovery failed: ${err.message}`); + } + + const numLanes = Math.min(readers.length, MAX_READER_LANES); + for (let i = 0; i < numLanes; i++) { + const session = new SmartcardSession(readers[i], i); + sessions.set(i, session); + await session.refresh().catch(() => {}); + } + + // Always provide lane 0 for backward compat with v0 (legacy single-reader) bridges. + // readerId is set so refresh() can late-bind the reader if discovery failed at startup. + if (!sessions.has(0)) { + sessions.set(0, new SmartcardSession(null, 0)); + } + + Log.Info(`smartcard: initialized ${numLanes} reader lane(s): [${readers.join(", ")}]`); + return sessions; +}; + +// Aggregate per-session state into a postMessage payload for the parent frame. +// Keeps legacy top-level fields (isExtensionEnabled, isReaderConnected, isCardPresent) +// for backward compat with kasmweb ControlPanel.js, and adds a `readers` array. +const broadcastStatus = (sessions) => { + const readers = []; + for (const [readerId, session] of sessions) { + readers.push({ + readerId, + readerName: session.readerName || null, + isCardPresent: !!session.cardAtr, + }); + } + + const status = { + isExtensionEnabled: readers.some((r) => !!r.readerName), + isReaderConnected: readers.some((r) => !!r.readerName), + isCardPresent: readers.some((r) => r.isCardPresent), + readers, + }; + + if (WebUtil.isInsideKasmVDI()) { + window.parent.postMessage({ action: "smartcard_status", value: status }, "*"); + } + + Log.Debug(`smartcard.status: ${JSON.stringify(status, null, 2)}`); +}; + export default async (rfb) => { Log.Debug("smartcard.initializeSmartcardRelay"); - const sendSmartcardResponse = (command, payload = new Uint8Array(0)) => { - Log.Debug(`smartcard.response: command=${commandToString(command)}, payloadLen=${payload.length}`); - const packet = createRelayPacket(command, payload); + const sessions = await initializeSessions(); + + const sendSmartcardResponse = (readerId, command, payload = new Uint8Array(0)) => { + Log.Debug( + `smartcard.response: reader[${readerId}] command=${commandToString(command)}, payloadLen=${payload.length}` + ); + const packet = createRelayPacket(command, readerId, payload); rfb.sendUnixRelayData("smartcard", packet); }; - const clientSession = new SmartcardSession(); - await clientSession.refresh(); - rfb.subscribeUnixRelay("smartcard", async (data) => { + let command, readerId, payload; try { - const { command, payload } = parseRelayPacket(data); - Log.Debug(`smartcard.request: command=${commandToString(command)}, payloadLen=${payload.length}`); + ({ command, readerId, payload } = parseRelayPacket(data)); + } catch (err) { + Log.Error(`smartcard: failed to parse relay packet: ${err.message}`); + return; + } + Log.Debug( + `smartcard.request: reader[${readerId}] command=${commandToString(command)}, payloadLen=${payload.length}` + ); + + let session = sessions.get(readerId); + if (!session) { + // Lane not yet initialized — create a placeholder; refresh() will late-bind the reader. + session = new SmartcardSession(null, readerId); + sessions.set(readerId, session); + } + + try { switch (command) { case REQUEST_INITIALIZE: - sendSmartcardResponse(RESPONSE_ACK); + sendSmartcardResponse(readerId, RESPONSE_ACK); break; case REQUEST_STATUS: - await clientSession.refresh(); - sendSmartcardResponse(RESPONSE_ACK, clientSession.cardAtr ? fromHex(clientSession.cardAtr) : new Uint8Array(0)); + await session.refresh(); + sendSmartcardResponse( + readerId, + RESPONSE_ACK, + session.cardAtr ? fromHex(session.cardAtr) : new Uint8Array(0) + ); + broadcastStatus(sessions); break; case REQUEST_POWER_ON: - await clientSession.powerOn(); - sendSmartcardResponse(RESPONSE_ACK); + await session.powerOn(); + sendSmartcardResponse(readerId, RESPONSE_ACK); break; case REQUEST_POWER_OFF: - await clientSession.powerOff(); - sendSmartcardResponse(RESPONSE_ACK); + await session.powerOff(); + sendSmartcardResponse(readerId, RESPONSE_ACK); break; case REQUEST_RESET: - sendSmartcardResponse(RESPONSE_ACK); + sendSmartcardResponse(readerId, RESPONSE_ACK); break; - case REQUEST_TRANSMIT: - await clientSession.powerOn(); - const response = await clientSession.transmit(payload); - sendSmartcardResponse(RESPONSE_ACK, response); + case REQUEST_TRANSMIT: { + await session.powerOn(); + const response = await session.transmit(payload); + sendSmartcardResponse(readerId, RESPONSE_ACK, response); break; + } default: - throw new Error(`Unknown binary command: 0x${command.toString(16)}`); + throw new Error(`unknown_command: 0x${command.toString(16)}`); } } catch (error) { - Log.Error(`Failed to process command: ${error.message}`); - sendSmartcardResponse(RESPONSE_ERROR, new TextEncoder().encode(error.message)); + Log.Error(`smartcard: reader[${readerId}]: ${error.message}`); + sendSmartcardResponse(readerId, RESPONSE_ERROR, new TextEncoder().encode(error.message)); } }); }; From e17e5635cee4c1f40da5488e4c6bb90a233b3e17 Mon Sep 17 00:00:00 2001 From: rspruel <214661994+rspruel@users.noreply.github.com> Date: Tue, 21 Jul 2026 23:10:38 -0400 Subject: [PATCH 2/3] Update smartcard.js REQUEST_INITIALIZE processing --- core/output/smartcard.js | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/core/output/smartcard.js b/core/output/smartcard.js index 8ffc837ba..b57fb9431 100644 --- a/core/output/smartcard.js +++ b/core/output/smartcard.js @@ -387,9 +387,33 @@ export default async (rfb) => { try { switch (command) { - case REQUEST_INITIALIZE: - sendSmartcardResponse(readerId, RESPONSE_ACK); + case REQUEST_INITIALIZE: { + // Reply with the bound reader list so the bridge can size its active + // lane count off real discovery instead of falling back to a static + // --readers value. Payload format must match bridge.py's + // parse_reader_list: repeated [name_len (1 byte)][name (UTF-8)], + // ordered by readerId — entry i binds to lane i. + const encoder = new TextEncoder(); + const chunks = []; + let i = 0; + while (sessions.has(i)) { + const boundName = sessions.get(i).readerName; + if (boundName) { + const nameBytes = encoder.encode(boundName); + chunks.push(new Uint8Array([nameBytes.length]), nameBytes); + } + i++; + } + const totalLength = chunks.reduce((sum, c) => sum + c.length, 0); + const listPayload = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + listPayload.set(chunk, offset); + offset += chunk.length; + } + sendSmartcardResponse(readerId, RESPONSE_ACK, listPayload); break; + } case REQUEST_STATUS: await session.refresh(); From ed815c9df1a64747956e26a5eba7408a0187ddfa Mon Sep 17 00:00:00 2001 From: "rodwin.spruel" Date: Wed, 22 Jul 2026 00:39:25 -0400 Subject: [PATCH 3/3] KASM-8734 Bug fixes --- core/output/smartcard.js | 69 ++++++++++++++++++---------------------- 1 file changed, 31 insertions(+), 38 deletions(-) diff --git a/core/output/smartcard.js b/core/output/smartcard.js index b57fb9431..d8dcc3f47 100644 --- a/core/output/smartcard.js +++ b/core/output/smartcard.js @@ -25,10 +25,9 @@ const REQUEST_INITIALIZE = 0x06; const RESPONSE_ACK = 0x80; const RESPONSE_ERROR = 0x81; -// Protocol version marker — outside the command/response byte range (0x01-0x06, 0x80-0x81) -// so v1 packets are unambiguously distinguishable from v0 legacy packets. -// v1 layout: [0x10][command (1)][reader_id (1)][U32 length (4)][payload] -// v0 layout: [command (1)][U16 length (2)][payload] (reader_id=0 implied) +// Protocol version marker, checked so a mismatched/garbled peer is rejected +// rather than silently misparsed. +// layout: [0x10][command (1)][reader_id (1)][U32 length (4)][payload] const PROTOCOL_VERSION_V1 = 0x10; const MAX_READER_LANES = 8; @@ -64,37 +63,21 @@ const createRelayPacket = (command, readerId, payload = new Uint8Array(0)) => { return packet; }; -// parseRelayPacket: the bridge (or a legacy client) sends requests. -// Returns { command, readerId, payload }. +// parseRelayPacket: the bridge sends requests. Returns { command, readerId, payload }. const parseRelayPacket = (data) => { - if (!data || data.length < 1) { - throw new Error("relay_packet_empty"); + if (!data || data.length < 7) { + throw new Error("relay_packet_too_short"); } - - if (data[0] === PROTOCOL_VERSION_V1) { - // v1: [0x10][command][reader_id][U32 length][payload] - if (data.length < 7) { - throw new Error("relay_packet_v1_too_short"); - } - const command = data[1]; - const readerId = data[2]; - const payloadLength = ((data[3] << 24) | (data[4] << 16) | (data[5] << 8) | data[6]) >>> 0; - if (data.length < 7 + payloadLength) { - throw new Error("relay_packet_v1_incomplete"); - } - return { command, readerId, payload: data.slice(7, 7 + payloadLength) }; - } - - // v0 legacy: [command][U16 length][payload], reader_id=0 implied - if (data.length < 3) { - throw new Error("relay_packet_v0_too_short"); + if (data[0] !== PROTOCOL_VERSION_V1) { + throw new Error(`relay_packet_unknown_version: 0x${data[0].toString(16)}`); } - const command = data[0]; - const payloadLength = (data[1] << 8) | data[2]; - if (data.length < 3 + payloadLength) { - throw new Error("relay_packet_v0_incomplete"); + const command = data[1]; + const readerId = data[2]; + const payloadLength = ((data[3] << 24) | (data[4] << 16) | (data[5] << 8) | data[6]) >>> 0; + if (data.length < 7 + payloadLength) { + throw new Error("relay_packet_incomplete"); } - return { command, readerId: 0, payload: data.slice(3, 3 + payloadLength) }; + return { command, readerId, payload: data.slice(7, 7 + payloadLength) }; }; const KASM_SMARTCARD_EXTENSION_ID = "cjkohjfgidilbllbjkdhpoeonjanpomo"; @@ -356,6 +339,7 @@ export default async (rfb) => { Log.Debug("smartcard.initializeSmartcardRelay"); const sessions = await initializeSessions(); + broadcastStatus(sessions); const sendSmartcardResponse = (readerId, command, payload = new Uint8Array(0)) => { Log.Debug( @@ -391,18 +375,27 @@ export default async (rfb) => { // Reply with the bound reader list so the bridge can size its active // lane count off real discovery instead of falling back to a static // --readers value. Payload format must match bridge.py's - // parse_reader_list: repeated [name_len (1 byte)][name (UTF-8)], - // ordered by readerId — entry i binds to lane i. + // parse_reader_list: repeated [reader_id (1 byte)][name_len (1 byte)] + // [name (UTF-8)], one entry per bound lane. reader_id is explicit + // (rather than inferred from list position) so a gap in the lane + // map never truncates or misattributes an entry. const encoder = new TextEncoder(); const chunks = []; - let i = 0; - while (sessions.has(i)) { - const boundName = sessions.get(i).readerName; + for (const [laneId, laneSession] of sessions) { + const boundName = laneSession.readerName; if (boundName) { const nameBytes = encoder.encode(boundName); - chunks.push(new Uint8Array([nameBytes.length]), nameBytes); + if (nameBytes.length > 255) { + // name_len is a single byte; a longer name would silently wrap + // and desync every entry after it, so drop this one instead. + Log.Warn( + `smartcard: reader[${laneId}] name too long (${nameBytes.length} bytes) ` + + "for REQUEST_INITIALIZE encoding, omitting from reader list" + ); + continue; + } + chunks.push(new Uint8Array([laneId, nameBytes.length]), nameBytes); } - i++; } const totalLength = chunks.reduce((sum, c) => sum + c.length, 0); const listPayload = new Uint8Array(totalLength);