@trezor/connect
Version:
High-level javascript interface for Trezor hardware wallet.
748 lines • 29.1 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.Device = void 0;
const crypto_1 = require("crypto");
const device_utils_1 = require("@trezor/device-utils");
const protocol_1 = require("@trezor/protocol");
const transport_1 = require("@trezor/transport");
const utils_1 = require("@trezor/utils");
const DeviceCommands_1 = require("./DeviceCommands");
const constants_1 = require("../constants");
const DeviceCurrentSession_1 = require("./DeviceCurrentSession");
const checkFirmwareRevision_1 = require("./checkFirmwareRevision");
const thp_1 = require("./thp");
const firmware_1 = require("../api/firmware");
const DataManager_1 = require("../data/DataManager");
const coinInfo_1 = require("../data/coinInfo");
const firmwareInfo_1 = require("../data/firmwareInfo");
const getLanguage_1 = require("../data/getLanguage");
const events_1 = require("../events");
const types_1 = require("../types");
const handshake_1 = require("./workflow/handshake");
const debug_1 = require("../utils/debug");
const deviceFeaturesUtils_1 = require("../utils/deviceFeaturesUtils");
const _log = (0, debug_1.initLog)('Device');
class Device extends utils_1.TypedEmitter {
transport;
transportPath;
bluetoothProps;
thp;
possibleHIDdevice;
sessionAcquired;
_protocol;
get protocol() {
return this._protocol;
}
getThpState() {
return this.thp;
}
unreadableError;
_firmwareStatus;
get firmwareStatus() {
return this._firmwareStatus;
}
_firmwareRelease;
get firmwareRelease() {
return this._firmwareRelease;
}
_features;
get features() {
return this._features;
}
wasUsedElsewhere = false;
acquirePromise;
releasePromise;
runAbort;
runPromise;
keepTransportSession = false;
currentSession;
instance = 0;
state = [];
stateStorage = undefined;
_unavailableCapabilities = {};
get unavailableCapabilities() {
return this._unavailableCapabilities;
}
_firmwareType;
get firmwareType() {
return this._firmwareType;
}
name = 'Trezor';
color;
availableTranslations = [];
authenticityChecks = {
firmwareRevision: null,
firmwareHash: null,
};
uniquePath;
lifecycle = new utils_1.TypedEmitter();
sessionDfd;
constructor({ id, transport, descriptor }) {
super();
this._protocol = protocol_1.v1;
this.uniquePath = id;
this.transport = transport;
this.transportPath = descriptor.path;
this.possibleHIDdevice = [0, 2].includes(descriptor.type);
this.bluetoothProps = descriptor.id ? { id: descriptor.id } : undefined;
this.sessionAcquired = null;
transport.on(transport_1.TRANSPORT.STOPPED, this.onTransportStopped);
transport.deviceEvents.on(this.transportPath, this.onTransportDeviceEvent);
}
onTransportStopped = () => this.disconnect();
onTransportDeviceEvent = (event) => {
switch (event.type) {
case transport_1.TRANSPORT.DEVICE_SESSION_CHANGED:
return this.updateDescriptor(event.descriptor);
case transport_1.TRANSPORT.DEVICE_REQUEST_RELEASE:
return this.usedElsewhere();
case transport_1.TRANSPORT.DEVICE_DISCONNECTED: {
return this.disconnect();
}
}
};
getSessionChangePromise() {
if (!this.sessionDfd) {
this.sessionDfd = (0, utils_1.createDeferred)();
this.sessionDfd.promise
.catch(() => { })
.finally(() => {
this.sessionDfd = undefined;
});
}
return this.sessionDfd.promise;
}
async waitAndCompareSession(response, sessionPromise) {
if (response.success) {
try {
if ((await sessionPromise) !== response.payload) {
return {
success: false,
error: transport_1.TRANSPORT_ERROR.SESSION_WRONG_PREVIOUS,
};
}
}
catch {
return {
success: false,
error: transport_1.TRANSPORT_ERROR.DEVICE_DISCONNECTED_DURING_ACTION,
};
}
}
return response;
}
acquire() {
const sessionPromise = this.getSessionChangePromise();
const previous = this.transport.getDescriptor(this.transportPath)?.session ?? null;
this.acquirePromise = this.transport
.acquire({ input: { path: this.transportPath, previous } })
.then(result => this.waitAndCompareSession(result, sessionPromise))
.then(result => {
if (result.success) {
this.wasUsedElsewhere = false;
this.sessionAcquired = result.payload;
this.currentSession = new DeviceCurrentSession_1.DeviceCurrentSession(this, this.transport, this.sessionAcquired);
return result;
}
else {
throw new Error(result.error);
}
})
.finally(() => {
this.acquirePromise = undefined;
});
return this.acquirePromise;
}
release() {
if (!this.sessionAcquired || this.keepTransportSession || this.releasePromise) {
return;
}
const sessionPromise = this.getSessionChangePromise();
this.releasePromise = this.transport
.release({ session: this.sessionAcquired, path: this.transportPath })
.then(result => this.waitAndCompareSession(result, sessionPromise))
.then(result => {
if (result.success) {
this.sessionAcquired = null;
}
return result;
})
.finally(() => {
this.releasePromise = undefined;
});
return this.releasePromise;
}
async setupThp() {
_log.info('Setup THP device');
this._protocol = protocol_1.v2;
if (this.transport.name === 'BridgeTransport' &&
!utils_1.versionUtils.isNewerOrEqual(this.transport.version, '3.0.0')) {
this.unreadableError = 'THP incompatible with bridge ' + this.transport.version;
}
else {
try {
await this.transport.loadMessages('thp', protocol_1.thp.getProtobufDefinitions);
this.thp = new protocol_1.thp.ThpState();
}
catch (error) {
this.unreadableError = error.message;
}
}
}
async handshake() {
if (this.isUsedElsewhere()) {
return true;
}
try {
await this.run();
}
catch (error) {
_log.warn(`device.run error.message: ${error.message}, code: ${error.code}`);
if (error.code === 'Device_NotFound' ||
error.code === 'Device_Disconnected' ||
error.message === transport_1.TRANSPORT_ERROR.DEVICE_NOT_FOUND ||
error.message === transport_1.TRANSPORT_ERROR.DEVICE_DISCONNECTED_DURING_ACTION ||
error.message === transport_1.TRANSPORT_ERROR.HTTP_ERROR) {
return false;
}
if ((this.possibleHIDdevice &&
error.message === transport_1.TRANSPORT_ERROR.INTERFACE_UNABLE_TO_OPEN_DEVICE) ||
error.message === transport_1.TRANSPORT_ERROR.LIBUSB_ERROR_ACCESS) {
this.unreadableError = error.message;
}
}
return true;
}
async updateDescriptor(descriptor) {
this.sessionDfd?.resolve(descriptor.session);
await Promise.all([this.acquirePromise, this.releasePromise]);
if (descriptor.session && descriptor.session !== this.sessionAcquired) {
this.usedElsewhere();
}
if (!descriptor.session) {
this.keepTransportSession = false;
}
this.lifecycle.emit(events_1.DEVICE.CHANGED);
}
run(fn, options = {}) {
if (this.runPromise) {
_log.warn('Previous call is still running');
throw constants_1.ERRORS.TypedError('Device_CallInProgress');
}
const wasUnacquired = this.isUnacquired();
this.runAbort = new AbortController();
const { signal } = this.runAbort;
this.runPromise = Promise.race([
this._runInner(fn, options, signal),
new Promise((_, reject) => {
signal.addEventListener('abort', () => reject(signal.reason));
}),
])
.catch(async (err) => {
this.keepTransportSession = false;
await this.acquirePromise;
await this.release();
throw err;
})
.finally(() => {
this.runAbort = undefined;
this.runPromise = undefined;
})
.then(() => {
if (wasUnacquired && !this.isUnacquired()) {
this.lifecycle.emit(events_1.DEVICE.CONNECT);
}
});
return this.runPromise;
}
async interrupt(reason) {
await this.currentSession?.abort(reason);
this.runAbort?.abort(reason);
await this.currentRun;
}
get currentRun() {
return this.runPromise?.catch(() => { });
}
usedElsewhere() {
this.wasUsedElsewhere = true;
if (!this.sessionAcquired) {
return;
}
this.transport.releaseDevice(this.sessionAcquired);
this.sessionAcquired = null;
_log.debug('interruptionFromOutside');
this.runAbort?.abort(constants_1.ERRORS.TypedError('Device_UsedElsewhere'));
}
async _runInner(fn, options, abortSignal) {
if (this.releasePromise) {
await this.releasePromise;
}
const acquireNeeded = !this.isUsedHere() || this.currentSession?.isDisposed();
if (acquireNeeded) {
await this.acquire();
}
if (abortSignal.aborted)
throw abortSignal.reason;
const { staticSessionId, deriveCardano } = this.getState() || {};
if (acquireNeeded || !staticSessionId || (!deriveCardano && options.useCardanoDerivation)) {
try {
await (0, handshake_1.handshakeCancel)({ device: this, logger: _log, signal: abortSignal });
if (this.protocol.name === 'v2') {
const withInteraction = !!fn;
await (0, thp_1.getThpChannel)(this, withInteraction);
}
else if (fn) {
await this.initialize(!!options.useCardanoDerivation);
}
else {
await this.getFeatures();
}
}
catch (error) {
_log.warn('Device._runInner error: ', error.message);
if (error.code === 'Device_ThpPairingTagInvalid') {
return Promise.reject(error);
}
return Promise.reject(constants_1.ERRORS.TypedError('Device_InitializeFailed', `Initialize failed: ${error.message}${error.code ? `, code: ${error.code}` : ''}`));
}
}
if (!options.skipFirmwareChecks) {
await this.checkFirmwareHashWithRetries();
await this.checkFirmwareRevisionWithRetries();
}
if (!options.skipLanguageChecks &&
this.features?.language &&
!this.features.language_version_matches &&
this.atLeast('2.7.0')) {
_log.info('language version mismatch. silently updating...');
try {
await this.changeLanguage({ language: this.features.language });
}
catch (err) {
_log.error('change language failed silently', err);
}
}
if (options.keepSession) {
this.keepTransportSession = true;
}
if (fn) {
await fn();
if (!options.skipFinalReload) {
await this.getFeatures();
}
}
if ((!this.keepTransportSession && typeof options.keepSession !== 'boolean') ||
options.keepSession === false) {
this.keepTransportSession = false;
await this.release();
}
}
getCurrentSession() {
if (!this.currentSession) {
throw constants_1.ERRORS.TypedError('Runtime', `Device: commands not defined`);
}
return this.currentSession;
}
getCommands() {
return (0, DeviceCommands_1.DeviceCommands)(this.getCurrentSession());
}
setInstance(instance = 0) {
if (this.instance !== instance) {
if (this.keepTransportSession) {
this.sessionAcquired = null;
this.keepTransportSession = false;
}
}
this.instance = instance;
}
getInstance() {
return this.instance;
}
getState() {
return this.state[this.instance];
}
setState(state) {
if (!state) {
delete this.state[this.instance];
}
else {
const prevState = this.state[this.instance];
const newState = {
...prevState,
...state,
};
this.state[this.instance] = newState;
this.stateStorage?.saveState(this, newState);
}
}
async initialize(useCardanoDerivation) {
let payload;
if (this.features) {
const { sessionId, deriveCardano } = this.getState() || {};
payload = {
derive_cardano: deriveCardano || useCardanoDerivation,
};
if (sessionId) {
payload.session_id = sessionId;
}
}
const { message } = await this.getCurrentSession().typedCall('Initialize', 'Features', payload);
this._updateFeatures(message);
this.setState({ deriveCardano: payload?.derive_cardano });
}
initStorage(storage) {
this.stateStorage = storage;
this.setState(storage.loadState(this));
}
async getFeatures() {
const { message } = await this.getCurrentSession().typedCall('GetFeatures', 'Features', {});
this._updateFeatures(message);
}
async checkFirmwareHashWithRetries() {
const lastResult = this.authenticityChecks.firmwareHash;
const notDoneYet = lastResult === null;
const attemptsDone = lastResult?.attemptCount ?? 0;
if (attemptsDone >= constants_1.FIRMWARE.HASH_CHECK_MAX_ATTEMPTS)
return;
const wasError = lastResult !== null && !lastResult.success;
const wasErrorRetriable = wasError && (0, utils_1.isArrayMember)(lastResult.error, constants_1.FIRMWARE.HASH_CHECK_RETRIABLE_ERRORS);
const lastErrorPayload = wasError ? lastResult?.errorPayload : null;
if (notDoneYet || wasErrorRetriable) {
const result = await this.checkFirmwareHash();
this.authenticityChecks = {
...this.authenticityChecks,
firmwareHash: result,
};
if (result === null)
return;
result.attemptCount = attemptsDone + 1;
if (result.success && lastErrorPayload) {
result.warningPayload = { lastErrorPayload, successOnAttempt: result.attemptCount };
}
}
}
async checkFirmwareHash() {
const createFailResult = (error, errorPayload) => ({
success: false,
error,
errorPayload,
});
const baseUrl = DataManager_1.DataManager.getSettings('binFilesBaseUrl');
const enabled = DataManager_1.DataManager.getSettings('enableFirmwareHashCheck');
if (!enabled || baseUrl === undefined)
return createFailResult('check-skipped');
const firmwareVersion = this.getVersion();
if (firmwareVersion === undefined || !this.features || this.features.bootloader_mode) {
return null;
}
const checkSupported = !this.unavailableCapabilities.getFirmwareHash;
if (!checkSupported)
return createFailResult('check-unsupported');
const release = (0, firmwareInfo_1.getReleases)(this.features.internal_model).find(r => utils_1.versionUtils.isEqual(r.version, firmwareVersion));
if (release === undefined)
return createFailResult('unknown-release');
const btcOnly = this.firmwareType === types_1.FirmwareType.BitcoinOnly;
const binary = await (0, firmware_1.getBinaryOptional)({ baseUrl, btcOnly, release });
if (binary === null) {
return createFailResult('check-unsupported');
}
if (binary.byteLength < 200) {
_log.warn(`Firmware binary for hash check suspiciously small (< 200 b)`);
return createFailResult('check-unsupported');
}
const strippedBinary = (0, firmware_1.stripFwHeaders)(binary);
const { hash: expectedHash, challenge } = (0, firmware_1.calculateFirmwareHash)(this.features.major_version, strippedBinary, (0, crypto_1.randomBytes)(32));
try {
const deviceResponse = await this.getCurrentSession().typedCall('GetFirmwareHash', 'FirmwareHash', { challenge });
if (!deviceResponse?.message?.hash) {
return createFailResult('other-error', 'Device response is missing hash');
}
if (deviceResponse.message.hash !== expectedHash) {
return createFailResult('hash-mismatch');
}
return { success: true };
}
catch (errorPayload) {
return createFailResult('other-error', (0, utils_1.serializeError)(errorPayload));
}
}
async checkFirmwareRevisionWithRetries() {
const lastResult = this.authenticityChecks.firmwareRevision;
const notDoneYet = lastResult === null;
const wasError = lastResult !== null && !lastResult.success;
const wasErrorRetriable = wasError && (0, utils_1.isArrayMember)(lastResult.error, constants_1.FIRMWARE.REVISION_CHECK_RETRIABLE_ERRORS);
if (notDoneYet || wasErrorRetriable) {
await this.checkFirmwareRevision();
}
}
async checkFirmwareRevision() {
const firmwareVersion = this.getVersion();
if (!firmwareVersion || !this.features) {
return;
}
if (this.features.bootloader_mode === true) {
return;
}
const release = (0, firmwareInfo_1.getRelease)(this.features.internal_model, firmwareVersion);
const result = await (0, checkFirmwareRevision_1.checkFirmwareRevision)({
internalModel: this.features.internal_model,
deviceRevision: this.features.revision,
firmwareVersion,
expectedRevision: release?.firmware_revision,
});
this.authenticityChecks = {
...this.authenticityChecks,
firmwareRevision: result,
};
}
async changeLanguage({ language, binary, }) {
if (language === 'en-US') {
return this._uploadTranslationData(null);
}
if (binary) {
return this._uploadTranslationData(binary);
}
const version = this.getVersion();
if (!version) {
throw constants_1.ERRORS.TypedError('Runtime', 'changeLanguage: device version unknown');
}
const downloadedBinary = await (0, getLanguage_1.getLanguage)({
language,
version,
internal_model: this.features.internal_model,
});
if (!downloadedBinary) {
throw constants_1.ERRORS.TypedError('Runtime', 'changeLanguage: translation not found');
}
return this._uploadTranslationData(downloadedBinary);
}
async _uploadTranslationData(payload) {
if (payload === null) {
const response = await this.getCurrentSession().typedCall('ChangeLanguage', ['Success'], { data_length: 0 });
return response.message;
}
const length = payload.byteLength;
let response = await this.getCurrentSession().typedCall('ChangeLanguage', ['DataChunkRequest', 'Success'], { data_length: length });
while (response.type !== 'Success') {
const start = response.message.data_offset;
const end = response.message.data_offset + response.message.data_length;
const chunk = payload.slice(start, end);
response = await this.getCurrentSession().typedCall('DataChunkAck', ['DataChunkRequest', 'Success'], {
data_chunk: Buffer.from(chunk).toString('hex'),
});
}
return response.message;
}
_updateFeatures(feat) {
const capabilities = (0, deviceFeaturesUtils_1.parseCapabilities)(feat);
feat.capabilities = capabilities;
if (this.features && this.features.session_id && !feat.session_id) {
feat.session_id = this.features.session_id;
}
feat.unlocked = feat.unlocked ?? true;
const revision = (0, deviceFeaturesUtils_1.parseRevision)(feat);
feat.revision = revision;
if (!feat.model && feat.major_version === 1) {
feat.model = '1';
}
if (!feat.internal_model || !device_utils_1.DeviceModelInternal[feat.internal_model]) {
feat.internal_model = (0, deviceFeaturesUtils_1.ensureInternalModelFeature)(feat.model);
}
const version = this.getVersion();
const newVersion = [
feat.major_version,
feat.minor_version,
feat.patch_version,
];
if (!version || !utils_1.versionUtils.isEqual(version, newVersion)) {
if (version) {
this.emit(events_1.DEVICE.FIRMWARE_VERSION_CHANGED, {
oldVersion: version,
newVersion,
device: this.toMessageObject(),
});
}
this._unavailableCapabilities = (0, deviceFeaturesUtils_1.getUnavailableCapabilities)(feat, (0, coinInfo_1.getAllNetworks)());
this._firmwareStatus = (0, firmwareInfo_1.getFirmwareStatus)(feat);
this._firmwareRelease = (0, firmwareInfo_1.getReleaseInfo)(feat);
this.availableTranslations = this.firmwareRelease?.translations ?? [];
}
this._features = feat;
if (feat.fw_vendor === 'Trezor Bitcoin-only') {
this._firmwareType = types_1.FirmwareType.BitcoinOnly;
}
else if (feat.fw_vendor === 'Trezor') {
this._firmwareType = types_1.FirmwareType.Universal;
}
else if (this.getMode() !== 'bootloader') {
this._firmwareType =
feat.capabilities &&
feat.capabilities.length > 0 &&
!feat.capabilities.includes('Capability_Bitcoin_like')
? types_1.FirmwareType.BitcoinOnly
: types_1.FirmwareType.Universal;
}
const deviceInfo = device_utils_1.models[feat.internal_model] ?? {
name: `Unknown ${feat.internal_model}`,
colors: {},
};
this.name = deviceInfo.name;
if (feat?.unit_color) {
const deviceUnitColor = feat.unit_color.toString();
if (deviceUnitColor in deviceInfo.colors) {
this.color = deviceInfo.colors[deviceUnitColor];
}
}
}
prompt(type, args) {
return new Promise(callback => {
if (!this.listenerCount(type)) {
const payload = {
success: false,
error: new Error(`${type} callback not configured`),
};
callback(payload);
}
else {
this.emit(type, { callback, ...args });
}
});
}
isUnacquired() {
return this.features === undefined;
}
isUnreadable() {
return !!this.unreadableError;
}
disconnect() {
_log.debug('Disconnect cleanup');
this.transport.off(transport_1.TRANSPORT.STOPPED, this.onTransportStopped);
this.transport.deviceEvents.off(this.transportPath, this.onTransportDeviceEvent);
this.removeAllListeners();
this.sessionDfd?.reject(new Error());
if (this.sessionAcquired) {
this.transport.releaseSync(this.sessionAcquired);
this.sessionAcquired = null;
}
this.lifecycle.emit(events_1.DEVICE.DISCONNECT);
return this.interrupt(constants_1.ERRORS.TypedError('Device_Disconnected'));
}
isBootloader() {
return this.features && !!this.features.bootloader_mode;
}
isInitialized() {
return this.features && !!this.features.initialized;
}
isSeedless() {
return this.features && !!this.features.no_backup;
}
getVersion() {
if (!this.features)
return;
return [
this.features.major_version,
this.features.minor_version,
this.features.patch_version,
];
}
atLeast(versions) {
const version = this.getVersion();
if (!this.features || !version)
return false;
const modelVersion = typeof versions === 'string' ? versions : versions[this.features.major_version - 1];
return utils_1.versionUtils.isNewerOrEqual(version, modelVersion);
}
isUsed() {
return !!this.transport.getDescriptor(this.transportPath)?.session;
}
isUsedHere() {
return !!this.sessionAcquired;
}
isUsedElsewhere() {
return this.isUsed() && !this.isUsedHere();
}
getUniquePath() {
return this.uniquePath;
}
isT1() {
return this.features ? this.features.major_version === 1 : false;
}
hasUnexpectedMode(allow, require) {
if (this.features) {
if (this.isBootloader() && !allow.includes(events_1.UI.BOOTLOADER)) {
return events_1.UI.BOOTLOADER;
}
if (!this.isInitialized() && !allow.includes(events_1.UI.INITIALIZE)) {
return events_1.UI.INITIALIZE;
}
if (this.isSeedless() && !allow.includes(events_1.UI.SEEDLESS)) {
return events_1.UI.SEEDLESS;
}
if (!this.isBootloader() && require.includes(events_1.UI.BOOTLOADER)) {
return events_1.UI.NOT_IN_BOOTLOADER;
}
}
return null;
}
getMode() {
if (this.features.bootloader_mode)
return 'bootloader';
if (!this.features.initialized)
return 'initialize';
if (this.features.no_backup)
return 'seedless';
return 'normal';
}
toMessageObject() {
const { name, uniquePath: path } = this;
const base = { path, name };
if (this.unreadableError) {
return {
...base,
type: 'unreadable',
error: this.unreadableError,
label: 'Unreadable device',
hid: this.possibleHIDdevice,
};
}
if (this.isUnacquired()) {
const sessionOwner = this.transport.getDescriptor(this.transportPath)?.sessionOwner;
return {
...base,
type: 'unacquired',
label: 'Unacquired device',
name: this.name,
transportSessionOwner: this.sessionAcquired ? undefined : sessionOwner,
bluetoothProps: this.bluetoothProps,
thp: this.thp?.serialize(),
};
}
const defaultLabel = 'My Trezor';
const label = this.features.label === '' || !this.features.label ? defaultLabel : this.features.label;
const status = this.isUsedElsewhere()
? 'occupied'
: (this.wasUsedElsewhere && 'used') || 'available';
return {
...base,
type: 'acquired',
id: this.features.device_id,
label,
_state: this.getState(),
state: this.getState()?.staticSessionId,
status,
mode: this.getMode(),
color: this.color,
firmware: this.firmwareStatus,
firmwareRelease: this.firmwareRelease,
firmwareType: this.firmwareType,
features: this.features,
unavailableCapabilities: this.unavailableCapabilities,
availableTranslations: this.availableTranslations,
authenticityChecks: this.authenticityChecks,
bluetoothProps: this.bluetoothProps,
thp: this.thp?.serialize(),
};
}
}
exports.Device = Device;
//# sourceMappingURL=Device.js.map