Skip to content

Commit

Permalink
feat: Convert Buffer and node crypto to web apis (#11)
Browse files Browse the repository at this point in the history
BREAKING CHANGE: exported functions are now async
  • Loading branch information
bitofbreeze authored Sep 8, 2024
1 parent 90fdbf2 commit e4b8a20
Show file tree
Hide file tree
Showing 3 changed files with 69 additions and 73 deletions.
71 changes: 33 additions & 38 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
/**
* This was copy/paste/modified/tested from https://npm.im/notp (MIT)
*/
import * as crypto from 'node:crypto'

import base32Encode from 'base32-encode'
import base32Decode from 'base32-decode'

Expand All @@ -11,7 +9,7 @@ import base32Decode from 'base32-decode'
// That said, if you're acting the role of both client and server and your TOTP
// is longer lived, you can definitely use a more secure algorithm like SHA256.
// Learn more: https://www.rfc-editor.org/rfc/rfc4226#page-25 (B.1. SHA-1 Status)
const DEFAULT_ALGORITHM = 'SHA1'
const DEFAULT_ALGORITHM = 'SHA-1'
const DEFAULT_CHAR_SET = '0123456789'
const DEFAULT_DIGITS = 6
const DEFAULT_WINDOW = 1
Expand All @@ -30,9 +28,9 @@ const DEFAULT_PERIOD = 30
* @param {string} [options.algorithm='SHA1'] - The algorithm to use for the
* HOTP. Defaults to 'SHA1'.
* @param {string} [options.charSet='0123456789'] - The character set to use, defaults to the numbers 0-9.
* @returns {string} The generated HOTP.
* @returns {Promise<string>} The generated HOTP.
*/
function generateHOTP(
async function generateHOTP(
secret,
{
counter = 0,
Expand All @@ -41,11 +39,16 @@ function generateHOTP(
charSet = DEFAULT_CHAR_SET,
} = {}
) {
const byteCounter = Buffer.from(intToBytes(counter))
const secretBuffer = Buffer.from(secret)
const hmac = crypto.createHmac(algorithm, secretBuffer)
const digest = hmac.update(byteCounter).digest('hex')
const hashBytes = hexToBytes(digest)
const byteCounter = intToBytes(counter)
const key = await crypto.subtle.importKey(
'raw',
secret,
{ name: 'HMAC', hash: algorithm },
false,
['sign']
)
const signature = await crypto.subtle.sign('HMAC', key, byteCounter)
const hashBytes = new Uint8Array(signature)
const offset = hashBytes[19] & 0xf
let hotpVal =
((hashBytes[offset] & 0x7f) << 24) |
Expand All @@ -67,7 +70,7 @@ function generateHOTP(
* configuration options.
*
* @param {string} otp - The OTP to verify.
* @param {Buffer} secret - The secret used to generate the HOTP.
* @param {ArrayBuffer} secret - The secret used to generate the HOTP.
* @param {Object} options - The configuration options for the HOTP.
* @param {number} [options.counter=0] - The counter value to use for the HOTP.
* Defaults to 0.
Expand All @@ -78,11 +81,11 @@ function generateHOTP(
* @param {string} [options.charSet='0123456789'] - The character set to use, defaults to the numbers 0-9.
* @param {number} [options.window=1] - The number of counter values to check
* before and after the current counter value. Defaults to 1.
* @returns {{delta: number}|null} An object with the `delta` property
* @returns {Promise<{delta: number}|null>} An object with the `delta` property
* indicating the number of counter values between the current counter value and
* the verified counter value, or `null` if the OTP could not be verified.
*/
function verifyHOTP(
async function verifyHOTP(
otp,
secret,
{
Expand All @@ -95,7 +98,7 @@ function verifyHOTP(
) {
for (let i = counter - window; i <= counter + window; ++i) {
if (
generateHOTP(secret, { counter: i, digits, algorithm, charSet }) === otp
await generateHOTP(secret, { counter: i, digits, algorithm, charSet }) === otp
) {
return { delta: i - counter }
}
Expand All @@ -117,18 +120,18 @@ function verifyHOTP(
* @param {string} [options.charSet='0123456789'] - The character set to use, defaults to the numbers 0-9.
* @param {string} [options.secret] The secret to use for the TOTP. It should be
* base32 encoded (you can use https://npm.im/thirty-two). Defaults to a random
* secret: base32Encode(crypto.randomBytes(10), 'RFC4648').
* @returns {{otp: string, secret: string, period: number, digits: number, algorithm: string, charSet: string}}
* secret: base32Encode(crypto.getRandomValues(new Uint8Array(10)), 'RFC4648').
* @returns {Promise<{otp: string, secret: string, period: number, digits: number, algorithm: string, charSet: string}>}
* The OTP, secret, and config options used to generate the OTP.
*/
export function generateTOTP({
export async function generateTOTP({
period = DEFAULT_PERIOD,
digits = DEFAULT_DIGITS,
algorithm = DEFAULT_ALGORITHM,
secret = base32Encode(crypto.randomBytes(10), 'RFC4648'),
secret = base32Encode(crypto.getRandomValues(new Uint8Array(10)), 'RFC4648'),
charSet = DEFAULT_CHAR_SET,
} = {}) {
const otp = generateHOTP(base32Decode(secret, 'RFC4648'), {
const otp = await generateHOTP(base32Decode(secret, 'RFC4648'), {
counter: getCounter(period),
digits,
algorithm,
Expand Down Expand Up @@ -191,11 +194,11 @@ export function getTOTPAuthUri({
* @param {number} [options.window] The number of OTPs to check before and after
* the current OTP. Defaults to 1.
*
* @returns {{delta: number}|null} an object with "delta" which is the delta
* @returns {Promise<{delta: number}|null>} an object with "delta" which is the delta
* between the current OTP and the OTP that was verified, or null if the OTP is
* invalid.
*/
export function verifyTOTP({
export async function verifyTOTP({
otp,
secret,
period,
Expand All @@ -212,7 +215,7 @@ export function verifyTOTP({
return null
}

return verifyHOTP(otp, Buffer.from(decodedSecret), {
return verifyHOTP(otp, new Uint8Array(decodedSecret), {
counter: getCounter(period),
digits,
window,
Expand All @@ -225,23 +228,15 @@ export function verifyTOTP({
* Converts a number to a byte array.
*
* @param {number} num The number to convert to a byte array.
* @returns {number[]} The byte array representation of the number.
* @returns {Uint8Array} The byte array representation of the number.
*/
function intToBytes(num) {
const buffer = Buffer.alloc(8)
// eslint-disable-next-line no-undef
buffer.writeBigInt64BE(BigInt(num))
return [...buffer]
}

/**
* Converts a hexadecimal string to a byte array.
*
* @param {string} hex The hexadecimal string to convert to a byte array.
* @returns {number[]} The byte array representation of the hexadecimal string.
*/
function hexToBytes(hex) {
return [...Buffer.from(hex, 'hex')]
const arr = new Uint8Array(8)
for (let i = 7; i >= 0; i--) {
arr[i] = num & 0xff
num = num >> 8
}
return arr
}

/**
Expand All @@ -255,4 +250,4 @@ function getCounter(period = DEFAULT_PERIOD) {
const now = new Date().getTime()
const counter = Math.floor(now / 1000 / period)
return counter
}
}
69 changes: 35 additions & 34 deletions index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,19 @@ import { test } from 'node:test'
import base32Encode from 'base32-encode'
import { generateTOTP, getTOTPAuthUri, verifyTOTP } from './index.js'

test('OTP can be generated and verified', () => {
const { secret, otp, algorithm, period, digits } = generateTOTP()
assert.strictEqual(algorithm, 'SHA1')
test('OTP can be generated and verified', async () => {
const { secret, otp, algorithm, period, digits } = await generateTOTP()

assert.strictEqual(algorithm, 'SHA-1')
assert.strictEqual(period, 30)
assert.strictEqual(digits, 6)
const result = verifyTOTP({ otp, secret })
const result = await verifyTOTP({ otp, secret })
assert.deepStrictEqual(result, { delta: 0 })
})

test('options can be customized', () => {
test('options can be customized', async () => {
const options = {
algorithm: 'SHA256',
algorithm: 'SHA-256',
period: 60,
digits: 8,
secret: base32Encode(
Expand All @@ -23,86 +24,86 @@ test('options can be customized', () => {
).toString(),
charSet: 'abcdef',
}
const { otp, ...config } = generateTOTP(options)
const { otp, ...config } = await generateTOTP(options)
assert.deepStrictEqual(config, options)
const result = verifyTOTP({ otp, ...config })
const result = await verifyTOTP({ otp, ...config })
assert.deepStrictEqual(result, { delta: 0 })
})

test('Verify TOTP within the specified time window', () => {
const { otp, secret } = generateTOTP()
const result = verifyTOTP({ otp, secret, window: 0 })
test('Verify TOTP within the specified time window', async () => {
const { otp, secret } = await generateTOTP()
const result = await verifyTOTP({ otp, secret, window: 0 })
assert.notStrictEqual(result, null)
})

test('Fail to verify an invalid TOTP', () => {
test('Fail to verify an invalid TOTP', async () => {
const secret = Math.random().toString()
const tooShortNumber = Math.random().toString().slice(2, 7)
const result = verifyTOTP({ otp: tooShortNumber, secret })
const result = await verifyTOTP({ otp: tooShortNumber, secret })
assert.strictEqual(result, null)
})

test('Fail to verify TOTP outside the specified time window', async () => {
const { otp, secret: key } = generateTOTP({ period: 0.0001 })
const { otp, secret: key } = await generateTOTP({ period: 0.0001 })
await new Promise((resolve) => setTimeout(resolve, 1))
const result = verifyTOTP({ otp, secret: key })
const result = await verifyTOTP({ otp, secret: key })
assert.strictEqual(result, null)
})

test('Clock drift is handled by window', async () => {
// super small period
const { otp, secret: key, period } = generateTOTP({ period: 0.0001 })
const { otp, secret: key, period } = await generateTOTP({ period: 0.0001 })
// waiting a tiny bit
await new Promise((resolve) => setTimeout(resolve, 1))
// super big window (to accomodate slow machines running this test)
const result = verifyTOTP({ otp, secret: key, window: 200, period })
const result = await verifyTOTP({ otp, secret: key, window: 200, period })
// should still validate
assert.notDeepStrictEqual(result, null)
})

test('Setting a different period config for generating and verifying will fail', () => {
test('Setting a different period config for generating and verifying will fail', async () => {
const desiredPeriod = 60
const { otp, secret, period } = generateTOTP({
const { otp, secret, period } = await generateTOTP({
period: desiredPeriod,
})
assert.strictEqual(period, desiredPeriod)
const result = verifyTOTP({ otp, secret, period: period + 1 })
const result = await verifyTOTP({ otp, secret, period: period + 1 })
assert.strictEqual(result, null)
})

test('Setting a different algo config for generating and verifying will fail', () => {
const desiredAlgo = 'SHA512'
const { otp, secret, algorithm } = generateTOTP({
test('Setting a different algo config for generating and verifying will fail', async () => {
const desiredAlgo = 'SHA-512'
const { otp, secret, algorithm } = await generateTOTP({
algorithm: desiredAlgo,
})
assert.strictEqual(algorithm, desiredAlgo)
const result = verifyTOTP({ otp, secret, algorithm: 'SHA1' })
const result = await verifyTOTP({ otp, secret, algorithm: 'SHA-1' })
assert.strictEqual(result, null)
})

test('Generating and verifying also works with the algorithm name alias', () => {
const desiredAlgo = 'SHA1'
const { otp, secret, algorithm } = generateTOTP({
test('Generating and verifying also works with the algorithm name alias', async () => {
const desiredAlgo = 'SHA-1'
const { otp, secret, algorithm } = await generateTOTP({
algorithm: desiredAlgo,
})
assert.strictEqual(algorithm, desiredAlgo)

const result = verifyTOTP({ otp, secret, algorithm: 'sha1' })
const result = await verifyTOTP({ otp, secret, algorithm: 'sha-1' })
assert.notStrictEqual(result, null)
})

test('Charset defaults to numbers', () => {
const { otp } = generateTOTP()
test('Charset defaults to numbers', async () => {
const { otp } = await generateTOTP()
assert.match(otp, /^[0-9]+$/)
})

test('Charset can be customized', () => {
const { otp } = generateTOTP({ charSet: 'abcdef' })
test('Charset can be customized', async () => {
const { otp } = await generateTOTP({ charSet: 'abcdef' })
assert.match(otp, /^[abcdef]+$/)
})

test('OTP Auth URI can be generated', () => {
const { otp: _otp, secret, ...totpConfig } = generateTOTP()
test('OTP Auth URI can be generated', async () => {
const { otp: _otp, secret, ...totpConfig } = await generateTOTP()
const issuer = Math.random().toString(16).slice(2)
const accountName = Math.random().toString(16).slice(2)
const uri = getTOTPAuthUri({
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"base32-encode": "^2.0.0"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"prettier": {
"semi": false,
Expand Down

0 comments on commit e4b8a20

Please sign in to comment.