Skip to content

Instantly share code, notes, and snippets.

@tunnckoCore
Last active August 23, 2024 20:54
Show Gist options
  • Save tunnckoCore/f61d8383632767b0996360130beb5440 to your computer and use it in GitHub Desktop.
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
// 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)));
}
@tunnckoCore
Copy link
Author

tunnckoCore commented Aug 23, 2024

Usage

import { generateBase32Secret, generateToken, validateToken } from './web-native-totp.js';

const secret = generateBase32Secret();
// => QZL7HPXH4TPPSNCN2746GS3J

const token = await generateToken(secret);
// => 687531

const valid = await validateToken(secret, token);
// => true

If you want to configure it with more digits or to use different algorithm, then pass optoins object to generateTotpToken

const token = await generateToken({
    // by default it generates a secret
    // secret: generateBase32Secret(),
    period: 60, // 30 seconds by default
    digits: 10, // 6 by default
    algorithm: 'SHA-512', // SHA-256 by default
})

Basically these are the available signature for the generateTotpToken

// generateToken()
// generateToken(secret, timestamp)
// generateToken(secret, digits)
// generateToken({ secret, digits, timestamp })

You can also use generateTotp which returns all the passed options, plus the auto-generated secret, plus the TOTP token

const result = await generateTotp();
// => {
//     secret: "XB2FZCEQFWUPEDQ6Y5CFT5KN",
//     period: 30,
//     digits: 6,
//     timestamp: 1724430640795,
//     algorithm: "SHA-256",
//     token: "581817"
//   }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment