diff --git a/app/scripts/comp/app/chal-resp-calculator.js b/app/scripts/comp/app/chal-resp-calculator.js index c8dfc6a0..a2460c54 100644 --- a/app/scripts/comp/app/chal-resp-calculator.js +++ b/app/scripts/comp/app/chal-resp-calculator.js @@ -41,6 +41,7 @@ const ChalRespCalculator = { complete() { const err = new Error(Locale.yubiKeyDisabledErrorHeader); err.userCanceled = true; + err.ykError = true; reject(err); } @@ -158,6 +159,7 @@ const ChalRespCalculator = { const err = new Error('User canceled the YubiKey no key prompt'); err.userCanceled = true; + err.ykError = true; return callback(err); } @@ -177,6 +179,7 @@ const ChalRespCalculator = { const err = new Error('User canceled the YubiKey touch prompt'); err.userCanceled = true; + err.ykError = true; return callback(err); } diff --git a/app/scripts/comp/app/usb-listener.js b/app/scripts/comp/app/usb-listener.js index caedfcc9..e30186a3 100644 --- a/app/scripts/comp/app/usb-listener.js +++ b/app/scripts/comp/app/usb-listener.js @@ -1,21 +1,28 @@ import { Events } from 'framework/events'; import { Logger } from 'util/logger'; -import { Launcher } from 'comp/launcher'; +import { NativeModules } from 'comp/launcher/native-modules'; import { AppSettingsModel } from 'models/app-settings-model'; -import { YubiKeyVendorId } from 'const/hardware'; import { Features } from 'util/features'; const logger = new Logger('usb-listener'); const UsbListener = { supported: Features.isDesktop, - attachedYubiKeys: [], + attachedYubiKeys: 0, init() { if (!this.supported) { return; } + Events.on('native-modules-yubikeys', (e) => { + if (e.numYubiKeys !== this.attachedYubiKeys) { + logger.debug(`YubiKeys changed ${this.attachedYubiKeys} => ${e.numYubiKeys}`); + this.attachedYubiKeys = e.numYubiKeys; + Events.emit('usb-devices-changed'); + } + }); + AppSettingsModel.on('change:enableUsb', (model, enabled) => { if (enabled) { this.start(); @@ -37,73 +44,25 @@ const UsbListener = { } try { - const ts = logger.ts(); - - this.usb = Launcher.reqNative('usb'); - - this.listen(); - - this.attachedYubiKeys = this.usb - .getDeviceList() - .filter(this.isYubiKey) - .map((device) => ({ device })); - - if (this.attachedYubiKeys.length > 0) { - logger.info(`${this.attachedYubiKeys.length} YubiKey(s) found`, logger.ts(ts)); - Events.emit('usb-devices-changed'); - } + NativeModules.startUsbListener(); } catch (e) { - logger.error('Error loading USB module', e); + logger.error('Error starting USB listener', e); } }, stop() { logger.info('Stopping USB listener'); - if (this.usb) { - if (this.attachedYubiKeys.length) { - this.attachedYubiKeys = []; - Events.emit('usb-devices-changed'); - } - - this.usb.off('attach', UsbListener.deviceAttached); - this.usb.off('detach', UsbListener.deviceDetached); - - this.usb = null; + try { + NativeModules.stopUsbListener(); + } catch (e) { + logger.error('Error stopping USB listener', e); } - }, - listen() { - this.usb.on('attach', UsbListener.deviceAttached); - this.usb.on('detach', UsbListener.deviceDetached); - }, - - deviceAttached(device) { - if (UsbListener.isYubiKey(device)) { - UsbListener.attachedYubiKeys.push({ device }); - logger.info(`YubiKey attached, total: ${UsbListener.attachedYubiKeys.length}`, device); + if (this.attachedYubiKeys) { + this.attachedYubiKeys = 0; Events.emit('usb-devices-changed'); } - }, - - deviceDetached(device) { - if (UsbListener.isYubiKey(device)) { - const index = UsbListener.attachedYubiKeys.findIndex( - (yk) => yk.device.deviceAddress === device.deviceAddress - ); - if (index >= 0) { - UsbListener.attachedYubiKeys.splice(index, 1); - logger.info( - `YubiKey detached, total: ${UsbListener.attachedYubiKeys.length}`, - device - ); - Events.emit('usb-devices-changed'); - } - } - }, - - isYubiKey(device) { - return device.deviceDescriptor.idVendor === YubiKeyVendorId; } }; diff --git a/app/scripts/comp/app/yubikey.js b/app/scripts/comp/app/yubikey.js index a86e916d..f38638d8 100644 --- a/app/scripts/comp/app/yubikey.js +++ b/app/scripts/comp/app/yubikey.js @@ -1,5 +1,6 @@ import { Events } from 'framework/events'; import { Launcher } from 'comp/launcher'; +import { NativeModules } from 'comp/launcher/native-modules'; import { Logger } from 'util/logger'; import { UsbListener } from 'comp/app/usb-listener'; import { AppSettingsModel } from 'models/app-settings-model'; @@ -14,13 +15,6 @@ const YubiKey = { process: null, aborted: false, - get ykChalResp() { - if (!this._ykChalResp) { - this._ykChalResp = Launcher.reqNative('yubikey-chalresp'); - } - return this._ykChalResp; - }, - cmd() { if (this._cmd) { return this._cmd; @@ -70,21 +64,20 @@ const YubiKey = { }, list(callback) { - this.ykChalResp.getYubiKeys({}, (err, yubiKeys) => { - if (err) { - return callback(err); - } - yubiKeys = yubiKeys.map(({ serial, vid, pid, version, slots }) => { - return { - vid, - pid, - serial, - slots, - fullName: this.getKeyFullName(pid, version, serial) - }; - }); - callback(null, yubiKeys); - }); + NativeModules.getYubiKeys({}) + .then((yubiKeys) => { + yubiKeys = yubiKeys.map(({ serial, vid, pid, version, slots }) => { + return { + vid, + pid, + serial, + slots, + fullName: this.getKeyFullName(pid, version, serial) + }; + }); + callback(null, yubiKeys); + }) + .catch(callback); }, getKeyFullName(pid, version, serial) { @@ -113,7 +106,7 @@ const YubiKey = { logger.info('Listing YubiKeys'); - if (UsbListener.attachedYubiKeys.length === 0) { + if (!UsbListener.attachedYubiKeys) { return callback(null, []); } @@ -171,9 +164,9 @@ const YubiKey = { logger.info('Repairing a stuck YubiKey'); let openTimeout; - const countYubiKeys = UsbListener.attachedYubiKeys.length; + const countYubiKeys = UsbListener.attachedYubiKeys; const onDevicesChangedDuringRepair = () => { - if (UsbListener.attachedYubiKeys.length === countYubiKeys) { + if (UsbListener.attachedYubiKeys === countYubiKeys) { logger.info('YubiKey was reconnected'); Events.off('usb-devices-changed', onDevicesChangedDuringRepair); clearTimeout(openTimeout); @@ -274,22 +267,24 @@ const YubiKey = { const paddedChallenge = Buffer.alloc(YubiKeyChallengeSize, padLen); challenge.copy(paddedChallenge); - this.ykChalResp.challengeResponse(yubiKey, paddedChallenge, slot, (err, response) => { - if (err) { - if (err.code === this.ykChalResp.YK_ENOKEY) { - err.noKey = true; + NativeModules.yubiKeyChallengeResponse( + yubiKey, + [...paddedChallenge], + slot, + (err, result) => { + if (result) { + result = Buffer.from(result); } - if (err.code === this.ykChalResp.YK_ETIMEOUT) { - err.timeout = true; + if (err) { + err.ykError = true; } - return callback(err); + return callback(err, result); } - callback(null, response); - }); + ); }, cancelChalResp() { - this.ykChalResp.cancelChallengeResponse(); + NativeModules.yubiKeyCancelChallengeResponse(); } }; diff --git a/app/scripts/comp/launcher/launcher-electron.js b/app/scripts/comp/launcher/launcher-electron.js index d553e3a1..06d44067 100644 --- a/app/scripts/comp/launcher/launcher-electron.js +++ b/app/scripts/comp/launcher/launcher-electron.js @@ -26,9 +26,6 @@ const Launcher = { remReq(mod) { return this.electron().remote.require(mod); }, - reqNative(mod) { - return this.electron().remote.app.reqNative(mod); - }, openLink(href) { if (/^(http|https|ftp|sftp|mailto):/i.test(href)) { this.electron().shell.openExternal(href); diff --git a/app/scripts/comp/launcher/native-modules.js b/app/scripts/comp/launcher/native-modules.js new file mode 100644 index 00000000..64a544ae --- /dev/null +++ b/app/scripts/comp/launcher/native-modules.js @@ -0,0 +1,176 @@ +import { Events } from 'framework/events'; +import { Logger } from 'util/logger'; +import { Launcher } from 'comp/launcher'; +import { Timeouts } from 'const/timeouts'; + +let NativeModules; + +if (Launcher) { + const logger = new Logger('native-module-connector'); + + let host; + let callId = 0; + let promises = {}; + let ykChalRespCallbacks = {}; + + const handlers = { + yubikeys(numYubiKeys) { + Events.emit('native-modules-yubikeys', { numYubiKeys }); + }, + + log(...args) { + logger.info('Message from host', ...args); + }, + + result({ callId, result, error }) { + const promise = promises[callId]; + if (promise) { + delete promises[callId]; + if (error) { + logger.error('Received an error', promise.cmd, error); + promise.reject(error); + } else { + promise.resolve(result); + } + } + }, + + 'yk-chal-resp-result'({ callbackId, error, result }) { + const callback = ykChalRespCallbacks[callbackId]; + if (callback) { + const willBeCalledAgain = error && error.touchRequested; + if (!willBeCalledAgain) { + delete ykChalRespCallbacks[callbackId]; + } + callback(error, result); + } + } + }; + + NativeModules = { + startHost() { + if (host) { + return; + } + + logger.debug('Starting native module host'); + + const path = Launcher.req('path'); + const appContentRoot = Launcher.remoteApp().getAppContentRoot(); + const mainModulePath = path.join(appContentRoot, 'native-module-host.js'); + + const { fork } = Launcher.req('child_process'); + + host = fork(mainModulePath); + + host.on('message', (message) => this.hostCallback(message)); + + host.on('error', (e) => this.hostError(e)); + host.on('exit', (code, sig) => this.hostExit(code, sig)); + + this.call('init', Launcher.remoteApp().getAppMainRoot()); + + if (this.usbListenerRunning) { + this.call('start-usb'); + } + }, + + hostError(e) { + logger.error('Host error', e); + }, + + hostExit(code, sig) { + logger.error(`Host exited with code ${code} and signal ${sig}`); + host = null; + + const err = new Error('Native module host crashed'); + + for (const promise of Object.values(promises)) { + promise.reject(err); + } + promises = {}; + + for (const callback of Object.values(ykChalRespCallbacks)) { + callback(err); + } + ykChalRespCallbacks = {}; + + if (code !== 0) { + this.autoRestartHost(); + } + }, + + hostCallback(message) { + const { cmd, args } = message; + // logger.debug('Callback', cmd, args); + if (handlers[cmd]) { + handlers[cmd](...args); + } else { + logger.error('No callback', cmd); + } + }, + + autoRestartHost() { + setTimeout(() => { + try { + this.startHost(); + } catch (e) { + logger.error('Native module host failed to auto-restart', e); + } + }, Timeouts.NativeModuleHostRestartTime); + }, + + call(cmd, ...args) { + return new Promise((resolve, reject) => { + if (!host) { + try { + this.startHost(); + } catch (e) { + return reject(e); + } + } + + callId++; + if (callId === Number.MAX_SAFE_INTEGER) { + callId = 1; + } + // logger.debug('Call', cmd, args, callId); + promises[callId] = { cmd, resolve, reject }; + host.send({ cmd, args, callId }); + }); + }, + + startUsbListener() { + this.call('start-usb'); + this.usbListenerRunning = true; + }, + + stopUsbListener() { + this.usbListenerRunning = false; + if (host) { + this.call('stop-usb'); + } + }, + + getYubiKeys(config) { + return this.call('get-yubikeys', config); + }, + + yubiKeyChallengeResponse(yubiKey, challenge, slot, callback) { + ykChalRespCallbacks[callId] = callback; + return this.call('yk-chal-resp', yubiKey, challenge, slot, callId); + }, + + yubiKeyCancelChallengeResponse() { + if (host) { + this.call('yk-cancel-chal-resp'); + } + }, + + argon2(password, salt, options) { + return this.call('argon2', password, salt, options); + } + }; +} + +export { NativeModules }; diff --git a/app/scripts/const/timeouts.js b/app/scripts/const/timeouts.js index 2eaf18c4..dc1ab74b 100644 --- a/app/scripts/const/timeouts.js +++ b/app/scripts/const/timeouts.js @@ -15,7 +15,8 @@ const Timeouts = { DefaultHttpRequest: 60000, ExternalDeviceReconnect: 3000, ExternalDeviceAfterReconnect: 1000, - FieldLabelDoubleClick: 300 + FieldLabelDoubleClick: 300, + NativeModuleHostRestartTime: 3000 }; export { Timeouts }; diff --git a/app/scripts/models/app-model.js b/app/scripts/models/app-model.js index 168f40fe..c37dc4e4 100644 --- a/app/scripts/models/app-model.js +++ b/app/scripts/models/app-model.js @@ -532,7 +532,7 @@ class AppModel { params, (err, file) => { if (err) { - if (err.name === 'KdbxError' || err.userCanceled) { + if (err.name === 'KdbxError' || err.ykError) { return callback(err); } logger.info( @@ -558,7 +558,7 @@ class AppModel { setTimeout(() => this.syncFile(file), 0); callback(err); } else { - if (err.name === 'KdbxError' || err.userCanceled) { + if (err.name === 'KdbxError' || err.ykError) { return callback(err); } logger.info( @@ -1239,13 +1239,13 @@ class AppModel { usbDevicesChanged() { const attachedYubiKeysCount = this.attachedYubiKeysCount; - this.attachedYubiKeysCount = UsbListener.attachedYubiKeys.length; + this.attachedYubiKeysCount = UsbListener.attachedYubiKeys; if (!this.settings.yubiKeyAutoOpen) { return; } - const isNewYubiKey = UsbListener.attachedYubiKeys.length > attachedYubiKeysCount; + const isNewYubiKey = UsbListener.attachedYubiKeys > attachedYubiKeysCount; const hasOpenFiles = this.files.some((file) => file.active && !file.external); if (isNewYubiKey && hasOpenFiles && !this.openingOtpDevice) { diff --git a/app/scripts/models/external/yubikey-otp-model.js b/app/scripts/models/external/yubikey-otp-model.js index 302a6241..680543f0 100644 --- a/app/scripts/models/external/yubikey-otp-model.js +++ b/app/scripts/models/external/yubikey-otp-model.js @@ -19,7 +19,7 @@ class YubiKeyOtpModel extends ExternalOtpDeviceModel { } onUsbDevicesChanged = () => { - if (UsbListener.attachedYubiKeys.length === 0) { + if (UsbListener.attachedYubiKeys === 0) { this.emit('ejected'); } }; diff --git a/app/scripts/util/kdbxweb/kdbxweb-init.js b/app/scripts/util/kdbxweb/kdbxweb-init.js index af7829bb..b61e34b9 100644 --- a/app/scripts/util/kdbxweb/kdbxweb-init.js +++ b/app/scripts/util/kdbxweb/kdbxweb-init.js @@ -1,8 +1,8 @@ import kdbxweb from 'kdbxweb'; import { Logger } from 'util/logger'; import { Features } from 'util/features'; -import { Launcher } from 'comp/launcher'; import { AppSettingsModel } from 'models/app-settings-model'; +import { NativeModules } from 'comp/launcher/native-modules'; const logger = new Logger('argon2'); @@ -29,36 +29,69 @@ const KdbxwebInit = { if (!global.WebAssembly) { return Promise.reject('WebAssembly is not supported'); } - if (Launcher && AppSettingsModel.nativeArgon2) { - const ts = logger.ts(); - const argon2 = Launcher.reqNative('argon2'); - logger.debug('Native argon2 runtime loaded (main thread)', logger.ts(ts)); + if (Features.isDesktop && AppSettingsModel.nativeArgon2) { + logger.debug('Using native argon2'); this.runtimeModule = { hash(args) { - return new Promise((resolve, reject) => { - const ts = logger.ts(); - argon2.hash( - Buffer.from(args.password), - Buffer.from(args.salt), - { - type: args.type, - version: args.version, - hashLength: args.length, - saltLength: args.salt.length, - timeCost: args.iterations, - parallelism: args.parallelism, - memoryCost: args.memory - }, - (err, res) => { - if (err) { - logger.error('Argon2 error', err); - return reject(err); - } - logger.debug('Argon2 hash calculated', logger.ts(ts)); - resolve(res); - } - ); - }); + const ts = logger.ts(); + + const password = makeXoredValue(args.password); + const salt = makeXoredValue(args.salt); + + return NativeModules.argon2(password, salt, { + type: args.type, + version: args.version, + hashLength: args.length, + saltLength: args.salt.length, + timeCost: args.iterations, + parallelism: args.parallelism, + memoryCost: args.memory + }) + .then((res) => { + password.data.fill(0); + salt.data.fill(0); + + logger.debug('Argon2 hash calculated', logger.ts(ts)); + + return readXoredValue(res); + }) + .catch((err) => { + password.data.fill(0); + salt.data.fill(0); + + logger.error('Argon2 error', err); + throw err; + }); + + function makeXoredValue(val) { + const data = Buffer.from(val); + const random = Buffer.from(kdbxweb.Random.getBytes(data.length)); + + for (let i = 0; i < data.length; i++) { + data[i] ^= random[i]; + } + + const result = { data: [...data], random: [...random] }; + + data.fill(0); + random.fill(0); + + return result; + } + + function readXoredValue(val) { + const data = Buffer.from(val.data); + const random = Buffer.from(val.random); + + for (let i = 0; i < data.length; i++) { + data[i] ^= random[i]; + } + + val.data.fill(0); + val.random.fill(0); + + return data; + } } }; return Promise.resolve(this.runtimeModule); diff --git a/app/scripts/views/open-view.js b/app/scripts/views/open-view.js index 5b004b61..16f2864a 100644 --- a/app/scripts/views/open-view.js +++ b/app/scripts/views/open-view.js @@ -106,7 +106,7 @@ class OpenView extends View { !this.model.settings.canOpen && !this.model.settings.canCreate && !(this.model.settings.canOpenDemo && !this.model.settings.demoOpened); - const hasYubiKeys = !!UsbListener.attachedYubiKeys.length; + const hasYubiKeys = !!UsbListener.attachedYubiKeys; const canOpenYubiKey = hasYubiKeys && this.model.settings.canOpenOtpDevice && @@ -992,7 +992,7 @@ class OpenView extends View { usbDevicesChanged() { if (this.model.settings.canOpenOtpDevice) { - const hasYubiKeys = !!UsbListener.attachedYubiKeys.length; + const hasYubiKeys = !!UsbListener.attachedYubiKeys; const showOpenIcon = hasYubiKeys && this.model.settings.yubiKeyShowIcon; this.$el.find('.open__icon-yubikey').toggleClass('hide', !showOpenIcon); diff --git a/app/scripts/views/settings/settings-file-view.js b/app/scripts/views/settings/settings-file-view.js index aea8c110..45643300 100644 --- a/app/scripts/views/settings/settings-file-view.js +++ b/app/scripts/views/settings/settings-file-view.js @@ -724,7 +724,7 @@ class SettingsFileView extends View { if (!Launcher || !AppSettingsModel.enableUsb || !AppSettingsModel.yubiKeyShowChalResp) { return; } - if (!UsbListener.attachedYubiKeys.length) { + if (!UsbListener.attachedYubiKeys) { if (this.yubiKeys.length) { this.yubiKeys = []; this.render(); @@ -738,7 +738,7 @@ class SettingsFileView extends View { this.render(); if ( userInitiated && - UsbListener.attachedYubiKeys.length && + UsbListener.attachedYubiKeys && !yubiKeys.length && Features.isMac ) { diff --git a/desktop/app.js b/desktop/app.js index 3d6770a3..90f227e8 100644 --- a/desktop/app.js +++ b/desktop/app.js @@ -1,7 +1,6 @@ const electron = require('electron'); const path = require('path'); const fs = require('fs'); -const { EventEmitter } = require('events'); let perfTimestamps = global.perfTimestamps; perfTimestamps.push({ name: 'loading app requires', ts: process.hrtime() }); @@ -16,7 +15,6 @@ let restartPending = false; let mainWindowPosition = {}; let updateMainWindowPositionTimeout = null; let mainWindowMaximized = false; -let usbBinding = null; const windowPositionFileName = 'window-position.json'; const portableConfigFileName = 'keeweb-portable.json'; @@ -187,10 +185,11 @@ app.setHookBeforeQuitEvent = (hooked) => { app.hookBeforeQuitEvent = !!hooked; }; app.setGlobalShortcuts = setGlobalShortcuts; -app.reqNative = reqNative; app.showAndFocusMainWindow = showAndFocusMainWindow; app.loadConfig = loadConfig; app.saveConfig = saveConfig; +app.getAppMainRoot = getAppMainRoot; +app.getAppContentRoot = getAppContentRoot; function setSystemAppearance() { if (process.platform === 'darwin') { @@ -402,7 +401,6 @@ function mainWindowClosing() { } function mainWindowClosed() { - usbBinding?.removeAllListeners(); app.removeAllListeners('remote-app-event'); } @@ -567,35 +565,20 @@ function setUserDataPaths() { perfTimestamps?.push({ name: 'portable check', ts: process.hrtime() }); - // eslint-disable-next-line no-console - console.log('Is portable:', isPortable); - if (isPortable) { const portableConfigDir = path.dirname(execPath); const portableConfigPath = path.join(portableConfigDir, portableConfigFileName); - // eslint-disable-next-line no-console - console.log('Portable config path:', portableConfigPath); - if (fs.existsSync(portableConfigPath)) { - // eslint-disable-next-line no-console - console.log('Portable config path exists'); - const portableConfig = JSON.parse(fs.readFileSync(portableConfigPath, 'utf8')); const portableUserDataDir = path.resolve(portableConfigDir, portableConfig.userDataDir); - // eslint-disable-next-line no-console - console.log('Portable user data dir:', portableUserDataDir); - if (!fs.existsSync(portableUserDataDir)) { fs.mkdirSync(portableUserDataDir); } app.setPath('userData', portableUserDataDir); usingPortableUserDataDir = true; - } else { - // eslint-disable-next-line no-console - console.log(`Portable config path doesn't exist`); } } @@ -743,55 +726,25 @@ function reportStartProfile() { emitRemoteEvent('start-profile', startProfile); } +function getAppMainRoot() { + if (isDev) { + return __dirname; + } else { + return process.mainModule.path; + } +} + +function getAppContentRoot() { + return __dirname; +} + function reqNative(mod) { const fileName = `${mod}-${process.platform}-${process.arch}.node`; const modulePath = `../node_modules/@keeweb/keeweb-native-modules/${fileName}`; - let fullPath; + const fullPath = path.join(getAppMainRoot(), modulePath); - if (isDev) { - fullPath = path.join(__dirname, modulePath); - } else { - const mainAsarPath = process.mainModule.path; - fullPath = path.join(mainAsarPath, modulePath); - - // Currently native modules can't be updated - // const latestAsarPath = __dirname; - // - // fullPath = path.join(latestAsarPath, modulePath); - // - // if (!fs.existsSync(fullPath)) { - // fullPath = path.join(mainAsarPath, modulePath); - // } - } - - const binding = require(fullPath); - - if (mod === 'usb') { - usbBinding = initUsb(binding); - } - - return binding; -} - -function initUsb(binding) { - Object.keys(EventEmitter.prototype).forEach((key) => { - binding[key] = EventEmitter.prototype[key]; - }); - - binding.on('newListener', () => { - if (binding.listenerCount('attach') === 0 && binding.listenerCount('detach') === 0) { - binding._enableHotplugEvents(); - } - }); - - binding.on('removeListener', () => { - if (binding.listenerCount('attach') === 0 && binding.listenerCount('detach') === 0) { - binding._disableHotplugEvents(); - } - }); - - return binding; + return require(fullPath); } function loadSettingsEncryptionKey() { diff --git a/desktop/native-module-host.js b/desktop/native-module-host.js new file mode 100644 index 00000000..17f308c6 --- /dev/null +++ b/desktop/native-module-host.js @@ -0,0 +1,244 @@ +const path = require('path'); +const crypto = require('crypto'); +const { EventEmitter } = require('events'); + +let appMainRoot; +const nativeModules = {}; + +const YubiKeyVendorIds = [0x1050]; +const attachedYubiKeys = []; +let usbListenerRunning = false; + +startListener(); + +const messageHandlers = { + init(root) { + appMainRoot = root; + }, + + 'start-usb'() { + if (usbListenerRunning) { + return; + } + + const usb = reqNative('usb'); + + fillAttachedYubiKeys(); + + usb.on('attach', usbDeviceAttached); + usb.on('detach', usbDeviceDetached); + + usb._enableHotplugEvents(); + + usbListenerRunning = true; + }, + + 'stop-usb'() { + if (!usbListenerRunning) { + return; + } + + const usb = reqNative('usb'); + + usb.off('attach', usbDeviceAttached); + usb.off('detach', usbDeviceDetached); + + usb._disableHotplugEvents(); + + usbListenerRunning = false; + attachedYubiKeys.length = 0; + }, + + 'get-yubikeys'(config) { + return new Promise((resolve, reject) => { + const ykChapResp = reqNative('yubikey-chalresp'); + ykChapResp.getYubiKeys(config, (err, yubiKeys) => { + if (err) { + reject(err); + } else { + resolve(yubiKeys); + } + }); + }); + }, + + 'yk-chal-resp'(yubiKey, challenge, slot, callbackId) { + const ykChalResp = reqNative('yubikey-chalresp'); + challenge = Buffer.from(challenge); + ykChalResp.challengeResponse(yubiKey, challenge, slot, (error, result) => { + if (error) { + if (error.code === ykChalResp.YK_ENOKEY) { + error.noKey = true; + } + if (error.code === ykChalResp.YK_ETIMEOUT) { + error.timeout = true; + } + } + if (result) { + result = [...result]; + } + return callback('yk-chal-resp-result', { callbackId, error, result }); + }); + }, + + 'yk-cancel-chal-resp'() { + const ykChalResp = reqNative('yubikey-chalresp'); + ykChalResp.cancelChallengeResponse(); + }, + + argon2(password, salt, options) { + const argon2 = reqNative('argon2'); + + password = readXoredValue(password); + salt = readXoredValue(salt); + + return new Promise((resolve, reject) => { + try { + argon2.hash(password, salt, options, (err, res) => { + password.fill(0); + salt.fill(0); + + if (err) { + reject(err); + } else { + const xoredRes = makeXoredValue(res); + res.fill(0); + + resolve(xoredRes); + + setTimeout(() => { + xoredRes.data.fill(0); + xoredRes.random.fill(0); + }, 0); + } + }); + } catch (e) { + reject(e); + } + }); + } +}; + +const moduleInit = { + usb(binding) { + Object.keys(EventEmitter.prototype).forEach((key) => { + binding[key] = EventEmitter.prototype[key]; + }); + return binding; + } +}; + +function isYubiKey(device) { + return YubiKeyVendorIds.includes(device.deviceDescriptor.idVendor); +} + +function usbDeviceAttached(device) { + if (isYubiKey(device)) { + attachedYubiKeys.push(device); + reportYubiKeys(); + } +} + +function usbDeviceDetached(device) { + if (isYubiKey(device)) { + const index = attachedYubiKeys.findIndex((yk) => yk.deviceAddress === device.deviceAddress); + if (index >= 0) { + attachedYubiKeys.splice(index, 1); + } + reportYubiKeys(); + } +} + +function fillAttachedYubiKeys() { + const usb = reqNative('usb'); + attachedYubiKeys.push(...usb.getDeviceList().filter(isYubiKey)); + reportYubiKeys(); +} + +function reportYubiKeys() { + callback('yubikeys', attachedYubiKeys.length); +} + +function reqNative(mod) { + if (nativeModules[mod]) { + return nativeModules[mod]; + } + + const fileName = `${mod}-${process.platform}-${process.arch}.node`; + + const modulePath = `../node_modules/@keeweb/keeweb-native-modules/${fileName}`; + const fullPath = path.join(appMainRoot, modulePath); + + let binding = require(fullPath); + + if (moduleInit[mod]) { + binding = moduleInit[mod](binding); + } + + nativeModules[mod] = binding; + return binding; +} + +function readXoredValue(val) { + const data = Buffer.from(val.data); + const random = Buffer.from(val.random); + + val.data.fill(0); + val.random.fill(0); + + for (let i = 0; i < data.length; i++) { + data[i] ^= random[i]; + } + + random.fill(0); + + return data; +} + +function makeXoredValue(val) { + const data = Buffer.from(val); + const random = crypto.randomBytes(data.length); + for (let i = 0; i < data.length; i++) { + data[i] ^= random[i]; + } + const result = { data: [...data], random: [...random] }; + data.fill(0); + random.fill(0); + return result; +} + +function startListener() { + process.on('message', ({ callId, cmd, args }) => { + Promise.resolve() + .then(() => { + const handler = messageHandlers[cmd]; + if (handler) { + return handler(...args); + } else { + throw new Error(`Handler not found: ${cmd}`); + } + }) + .then((result) => { + callback('result', { callId, result }); + }) + .catch((error) => { + error = { + name: error.name, + message: error.message, + stack: error.stack, + code: error.code + }; + callback('result', { callId, error }); + }); + }); + + process.on('disconnect', () => { + process.exit(0); + }); +} + +function callback(cmd, ...args) { + try { + process.send({ cmd, args }); + } catch {} +}