initial commit
[squeep-totp] / lib / hotp.js
diff --git a/lib/hotp.js b/lib/hotp.js
new file mode 100644 (file)
index 0000000..0fcdabc
--- /dev/null
@@ -0,0 +1,106 @@
+'use strict';
+
+const crypto = require('crypto');
+
+class HMACBasedOneTimePassword {
+  /**
+   * 
+   * @param {Object} options
+   * @param {Number=} options.codeLength
+   * @param {BigInt|Number|String=} options.counter
+   * @param {Buffer|String} options.key
+   * @param {String=} options.keyEncoding
+   * @param {String=} options.algorithm
+   */
+  constructor(options) {
+    Object.assign(this, this._defaultOptions, options);
+    this.keyBuffer = Buffer.isBuffer(this.key) ? this.key : Buffer.from(this.key, this.keyEncoding);
+    const expectedKeyLength = this._algorithmKeyLength(this.algorithm);
+    if (this.keyBuffer.length !== expectedKeyLength) {
+      throw new RangeError('key size does not match algorithm');
+    }
+    if (typeof(this.counter) !== 'bigint') {
+      this.counter = BigInt(this.counter);
+    }
+  }
+
+  get _defaultOptions() {
+    return {
+      codeLength: 6,
+      counter: 0n,
+      key: undefined,
+      keyEncoding: 'ascii',
+      algorithm: 'sha1',
+    };
+  }
+
+  get _algorithmKeyLengths() {
+    return {
+      'sha1': 20,
+    };
+  }
+
+  /**
+   * 
+   * @param {String} algorithm 
+   * @returns {Number}
+   */
+  _algorithmKeyLength(algorithm) {
+    if (!(this._algorithmKeyLengths[algorithm])) {
+      throw new RangeError(`unsupported algorithm '${algorithm}'`);
+    }
+    return this._algorithmKeyLengths[algorithm]; // eslint-disable-line security/detect-object-injection
+  }
+
+  /**
+   * 
+   * @param {BigInt} count
+   * @returns {Buffer}
+   */
+  _hmac(count) {
+    const counterBuffer = Buffer.alloc(8);
+    counterBuffer.writeBigUInt64BE(count);
+    return crypto.createHmac(this.algorithm, this.keyBuffer)
+      .update(counterBuffer)
+      .digest();
+  }
+
+  /**
+   * 
+   * @param {BigInt} count
+   * @returns {Number}
+   */
+  _truncate(count) {
+    const digest = this._hmac(count);
+    const offset = digest[digest.length - 1] & 0x0f;
+    return digest.readUInt32BE(offset) & 0x7fffffff;
+  }
+
+  /**
+   * 
+   * @param {BigInt=} count
+   * @returns {String}
+   */
+  generate(count) {
+    const code = this._truncate(count ?? this.counter);
+    const codeString = ('0'.repeat(this.codeLength + 1) + code.toString(10)).slice(0 - this.codeLength);
+    if (count === undefined) {
+      this.counter += 1n;
+    }
+    return codeString;
+  }
+
+  /**
+   * 
+   * @param {String} hotp
+   * @param {BigInt=} count
+   * @returns {Boolean}
+   */
+  validate(hotp, count) {
+    const codeString = this.generate(count);
+    return codeString === hotp.trim();
+  }
+
+}
+
+module.exports = HMACBasedOneTimePassword;
\ No newline at end of file