@imajin/rx-otp
Version:
HMAC-based (HOTP) and Time-based (TOTP) One-Time Password manager. Works with Google Authenticator for Two-Factor Authentication.
109 lines (108 loc) • 5.45 kB
JavaScript
import * as bigInt from 'big-integer';
import { Buffer } from 'buffer';
import * as ConvertBase from 'convert-base';
import * as crypto from 'crypto';
import * as pad from 'pad-component';
import { from, mergeMap, Observable, of, throwError } from 'rxjs';
import { defaultIfEmpty, filter, map, take } from 'rxjs/operators';
import { Validator } from '../schemas/validator';
const converter = new ConvertBase();
export class HOTP {
}
HOTP.DIGITS_POWER = [1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000, 1000000000, 10000000000];
HOTP.DOUBLE_DIGITS = [0, 2, 4, 6, 8, 1, 3, 5, 7, 9];
HOTP.generate = (key, options = {}) => of(Object.assign(Object.assign({}, options), { key }))
.pipe(mergeMap((data) => Validator.validateDataWithSchemaReference('/rx-otp/schemas/hotp-generate.json', data)), map((_) => ({
key: _.key_format === 'str' ?
Buffer.from(_.key) :
Buffer.from(_.key, 'hex'),
counter: _.counter_format === 'int' ?
Buffer.from(pad.left(converter.convert(_.counter, 10, 16), 16, '0'), 'hex') :
Buffer.from(pad.left(_.counter, 16, '0'), 'hex'),
code_digits: _.code_digits,
add_checksum: _.add_checksum,
truncation_offset: _.truncation_offset,
algorithm: _.algorithm
})), mergeMap((_) => HOTP._generateOTP(_.key, _.counter, _.code_digits, _.add_checksum, _.truncation_offset, _.algorithm)));
HOTP.verify = (token, key, options = {}) => of(Object.assign(Object.assign({}, options), { token, key }))
.pipe(mergeMap((data) => Validator.validateDataWithSchemaReference('/rx-otp/schemas/hotp-verify.json', data)), map((_) => ({
token: _.token,
key: _.key_format === 'str' ?
Buffer.from(_.key) :
Buffer.from(_.key, 'hex'),
window: bigInt(_.window),
counter: _.counter_format === 'int' ?
bigInt(_.counter) :
bigInt(_.counter, 16),
counter_format: _.counter_format,
code_digits: _.token.length,
add_checksum: _.add_checksum,
truncation_offset: _.truncation_offset,
algorithm: _.algorithm,
previous_otp_allowed: _.previous_otp_allowed
})), map((_) => Object.assign({}, _, { min: _.counter, max: _.counter.add(_.window) })), mergeMap((_) => of(_)
.pipe(filter(__ => !!__.previous_otp_allowed), map(__ => Object.assign({}, __, { min: __.min.subtract(__.window) })), map(__ => !!__.min.isNegative() ?
Object.assign({}, __, { min: bigInt() }) :
__), defaultIfEmpty(_))), mergeMap((_) => new Observable(subscriber => {
let iterator = [];
for (let i = _.min; i.lesserOrEquals(_.max); i = i.next()) {
iterator = iterator.concat(i);
}
const data = Object.assign(Object.assign({}, _), { iterator });
delete data.window;
delete data.min;
delete data.max;
subscriber.next(data);
subscriber.complete();
})), mergeMap((_) => HOTP._verifyWithIteration(_)));
HOTP._calcChecksum = (num, digits) => {
let doubleDigit = true;
let total = 0;
while (0 < digits--) {
let digit = parseInt(`${num % 10}`);
num /= 10;
if (doubleDigit) {
digit = HOTP.DOUBLE_DIGITS[digit];
}
total += digit;
doubleDigit = !doubleDigit;
}
let result = total % 10;
if (result > 0) {
result = 10 - result;
}
return result;
};
HOTP._generateOTP = (key, counter, code_digits, add_checksum, truncation_offset, algorithm) => of(crypto.createHmac(algorithm, key))
.pipe(map(hmac => Buffer.from(hmac.update(counter).digest('hex'), 'hex')), map(hash => ({
hash,
offset: ((0 <= truncation_offset) && (truncation_offset < (hash.length - 4))) ?
truncation_offset :
(hash[hash.length - 1] & 0xf)
})), map((_) => ((_.hash[_.offset] & 0x7f) << 24) |
((_.hash[_.offset + 1] & 0xff) << 16) |
((_.hash[_.offset + 2] & 0xff) << 8) |
(_.hash[_.offset + 3] & 0xff)), map(binary => binary % HOTP.DIGITS_POWER[code_digits]), map(otp => add_checksum ? ((otp * 10) + HOTP._calcChecksum(otp, code_digits)) : otp), map(otp => ({ result: otp.toString(), digits: add_checksum ? (code_digits + 1) : code_digits })), map((_) => pad.left(_.result, _.digits, '0')));
HOTP._verifyWithIteration = (data) => from(data.iterator)
.pipe(mergeMap((i) => of({
key: data.key,
counter: data.counter_format === 'int' ?
Buffer.from(pad.left(converter.convert(parseInt(i.toString()), 10, 16), 16, '0'), 'hex') :
Buffer.from(pad.left(i.toString(16).toUpperCase(), 16, '0'), 'hex'),
code_digits: data.code_digits,
add_checksum: data.add_checksum,
truncation_offset: data.truncation_offset,
algorithm: data.algorithm
})
.pipe(mergeMap((params) => HOTP._generateOTP(params.key, params.counter, params.code_digits, params.add_checksum, params.truncation_offset, params.algorithm)), map(_ => ({ token: _, it: i })))), filter(_ => _.token === data.token), take(1), map(_ => _.it), map(_ => _.subtract(data.counter)), mergeMap((delta) => of(data.counter_format)
.pipe(filter(_ => _ === 'hex'), map(() => ({
delta: delta.isNegative() ?
`-${pad.left(delta.toString(16).toUpperCase().substr(1), 16, '0')}` :
pad.left(delta.toString(16).toUpperCase(), 16, '0'),
delta_format: 'hex'
})), defaultIfEmpty({
delta: parseInt(delta.toString()),
delta_format: 'int'
}))), defaultIfEmpty(undefined), mergeMap(_ => !!_ ?
of(_) :
throwError(() => new Error(`The token '${data.token}' doesn't match for the given parameters`))));