One-time password implementation for NodeJS and the browser.
0
otp2fa.js
148 lines 5.3 kB View raw
1const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; 2const REGEX = new RegExp(`^[${CHARS}]{7,}$`, 'i'); 3 4function intToBuffer(number) { 5 const buffer = new Uint8Array(8); 6 for (let i = 7; i >= 0; i--) { 7 buffer[i] = number & 0xFF; 8 number >>= 8; 9 } 10 return buffer; 11} 12 13function base32ToBuffer(base32) { 14 const bytes = []; 15 let bits = 0, value = 0; 16 17 for (const char of base32.replace(/[=]+$/, '')) { 18 const index = CHARS.indexOf(char.toUpperCase()); 19 if (index === -1) 20 throw new Error(`Invalid character: ${char}`); 21 value = (value << 5) | index; 22 bits += 5; 23 if (bits >= 8) 24 bytes.push((value >> (bits -= 8)) & 0xFF); 25 } 26 27 return new Uint8Array(bytes); 28} 29 30/** 31 * Pad an otp number with leading zeros. 32 * @param {number} otp 33 * @param {6|8|10} [digits=6] 34 */ 35export function padotp(otp, digits=6) { 36 return `${otp}`.padStart(digits, '0').slice(-digits); 37} 38 39/** 40 * Generates an HMAC-based one-time password (HOTP). 41 * @param {Uint8Array|string} secret 42 * The shared secret key used to generate the HMAC. 43 * @param {Uint8Array|number} counter 44 * The counter value, incremented each time an OTP is generated. 45 * @param {'SHA-1'|'SHA-256'|'SHA-512'} [algorithm='SHA-1'] 46 * The hash algorithm used in the HMAC function. 47 * @returns {Promise<number>} 48 * A promise that resolves to a 31-bit integer representing the generated OTP. 49 */ 50export async function hotp(secret, counter, algorithm='SHA-1') { 51 if (typeof(secret) === 'string') 52 secret = base32ToBuffer(secret); 53 if (typeof(counter) === 'number') 54 counter = intToBuffer(counter); 55 56 // Generate a signature using the HMAC algorithm. 57 // https://developer.mozilla.org/docs/Web/API/SubtleCrypto/sign 58 const buffer = new Uint8Array( 59 await crypto.subtle.sign( 60 'HMAC', 61 // Import the secret key to create a cryptographic key. 62 // https://developer.mozilla.org/docs/Web/API/SubtleCrypto/importKey 63 await crypto.subtle.importKey( 64 'raw', // the key material is in raw binary format 65 secret, // the secret used as the key for the HMAC 66 { name: 'HMAC', hash: algorithm }, 67 false, // the key is non-extractable 68 ['sign'] // the key can only be used for signing 69 ), 70 counter // the data to be signed 71 ) // ArrayBuffer 72 ); 73 74 // Extract the last 4 bits of the hash buffer as a decimal integer. 75 // The offset indicates where in the hash to start extracting the OTP. 76 const offset = buffer[buffer.length - 1] & 0xF; 77 78 // Extract 4 bytes starting from the calculated offset. 79 const otp = // dynamic truncation 80 buffer[offset] << 24 | buffer[offset + 1] << 16 | 81 buffer[offset + 2] << 8 | buffer[offset + 3]; 82 83 // Clear the highest bit, ensuring the result is a 31-bit integer. 84 return otp & 0x7FFFFFFF; 85} 86 87/** 88 * Generates a time-based one-time password (TOTP). 89 * @param {Uint8Array} secret 90 * The shared secret key used to generate the HMAC. 91 * @param {number} time 92 * The Unix timestamp, in seconds. 93 * @param {number} [period=30] 94 * The period that the passcode will be valid for, in seconds. 95 * @param {'SHA-1'|'SHA-256'|'SHA-512'} [algorithm='SHA-1'] 96 * The hash algorithm used in the HMAC function. 97 * @returns {Promise<number>} 98 * A promise that resolves to a 31-bit integer representing the generated OTP. 99 * @example 100 * const time = Math.floor(Date.now() / 1000); 101 * const code = await totp(generateSecret(), time, 30); 102 * console.log(padotp(code, 6), 30-time%30, 'seconds'); 103 */ 104export async function totp(secret, time, period=30, algorithm='SHA-1') { 105 const counter = Math.floor(time / period); 106 return await hotp(secret, counter, algorithm); 107} 108 109/** 110 * Generates a random base32 secret of the specified length. 111 */ 112export function generateSecret(length=24) { 113 let secret = ''; 114 while (length-- > 0) 115 secret += CHARS[Math.floor(Math.random() * CHARS.length)]; 116 return secret; 117} 118 119/** 120 * Check if the specified secret is valid. 121 * @param {string} secret 122 */ 123export function isValidSecret(secret) { 124 return REGEX.test(secret); 125} 126 127/** 128 * Generates an OTP auth URL. May be encoded in a QR code. 129 * @param {string} issuer Provider or service managing the account. 130 * @param {string} label Account name; typically an e-mail address. 131 * @param {string} secret Key value encoded in Base32 (RFC-3548). 132 * @param {object} [params] 133 * @param {'SHA1'|'SHA256'|'SHA512'} [params.algorithm='SHA1'] 134 * @param {6|8|10} [params.digits=6] 135 * @param {number} [params.period=30] 136 * @param {number} [params.counter] 137 * @see https://github.com/google/google-authenticator/wiki/Key-Uri-Format 138 */ 139export function generateAuthURL(issuer, label, secret, params={}) { 140 const type = params.counter == null ? 'totp' : 'hotp'; 141 const il = [encodeURIComponent(issuer), encodeURIComponent(label)]; 142 const url = new URL(`otpauth://${type||'totp'}/${il[0]}:${il[1]}`); 143 url.searchParams.set('issuer', issuer); 144 url.searchParams.set('secret', secret); 145 for (const key of ['algorithm', 'digits', 'period', 'counter']) 146 if (params[key]) url.searchParams.set(key, params[key]); 147 return url; 148}
Sign up or login to add to the discussion