/** * OATH - HOTP (HMAC-based One-time Password Algorithm) * * @author ArrayIterator * @link https://tools.ietf.org/html/rfc4226 * @link https://en.wikipedia.org/wiki/HMAC-based_One-time_Password_Algorithm */ class HOTP { /** * SHA1 * Implementation of SHA1 algorithm as described in RFC 3174 * @see https://www.ietf.org/rfc/rfc3174.txt * * @param {string} string input to be hashed * @param {boolean} raw=false If the optional binary is set to true, then the sha1 digest is instead returned in raw binary format with a length of 20, otherwise the returned value is a 40-character hexadecimal number. * @return {string|false} Calculated the sha1 hash of a string, returning false if fail */ static sha1(string, raw = false) { string = typeof string === 'number' ? string.toString() : ( typeof string === 'boolean' ? (string ? '1' : '0') : string + '' ); const strLen = string.length; const len = strLen * 8; const binLen = strLen >> 2; if (binLen <= 0) { return false; } const rotate_left = (n, s) => (n << s) | (n >>> (32 - s)); const safe_add_16b = (x, y) => ((x >> 16) + (y >> 16) + (((x & 0xFFFF) + (y & 0xFFFF)) >> 16) << 16) | (((x & 0xFFFF) + (y & 0xFFFF)) & 0xFFFF); /** * Perform the appropriate triplet combination function for the current iteration * @param {number} t * @param {number} b * @param {number} c * @param {number} d * @return {number} */ const sha1_ft = (t, b, c, d) => { if (t < 20) { return (b & c) | ((~b) & d); } if (t < 40) { return b ^ c ^ d; } if (t < 60) { return (b & c) | (b & d) | (c & d); } return b ^ c ^ d; } /** * Determine the appropriate additive constant for the current iteration * @param {number} t * @return {number} */ const sha1_kt = (t) => { if (t < 20) { return 1518500249; } if (t < 40) { return 1859775393; } if (t < 60) { return -1894007588; } return -899497514; } let i, t; let binArray = new Array(binLen); for (i = 0; i < len; i += 8) { binArray[i >> 5] |= (string.charCodeAt(i / 8) & 0xFF) << (24 - i % 32); } /* append padding */ binArray[len >> 5] |= 0x80 << (24 - len % 32); binArray[((len + 64 >> 9) << 4) + 15] = len; let wordArray = new Array(80), a = 1732584193, b = -271733879, c = -1732584194, d = 271733878, e = -1009589776; for (i = 0; i < binArray.length; i += 16) { let oldA = a, oldB = b, oldC = c, oldD = d, oldE = e; for (let j = 0; j < 80; j++) { wordArray[j] = j < 16 ? binArray[i + j] : rotate_left(wordArray[j - 3] ^ wordArray[j - 8] ^ wordArray[j - 14] ^ wordArray[j - 16], 1); t = safe_add_16b( safe_add_16b( rotate_left(a, 5), sha1_ft(j, b, c, d) ), safe_add_16b( safe_add_16b(e, wordArray[j]), sha1_kt(j) ) ); e = d; d = c; c = rotate_left(b, 30); b = a; a = t; } a = safe_add_16b(a, oldA); b = safe_add_16b(b, oldB); c = safe_add_16b(c, oldC); d = safe_add_16b(d, oldD); e = safe_add_16b(e, oldE); } binArray = [a, b, c, d, e]; // reuse binArray variable let hash = ''; const HEX = '0123456789abcdef'; for (let i = 0; i < binArray.length * 4; i++) { hash += HEX.charAt((binArray[i >> 2] >> ((3 - i % 4) * 8 + 4)) & 0xF); hash += HEX.charAt((binArray[i >> 2] >> ((3 - i % 4) * 8)) & 0xF); } if (!raw) { return hash; } let rawData = ''; for (let i = 0; i < hash.length; i += 2) { rawData += String.fromCharCode(parseInt(hash.substring(i, i + 2), 16)); } return rawData; } /** * HMAC-SHA1 * Implementation of HMAC-SHA1 algorithm as described in RFC 2104 * @see https://www.ietf.org/rfc/rfc2104.txt * * @param {string} string input to be hashed * @param {string} key secret key * @param {boolean} raw=false If the optional binary is set to true, then the sha1 digest is instead returned in raw binary format with a length of 20, otherwise the returned value is a 40-character hexadecimal number. * @return {string|false} returning hashed data, otherwise false if fail */ static hmac_sha1(string, key, raw = false) { key = typeof key === 'number' ? key.toString() : ( // boolean is 1 or 0 typeof key === 'boolean' ? (key ? '1' : '0') : key + '' ); string = typeof string === 'number' ? string.toString() : ( typeof string === 'boolean' ? (string ? '1' : '0') : string + '' ); if (key.length > 64) { // keys longer than block-size are shortened key = HOTP.sha1(key, true); if (key === false) { return false; } } const bytes = new Array(64); let len = key.length; while (len--) { bytes[len] = key.charCodeAt(len) & 0xFF; } let oPadding = '', iPadding = ''; while (bytes.length > 0) { const byte = bytes.shift(); oPadding += String.fromCharCode(byte ^ 0x5C); iPadding += String.fromCharCode(byte ^ 0x36); } const iPadRes = HOTP.sha1(iPadding + string, true); return iPadRes ? HOTP.sha1(oPadding + iPadRes, raw) : false; } /** * Decode base32 encoded string * * @param {string} encoded base32 encoded string * @return {string} decoded string */ static decode_base32(encoded) { if (typeof encoded !== 'string') { return ''; } encoded = encoded .toUpperCase() // make uppercase characters .replace(/[^A-Z2-7=]/g, ''); // replace invalid if (encoded === '') { return ''; } const base32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; const strLen = encoded.length; let decoded = ''; let n = 0; let bitLength = 5; let val = base32.indexOf(encoded[0]); while (n < strLen) { if (bitLength < 8) { val = val << 5; bitLength += 5; n++; if (encoded[n] === '=') { n = strLen; continue; } val += base32.indexOf(encoded[n]); continue; } let shift = bitLength - 8; decoded += String.fromCharCode(val >> shift); val = val & ((1 << shift) - 1); bitLength -= 8; } return decoded; } /** * Generate a new secret key * OATH HOTP (HMAC-based One-time Password Algorithm) require at least 128 bits (16 bytes) of secret key * - 128 bits (16 bytes) * - 160 bits (20 bytes) * - 256 bits (32 bytes) * * @param {number<16, 32>} length=16 * @return {string} generated secret key * @throws {TypeError} Invalid length, length must be an integer * @throws {RangeError} Invalid length, length must be at least 16 and at most 32 */ generateKey(length = 16) { if (!Number.isInteger(length)) { throw new TypeError('Invalid length, length must be an integer'); } if (length < 16) { throw new RangeError('Invalid length, length must be at least 16'); } if (length > 32) { throw new RangeError('Invalid length, length must be at most 32'); } const timeDate = new Date(); const year = timeDate.getFullYear(); const month = timeDate.getMonth(); const date = timeDate.getDate(); const hour = timeDate.getHours(); const minute = timeDate.getMinutes(); const second = timeDate.getSeconds(); // time from date for prefix of the key, convert decimal to base32 const base32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; const BASE = (timeDate.getTime()) % 32; let result = base32[BASE]; result += base32[(year + BASE) % 32]; result += base32[(month + BASE) % 32]; result += base32[(date + BASE) % 32]; result += base32[(hour + BASE) % 32]; result += base32[(minute + BASE) % 32]; result += base32[(second + BASE) % 32]; length -= result.length; for (let i = 0; i < length; i++) { // random number from 0 to 31 offset result += base32[Math.floor(Math.random() * (32))]; } return result; } /** * Generate HOTP * * @param {string} key the shared secret * @param {number} movingFactor the counter, time, or other value that changes on a per use basis. * @return {string} HOTP * @ref https://datatracker.ietf.org/doc/html/rfc4226#section-5.3 * @throws {TypeError} Invalid key if key must be as a string * @throws {TypeError} Invalid counter, counter must be an integer * @throws {RangeError} Invalid counter, counter must be a positive integer */ generateHOTP(key, movingFactor) { if (typeof key !== 'string') { throw new TypeError('Invalid key, key must be a string'); } if (!Number.isInteger(movingFactor)) { throw new TypeError('Invalid counter, counter must be an integer'); } if (movingFactor < 0) { throw new RangeError('Invalid counter, counter must be a positive integer'); } const counter = new Array(8); for (let i = 7; i >= 0; i--) { counter[i] = String.fromCharCode(movingFactor & 0xff); movingFactor >>= 8; } const decoded_key = HOTP.decode_base32(key); /** * hash the counter bytes with HMAC-SHA-1 as raw output * @type {string} */ return HOTP.hmac_sha1(counter.join(''), decoded_key); } } /** * OATH - TOTP (Time-based One-time Password Algorithm) * * @link https://tools.ietf.org/html/rfc6238 */ class TOTP extends HOTP { /** * Truncate the HMAC-SHA-1 * * @param {string} hmac_result HMAC result * @return {number} truncated HOTP * * @ref https://datatracker.ietf.org/doc/html/rfc4226#section-5.4 * @ref https://datatracker.ietf.org/doc/html/rfc4226#section-5.2 * @private * @throws {TypeError} Invalid HMAC result, HMAC result must be a string */ truncate(hmac_result) { if (typeof hmac_result !== 'string') { throw new TypeError('Invalid HMAC result, HMAC result must be a string'); } if (hmac_result.length !== 20) { throw new RangeError('Invalid HMAC result, HMAC result must be 20 bytes'); } // HOTP(K,C) = Truncate(HMAC-SHA-1(K,C)) /* int offset = hmac_result[19] & 0xf ; int bin_code = (hmac_result[offset] & 0x7f) << 24 | (hmac_result[offset+1] & 0xff) << 16 | (hmac_result[offset+2] & 0xff) << 8 | (hmac_result[offset+3] & 0xff) ; */ const offset = hmac_result[19].charCodeAt(0) & 0xf; const bin_code = (hmac_result[offset].charCodeAt(0) & 0x7f) << 24 | (hmac_result[offset + 1].charCodeAt(0) & 0xff) << 16 | (hmac_result[offset + 2].charCodeAt(0) & 0xff) << 8 | (hmac_result[offset + 3].charCodeAt(0) & 0xff); return bin_code % 1000000; } /** * Calculates the checksum using the credit card algorithm. * This algorithm has the advantage that it detects any single * mistyped digit and any single transposition of * adjacent digits. * * @param {number} num the number to calculate the checksum for * @param {number} digits number of significant places in the number * * @return {number} the checksum of num * @private */ calcChecksum(num, digits) { let doubleDigit = true; let total = 0; while (digits-- > 0) { let digit = num % 10; num = Math.floor(num / 10); if (doubleDigit) { digit = [0, 2, 4, 6, 8, 1, 3, 5, 7, 9][digit]; } total += digit; doubleDigit = !doubleDigit; } let result = total % 10; if (result > 0) { result = 10 - result; } return result; } /** * Generate TOTP * * @param {string} secret the shared secret of base32 encoded * @param {number} movingFactor=30 the counter, time * @param {6|8} codeDigits=6 number of digits in the OTP * @param {boolean} addChecksum=false add checksum to the OTP * @param {number} window=0 the time step window * @return {string} TOTP * @throws {TypeError} Invalid secret, secret must be a string * @throws {TypeError} Invalid counter, counter must be an integer * @throws {RangeError} Invalid code digits, code digits must be 6 or 8 */ generateTOTP(secret, movingFactor = 30, codeDigits = 6, addChecksum= false, window = 0) { if (typeof secret !== 'string') { throw new TypeError('Invalid secret, secret must be a string'); } if (!Number.isInteger(movingFactor)) { throw new TypeError('Invalid counter, counter must be an integer'); } if (movingFactor < 0) { throw new RangeError('Invalid counter, counter must be a positive integer'); } // if (codeDigits !== 6 && codeDigits !== 8) { if (codeDigits < 6 || codeDigits > 8) { throw new RangeError('Invalid code digits, code digits must be 6 or 8'); } window = typeof window === 'number' ? Math.floor(window) : 0; const DIGITS_POWER = [1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000]; const timeDate = new Date(); const counter = Math.floor(timeDate.getTime() / 1000 / movingFactor) + window; const digits = addChecksum ? (codeDigits + 1) : codeDigits; const hotp = this.generateHOTP(secret, counter); // convert hex to binary let binary = ''; for (let i = 0; i < hotp.length; i += 2) { binary += String.fromCharCode(parseInt(hotp.substring(i, i + 2), 16)); } const truncated = this.truncate(binary); let otp = truncated % DIGITS_POWER[codeDigits]; if (addChecksum) { otp = (otp * 10) + this.calcChecksum(otp, codeDigits); } otp = otp.toString(); while (otp.length < digits) { otp = '0' + otp; } return otp; } /** * Verify TOTP * * @param {string} secret the shared secret of base32 encoded * @param {string|number} code the OTP * @param {number} timespan=30 in seconds of the time window * @param {number} step=1 the time step window * @return {boolean} true if valid */ verifyTOTP(secret, code, timespan = 30, step = 1) { if (typeof secret !== 'string') { throw new TypeError('Invalid secret, secret must be a string'); } if (!Number.isInteger(timespan)) { throw new TypeError('Invalid timespan, timespan must be an integer'); } code = Number.isInteger(code) ? code.toString() : code; if (typeof code !== 'string') { throw new TypeError('Invalid code, code must be a string'); } if (code.length > 9 || code.length < 6) { // 9 is additional checksum return false; } step = typeof step !== 'number' ? 1 : Math.floor(step); step = step < 0 ? 0 : step; const addChecksum = code.length % 2 !== 0; const codeDigits = addChecksum ? code.length - 1 : code.length; if (step === 0) { return code === this.generateTOTP(secret, timespan, codeDigits, addChecksum); } for (let window = -step; window <= step; window++) { const totp = this.generateTOTP(secret, timespan, codeDigits, addChecksum, window); if (code === totp) { return true; } } return false; } } //module.exports = { // HOTP, // TOTP //}; // // const totp = new TOTP(); // // console.log(totp.generateTOTP('SECRETKEY16CHARS')); // console.log(totp.verifyTOTP('SECRETKEY16CHARS', totp.generateTOTP('SECRETKEY16CHARS')));