UNPKG

@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
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`))));