keeweb/app/scripts/models/external/yubikey-otp-model.js

276 lines
8.7 KiB
JavaScript

import { Events } from 'framework/events';
import { ExternalOtpDeviceModel } from 'models/external/external-otp-device-model';
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;
const logger = new Logger('yubikey');
class YubiKeyOtpModel extends ExternalOtpDeviceModel {
constructor(props) {
super({
id: 'yubikey',
name: 'YubiKey',
shortName: 'YubiKey',
deviceClassName: 'YubiKey',
...props
});
}
static get ykmanStatus() {
return ykmanStatus;
}
onUsbDevicesChanged = () => {
if (UsbListener.attachedYubiKeys.length === 0) {
this.emit('ejected');
}
};
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();
}
});
}
_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();
}
});
}
_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);
}
};
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);
}
});
}
_openComplete() {
this.active = true;
this._buildEntryMap();
Events.on('usb-devices-changed', this.onUsbDevicesChanged);
}
cancelOpen() {
logger.info('Cancel open');
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);
}
});
}
cancelGetOtp(entry, ps) {
if (ps) {
ps.kill();
}
}
close(callback) {
Events.off('usb-devices-changed', this.onUsbDevicesChanged);
this.set({
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
});
export { YubiKeyOtpModel };