Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
282 changes: 207 additions & 75 deletions core/output/smartcard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -25,6 +25,13 @@ const REQUEST_INITIALIZE = 0x06;
const RESPONSE_ACK = 0x80;
const RESPONSE_ERROR = 0x81;

// 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;

const commandToString = (command) => {
return {
[REQUEST_STATUS]: "REQUEST_STATUS",
Expand All @@ -38,41 +45,50 @@ 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 sends requests. Returns { command, readerId, payload }.
const parseRelayPacket = (data) => {
if (data.length < 3) {
throw new Error("invalid_relay_packet");
if (!data || data.length < 7) {
throw new Error("relay_packet_too_short");
}

const command = data[0];
const payloadLength = (data[1] << 8) | data[2];

if (data.length < 3 + payloadLength) {
throw new Error("invalid_relay_packet");
if (data[0] !== PROTOCOL_VERSION_V1) {
throw new Error(`relay_packet_unknown_version: 0x${data[0].toString(16)}`);
}

return {
command,
payload: data.slice(3, 3 + payloadLength),
};
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, payload: data.slice(7, 7 + 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;
Expand All @@ -82,65 +98,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;
}
Expand Down Expand Up @@ -182,7 +195,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);
Expand Down Expand Up @@ -260,59 +272,179 @@ 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();
broadcastStatus(sessions);

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);
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 [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 = [];
for (const [laneId, laneSession] of sessions) {
const boundName = laneSession.readerName;
if (boundName) {
const nameBytes = encoder.encode(boundName);
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);
}
}
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 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));
}
});
};