@ledgerhq/live-common
Version:
Common ground for the Ledger Live apps
344 lines • 17.3 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.warnings = void 0;
exports.createDeviceSocket = createDeviceSocket;
const logs_1 = require("@ledgerhq/logs");
const isomorphic_ws_1 = __importDefault(require("isomorphic-ws"));
const rxjs_1 = require("rxjs");
const errors_1 = require("@ledgerhq/errors");
const deviceAccess_1 = require("../hw/deviceAccess");
const live_env_1 = require("@ledgerhq/live-env");
const sha3_1 = require("@noble/hashes/sha3");
const ids_1 = require("@ledgerhq/client-ids/ids");
const LOG_TYPE = "socket";
const ALLOW_SECURE_CHANNEL_DELAY = 500;
const warningsSubject = new rxjs_1.Subject();
exports.warnings = warningsSubject.asObservable();
/**
* use Ledger WebSocket API to exchange data with the device
* Returns an Observable of the final result
*/
function createDeviceSocket(transport, { url, unresponsiveExpectedDuringBulk, context, }) {
const tracer = new logs_1.LocalTracer(LOG_TYPE, {
...context,
function: "createDeviceSocket",
transportContext: transport.getTraceContext(),
});
tracer.trace("Starting web socket communication", { url, unresponsiveExpectedDuringBulk });
return new rxjs_1.Observable(o => {
let deviceError = null; // error originating from device (connection/response/rejection...)
let unsubscribed = false; // subscriber wants to stops everything
let bulkSubscription = null; // subscription to the bulk observable
let correctlyFinished = false; // the socket logic reach a normal termination
let inBulkMode = false; // we have an array of apdus to exchange, without the need of more WS messages.
let allowSecureChannelTimeout = null; // allows to delay/cancel the user confirmation event
let deviceIdCaptured = false; // track if we've already captured the device id
const ws = new isomorphic_ws_1.default(url);
ws.onopen = () => {
tracer.trace("Socket opened", { url });
o.next({
type: "opened",
});
};
ws.onerror = e => {
tracer.trace("Socket error", { e });
if (inBulkMode)
return; // in bulk case, we ignore any network events because we just need to unroll APDUs with the device
o.error(new errors_1.WebsocketConnectionError(e.message, {
url,
}));
};
ws.onclose = () => {
tracer.trace("Socket closed", { url, inBulkMode, correctlyFinished });
if (inBulkMode)
return; // in bulk case, we ignore any network events because we just need to unroll APDUs with the device
if (correctlyFinished) {
o.complete();
}
else {
tracer.trace(`Socket closed, not correctly finished, device error: ${deviceError}`, {
deviceError,
inBulkMode,
});
// Nb Give priority to the cached error from a device connection, since websocket closes give
// us no information on what caused the close.
o.error(deviceError || new errors_1.WebsocketConnectionError("closed"));
}
};
ws.onmessage = async (e) => {
if (unsubscribed)
return;
deviceError = null; // If we continue to receive messages, the cached error is obsolete.
try {
const input = JSON.parse(e.data);
tracer.trace("Socket in", { type: input.query });
switch (input.query) {
case "exchange": {
tracer.trace("Socket in: exchange", {
nonce: input?.nonce,
uuid: input?.uuid,
session: input?.session,
});
// A single ping-pong apdu with the HSM
const { nonce } = input;
const apdu = Buffer.from(input.data, "hex");
o.next({
type: "exchange-before",
nonce,
apdu,
});
// Detect the specific exchange that triggers the allow secure channel request.
let pendingUserAllowSecureChannel = false;
if (apdu.slice(0, 2).toString("hex") === "e051") {
pendingUserAllowSecureChannel = true;
allowSecureChannelTimeout = setTimeout(() => {
if (unsubscribed)
return;
o.next({
type: "device-permission-requested",
});
// Nb Permission is only requested once per reboot, delaying the event
// prevents the UI from flashing the rendering for allowing.
}, ALLOW_SECURE_CHANNEL_DELAY);
}
// Detect GetCertificate APDU to extract device ID
const shouldCaptureDeviceId = !deviceIdCaptured && apdu.slice(0, 2).toString("hex") === "e052";
const r = await transport.exchange(apdu);
if (allowSecureChannelTimeout) {
clearTimeout(allowSecureChannelTimeout);
allowSecureChannelTimeout = null;
}
if (unsubscribed)
return;
const status = r.readUInt16BE(r.length - 2);
let response;
switch (status) {
case errors_1.StatusCodes.OK:
response = "success";
break;
case errors_1.StatusCodes.LOCKED_DEVICE:
o.error(new errors_1.TransportStatusError(status));
return;
case errors_1.StatusCodes.USER_REFUSED_ON_DEVICE:
case errors_1.StatusCodes.CONDITIONS_OF_USE_NOT_SATISFIED:
if (pendingUserAllowSecureChannel) {
o.error(new errors_1.UserRefusedAllowManager());
return;
}
// Fallthrough is literally what we want when not allowing a secure channel.
// eslint-disable-next-line no-fallthrough
default:
// Nb Other errors may not throw directly, we will instead keep track of
// them and throw them if the next event from the ws connection is a disconnect
// otherwise, we clear them.
response = "error";
deviceError = new errors_1.TransportStatusError(status);
break;
}
if (pendingUserAllowSecureChannel) {
o.next({
type: "device-permission-granted",
});
}
// Extract device ID from GetCertificate response
if (shouldCaptureDeviceId && status === errors_1.StatusCodes.OK) {
try {
const responseData = r.slice(0, r.length - 2);
const headerLength = responseData[0];
const publicKeyLengthOffset = 1 + headerLength;
const publicKeyLength = responseData[publicKeyLengthOffset];
const publicKeyOffset = publicKeyLengthOffset + 1;
const publicKey = responseData.slice(publicKeyOffset, publicKeyOffset + publicKeyLength);
// Compute device ID as SHA3-256 hash of the public key
const deviceIdHash = (0, sha3_1.sha3_256)(publicKey);
const deviceIdString = Buffer.from(deviceIdHash).toString("hex");
const deviceId = ids_1.DeviceId.fromString(deviceIdString);
deviceIdCaptured = true;
o.next({
type: "device-id",
deviceId,
});
}
catch (err) {
tracer.trace("Failed to extract device ID from GetCertificate response", { err });
}
}
const data = r.slice(0, r.length - 2);
o.next({
type: "exchange",
nonce,
apdu,
status,
data,
});
const msg = {
nonce,
response,
data: data.toString("hex"),
};
tracer.trace("Socket out", { response: msg.response });
const strMsg = JSON.stringify(msg);
ws.send(strMsg);
break;
}
case "bulk": {
tracer.trace("Socket in: bulk", {
apduCount: input?.data?.length,
nonce: input?.nonce,
uuid: input?.uuid,
session: input?.session,
});
// In bulk, a lot of APDUs will be unrolled, and the web socket is no longer needed
inBulkMode = true;
ws.close();
const { data } = input;
const notify = index => o.next({
type: "bulk-progress",
progress: index / data.length,
index,
total: data.length,
});
// we use a promise to wait for the bulk to finish
await new Promise((resolve, reject) => {
let i = 0;
notify(0);
// if the bulk payload includes trailing empty strings we end up
// sending empty data to the device and causing a disconnect.
const cleanData = data
.map(d => (d !== "" ? Buffer.from(d, "hex") : null))
.filter(Boolean);
// we also use a subscription to be able to cancel the bulk if the user unsubscribes
bulkSubscription = transport.exchangeBulk(cleanData, {
next: () => {
notify(++i);
},
error: e => reject(e),
complete: () => resolve(null),
});
});
if (unsubscribed) {
tracer.trace("unsubscribed before end of bulk");
return;
}
correctlyFinished = true;
o.complete();
break;
}
case "success": {
// A final success event with some data payload
const payload = input.result || input.data;
tracer.trace("Socket in: success", {
payload,
inBulkMode,
nonce: input?.nonce,
uuid: input?.uuid,
session: input?.session,
});
// Once entered in bulk mode, we close the websocket and don't react to any other messages
if (inBulkMode)
break;
if (payload) {
o.next({
type: "result",
payload,
});
}
correctlyFinished = true;
o.complete();
break;
}
case "error": {
tracer.trace("Socket in: error", {
errorData: input?.data,
inBulkMode,
nonce: input?.nonce,
uuid: input?.uuid,
session: input?.session,
});
// Once entered in bulk mode, we close the websocket and don't react to any other messages
if (inBulkMode)
break;
// An error from HSM
throw new errors_1.DeviceSocketFail(input.data, {
url,
});
}
case "warning": {
tracer.trace("Socket in: warning", {
warningData: input?.data,
inBulkMode,
nonce: input?.nonce,
uuid: input?.uuid,
session: input?.session,
});
// Once entered in bulk mode, we close the websocket and don't react to any other messages
if (inBulkMode)
break;
// A warning from HSM
o.next({
type: "warning",
message: input.data,
});
warningsSubject.next(input.data);
break;
}
default:
tracer.trace("Socket in: cannot handle msg of type", { input });
}
}
catch (err) {
deviceError = err;
tracer.trace("Socket message error", { err });
o.error(err);
}
};
const onDisconnect = e => {
transport.off("disconnect", onDisconnect);
tracer.trace(`Socket disconnected. Emitting a DisconnectedDeviceDuringOperation. Error: ${e}`, { error: e });
const error = new errors_1.DisconnectedDeviceDuringOperation((e && e.message) || "");
deviceError = error;
o.error(error);
};
const onUnresponsiveDevice = () => {
tracer.trace(`Device unresponsive`, {
inBulkMode,
unresponsiveExpectedDuringBulk,
allowSecureChannelTimeout,
unsubscribed,
});
// Nb Don't consider the device as locked if we are in a blocking apdu exchange, ie
// one that requires user confirmation to complete.
if (inBulkMode && unresponsiveExpectedDuringBulk)
return;
if (allowSecureChannelTimeout)
return;
if (unsubscribed)
return;
o.error(new errors_1.ManagerDeviceLockedError());
};
transport.on("disconnect", onDisconnect);
transport.on("unresponsive", onUnresponsiveDevice);
return () => {
unsubscribed = true;
if (bulkSubscription) {
bulkSubscription.unsubscribe();
}
transport.off("disconnect", onDisconnect);
transport.off("unresponsive", onUnresponsiveDevice);
if (!correctlyFinished && !deviceError && inBulkMode) {
// if it was not normally ended, we might have to flush it
if ((0, live_env_1.getEnv)("DEVICE_CANCEL_APDU_FLUSH_MECHANISM")) {
(0, deviceAccess_1.cancelDeviceAction)(transport);
}
}
if (ws.readyState === 1) {
// connection still active. we close it from client (e.g. user unsubscribe)
ws.close();
}
};
});
}
//# sourceMappingURL=index.js.map