From 81d4c5c403b16c238260ad5ec44c31bd627fd5db Mon Sep 17 00:00:00 2001 From: antelle Date: Sun, 24 May 2020 18:39:56 +0200 Subject: [PATCH] extracted YubiKey module --- app/scripts/comp/app/yubikey.js | 206 +++++++++++++++ app/scripts/const/default-app-settings.js | 2 +- app/scripts/locales/base.json | 2 +- .../models/external/yubikey-otp-model.js | 249 ++++-------------- app/scripts/views/open-view.js | 31 ++- .../views/settings/settings-devices-view.js | 16 +- app/templates/settings/settings-devices.hbs | 12 +- 7 files changed, 293 insertions(+), 225 deletions(-) create mode 100644 app/scripts/comp/app/yubikey.js diff --git a/app/scripts/comp/app/yubikey.js b/app/scripts/comp/app/yubikey.js new file mode 100644 index 00000000..622c39ce --- /dev/null +++ b/app/scripts/comp/app/yubikey.js @@ -0,0 +1,206 @@ +import { Events } from 'framework/events'; +import { Launcher } from 'comp/launcher'; +import { Logger } from 'util/logger'; +import { UsbListener } from 'comp/app/usb-listener'; +import { AppSettingsModel } from 'models/app-settings-model'; +import { Timeouts } from 'const/timeouts'; +import { Locale } from 'util/locale'; + +const logger = new Logger('yubikey'); + +const YubiKey = { + ykmanStatus: undefined, + process: null, + aborted: false, + + checkToolStatus() { + if (this.ykmanStatus === 'ok') { + return Promise.resolve(this.ykmanStatus); + } + return new Promise(resolve => { + this.ykmanStatus = 'checking'; + Launcher.spawn({ + cmd: 'ykman', + args: ['-v'], + noStdOutLogging: true, + complete: (err, stdout, code) => { + if (err || code !== 0) { + this.ykmanStatus = 'error'; + } else { + this.ykmanStatus = 'ok'; + } + resolve(this.ykmanStatus); + } + }); + }); + }, + + abort() { + logger.info('Aborting'); + if (this.process) { + logger.info('Killing the process'); + try { + this.process.kill(); + } catch {} + } + this.aborted = true; + this.process = null; + }, + + list(callback) { + this._list(callback, true); + }, + + _list(callback, canRetry) { + if (this.process) { + return callback('Already in progress'); + } + this.aborted = false; + + logger.info('Listing YubiKeys'); + + if (UsbListener.attachedYubiKeys.length === 0) { + return callback(null, []); + } + + this.process = Launcher.spawn({ + cmd: 'ykman', + args: ['list'], + noStdOutLogging: true, + complete: (err, stdout) => { + this.process = null; + + if (this.aborted) { + return callback('Aborted'); + } + if (err) { + return callback(err); + } + + const yubiKeysIncludingEmpty = stdout + .trim() + .split(/\n/g) + .map(line => (line.match(/\d{5,}$/g) || [])[0]); + + const yubiKeys = yubiKeysIncludingEmpty.filter(s => s); + + if ( + yubiKeysIncludingEmpty.length === 1 && + yubiKeys.length === 0 && + stdout.startsWith('YubiKey') && + stdout.includes('CCID') && + !stdout.includes('Serial') + ) { + logger.info('The YubiKey is probably stuck'); + if (!AppSettingsModel.yubiKeyStuckWorkaround) { + return callback(Locale.yubiKeyStuckError); + } + if (canRetry) { + return this._repairStuckYubiKey(callback); + } + } + + if (!yubiKeys.length) { + return callback('No YubiKeys returned by "ykman list"'); + } + + callback(null, yubiKeys); + } + }); + }, + + _repairStuckYubiKey(callback) { + logger.info('Repairing a stuck YubiKey'); + + let openTimeout; + const countYubiKeys = UsbListener.attachedYubiKeys.length; + const onDevicesChangedDuringRepair = () => { + if (UsbListener.attachedYubiKeys.length === countYubiKeys) { + logger.info('YubiKey was reconnected'); + Events.off('usb-devices-changed', onDevicesChangedDuringRepair); + clearTimeout(openTimeout); + this.aborted = false; + setTimeout(() => { + this._list(callback, false); + }, Timeouts.ExternalDeviceAfterReconnect); + } + }; + Events.on('usb-devices-changed', onDevicesChangedDuringRepair); + + Launcher.spawn({ + cmd: 'ykman', + args: ['config', 'usb', '-e', 'oath', '-f'], + noStdOutLogging: true, + complete: err => { + logger.info('Repair complete', err ? 'with error' : 'OK'); + if (err) { + Events.off('usb-devices-changed', onDevicesChangedDuringRepair); + return callback(`YubiKey repair error: ${err}`); + } + openTimeout = setTimeout(() => { + Events.off('usb-devices-changed', onDevicesChangedDuringRepair); + }, Timeouts.ExternalDeviceReconnect); + } + }); + }, + + getOtpCodes(serial, callback) { + if (this.process) { + return callback('Already in progress'); + } + this.aborted = false; + + this.process = Launcher.spawn({ + cmd: 'ykman', + args: ['-d', serial, 'oath', 'code'], + noStdOutLogging: true, + throwOnStdErr: true, + complete: (err, stdout) => { + this.process = null; + + if (this.aborted) { + return callback('Aborted'); + } + if (err) { + return callback(err); + } + + const codes = []; + + for (const line of stdout.split('\n')) { + const match = line.match(/^(.*?):(.*?)\s+(.*)$/); + if (!match) { + continue; + } + const [, title, user, code] = match; + const needsTouch = !code.match(/^\d+$/); + + codes.push({ + title, + user, + needsTouch + }); + } + + callback(null, codes); + } + }); + }, + + getOtp(serial, entry, callback) { + return Launcher.spawn({ + cmd: 'ykman', + args: ['-d', serial, 'oath', 'code', '--single', entry], + noStdOutLogging: true, + complete: (err, stdout) => { + if (err) { + return callback(err); + } + const otp = stdout.trim(); + callback(null, otp); + } + }); + } +}; + +export { YubiKey }; diff --git a/app/scripts/const/default-app-settings.js b/app/scripts/const/default-app-settings.js index 5508267b..b03f1aa2 100644 --- a/app/scripts/const/default-app-settings.js +++ b/app/scripts/const/default-app-settings.js @@ -41,7 +41,7 @@ const DefaultAppSettings = { yubiKeyAutoOpen: false, // auto-load one-time codes when there are open files yubiKeyMatchEntries: true, // show matching one-time codes in entries yubiKeyShowChalResp: true, // show YubiKey challenge-response option - yubiKeyOathWorkaround: false, // enable the workaround for YubiKey OATH issues + yubiKeyStuckWorkaround: false, // enable the workaround for stuck YubiKeys canOpen: true, // can select and open new files canOpenDemo: true, // can open a demo file diff --git a/app/scripts/locales/base.json b/app/scripts/locales/base.json index abf4c767..e3d7f597 100644 --- a/app/scripts/locales/base.json +++ b/app/scripts/locales/base.json @@ -604,7 +604,7 @@ "setDevicesYubiKeyChalRespTitle": "Challenge-Response", "setDevicesYubiKeyChalRespDesc": "It's also possible to use a YubiKey in challenge-response mode, so that a piece of private key used to encrypt files resides on a YubiKey.", "setDevicesYubiKeyChalRespShow": "Show the option to use a YubiKey when opening files", - "setDevicesYubiKeyOathWorkaround": "Reconnect the YubiKey if it hangs when loading one-time codes", + "setDevicesYubiKeyStuckWorkaround": "Reconnect the YubiKey if it seems to be stuck during loading", "setAboutTitle": "About", "setAboutBuilt": "This app is built with these awesome tools", diff --git a/app/scripts/models/external/yubikey-otp-model.js b/app/scripts/models/external/yubikey-otp-model.js index 69d59bcd..c8b0ff1b 100644 --- a/app/scripts/models/external/yubikey-otp-model.js +++ b/app/scripts/models/external/yubikey-otp-model.js @@ -4,11 +4,7 @@ import { ExternalOtpEntryModel } from 'models/external/external-otp-entry-model' import { Launcher } from 'comp/launcher'; import { Logger } from 'util/logger'; import { UsbListener } from 'comp/app/usb-listener'; -import { AppSettingsModel } from 'models/app-settings-model'; -import { Timeouts } from 'const/timeouts'; -import { Locale } from 'util/locale'; - -let ykmanStatus; +import { YubiKey } from 'comp/app/yubikey'; const logger = new Logger('yubikey'); @@ -23,10 +19,6 @@ class YubiKeyOtpModel extends ExternalOtpDeviceModel { }); } - static get ykmanStatus() { - return ykmanStatus; - } - onUsbDevicesChanged = () => { if (UsbListener.attachedYubiKeys.length === 0) { this.emit('ejected'); @@ -34,157 +26,61 @@ class YubiKeyOtpModel extends ExternalOtpDeviceModel { }; open(callback) { - this._open(callback, true); - } - - _open(callback, canRetry) { - logger.info('Listing YubiKeys'); - - if (UsbListener.attachedYubiKeys.length === 0) { - return callback('No YubiKeys'); - } - this.openProcess = Launcher.spawn({ - cmd: 'ykman', - args: ['list'], - noStdOutLogging: true, - complete: (err, stdout) => { - this.openProcess = null; - if (this.openAborted) { - return callback('Open aborted'); - } - if (err) { - return callback(err); - } - - const yubiKeysIncludingEmpty = stdout - .trim() - .split(/\n/g) - .map(line => (line.match(/\d{5,}$/g) || [])[0]); - - const yubiKeys = yubiKeysIncludingEmpty.filter(s => s); - - if ( - yubiKeysIncludingEmpty.length === 1 && - yubiKeys.length === 0 && - stdout.startsWith('YubiKey') && - stdout.includes('CCID') && - !stdout.includes('Serial') - ) { - logger.info('The YubiKey is probably stuck'); - if (!AppSettingsModel.yubiKeyOathWorkaround) { - return callback(Locale.yubiKeyStuckError); - } - if (canRetry) { - this._repairStuckYubiKey(callback); - return; - } - } - - if (!yubiKeys || !yubiKeys.length) { - return callback('No YubiKeys returned by "ykman list"'); - } - - let openSuccess = 0; - const openErrors = []; - const openNextYubiKey = () => { - const yubiKey = yubiKeys.shift(); - this._addYubiKey(yubiKey, err => { - if (this.openAborted) { - return callback('Open aborted'); - } - if (err) { - openErrors.push(err); - } else { - openSuccess++; - } - if (yubiKeys && yubiKeys.length) { - openNextYubiKey(); - } else { - if (openSuccess) { - this._openComplete(); - } - callback(openSuccess ? null : openErrors[0]); - } - }); - }; - openNextYubiKey(); + YubiKey.list((err, yubiKeys) => { + if (err) { + return callback(err); } + + let openSuccess = 0; + const openErrors = []; + const openNextYubiKey = () => { + const yubiKey = yubiKeys.shift(); + this._addYubiKey(yubiKey, err => { + if (YubiKey.aborted) { + return callback('Aborted'); + } + if (err) { + openErrors.push(err); + } else { + openSuccess++; + } + if (yubiKeys && yubiKeys.length) { + openNextYubiKey(); + } else { + if (openSuccess) { + this._openComplete(); + } + callback(openSuccess ? null : openErrors[0]); + } + }); + }; + openNextYubiKey(); }); } _addYubiKey(serial, callback) { logger.info('Adding YubiKey', serial); - this.openProcess = Launcher.spawn({ - cmd: 'ykman', - args: ['-d', serial, 'oath', 'code'], - noStdOutLogging: true, - throwOnStdErr: true, - complete: (err, stdout) => { - this.openProcess = null; - if (this.openAborted) { - return callback('Open aborted'); - } - if (err) { - return callback(err); - } - for (const line of stdout.split('\n')) { - const match = line.match(/^(.*?):(.*?)\s+(.*)$/); - if (!match) { - continue; - } - const [, title, user, code] = match; - const needsTouch = !code.match(/^\d+$/); - - this.entries.push( - new ExternalOtpEntryModel({ - id: this.entryId(title, user), - device: this, - deviceSubId: serial, - icon: 'clock-o', - title, - user, - needsTouch - }) - ); - } - callback(); + YubiKey.getOtpCodes(serial, (err, codes) => { + if (err) { + return callback(err); } - }); - } - _repairStuckYubiKey(callback) { - logger.info('Repairing a stuck YubiKey'); - - let openTimeout; - const countYubiKeys = UsbListener.attachedYubiKeys.length; - const onDevicesChangedDuringRepair = () => { - if (UsbListener.attachedYubiKeys.length === countYubiKeys) { - logger.info('YubiKey was reconnected'); - Events.off('usb-devices-changed', onDevicesChangedDuringRepair); - clearTimeout(openTimeout); - this.openAborted = false; - setTimeout(() => { - this._open(callback, false); - }, Timeouts.ExternalDeviceAfterReconnect); + for (const code of codes) { + this.entries.push( + new ExternalOtpEntryModel({ + id: this.entryId(code.title, code.user), + device: this, + deviceSubId: serial, + icon: 'clock-o', + title: code.title, + user: code.user, + needsTouch: code.needsTouch + }) + ); } - }; - Events.on('usb-devices-changed', onDevicesChangedDuringRepair); - Launcher.spawn({ - cmd: 'ykman', - args: ['config', 'usb', '-e', 'oath', '-f'], - noStdOutLogging: true, - complete: err => { - logger.info('Repair complete', err ? 'with error' : 'OK'); - if (err) { - Events.off('usb-devices-changed', onDevicesChangedDuringRepair); - return callback(err); - } - openTimeout = setTimeout(() => { - Events.off('usb-devices-changed', onDevicesChangedDuringRepair); - }, Timeouts.ExternalDeviceReconnect); - } + callback(); }); } @@ -195,38 +91,15 @@ class YubiKeyOtpModel extends ExternalOtpDeviceModel { } cancelOpen() { - logger.info('Cancel open'); + YubiKey.abort(); Events.off('usb-devices-changed', this.onUsbDevicesChanged); - this.openAborted = true; - if (this.openProcess) { - logger.info('Killing the process'); - try { - this.openProcess.kill(); - } catch {} - } } getOtp(entry, callback) { const msPeriod = 30000; const timeLeft = msPeriod - (Date.now() % msPeriod) + 500; - return Launcher.spawn({ - cmd: 'ykman', - args: [ - '-d', - entry.deviceSubId, - 'oath', - 'code', - '--single', - `${entry.title}:${entry.user}` - ], - noStdOutLogging: true, - complete: (err, stdout) => { - if (err) { - return callback(err, null, timeLeft); - } - const otp = stdout.trim(); - callback(null, otp, timeLeft); - } + YubiKey.getOtp(entry.deviceSubId, `${entry.title}:${entry.user}`, (err, otp) => { + callback(err, otp, timeLeft); }); } @@ -242,34 +115,10 @@ class YubiKeyOtpModel extends ExternalOtpDeviceModel { active: false }); } - - static checkToolStatus() { - if (ykmanStatus === 'ok') { - return Promise.resolve(); - } - return new Promise(resolve => { - ykmanStatus = 'checking'; - Launcher.spawn({ - cmd: 'ykman', - args: ['-v'], - noStdOutLogging: true, - complete: (err, stdout, code) => { - if (err || code !== 0) { - ykmanStatus = 'error'; - } else { - ykmanStatus = 'ok'; - } - resolve(); - } - }); - }); - } } YubiKeyOtpModel.defineModelProperties({ - onUsbDevicesChanged: null, - openProcess: null, - openAborted: false + onUsbDevicesChanged: null }); export { YubiKeyOtpModel }; diff --git a/app/scripts/views/open-view.js b/app/scripts/views/open-view.js index 4f977dc6..aea34b20 100644 --- a/app/scripts/views/open-view.js +++ b/app/scripts/views/open-view.js @@ -9,7 +9,7 @@ import { SecureInput } from 'comp/browser/secure-input'; import { Launcher } from 'comp/launcher'; import { Alerts } from 'comp/ui/alerts'; import { UsbListener } from 'comp/app/usb-listener'; -import { YubiKeyOtpModel } from 'models/external/yubikey-otp-model'; +import { YubiKey } from 'comp/app/yubikey'; import { Keys } from 'const/keys'; import { Comparators } from 'util/data/comparators'; import { Features } from 'util/features'; @@ -47,7 +47,7 @@ class OpenView extends View { 'keypress .open__pass-input': 'inputKeypress', 'click .open__pass-enter-btn': 'openDb', 'click .open__settings-key-file': 'openKeyFile', - 'click .open__settings-yubikey': 'selectYubiKey', + 'click .open__settings-yubikey': 'selectYubiKeyChalResp', 'click .open__last-item': 'openLast', 'click .open__icon-generate': 'toggleGenerator', dragover: 'dragover', @@ -445,10 +445,6 @@ class OpenView extends View { this.showOpenFileInfo(fileInfo, true); } - selectYubiKey() { - Alerts.notImplemented(); - } - removeFile(id) { this.model.removeFileInfo(id); this.$el.find('.open__last-item[data-id="' + id + '"]').remove(); @@ -1019,15 +1015,15 @@ class OpenView extends View { const icon = this.$el.find('.open__icon-yubikey'); icon.toggleClass('flip3d', true); - YubiKeyOtpModel.checkToolStatus().then(() => { - if (YubiKeyOtpModel.ykmanStatus !== 'ok') { + YubiKey.checkToolStatus().then(status => { + if (status !== 'ok') { icon.toggleClass('flip3d', false); this.inputEl.removeAttr('disabled'); this.busy = false; return Events.emit('toggle-settings', 'devices'); } this.otpDevice = this.model.openOtpDevice(err => { - if (err && !this.otpDevice.openAborted) { + if (err && !YubiKey.aborted) { Alerts.error({ header: Locale.openError, body: Locale.openErrorDescription, @@ -1042,6 +1038,23 @@ class OpenView extends View { }); } } + + selectYubiKeyChalResp() { + if (this.busy) { + return; + } + this.busy = true; + YubiKey.checkToolStatus().then(status => { + if (status !== 'ok') { + this.busy = false; + return Events.emit('toggle-settings', 'devices'); + } + + this.busy = false; + + Alerts.notImplemented(); + }); + } } export { OpenView }; diff --git a/app/scripts/views/settings/settings-devices-view.js b/app/scripts/views/settings/settings-devices-view.js index 02219437..0a745e99 100644 --- a/app/scripts/views/settings/settings-devices-view.js +++ b/app/scripts/views/settings/settings-devices-view.js @@ -1,7 +1,7 @@ import { Events } from 'framework/events'; import { View } from 'framework/views/view'; import { AppSettingsModel } from 'models/app-settings-model'; -import { YubiKeyOtpModel } from 'models/external/yubikey-otp-model'; +import { YubiKey } from 'comp/app/yubikey'; import { Links } from 'const/links'; import { UsbListener } from 'comp/app/usb-listener'; import template from 'templates/settings/settings-devices.hbs'; @@ -15,13 +15,13 @@ class SettingsDevicesView extends View { 'change .settings__yubikey-auto-open': 'changeYubiKeyAutoOpen', 'change .settings__yubikey-match-entries': 'changeYubiKeyMatchEntries', 'change .settings__yubikey-chalresp-show': 'changeYubiKeyShowChalResp', - 'change .settings__yubikey-oath-workaround': 'changeYubiKeyOathWorkaround' + 'change .settings__yubikey-stuck-workaround': 'changeYubiKeyStuckWorkaround' }; constructor(...args) { super(...args); - if (!['ok', 'checking'].includes(YubiKeyOtpModel.ykmanStatus)) { - this.toolCheckPromise = YubiKeyOtpModel.checkToolStatus(); + if (!['ok', 'checking'].includes(YubiKey.ykmanStatus)) { + this.toolCheckPromise = YubiKey.checkToolStatus(); } } @@ -33,12 +33,12 @@ class SettingsDevicesView extends View { super.render({ supported: UsbListener.supported, enableUsb: UsbListener.supported && AppSettingsModel.enableUsb, - ykmanStatus: YubiKeyOtpModel.ykmanStatus, + ykmanStatus: YubiKey.ykmanStatus, yubiKeyShowIcon: AppSettingsModel.yubiKeyShowIcon, yubiKeyAutoOpen: AppSettingsModel.yubiKeyAutoOpen, yubiKeyMatchEntries: AppSettingsModel.yubiKeyMatchEntries, yubiKeyShowChalResp: AppSettingsModel.yubiKeyShowChalResp, - yubiKeyOathWorkaround: AppSettingsModel.yubiKeyOathWorkaround, + yubiKeyStuckWorkaround: AppSettingsModel.yubiKeyStuckWorkaround, yubiKeyManualLink: Links.YubiKeyManual, ykmanInstallLink: Links.YubiKeyManagerInstall }); @@ -70,8 +70,8 @@ class SettingsDevicesView extends View { this.render(); } - changeYubiKeyOathWorkaround(e) { - AppSettingsModel.yubiKeyOathWorkaround = e.target.checked; + changeYubiKeyStuckWorkaround(e) { + AppSettingsModel.yubiKeyStuckWorkaround = e.target.checked; this.render(); } } diff --git a/app/templates/settings/settings-devices.hbs b/app/templates/settings/settings-devices.hbs index 694c64af..d1a77376 100644 --- a/app/templates/settings/settings-devices.hbs +++ b/app/templates/settings/settings-devices.hbs @@ -14,10 +14,15 @@ {{#res 'setDevicesYubiKeyToolsDesc'}}ykman{{/res}} {{#res 'setDevicesYubiKeyToolsDesc2'}}{{res 'setDevicesYubiKeyToolsDescLink'}}{{/res}}

-
+

{{#ifeq ykmanStatus 'checking'}}{{#res 'setDevicesYubiKeyToolsStatusChecking'}}ykman{{/res}}...{{/ifeq}} {{#ifeq ykmanStatus 'ok'}}{{#res 'setDevicesYubiKeyToolsStatusOk'}}ykman{{/res}}{{/ifeq}} {{#ifeq ykmanStatus 'error'}}{{#res 'setDevicesYubiKeyToolsStatusError'}}ykman{{/res}}{{/ifeq}} +

+
+ +

{{res 'setDevicesYubiKeyOtpTitle'}}

{{res 'setDevicesYubiKeyOtpDesc'}}

@@ -36,11 +41,6 @@ {{#if yubiKeyAutoOpen}}checked{{/if}} />
-
- - -

{{res 'setDevicesYubiKeyChalRespTitle'}}

{{res 'setDevicesYubiKeyChalRespDesc'}}