perryrh0dan/2fa

View on GitHub
src/hotp/hotp.ts

Summary

Maintainability
A
4 hrs
Test Coverage
import { createHmac } from 'crypto';
import { base32Decode } from '../base32';
import { HtopGenerateOptions, HtopVerifyOptions, VerifyDelta } from './types';
import { arrayBufferToBuffer } from '../utils';

/**
 * Digest the one-time passcode options.
 */
export function digest(options: HtopGenerateOptions): Buffer {
  // unpack options
  const secret = options.secret;
  const counter = options.counter;
  const encoding = options.encoding || 'ascii';
  const algorithm = options.algorithm || 'sha1';

  // convert secret to buffer
  let secretBuffer: Buffer;
  if (!Buffer.isBuffer(secret)) {
    if (encoding === 'base32') {
      const arrayBuffer = base32Decode(secret);
      secretBuffer = arrayBufferToBuffer(arrayBuffer);
    } else {
      secretBuffer = Buffer.from(secret, encoding);
    }
  } else {
    secretBuffer = secret;
  }


  let secret_buffer_size;
  if (algorithm === 'sha1') {
    secret_buffer_size = 20; // 20 bytes
  } else if (algorithm === 'sha256') {
    secret_buffer_size = 32; // 32 bytes
  } else if (algorithm === 'sha512') {
    secret_buffer_size = 64; // 64 bytes
  }

  // The secret for sha1, sha256 and sha512 needs to be a fixed number of bytes for the one-time-password to be calculated correctly
  // Pad the buffer to the correct size be repeating the secret to the desired length
  if (secret_buffer_size && secret.length !== secret_buffer_size) {
    secretBuffer = new Buffer(
      Array(Math.ceil(secret_buffer_size / secretBuffer.length) + 1).join(
        secretBuffer.toString('hex')
      ),
      'hex'
    ).slice(0, secret_buffer_size);
  }

  // create an buffer from the counter
  let buf = new Buffer(8);
  let tmp = counter;
  for (let i = 0; i < 8; i++) {
    // mask 0xff over number to get last 8
    buf[7 - i] = tmp & 0xff;

    // shift 8 and get ready to loop over the next batch of 8
    tmp = tmp >> 8;
  }

  // init hmac with the key
  const hmac = createHmac(algorithm, secretBuffer);

  // update hmac with the counter
  hmac.update(buf);

  // return the digest
  return hmac.digest();
}

/**
 * Generate a counter-based one-time token. Specify the key and counter, and
 * receive the one-time password for that counter position as a string. You can
 * also specify a token length, as well as the encoding (ASCII, hexadecimal, or
 * base32) and the hashing algorithm to use (SHA1, SHA256, SHA512).
 */
export function hotpGenerate(options: HtopGenerateOptions): string {
  // unpack options
  const digits = options.digits || 6;

  // digest the options
  const hmacDigest = options.digest || digest(options);

  // compute HOTP offset
  const offset = hmacDigest[hmacDigest.length - 1] & 0xf;

  // calculate binary code (RFC4226 5.4)
  const code =
    ((hmacDigest[offset] & 0x7f) << 24) |
    ((hmacDigest[offset + 1] & 0xff) << 16) |
    ((hmacDigest[offset + 2] & 0xff) << 8) |
    (hmacDigest[offset + 3] & 0xff);

  // left-pad code
  const codeArray = new Array(digits + 1).join('0') + code.toString(10);

  // return length number off digits
  return codeArray.substr(-digits);
}

/**
 * Verify a counter-based one-time token against the secret and return the delta.
 * By default, it verifies the token at the given counter value, with no leeway
 * (no look-ahead or look-behind). A token validated at the current counter value
 * will have a delta of 0.
 *
 * You can specify a window to add more leeway to the verification process.
 * Setting the window param will check for the token at the given counter value
 * as well as `window` tokens ahead (one-sided window). See param for more info.
 *
 * `verifyDelta()` will return the delta between the counter value of the token
 * and the given counter value. For example, if given a counter 5 and a window
 * 10, `verifyDelta()` will look at tokens from 5 to 15, inclusive. If it finds
 * it at counter position 7, it will return `{ delta: 2 }`.
 */
export function hotpVerifyDelta(options: HtopVerifyOptions): VerifyDelta {
  // unpack options
  var token = options.token;
  var digits = options.digits || 6;
  var window = options.window || 0;
  var counter = options.counter || 0;

  // fail if token is not of correct length
  if (token.length !== digits) {
    throw new Error('Wrong token length');
  }

  // parse token to integer
  const tokenNumber = parseInt(token, 10);

  // fail if token is NA
  if (isNaN(tokenNumber)) {
    throw new Error('Cant parse token to number');
  }

  // loop from C to C + W inclusive
  for (let i = counter; i <= counter + window; ++i) {
    options.counter = i;
    // domain-specific constant-time comparison for integer codes
    if (parseInt(hotpGenerate(options), 10) === tokenNumber) {
      // found a matching code, return delta
      return { delta: i - counter };
    }
  }

  throw new Error('No matching code found');
}

/**
 * Verify a counter-based one-time token against the secret and return true if
 * it verifies. Helper function for `hotp.verifyDelta()`` that returns a boolean
 * instead of an object. For more on how to use a window with this, see
 * {@link hotp.verifyDelta}.
 */
export function hotpVerify(options: HtopVerifyOptions): boolean {
  try {
    return hotpVerifyDelta(options) != null;
  } catch (error) {
    return false;
  }
}