Skip to content

Instantly share code, notes, and snippets.

@ushuz
Last active November 19, 2021 05:42
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save ushuz/82ae58d26a7a1bc945d265577026aa78 to your computer and use it in GitHub Desktop.
Save ushuz/82ae58d26a7a1bc945d265577026aa78 to your computer and use it in GitHub Desktop.
Minimal TOTP implementation based on Web Crypto API that runs in browser
// Minimal TOTP implementation based on Web Crypto API that runs in browser, based on:
// https://www.laroberto.com/totp-primer/
// https://github.com/BYossarian/base-desires/blob/601fb587bbc495e299a4f24b78d5ca101c7db2ce/index.js#L98-L149
const computeHOTP = async (secret, counter) => {
// https://tools.ietf.org/html/rfc4226#section-5.1
const formatCounter = (counter) => {
const binStr = ('0'.repeat(64) + counter.toString(2)).slice(-64);
let intArr = [];
for (let i = 0; i < 8; i++) {
intArr[i] = parseInt(binStr.slice(i * 8, i * 8 + 8), 2);
}
return Uint8Array.from(intArr).buffer;
};
// https://tools.ietf.org/html/rfc4226#section-5.4
const truncate = (buffer) => {
const offset = buffer[buffer.length - 1] & 0xf;
return (
((buffer[offset] & 0x7f) << 24) |
((buffer[offset+1] & 0xff) << 16) |
((buffer[offset+2] & 0xff) << 8) |
((buffer[offset+3] & 0xff))
);
};
// https://github.com/BYossarian/base-desires/blob/601fb587bbc495e299a4f24b78d5ca101c7db2ce/index.js#L98-L149
const base32ToBuffer = (string) => {
// strip blankspace and padding, convert to uppercase
string = string.replace(/[\s=]/g, '').toUpperCase();
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
const buffer = [];
// inital values
let bitsAsInt = 0;
let bitsInInt = 0;
// iterate over each character
for (const char of string) {
// decode current character
const charValue = chars.indexOf(char);
// throw invalid character
if (charValue < 0) throw new Error(`Invalid character in base32 string: ${char}`);
// build bitsAsInt by merging the current base32 character value
bitsAsInt = (bitsAsInt << 5) | charValue;
bitsInInt += 5;
// since JS bitshift can't handle numbers bigger than 32-bits
// we need to consume the bits as soon as we have whole bytes:
// (doing this also automatically takes care of ignoring the
// 0s added as padding as part of the base32 encoding process)
while (bitsInInt > 7) {
// we're going to consume a byte so:
bitsInInt -= 8;
// put highest 8-bits into buffer
buffer.push(bitsAsInt >> bitsInInt);
// discard consumed 8-bits
bitsAsInt = bitsAsInt & (~(0xff << bitsInInt));
}
}
return Uint8Array.from(buffer).buffer;
}
return crypto.subtle.importKey(
'raw',
base32ToBuffer(secret),
{ name: 'HMAC', hash: {name: 'SHA-1'} },
false,
['sign']
).then((key) => {
return crypto.subtle.sign('HMAC', key, formatCounter(counter))
}).then((result) => {
return ('000000' + (truncate(new Uint8Array(result)) % 10 ** 6 )).slice(-6)
});
};
const computeTOTP = async (secret) => {
const window = 30 * 1000
const counter = Math.floor(Date.now() / window);
console.debug(secret, counter)
return await computeHOTP(secret, counter);
}
export { computeTOTP }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment