X-Git-Url: https://git.squeep.com/?a=blobdiff_plain;f=lib%2Fhotp.js;fp=lib%2Fhotp.js;h=0fcdabcb91d8d0a0e8dad11bcfa7faf05fb0bc36;hb=2207e51e71606cbd1b4e3a688d450a79853dc8e9;hp=0000000000000000000000000000000000000000;hpb=2fdc701c2749c8d2817502c750f1011e99e21cc1;p=squeep-totp diff --git a/lib/hotp.js b/lib/hotp.js new file mode 100644 index 0000000..0fcdabc --- /dev/null +++ b/lib/hotp.js @@ -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