Last active
November 19, 2021 05:42
-
-
Save ushuz/82ae58d26a7a1bc945d265577026aa78 to your computer and use it in GitHub Desktop.
Minimal TOTP implementation based on Web Crypto API that runs in browser
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
// 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