Skip to content

Commit e4b8a20

Browse files
authored
feat: Convert Buffer and node crypto to web apis (#11)
BREAKING CHANGE: exported functions are now async
1 parent 90fdbf2 commit e4b8a20

File tree

3 files changed

+69
-73
lines changed

3 files changed

+69
-73
lines changed

index.js

Lines changed: 33 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
/**
22
* This was copy/paste/modified/tested from https://npm.im/notp (MIT)
33
*/
4-
import * as crypto from 'node:crypto'
5-
64
import base32Encode from 'base32-encode'
75
import base32Decode from 'base32-decode'
86

@@ -11,7 +9,7 @@ import base32Decode from 'base32-decode'
119
// That said, if you're acting the role of both client and server and your TOTP
1210
// is longer lived, you can definitely use a more secure algorithm like SHA256.
1311
// Learn more: https://www.rfc-editor.org/rfc/rfc4226#page-25 (B.1. SHA-1 Status)
14-
const DEFAULT_ALGORITHM = 'SHA1'
12+
const DEFAULT_ALGORITHM = 'SHA-1'
1513
const DEFAULT_CHAR_SET = '0123456789'
1614
const DEFAULT_DIGITS = 6
1715
const DEFAULT_WINDOW = 1
@@ -30,9 +28,9 @@ const DEFAULT_PERIOD = 30
3028
* @param {string} [options.algorithm='SHA1'] - The algorithm to use for the
3129
* HOTP. Defaults to 'SHA1'.
3230
* @param {string} [options.charSet='0123456789'] - The character set to use, defaults to the numbers 0-9.
33-
* @returns {string} The generated HOTP.
31+
* @returns {Promise<string>} The generated HOTP.
3432
*/
35-
function generateHOTP(
33+
async function generateHOTP(
3634
secret,
3735
{
3836
counter = 0,
@@ -41,11 +39,16 @@ function generateHOTP(
4139
charSet = DEFAULT_CHAR_SET,
4240
} = {}
4341
) {
44-
const byteCounter = Buffer.from(intToBytes(counter))
45-
const secretBuffer = Buffer.from(secret)
46-
const hmac = crypto.createHmac(algorithm, secretBuffer)
47-
const digest = hmac.update(byteCounter).digest('hex')
48-
const hashBytes = hexToBytes(digest)
42+
const byteCounter = intToBytes(counter)
43+
const key = await crypto.subtle.importKey(
44+
'raw',
45+
secret,
46+
{ name: 'HMAC', hash: algorithm },
47+
false,
48+
['sign']
49+
)
50+
const signature = await crypto.subtle.sign('HMAC', key, byteCounter)
51+
const hashBytes = new Uint8Array(signature)
4952
const offset = hashBytes[19] & 0xf
5053
let hotpVal =
5154
((hashBytes[offset] & 0x7f) << 24) |
@@ -67,7 +70,7 @@ function generateHOTP(
6770
* configuration options.
6871
*
6972
* @param {string} otp - The OTP to verify.
70-
* @param {Buffer} secret - The secret used to generate the HOTP.
73+
* @param {ArrayBuffer} secret - The secret used to generate the HOTP.
7174
* @param {Object} options - The configuration options for the HOTP.
7275
* @param {number} [options.counter=0] - The counter value to use for the HOTP.
7376
* Defaults to 0.
@@ -78,11 +81,11 @@ function generateHOTP(
7881
* @param {string} [options.charSet='0123456789'] - The character set to use, defaults to the numbers 0-9.
7982
* @param {number} [options.window=1] - The number of counter values to check
8083
* before and after the current counter value. Defaults to 1.
81-
* @returns {{delta: number}|null} An object with the `delta` property
84+
* @returns {Promise<{delta: number}|null>} An object with the `delta` property
8285
* indicating the number of counter values between the current counter value and
8386
* the verified counter value, or `null` if the OTP could not be verified.
8487
*/
85-
function verifyHOTP(
88+
async function verifyHOTP(
8689
otp,
8790
secret,
8891
{
@@ -95,7 +98,7 @@ function verifyHOTP(
9598
) {
9699
for (let i = counter - window; i <= counter + window; ++i) {
97100
if (
98-
generateHOTP(secret, { counter: i, digits, algorithm, charSet }) === otp
101+
await generateHOTP(secret, { counter: i, digits, algorithm, charSet }) === otp
99102
) {
100103
return { delta: i - counter }
101104
}
@@ -117,18 +120,18 @@ function verifyHOTP(
117120
* @param {string} [options.charSet='0123456789'] - The character set to use, defaults to the numbers 0-9.
118121
* @param {string} [options.secret] The secret to use for the TOTP. It should be
119122
* base32 encoded (you can use https://npm.im/thirty-two). Defaults to a random
120-
* secret: base32Encode(crypto.randomBytes(10), 'RFC4648').
121-
* @returns {{otp: string, secret: string, period: number, digits: number, algorithm: string, charSet: string}}
123+
* secret: base32Encode(crypto.getRandomValues(new Uint8Array(10)), 'RFC4648').
124+
* @returns {Promise<{otp: string, secret: string, period: number, digits: number, algorithm: string, charSet: string}>}
122125
* The OTP, secret, and config options used to generate the OTP.
123126
*/
124-
export function generateTOTP({
127+
export async function generateTOTP({
125128
period = DEFAULT_PERIOD,
126129
digits = DEFAULT_DIGITS,
127130
algorithm = DEFAULT_ALGORITHM,
128-
secret = base32Encode(crypto.randomBytes(10), 'RFC4648'),
131+
secret = base32Encode(crypto.getRandomValues(new Uint8Array(10)), 'RFC4648'),
129132
charSet = DEFAULT_CHAR_SET,
130133
} = {}) {
131-
const otp = generateHOTP(base32Decode(secret, 'RFC4648'), {
134+
const otp = await generateHOTP(base32Decode(secret, 'RFC4648'), {
132135
counter: getCounter(period),
133136
digits,
134137
algorithm,
@@ -191,11 +194,11 @@ export function getTOTPAuthUri({
191194
* @param {number} [options.window] The number of OTPs to check before and after
192195
* the current OTP. Defaults to 1.
193196
*
194-
* @returns {{delta: number}|null} an object with "delta" which is the delta
197+
* @returns {Promise<{delta: number}|null>} an object with "delta" which is the delta
195198
* between the current OTP and the OTP that was verified, or null if the OTP is
196199
* invalid.
197200
*/
198-
export function verifyTOTP({
201+
export async function verifyTOTP({
199202
otp,
200203
secret,
201204
period,
@@ -212,7 +215,7 @@ export function verifyTOTP({
212215
return null
213216
}
214217

215-
return verifyHOTP(otp, Buffer.from(decodedSecret), {
218+
return verifyHOTP(otp, new Uint8Array(decodedSecret), {
216219
counter: getCounter(period),
217220
digits,
218221
window,
@@ -225,23 +228,15 @@ export function verifyTOTP({
225228
* Converts a number to a byte array.
226229
*
227230
* @param {number} num The number to convert to a byte array.
228-
* @returns {number[]} The byte array representation of the number.
231+
* @returns {Uint8Array} The byte array representation of the number.
229232
*/
230233
function intToBytes(num) {
231-
const buffer = Buffer.alloc(8)
232-
// eslint-disable-next-line no-undef
233-
buffer.writeBigInt64BE(BigInt(num))
234-
return [...buffer]
235-
}
236-
237-
/**
238-
* Converts a hexadecimal string to a byte array.
239-
*
240-
* @param {string} hex The hexadecimal string to convert to a byte array.
241-
* @returns {number[]} The byte array representation of the hexadecimal string.
242-
*/
243-
function hexToBytes(hex) {
244-
return [...Buffer.from(hex, 'hex')]
234+
const arr = new Uint8Array(8)
235+
for (let i = 7; i >= 0; i--) {
236+
arr[i] = num & 0xff
237+
num = num >> 8
238+
}
239+
return arr
245240
}
246241

247242
/**
@@ -255,4 +250,4 @@ function getCounter(period = DEFAULT_PERIOD) {
255250
const now = new Date().getTime()
256251
const counter = Math.floor(now / 1000 / period)
257252
return counter
258-
}
253+
}

index.test.js

Lines changed: 35 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,19 @@ import { test } from 'node:test'
33
import base32Encode from 'base32-encode'
44
import { generateTOTP, getTOTPAuthUri, verifyTOTP } from './index.js'
55

6-
test('OTP can be generated and verified', () => {
7-
const { secret, otp, algorithm, period, digits } = generateTOTP()
8-
assert.strictEqual(algorithm, 'SHA1')
6+
test('OTP can be generated and verified', async () => {
7+
const { secret, otp, algorithm, period, digits } = await generateTOTP()
8+
9+
assert.strictEqual(algorithm, 'SHA-1')
910
assert.strictEqual(period, 30)
1011
assert.strictEqual(digits, 6)
11-
const result = verifyTOTP({ otp, secret })
12+
const result = await verifyTOTP({ otp, secret })
1213
assert.deepStrictEqual(result, { delta: 0 })
1314
})
1415

15-
test('options can be customized', () => {
16+
test('options can be customized', async () => {
1617
const options = {
17-
algorithm: 'SHA256',
18+
algorithm: 'SHA-256',
1819
period: 60,
1920
digits: 8,
2021
secret: base32Encode(
@@ -23,86 +24,86 @@ test('options can be customized', () => {
2324
).toString(),
2425
charSet: 'abcdef',
2526
}
26-
const { otp, ...config } = generateTOTP(options)
27+
const { otp, ...config } = await generateTOTP(options)
2728
assert.deepStrictEqual(config, options)
28-
const result = verifyTOTP({ otp, ...config })
29+
const result = await verifyTOTP({ otp, ...config })
2930
assert.deepStrictEqual(result, { delta: 0 })
3031
})
3132

32-
test('Verify TOTP within the specified time window', () => {
33-
const { otp, secret } = generateTOTP()
34-
const result = verifyTOTP({ otp, secret, window: 0 })
33+
test('Verify TOTP within the specified time window', async () => {
34+
const { otp, secret } = await generateTOTP()
35+
const result = await verifyTOTP({ otp, secret, window: 0 })
3536
assert.notStrictEqual(result, null)
3637
})
3738

38-
test('Fail to verify an invalid TOTP', () => {
39+
test('Fail to verify an invalid TOTP', async () => {
3940
const secret = Math.random().toString()
4041
const tooShortNumber = Math.random().toString().slice(2, 7)
41-
const result = verifyTOTP({ otp: tooShortNumber, secret })
42+
const result = await verifyTOTP({ otp: tooShortNumber, secret })
4243
assert.strictEqual(result, null)
4344
})
4445

4546
test('Fail to verify TOTP outside the specified time window', async () => {
46-
const { otp, secret: key } = generateTOTP({ period: 0.0001 })
47+
const { otp, secret: key } = await generateTOTP({ period: 0.0001 })
4748
await new Promise((resolve) => setTimeout(resolve, 1))
48-
const result = verifyTOTP({ otp, secret: key })
49+
const result = await verifyTOTP({ otp, secret: key })
4950
assert.strictEqual(result, null)
5051
})
5152

5253
test('Clock drift is handled by window', async () => {
5354
// super small period
54-
const { otp, secret: key, period } = generateTOTP({ period: 0.0001 })
55+
const { otp, secret: key, period } = await generateTOTP({ period: 0.0001 })
5556
// waiting a tiny bit
5657
await new Promise((resolve) => setTimeout(resolve, 1))
5758
// super big window (to accomodate slow machines running this test)
58-
const result = verifyTOTP({ otp, secret: key, window: 200, period })
59+
const result = await verifyTOTP({ otp, secret: key, window: 200, period })
5960
// should still validate
6061
assert.notDeepStrictEqual(result, null)
6162
})
6263

63-
test('Setting a different period config for generating and verifying will fail', () => {
64+
test('Setting a different period config for generating and verifying will fail', async () => {
6465
const desiredPeriod = 60
65-
const { otp, secret, period } = generateTOTP({
66+
const { otp, secret, period } = await generateTOTP({
6667
period: desiredPeriod,
6768
})
6869
assert.strictEqual(period, desiredPeriod)
69-
const result = verifyTOTP({ otp, secret, period: period + 1 })
70+
const result = await verifyTOTP({ otp, secret, period: period + 1 })
7071
assert.strictEqual(result, null)
7172
})
7273

73-
test('Setting a different algo config for generating and verifying will fail', () => {
74-
const desiredAlgo = 'SHA512'
75-
const { otp, secret, algorithm } = generateTOTP({
74+
test('Setting a different algo config for generating and verifying will fail', async () => {
75+
const desiredAlgo = 'SHA-512'
76+
const { otp, secret, algorithm } = await generateTOTP({
7677
algorithm: desiredAlgo,
7778
})
7879
assert.strictEqual(algorithm, desiredAlgo)
79-
const result = verifyTOTP({ otp, secret, algorithm: 'SHA1' })
80+
const result = await verifyTOTP({ otp, secret, algorithm: 'SHA-1' })
8081
assert.strictEqual(result, null)
8182
})
8283

83-
test('Generating and verifying also works with the algorithm name alias', () => {
84-
const desiredAlgo = 'SHA1'
85-
const { otp, secret, algorithm } = generateTOTP({
84+
test('Generating and verifying also works with the algorithm name alias', async () => {
85+
const desiredAlgo = 'SHA-1'
86+
const { otp, secret, algorithm } = await generateTOTP({
8687
algorithm: desiredAlgo,
8788
})
8889
assert.strictEqual(algorithm, desiredAlgo)
8990

90-
const result = verifyTOTP({ otp, secret, algorithm: 'sha1' })
91+
const result = await verifyTOTP({ otp, secret, algorithm: 'sha-1' })
9192
assert.notStrictEqual(result, null)
9293
})
9394

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

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

104-
test('OTP Auth URI can be generated', () => {
105-
const { otp: _otp, secret, ...totpConfig } = generateTOTP()
105+
test('OTP Auth URI can be generated', async () => {
106+
const { otp: _otp, secret, ...totpConfig } = await generateTOTP()
106107
const issuer = Math.random().toString(16).slice(2)
107108
const accountName = Math.random().toString(16).slice(2)
108109
const uri = getTOTPAuthUri({

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838
"base32-encode": "^2.0.0"
3939
},
4040
"engines": {
41-
"node": ">=18"
41+
"node": ">=20"
4242
},
4343
"prettier": {
4444
"semi": false,

0 commit comments

Comments
 (0)