Skip to content

Instantly share code, notes, and snippets.

@ArrayIterator
Last active December 27, 2024 16:47
Show Gist options
  • Save ArrayIterator/f066329b36a83dbb2bab750e35e41bfa to your computer and use it in GitHub Desktop.
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)
/**
* 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