2019-09-15 14:16:32 +02:00
|
|
|
import { Logger } from 'util/logger';
|
2016-04-01 21:19:27 +02:00
|
|
|
|
2017-01-31 07:50:28 +01:00
|
|
|
const logger = new Logger('otp');
|
2016-04-01 21:19:27 +02:00
|
|
|
|
2020-06-01 16:53:51 +02:00
|
|
|
const Otp = function (url, params) {
|
2016-03-31 22:52:04 +02:00
|
|
|
if (['hotp', 'totp'].indexOf(params.type) < 0) {
|
|
|
|
throw 'Bad type: ' + params.type;
|
|
|
|
}
|
|
|
|
if (!params.secret) {
|
|
|
|
throw 'Empty secret';
|
|
|
|
}
|
|
|
|
if (params.algorithm && ['SHA1', 'SHA256', 'SHA512'].indexOf(params.algorithm) < 0) {
|
|
|
|
throw 'Bad algorithm: ' + params.algorithm;
|
|
|
|
}
|
2019-09-11 22:41:00 +02:00
|
|
|
if (params.digits && ['6', '7', '8'].indexOf(params.digits) < 0) {
|
2016-03-31 22:52:04 +02:00
|
|
|
throw 'Bad digits: ' + params.digits;
|
|
|
|
}
|
|
|
|
if (params.type === 'hotp' && !params.counter) {
|
|
|
|
throw 'Bad counter: ' + params.counter;
|
|
|
|
}
|
2019-08-16 23:05:39 +02:00
|
|
|
if ((params.period && isNaN(params.period)) || params.period < 1) {
|
2016-03-31 22:52:04 +02:00
|
|
|
throw 'Bad period: ' + params.period;
|
|
|
|
}
|
2016-04-01 21:19:27 +02:00
|
|
|
|
2016-03-31 22:52:04 +02:00
|
|
|
this.url = url;
|
2016-04-01 21:19:27 +02:00
|
|
|
|
2016-03-31 22:52:04 +02:00
|
|
|
this.type = params.type;
|
|
|
|
this.account = params.account;
|
|
|
|
this.secret = params.secret;
|
|
|
|
this.issuer = params.issuer;
|
|
|
|
this.algorithm = params.algorithm ? params.algorithm.toUpperCase() : 'SHA1';
|
|
|
|
this.digits = params.digits ? +params.digits : 6;
|
|
|
|
this.counter = params.counter;
|
|
|
|
this.period = params.period ? +params.period : 30;
|
2016-04-01 21:19:27 +02:00
|
|
|
|
|
|
|
this.key = Otp.fromBase32(this.secret);
|
2016-04-04 20:45:17 +02:00
|
|
|
if (!this.key) {
|
|
|
|
throw 'Bad key: ' + this.key;
|
|
|
|
}
|
2016-04-01 21:19:27 +02:00
|
|
|
};
|
|
|
|
|
2020-06-01 16:53:51 +02:00
|
|
|
Otp.prototype.next = function (callback) {
|
2017-01-31 07:50:28 +01:00
|
|
|
let valueForHashing;
|
|
|
|
let timeLeft;
|
2016-04-01 23:16:23 +02:00
|
|
|
if (this.type === 'totp') {
|
2017-01-31 07:50:28 +01:00
|
|
|
const now = Date.now();
|
|
|
|
const epoch = Math.round(now / 1000);
|
2016-04-01 23:16:23 +02:00
|
|
|
valueForHashing = Math.floor(epoch / this.period);
|
2017-01-31 07:50:28 +01:00
|
|
|
const msPeriod = this.period * 1000;
|
2016-04-01 23:16:23 +02:00
|
|
|
timeLeft = msPeriod - (now % msPeriod);
|
|
|
|
} else {
|
|
|
|
valueForHashing = this.counter;
|
|
|
|
}
|
2017-01-31 07:50:28 +01:00
|
|
|
const data = new Uint8Array(8).buffer;
|
2016-04-02 15:57:38 +02:00
|
|
|
new DataView(data).setUint32(4, valueForHashing);
|
2016-07-17 13:30:38 +02:00
|
|
|
this.hmac(data, (sig, err) => {
|
2016-04-01 21:19:27 +02:00
|
|
|
if (!sig) {
|
|
|
|
logger.error('OTP calculation error', err);
|
2020-04-15 16:50:01 +02:00
|
|
|
return callback(err);
|
2016-04-01 21:19:27 +02:00
|
|
|
}
|
|
|
|
sig = new DataView(sig);
|
2017-01-31 07:50:28 +01:00
|
|
|
const offset = sig.getInt8(sig.byteLength - 1) & 0xf;
|
2019-09-25 21:54:10 +02:00
|
|
|
const hmac = sig.getUint32(offset) & 0x7fffffff;
|
|
|
|
let pass;
|
2019-09-25 22:25:52 +02:00
|
|
|
if (this.issuer === 'Steam') {
|
2019-09-25 22:47:44 +02:00
|
|
|
pass = Otp.hmacToSteamCode(hmac);
|
2019-09-25 22:25:52 +02:00
|
|
|
} else {
|
2019-09-25 22:47:44 +02:00
|
|
|
pass = Otp.hmacToDigits(hmac, this.digits);
|
2019-09-25 22:25:52 +02:00
|
|
|
}
|
2020-04-15 16:50:01 +02:00
|
|
|
callback(null, pass, timeLeft);
|
2016-04-01 21:19:27 +02:00
|
|
|
});
|
|
|
|
};
|
|
|
|
|
2020-06-01 16:53:51 +02:00
|
|
|
Otp.prototype.hmac = function (data, callback) {
|
2017-01-31 07:50:28 +01:00
|
|
|
const subtle = window.crypto.subtle || window.crypto.webkitSubtle;
|
|
|
|
const algo = { name: 'HMAC', hash: { name: this.algorithm.replace('SHA', 'SHA-') } };
|
2019-08-16 23:05:39 +02:00
|
|
|
subtle
|
|
|
|
.importKey('raw', this.key, algo, false, ['sign'])
|
2020-06-01 16:53:51 +02:00
|
|
|
.then((key) => {
|
2019-08-16 23:05:39 +02:00
|
|
|
subtle
|
|
|
|
.sign(algo, key, data)
|
2020-06-01 16:53:51 +02:00
|
|
|
.then((sig) => {
|
2019-08-16 23:05:39 +02:00
|
|
|
callback(sig);
|
|
|
|
})
|
2020-06-01 16:53:51 +02:00
|
|
|
.catch((err) => {
|
2019-08-16 23:05:39 +02:00
|
|
|
callback(null, err);
|
|
|
|
});
|
2016-04-01 21:19:27 +02:00
|
|
|
})
|
2020-06-01 16:53:51 +02:00
|
|
|
.catch((err) => {
|
2019-08-16 23:05:39 +02:00
|
|
|
callback(null, err);
|
|
|
|
});
|
2016-04-01 21:19:27 +02:00
|
|
|
};
|
|
|
|
|
2020-06-01 16:53:51 +02:00
|
|
|
Otp.hmacToDigits = function (hmac, length) {
|
2019-09-25 22:25:52 +02:00
|
|
|
let code = hmac.toString();
|
|
|
|
code = Otp.leftPad(code.substr(code.length - length), length);
|
|
|
|
return code;
|
|
|
|
};
|
2019-09-25 21:54:10 +02:00
|
|
|
|
2020-06-01 16:53:51 +02:00
|
|
|
Otp.hmacToSteamCode = function (hmac) {
|
2019-09-25 22:25:52 +02:00
|
|
|
const steamChars = '23456789BCDFGHJKMNPQRTVWXY';
|
|
|
|
let code = '';
|
|
|
|
for (let i = 0; i < 5; ++i) {
|
|
|
|
code += steamChars.charAt(hmac % steamChars.length);
|
|
|
|
hmac /= steamChars.length;
|
|
|
|
}
|
|
|
|
return code;
|
|
|
|
};
|
2019-09-25 21:54:10 +02:00
|
|
|
|
2020-06-01 16:53:51 +02:00
|
|
|
Otp.fromBase32 = function (str) {
|
2019-10-27 18:59:09 +01:00
|
|
|
str = str.replace(/\s/g, '');
|
2017-01-31 07:50:28 +01:00
|
|
|
const alphabet = 'abcdefghijklmnopqrstuvwxyz234567';
|
|
|
|
let bin = '';
|
|
|
|
let i;
|
2016-04-01 21:19:27 +02:00
|
|
|
for (i = 0; i < str.length; i++) {
|
2017-01-31 07:50:28 +01:00
|
|
|
const ix = alphabet.indexOf(str[i].toLowerCase());
|
2016-04-01 21:19:27 +02:00
|
|
|
if (ix < 0) {
|
2016-04-04 20:45:17 +02:00
|
|
|
return null;
|
2016-04-01 21:19:27 +02:00
|
|
|
}
|
|
|
|
bin += Otp.leftPad(ix.toString(2), 5);
|
|
|
|
}
|
2017-01-31 07:50:28 +01:00
|
|
|
const hex = new Uint8Array(Math.floor(bin.length / 8));
|
2016-04-01 21:19:27 +02:00
|
|
|
for (i = 0; i < hex.length; i++) {
|
2017-01-31 07:50:28 +01:00
|
|
|
const chunk = bin.substr(i * 8, 8);
|
2016-04-01 21:19:27 +02:00
|
|
|
hex[i] = parseInt(chunk, 2);
|
|
|
|
}
|
|
|
|
return hex.buffer;
|
|
|
|
};
|
|
|
|
|
2020-06-01 16:53:51 +02:00
|
|
|
Otp.leftPad = function (str, len) {
|
2016-04-01 21:19:27 +02:00
|
|
|
while (str.length < len) {
|
|
|
|
str = '0' + str;
|
|
|
|
}
|
|
|
|
return str;
|
2016-03-31 22:52:04 +02:00
|
|
|
};
|
|
|
|
|
2020-06-01 16:53:51 +02:00
|
|
|
Otp.parseUrl = function (url) {
|
2024-04-06 18:38:36 +02:00
|
|
|
const match = /^otpauth:\/\/(\w+)(?:\/([^\?]+)\?|\?)(.*)/i.exec(url);
|
2016-03-31 22:52:04 +02:00
|
|
|
if (!match) {
|
|
|
|
throw 'Not OTP url';
|
|
|
|
}
|
2017-01-31 07:50:28 +01:00
|
|
|
const params = {};
|
2024-04-06 18:38:36 +02:00
|
|
|
const label = decodeURIComponent(match[2] ?? 'default');
|
2016-03-31 22:52:04 +02:00
|
|
|
if (label) {
|
2017-01-31 07:50:28 +01:00
|
|
|
const parts = label.split(':');
|
2016-03-31 22:52:04 +02:00
|
|
|
params.issuer = parts[0].trim();
|
|
|
|
if (parts.length > 1) {
|
|
|
|
params.account = parts[1].trim();
|
|
|
|
}
|
|
|
|
}
|
2024-04-06 18:38:36 +02:00
|
|
|
params.type = match[1].toLowerCase(); // returns "totp"
|
|
|
|
// match[3] = secret=XXXXXXXXXXXXX&period=30&digits=6&algorithm=SHA1
|
2020-06-01 16:53:51 +02:00
|
|
|
match[3].split('&').forEach((part) => {
|
2017-01-31 07:50:28 +01:00
|
|
|
const parts = part.split('=', 2);
|
2016-03-31 22:52:04 +02:00
|
|
|
params[parts[0].toLowerCase()] = decodeURIComponent(parts[1]);
|
|
|
|
});
|
|
|
|
return new Otp(url, params);
|
|
|
|
};
|
|
|
|
|
2020-06-01 16:53:51 +02:00
|
|
|
Otp.isSecret = function (str) {
|
2016-04-04 20:45:17 +02:00
|
|
|
return !!Otp.fromBase32(str);
|
|
|
|
};
|
|
|
|
|
2020-06-01 16:53:51 +02:00
|
|
|
Otp.makeUrl = function (secret, period, digits) {
|
2019-08-16 23:05:39 +02:00
|
|
|
return (
|
|
|
|
'otpauth://totp/default?secret=' +
|
|
|
|
secret +
|
|
|
|
(period ? '&period=' + period : '') +
|
|
|
|
(digits ? '&digits=' + digits : '')
|
|
|
|
);
|
2016-04-04 20:45:17 +02:00
|
|
|
};
|
|
|
|
|
2019-09-15 14:16:32 +02:00
|
|
|
export { Otp };
|