Last active
December 27, 2024 16:47
-
-
Save ArrayIterator/f066329b36a83dbb2bab750e35e41bfa to your computer and use it in GitHub Desktop.
OATH - HOTP (HMAC-based One-time Password Algorithm) - TOTP (Time-based One-time Password Algorithm)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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'))); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment