extracted YubiKey module

This commit is contained in:
antelle 2020-05-24 18:39:56 +02:00
parent bf7dec2437
commit 81d4c5c403
No known key found for this signature in database
GPG Key ID: 63C9777AAB7C563C
7 changed files with 293 additions and 225 deletions

View File

@ -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 };

View File

@ -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

View File

@ -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",

View File

@ -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 };

View File

@ -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 };

View File

@ -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();
}
}

View File

@ -14,10 +14,15 @@
{{#res 'setDevicesYubiKeyToolsDesc'}}<code>ykman</code>{{/res}}
{{#res 'setDevicesYubiKeyToolsDesc2'}}<a href="{{ykmanInstallLink}}" target="_blank">{{res 'setDevicesYubiKeyToolsDescLink'}}</a>{{/res}}
</p>
<div>
<p>
{{#ifeq ykmanStatus 'checking'}}{{#res 'setDevicesYubiKeyToolsStatusChecking'}}<code>ykman</code>{{/res}}...{{/ifeq}}
{{#ifeq ykmanStatus 'ok'}}{{#res 'setDevicesYubiKeyToolsStatusOk'}}<code>ykman</code>{{/res}}{{/ifeq}}
{{#ifeq ykmanStatus 'error'}}{{#res 'setDevicesYubiKeyToolsStatusError'}}<code>ykman</code>{{/res}}{{/ifeq}}
</p>
<div>
<input type="checkbox" class="settings__input input-base settings__yubikey-stuck-workaround" id="settings__yubikey-stuck-workaround"
{{#if yubiKeyStuckWorkaround}}checked{{/if}} />
<label for="settings__yubikey-stuck-workaround">{{res 'setDevicesYubiKeyStuckWorkaround'}}</label>
</div>
<h3>{{res 'setDevicesYubiKeyOtpTitle'}}</h3>
<p>{{res 'setDevicesYubiKeyOtpDesc'}}</p>
@ -36,11 +41,6 @@
{{#if yubiKeyAutoOpen}}checked{{/if}} />
<label for="settings__yubikey-auto-open">{{res 'setDevicesYubiKeyOtpAutoOpen'}}</label>
</div>
<div>
<input type="checkbox" class="settings__input input-base settings__yubikey-oath-workaround" id="settings__yubikey-oath-workaround"
{{#if yubiKeyOathWorkaround}}checked{{/if}} />
<label for="settings__yubikey-oath-workaround">{{res 'setDevicesYubiKeyOathWorkaround'}}</label>
</div>
<h3>{{res 'setDevicesYubiKeyChalRespTitle'}}</h3>
<p>{{res 'setDevicesYubiKeyChalRespDesc'}}</p>
<div>