KASM-7297 Smartcard Passthrough (#157)

* KASM-7297 Smartcard relay

* KASM-7297 Update reference to production extension

* KASM-7297 Wrap SmartcardSession.transmit in beginTransaction/endTransaction blocks

* KASM-7297 Update Kasm UI with reader/card status

* KASM-7297 Properly clear context on SmartcardSession._releaseContext

* KASM-7297 Workaround for incorrect list_readers return value in smartcard native client

* KASM-7287 Workaround for list_readers for ChromeOS handling

* KASM-7287 Improvements to smartcard status refresh

* KASM-7297 Remove unreachable code

* KASM-7297 Updated fromHex function to work for empty values
This commit is contained in:
Mariusz Marciniak 2025-08-15 16:12:57 +02:00 committed by GitHub
parent 6a8e7349b1
commit 77f29babbc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 320 additions and 0 deletions

318
core/output/smartcard.js Normal file
View File

@ -0,0 +1,318 @@
import * as Log from "../../core/util/logging.js";
import * as WebUtil from "../../app/webutil.js";
// utilities
const toHex = (data) => {
return Array.from(data)
.map((b) => b.toString(16).padStart(2, "0").toUpperCase())
.join("")
.replace(/ /g, "");
};
const fromHex = (data = "") => {
if (data.length === 0) return new Uint8Array(0);
if (!/^[0-9a-fA-F]*$/.test(data)) throw new Error(`invalid_hex_string: ${data}`);
return new Uint8Array(data.match(/.{1,2}/g).map((b) => parseInt(b, 16)));
};
// smartcard relay packets
const REQUEST_STATUS = 0x01;
const REQUEST_POWER_ON = 0x02;
const REQUEST_POWER_OFF = 0x03;
const REQUEST_RESET = 0x04;
const REQUEST_TRANSMIT = 0x05;
const REQUEST_INITIALIZE = 0x06;
const RESPONSE_ACK = 0x80;
const RESPONSE_ERROR = 0x81;
const commandToString = (command) => {
return {
[REQUEST_STATUS]: "REQUEST_STATUS",
[REQUEST_POWER_ON]: "REQUEST_POWER_ON",
[REQUEST_POWER_OFF]: "REQUEST_POWER_OFF",
[REQUEST_RESET]: "REQUEST_RESET",
[REQUEST_TRANSMIT]: "REQUEST_TRANSMIT",
[REQUEST_INITIALIZE]: "REQUEST_INITIALIZE",
[RESPONSE_ACK]: "RESPONSE_ACK",
[RESPONSE_ERROR]: "RESPONSE_ERROR",
}[command] || `0x${command.toString(16).toUpperCase()}`;
};
const createRelayPacket = (command, 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);
return packet;
};
const parseRelayPacket = (data) => {
if (data.length < 3) {
throw new Error("invalid_relay_packet");
}
const command = data[0];
const payloadLength = (data[1] << 8) | data[2];
if (data.length < 3 + payloadLength) {
throw new Error("invalid_relay_packet");
}
return {
command,
payload: data.slice(3, 3 + payloadLength),
};
};
const KASM_SMARTCARD_EXTENSION_ID = "cjkohjfgidilbllbjkdhpoeonjanpomo";
class SmartcardSession {
constructor() {
this.context = null;
this.cardAtr = null;
this.cardHandle = null;
this.activeProtocol = null;
this.lastTransmitAt = null;
this.lastRefreshAt = null;
}
async refresh() {
// skip check if we have refreshed recently
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");
}
this.cardAtr = await this._getStatusChange(refreshContext, this.readers[0]).then(({ atr }) => atr);
} catch (error) {
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);
}
}
async powerOn() {
this.context = this.context || (await this._establishContext());
if (!this.cardHandle || !this.activeProtocol) {
const { cardHandle, activeProtocol } = await this._connect(this.context, this.readers[0]);
this.cardHandle = cardHandle;
this.activeProtocol = activeProtocol;
}
}
async powerOff() {
if (this.context && this.cardHandle) {
await this._disconnect(this.context, this.cardHandle);
await this._releaseContext(this.context);
}
this.context = null;
this.cardHandle = null;
this.cardAtr = null;
this.activeProtocol = null;
}
async transmit(apdu) {
try {
await this._beginTransaction();
} catch (error) {}
try {
this.lastTransmitAt = Date.now();
return await this._transmit(apdu);
} catch (error) {
this.lastTransmitAt = null;
throw error;
} finally {
await this._endTransaction();
}
}
async _establishContext() {
return await this._callExtension("establish_context", 0).then(([status, context]) => context);
}
async _releaseContext(context) {
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);
});
}
async _getStatusChange(context, reader) {
return await this._callExtension("get_status_change", context, 0, 1, 0, 0, reader).then(
([status, readerCount, currentState, eventState, atr]) => ({
status,
readerCount,
currentState,
eventState,
atr,
})
);
}
async _connect(context, reader) {
return await this._callExtension("connect", context, 2, 3, reader).then(
([status, cardContext, cardHandle, activeProtocol]) => ({
cardHandle,
activeProtocol,
})
);
}
async _disconnect(context, cardHandle) {
return await this._callExtension("disconnect", context, cardHandle, 0).then(([status]) => status);
}
async _beginTransaction() {
return await this._callExtension("begin_transaction", this.context, this.cardHandle).then(([status]) => status);
}
async _transmit(apdu) {
return await this._callExtension("transmit", this.context, this.cardHandle, this.activeProtocol, toHex(apdu)).then(
([status, context, card, protocol, response]) => fromHex(response)
);
}
async _endTransaction(disposition = 0) {
return await this._callExtension("end_transaction", this.context, this.cardHandle, disposition).then(
([status]) => status
);
}
async _callExtension(name, ...args) {
return new Promise((resolve, reject) => {
const deviceId = "smartcard-relay";
const completionId = Date.now().toString() + Math.random().toString(36);
const message = {
deviceId,
completionId,
type: name,
args: args.join(","),
};
const onResponse = (response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
if (response.status === "error" || response.result[0] !== "0x00000000") {
reject(new Error(response.result[0] || "0x80100001"));
} else {
resolve(response.result);
}
};
chrome.runtime.sendMessage(KASM_SMARTCARD_EXTENSION_ID, message, onResponse);
});
}
}
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);
rfb.sendUnixRelayData("smartcard", packet);
};
const clientSession = new SmartcardSession();
await clientSession.refresh();
rfb.subscribeUnixRelay("smartcard", async (data) => {
try {
const { command, payload } = parseRelayPacket(data);
Log.Debug(`smartcard.request: command=${commandToString(command)}, payloadLen=${payload.length}`);
switch (command) {
case REQUEST_INITIALIZE:
sendSmartcardResponse(RESPONSE_ACK);
break;
case REQUEST_STATUS:
await clientSession.refresh();
sendSmartcardResponse(RESPONSE_ACK, clientSession.cardAtr ? fromHex(clientSession.cardAtr) : new Uint8Array(0));
break;
case REQUEST_POWER_ON:
await clientSession.powerOn();
sendSmartcardResponse(RESPONSE_ACK);
break;
case REQUEST_POWER_OFF:
await clientSession.powerOff();
sendSmartcardResponse(RESPONSE_ACK);
break;
case REQUEST_RESET:
sendSmartcardResponse(RESPONSE_ACK);
break;
case REQUEST_TRANSMIT:
await clientSession.powerOn();
const response = await clientSession.transmit(payload);
sendSmartcardResponse(RESPONSE_ACK, response);
break;
default:
throw new Error(`Unknown binary command: 0x${command.toString(16)}`);
}
} catch (error) {
Log.Error(`Failed to process command: ${error.message}`);
sendSmartcardResponse(RESPONSE_ERROR, new TextEncoder().encode(error.message));
}
});
};

View File

@ -21,6 +21,7 @@ import Inflator from "./inflator.js";
import Deflator from "./deflator.js";
import Keyboard from "./input/keyboard.js";
import initializePrinterRelay from "./output/printer.js";
import initializeSmartcardRelay from "./output/smartcard.js";
import GestureHandler from "./input/gesturehandler.js";
import Cursor from "./util/cursor.js";
import Websock from "./websock.js";
@ -3102,6 +3103,7 @@ export default class RFB extends EventTargetMixin {
//Register pipe based extensions
initializePrinterRelay(this);
initializeSmartcardRelay(this);
return true;
}