One-time password implementation for NodeJS and the browser.
0
otp2fa.js
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}