From 7f1cdbbb6e9175512f6b08779923db970317978c Mon Sep 17 00:00:00 2001 From: bitofbreeze <9855031+bitofbreeze@users.noreply.github.com> Date: Tue, 10 Sep 2024 17:43:35 +0900 Subject: [PATCH] [ Request ] Replace Buffer with equivalent `Uint8Array` and `crypto` with Web Crypto (#74) This makes the package fully "serverless" compatible. Author: @bitofbreeze --- .github/workflows/main.yml | 8 ++--- .github/workflows/publish.yml | 2 +- package.json | 5 +++- src/index.ts | 6 ++-- src/utils.ts | 55 ++++++++++++++++++++++++++++++++--- 5 files changed, 63 insertions(+), 13 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b339d42..bf294b5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -23,7 +23,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 20 - name: Install Dependencies uses: bahmutov/npm-install@v1.8.28 @@ -47,7 +47,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 20 - name: Install Dependencies uses: bahmutov/npm-install@v1.8.28 @@ -71,7 +71,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 20 - name: Install Dependencies uses: bahmutov/npm-install@v1.8.28 @@ -95,7 +95,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 20 - name: Install Dependencies uses: bahmutov/npm-install@v1.8.28 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index c7308a0..bc80974 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -14,7 +14,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v3 with: - node-version: 16 + node-version: 20 registry-url: https://registry.npmjs.org/ - name: Install Dependencies diff --git a/package.json b/package.json index cd4083e..582158a 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "/public/dist" ], "dependencies": { - "@epic-web/totp": "^1.1.3", + "@epic-web/totp": "^2.0.0", "base32-encode": "^2.0.0", "jose": "^5.8.0" }, @@ -62,5 +62,8 @@ "vite": "^4.5.3", "vite-tsconfig-paths": "^4.3.2", "vitest": "^1.6.0" + }, + "engines": { + "node": ">=20" } } diff --git a/src/index.ts b/src/index.ts index 001fcf4..6faa1f9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -288,7 +288,7 @@ export class TOTPStrategy extends Strategy { private readonly sendTOTP: SendTOTP private readonly validateEmail: ValidateEmail private readonly _totpGenerationDefaults = { - algorithm: 'SHA256', // More secure than SHA1 + algorithm: 'SHA-256', // More secure than SHA1 charSet: 'abcdefghijklmnpqrstuvwxyzABCDEFGHIJKLMNPQRSTUVWXYZ123456789', // No O or 0 digits: 6, period: 60, @@ -438,7 +438,7 @@ export class TOTPStrategy extends Strategy { const isValidEmail = await this.validateEmail(email) if (!isValidEmail) throw new Error(this.customErrors.invalidEmail) - const { otp: code, secret } = generateTOTP({ + const { otp: code, secret } = await generateTOTP({ ...this.totpGeneration, secret: this.totpGeneration.secret ?? generateSecret(), }) @@ -504,7 +504,7 @@ export class TOTPStrategy extends Strategy { if (Date.now() - totpData.createdAt > this.totpGeneration.period * 1000) { throw new Error(this.customErrors.expiredTotp) } - if (!verifyTOTP({ ...this.totpGeneration, secret: totpData.secret, otp: code })) { + if (!await verifyTOTP({ ...this.totpGeneration, secret: totpData.secret, otp: code })) { throw new Error(this.customErrors.invalidTotp) } } catch (error) { diff --git a/src/utils.ts b/src/utils.ts index 67d82b1..0dd5a8f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,15 +1,15 @@ import type { TOTPData, TOTPSessionData } from './index.js' import type { AuthenticateOptions } from 'remix-auth' import { ERRORS } from './constants.js' - import base32Encode from 'base32-encode' -import * as crypto from 'node:crypto' /** * TOTP Generation. */ export function generateSecret() { - return base32Encode(crypto.randomBytes(32), 'RFC4648').toString() as string + const randomBytes = new Uint8Array(32); + crypto.getRandomValues(randomBytes); + return base32Encode(randomBytes, 'RFC4648').toString() as string } export function generateMagicLink(options: { @@ -24,6 +24,53 @@ export function generateMagicLink(options: { return url.toString() } +// https://github.com/sindresorhus/uint8array-extras/blob/main/index.js#L222 +const hexToDecimalLookupTable = { + 0: 0, + 1: 1, + 2: 2, + 3: 3, + 4: 4, + 5: 5, + 6: 6, + 7: 7, + 8: 8, + 9: 9, + a: 10, + b: 11, + c: 12, + d: 13, + e: 14, + f: 15, + A: 10, + B: 11, + C: 12, + D: 13, + E: 14, + F: 15, +}; +function hexToUint8Array(hexString: string) { + if (hexString.length % 2 !== 0) { + throw new Error('Invalid Hex string length.'); + } + + const resultLength = hexString.length / 2; + const bytes = new Uint8Array(resultLength); + + for (let index = 0; index < resultLength; index++) { + const highNibble = hexToDecimalLookupTable[hexString[index * 2] as keyof typeof hexToDecimalLookupTable]; + const lowNibble = hexToDecimalLookupTable[hexString[(index * 2) + 1] as keyof typeof hexToDecimalLookupTable]; + + if (highNibble === undefined || lowNibble === undefined) { + throw new Error(`Invalid Hex character encountered at position ${index * 2}`); + } + + bytes[index] = (highNibble << 4) | lowNibble; + } + + return bytes; +} + /** * Miscellaneous. */ @@ -31,7 +78,7 @@ export function asJweKey(secret: string) { if (!/^[0-9a-fA-F]{64}$/.test(secret)) { throw new Error('Secret must be a string with 64 hex characters.') } - return Buffer.from(secret, 'hex') + return hexToUint8Array(secret) } export function coerceToOptionalString(value: unknown) {