Last active
August 23, 2024 20:54
-
-
Save tunnckoCore/f61d8383632767b0996360130beb5440 to your computer and use it in GitHub Desktop.
Web Crypto API native TOTP (Time-based One-Time Passwords), in 60 lines
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
// SPDX-License-Identifier: Apache-2.0 | |
// RFC 4648 base32 alphabet without pad | |
export const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; | |
/** | |
* Generate secret | |
* @param length optional, length (defaults to `24`) | |
* @returns secret | |
*/ | |
export function generateBase32Secret(size = 24) { | |
return crypto | |
.getRandomValues(new Uint8Array(size)) | |
.reduce( | |
(acc, value) => acc + BASE32_ALPHABET[Math.floor((value * BASE32_ALPHABET.length) / 256)], | |
'', | |
); | |
} | |
export async function generateToken(x = {}, y = 0) { | |
const resp = await generateTotp(x, y); | |
return resp.token; | |
} | |
/** | |
* Generate token | |
* @param secret secret | |
* @param timestamp optional, timestamp used for deterministic unit tests (defaults to current timestamp) | |
* @returns token | |
*/ | |
export async function generateTotp(options = {}, x = 0) { | |
const timestamp = x && x > 1000 ? x : Date.now(); | |
// const dgits = x && x < 1000 ? x : 6; | |
const opts = { | |
secret: generateBase32Secret(), | |
period: 30, | |
// digits: dgits, | |
timestamp, | |
algorithm: 'SHA-256', | |
...(typeof options === 'string' ? { secret: options } : options), | |
digits: 6, // force 6, cuz buggy when another | |
}; | |
const counter = hexToUint8( | |
`0000000000000000${Math.floor(Math.round(opts.timestamp / 1000) / opts.period).toString(16)}`.slice( | |
-16, | |
), | |
); | |
const data = await createHmac(hexToUint8(base32ToHex(secret)), counter); | |
const hex = Array.from(data) | |
.map((byte) => byte.toString(16).padStart(2, '0')) | |
.join(''); | |
const token = (parseInt(hex.substr(parseInt(hex.slice(-1), 16) * 2, 8), 16) & 2147483647) | |
.toString() | |
.slice(-opts.digits); | |
return { ...opts, token }; | |
} | |
/** | |
* Generate OTP URI | |
* See https://github.com/google/google-authenticator/wiki/Key-Uri-Format | |
* | |
* @param label label | |
* @param username username | |
* @param secret secret | |
* @param issuer issuer | |
* @returns URI | |
*/ | |
export function generateUri(secret, options = {}) { | |
const opts = { | |
label: '', | |
username: '', | |
issuer: '', | |
algorithm: 'SHA256', | |
period: 30, | |
...options, | |
digits: 6, // force 6, cuz buggy when another | |
}; | |
opts.algorithm = opts.algorithm.replace('-', ''); | |
return `otpauth://totp/${encodeURIComponent(opts.label)}:${encodeURIComponent( | |
opts.username, | |
)}?secret=${encodeURIComponent(secret)}&issuer=${encodeURIComponent(opts.issuer)}&algorithm=${opts.algorithm}&digits=${opts.digits}&period=${opts.period}`; | |
} | |
/** | |
* Validate token | |
* @param secret secret | |
* @param token token | |
* @param timestamp optional, timestamp used for deterministic unit tests (defaults to current timestamp) | |
* @returns boolean | |
*/ | |
export async function validateToken(secret, token, timestamp = Date.now()) { | |
const tkn = await generateTotp(secret, timestamp); | |
return tkn.token === String(token); | |
} | |
async function createSubtleSecretKey(secret, algo = 'SHA-256') { | |
return await crypto.subtle.importKey( | |
'raw', | |
typeof secret === 'string' ? new TextEncoder().encode(secret) : secret, | |
typeof algo === 'string' ? { name: 'HMAC', hash: algo } : algo, | |
false, | |
['sign'], | |
); | |
} | |
export async function createHmac(secret, counter, algo = 'SHA-256') { | |
const key = await createSubtleSecretKey(secret, algo); | |
const sig = await crypto.subtle.sign('HMAC', key, counter); | |
return new Uint8Array(sig); | |
} | |
export function base32ToHex(str) { | |
let bits = ''; | |
for (let index = 0; index < str.length; index++) { | |
const value = BASE32_ALPHABET.indexOf(str.charAt(index)); | |
bits += `00000${value.toString(2)}`.slice(-5); | |
} | |
let hex = ''; | |
for (let index = 0; index < bits.length - 3; index += 4) { | |
const chunk = bits.substring(index, index + 4); | |
hex = hex + parseInt(chunk, 2).toString(16); | |
} | |
return hex; | |
} | |
export function hexToUint8(x) { | |
return new Uint8Array(x.match(/.{1,2}/g).map((byte) => parseInt(byte, 16))); | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Usage
If you want to configure it with more digits or to use different algorithm, then pass
optoins
object togenerateTotpToken
Basically these are the available signature for the
generateTotpToken
You can also use
generateTotp
which returns all the passed options, plus the auto-generated secret, plus the TOTP token