diff --git a/biome.json b/biome.json index 958c181..105ae10 100644 --- a/biome.json +++ b/biome.json @@ -25,8 +25,6 @@ } }, "files": { - "ignore": [ - "dist" - ] + "ignore": ["dist"] } -} \ No newline at end of file +} diff --git a/package.json b/package.json index 8f991a2..ba2d5fb 100644 --- a/package.json +++ b/package.json @@ -1,82 +1,80 @@ { - "name": "@better-auth/utils", - "version": "0.2.1", - "license": "MIT", - "description": "A collection of utilities for better-auth", - "main": "./dist/index.cjs", - "module": "./dist/index.mjs", - "scripts": { - "test": "vitest", - "typecheck": "tsc --noEmit", - "build": "unbuild", - "lint:fix": "biome check . --write" - }, - "keywords": [ - "auth", - "utils", - "typescript", - "better-auth", - "better-auth-utils" - ], - "author": "Bereket Engida", - "repository": { - "type": "git", - "url": "https://github.com/better-auth/utils" - }, - "dependencies": { - "uncrypto": "^0.1.3" - }, - "devDependencies": { - "@biomejs/biome": "^1.9.4", - "@types/node": "^22.10.1", - "bumpp": "^9.9.0", - "happy-dom": "^15.11.7", - "unbuild": "^2.0.0", - "vitest": "^2.1.8" - }, - "exports": { - ".": { - "import": "./dist/index.mjs", - "require": "./dist/index.cjs" - }, - "./base64": { - "import": "./dist/base64.mjs", - "require": "./dist/base64.cjs" - }, - "./binary": { - "import": "./dist/binary.mjs", - "require": "./dist/binary.cjs" - }, - "./hash": { - "import": "./dist/hash.mjs", - "require": "./dist/hash.cjs" - }, - "./ecdsa": { - "import": "./dist/ecdsa.mjs", - "require": "./dist/ecdsa.cjs" - }, - "./hex": { - "import": "./dist/hex.mjs", - "require": "./dist/hex.cjs" - }, - "./hmac": { - "import": "./dist/hmac.mjs", - "require": "./dist/hmac.cjs" - }, - "./otp": { - "import": "./dist/otp.mjs", - "require": "./dist/otp.cjs" - }, - "./random": { - "import": "./dist/random.mjs", - "require": "./dist/random.cjs" - }, - "./rsa": { - "import": "./dist/rsa.mjs", - "require": "./dist/rsa.cjs" - } - }, - "files": [ - "dist" - ] -} \ No newline at end of file + "name": "@better-auth/utils", + "version": "0.2.1", + "license": "MIT", + "description": "A collection of utilities for better-auth", + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "scripts": { + "test": "vitest", + "typecheck": "tsc --noEmit", + "build": "unbuild", + "lint:fix": "biome check . --write" + }, + "keywords": [ + "auth", + "utils", + "typescript", + "better-auth", + "better-auth-utils" + ], + "author": "Bereket Engida", + "repository": { + "type": "git", + "url": "https://github.com/better-auth/utils" + }, + "dependencies": { + "uncrypto": "^0.1.3" + }, + "devDependencies": { + "@biomejs/biome": "^1.9.4", + "@types/node": "^22.10.1", + "bumpp": "^9.9.0", + "happy-dom": "^15.11.7", + "unbuild": "^2.0.0", + "vitest": "^2.1.8" + }, + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + }, + "./base64": { + "import": "./dist/base64.mjs", + "require": "./dist/base64.cjs" + }, + "./binary": { + "import": "./dist/binary.mjs", + "require": "./dist/binary.cjs" + }, + "./hash": { + "import": "./dist/hash.mjs", + "require": "./dist/hash.cjs" + }, + "./ecdsa": { + "import": "./dist/ecdsa.mjs", + "require": "./dist/ecdsa.cjs" + }, + "./hex": { + "import": "./dist/hex.mjs", + "require": "./dist/hex.cjs" + }, + "./hmac": { + "import": "./dist/hmac.mjs", + "require": "./dist/hmac.cjs" + }, + "./otp": { + "import": "./dist/otp.mjs", + "require": "./dist/otp.cjs" + }, + "./random": { + "import": "./dist/random.mjs", + "require": "./dist/random.cjs" + }, + "./rsa": { + "import": "./dist/rsa.mjs", + "require": "./dist/rsa.cjs" + } + }, + "files": ["dist"] +} diff --git a/src/base32.ts b/src/base32.ts new file mode 100644 index 0000000..7658d4d --- /dev/null +++ b/src/base32.ts @@ -0,0 +1,193 @@ +//inspired by oslo implementation by pilcrowonpaper: https://github.com/pilcrowonpaper/oslo/blob/main/src/encoding/base32.ts + +import type { TypedArray } from "./type"; + +/** + * Returns the Base32 alphabet based on the encoding type. + * @param hex - Whether to use the hexadecimal Base32 alphabet. + * @returns The appropriate Base32 alphabet. + */ +function getAlphabet(hex: boolean): string { + return hex + ? "0123456789ABCDEFGHIJKLMNOPQRSTUV" + : "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; +} + +/** + * Creates a decode map for the given alphabet. + * @param alphabet - The Base32 alphabet. + * @returns A map of characters to their corresponding values. + */ +function createDecodeMap(alphabet: string): Map { + const decodeMap = new Map(); + for (let i = 0; i < alphabet.length; i++) { + decodeMap.set(alphabet[i]!, i); + } + return decodeMap; +} + +/** + * Encodes a Uint8Array into a Base32 string. + * @param data - The data to encode. + * @param alphabet - The Base32 alphabet to use. + * @param padding - Whether to include padding. + * @returns The Base32 encoded string. + */ +function base32Encode( + data: Uint8Array, + alphabet: string, + padding: boolean, +): string { + let result = ""; + let buffer = 0; + let shift = 0; + + for (const byte of data) { + buffer = (buffer << 8) | byte; + shift += 8; + while (shift >= 5) { + shift -= 5; + result += alphabet[(buffer >> shift) & 0x1f]; + } + } + + if (shift > 0) { + result += alphabet[(buffer << (5 - shift)) & 0x1f]; + } + + if (padding) { + const padCount = (8 - (result.length % 8)) % 8; + result += "=".repeat(padCount); + } + + return result; +} + +/** + * Decodes a Base32 string into a Uint8Array. + * @param data - The Base32 encoded string. + * @param alphabet - The Base32 alphabet to use. + * @returns The decoded Uint8Array. + */ +function base32Decode(data: string, alphabet: string): Uint8Array { + const decodeMap = createDecodeMap(alphabet); + const result: number[] = []; + let buffer = 0; + let bitsCollected = 0; + + for (const char of data) { + if (char === "=") break; + const value = decodeMap.get(char); + if (value === undefined) { + throw new Error(`Invalid Base32 character: ${char}`); + } + buffer = (buffer << 5) | value; + bitsCollected += 5; + + while (bitsCollected >= 8) { + bitsCollected -= 8; + result.push((buffer >> bitsCollected) & 0xff); + } + } + + return Uint8Array.from(result); +} + +/** + * Base32 encoding and decoding utility. + */ +export const base32 = { + /** + * Encodes data into a Base32 string. + * @param data - The data to encode (ArrayBuffer, TypedArray, or string). + * @param options - Encoding options. + * @returns The Base32 encoded string. + */ + encode( + data: ArrayBuffer | TypedArray | string, + options: { padding?: boolean } = {}, + ): string { + const alphabet = getAlphabet(false); + const buffer = + typeof data === "string" + ? new TextEncoder().encode(data) + : new Uint8Array(data); + return base32Encode(buffer, alphabet, options.padding ?? true); + }, + + /** + * Decodes a Base32 string into a Uint8Array. + * @param data - The Base32 encoded string or ArrayBuffer/TypedArray. + * @returns The decoded Uint8Array. + */ + decode(data: string | ArrayBuffer | TypedArray): Uint8Array { + if (typeof data !== "string") { + data = new TextDecoder().decode(data); + } + const alphabet = getAlphabet(false); + return base32Decode(data, alphabet); + }, +}; + +/** + * Base32hex encoding and decoding utility. + */ +export const base32hex = { + /** + * Encodes data into a Base32hex string. + * @param data - The data to encode (ArrayBuffer, TypedArray, or string). + * @param options - Encoding options. + * @returns The Base32hex encoded string. + */ + encode( + data: ArrayBuffer | TypedArray | string, + options: { padding?: boolean } = {}, + ): string { + const alphabet = getAlphabet(true); + const buffer = + typeof data === "string" + ? new TextEncoder().encode(data) + : new Uint8Array(data); + return base32Encode(buffer, alphabet, options.padding ?? true); + }, + + /** + * Decodes a Base32hex string into a Uint8Array. + * @param data - The Base32hex encoded string. + * @returns The decoded Uint8Array. + */ + decode(data: string): Uint8Array { + const alphabet = getAlphabet(true); + return base32Decode(data, alphabet); + }, +}; + +/** @deprecated Use `base32.encode()` instead */ +export function encodeBase32( + data: ArrayBuffer | TypedArray, + options?: { padding?: boolean }, +): string { + return base32.encode(data, { + padding: options?.padding ?? true, + }); +} + +/** @deprecated Use `base32.decode()` instead */ +export function decodeBase32(data: string): Uint8Array { + return base32.decode(data); +} + +/** @deprecated Use `base32hex.encode()` instead */ +export function encodeBase32hex( + data: ArrayBuffer | TypedArray, + options?: { padding?: boolean }, +): string { + return base32hex.encode(data, { + padding: options?.padding ?? true, + }); +} + +/** @deprecated Use `base32hex.decode()` instead */ +export function decodeBase32hex(data: string): Uint8Array { + return base32hex.decode(data); +} diff --git a/src/hash.test.ts b/src/hash.test.ts index 34ef801..5783809 100644 --- a/src/hash.test.ts +++ b/src/hash.test.ts @@ -41,14 +41,18 @@ describe("digest", () => { }); it("handles input as an ArrayBufferView", async () => { - const hash = await createHash("SHA-256").digest(new Uint8Array(inputBuffer)); + const hash = await createHash("SHA-256").digest( + new Uint8Array(inputBuffer), + ); expect(hash).toBeInstanceOf(ArrayBuffer); }); }); describe("Error handling", () => { it("throws an error for unsupported hash algorithms", async () => { - await expect(createHash("SHA-10" as any).digest(inputString)).rejects.toThrow(); + await expect( + createHash("SHA-10" as any).digest(inputString), + ).rejects.toThrow(); }); it("throws an error for invalid input types", async () => { diff --git a/src/hex.test.ts b/src/hex.test.ts index 52e0e48..7bbaa19 100644 --- a/src/hex.test.ts +++ b/src/hex.test.ts @@ -1,70 +1,59 @@ -import { describe, it, expect } from 'vitest'; -import { hex } from './hex'; - -describe('hex', () => { - describe('encode', () => { - it('should encode a string to hexadecimal', () => { - const input = "Hello, World!"; - expect(hex.encode(input)).toBe( - Buffer.from(input).toString("hex"), - ); - }); - - it('should encode an ArrayBuffer to hexadecimal', () => { - const input = new TextEncoder().encode("Hello").buffer; - expect(hex.encode(input)).toBe( - Buffer.from(input).toString("hex"), - ); - }); - - it('should encode a TypedArray to hexadecimal', () => { - const input = new Uint8Array([72, 101, 108, 108, 111]); - expect(hex.encode(input)).toBe( - Buffer.from(input).toString("hex"), - ); - }); - }); - - describe('decode', () => { - it('should decode a hexadecimal string to its original value', () => { - const expected = "Hello, World!"; - expect(hex.decode( - Buffer.from(expected).toString("hex"), - )).toBe(expected); - }); - - it('should handle decoding of a hexadecimal string to binary data', () => { - const expected = "Hello"; - expect(hex.decode( - Buffer.from(expected).toString("hex"), - )).toBe(expected); - }); - - it('should throw an error for an odd-length string', () => { - const input = "123"; - expect(() => hex.decode(input)).toThrow(Error); - }); - - it('should throw an error for a non-hexadecimal string', () => { - const input = "zzzz"; - expect(() => hex.decode(input)).toThrow(Error); - }); - }); - - describe('round-trip tests', () => { - it('should return the original string after encoding and decoding', () => { - const input = "Hello, Hex!"; - const encoded = hex.encode(input); - const decoded = hex.decode(encoded); - expect(decoded).toBe(input); - }); - - it('should handle empty strings', () => { - const input = ""; - const encoded = hex.encode(input); - const decoded = hex.decode(encoded); - expect(decoded).toBe(input); - }); - }); +import { describe, it, expect } from "vitest"; +import { hex } from "./hex"; + +describe("hex", () => { + describe("encode", () => { + it("should encode a string to hexadecimal", () => { + const input = "Hello, World!"; + expect(hex.encode(input)).toBe(Buffer.from(input).toString("hex")); + }); + + it("should encode an ArrayBuffer to hexadecimal", () => { + const input = new TextEncoder().encode("Hello").buffer; + expect(hex.encode(input)).toBe(Buffer.from(input).toString("hex")); + }); + + it("should encode a TypedArray to hexadecimal", () => { + const input = new Uint8Array([72, 101, 108, 108, 111]); + expect(hex.encode(input)).toBe(Buffer.from(input).toString("hex")); + }); + }); + + describe("decode", () => { + it("should decode a hexadecimal string to its original value", () => { + const expected = "Hello, World!"; + expect(hex.decode(Buffer.from(expected).toString("hex"))).toBe(expected); + }); + + it("should handle decoding of a hexadecimal string to binary data", () => { + const expected = "Hello"; + expect(hex.decode(Buffer.from(expected).toString("hex"))).toBe(expected); + }); + + it("should throw an error for an odd-length string", () => { + const input = "123"; + expect(() => hex.decode(input)).toThrow(Error); + }); + + it("should throw an error for a non-hexadecimal string", () => { + const input = "zzzz"; + expect(() => hex.decode(input)).toThrow(Error); + }); + }); + + describe("round-trip tests", () => { + it("should return the original string after encoding and decoding", () => { + const input = "Hello, Hex!"; + const encoded = hex.encode(input); + const decoded = hex.decode(encoded); + expect(decoded).toBe(input); + }); + + it("should handle empty strings", () => { + const input = ""; + const encoded = hex.encode(input); + const decoded = hex.decode(encoded); + expect(decoded).toBe(input); + }); + }); }); - diff --git a/src/hex.ts b/src/hex.ts index b383b25..6ab77bb 100644 --- a/src/hex.ts +++ b/src/hex.ts @@ -1,42 +1,38 @@ import type { TypedArray } from "./type"; - const hexadecimal = "0123456789abcdef"; export const hex = { - encode: (data: - string | ArrayBuffer | TypedArray) => { - if (typeof data === "string") { - data = new TextEncoder().encode(data); - } - if (data.byteLength === 0) { - return ""; - } - const buffer = new Uint8Array(data); - let result = ""; - for (const byte of buffer) { - result += byte.toString(16).padStart(2, "0"); - } - return result; - }, - decode: (data: string - | ArrayBuffer | TypedArray - ) => { - if (!data) { - return ""; - } - if (typeof data === "string") { - if (data.length % 2 !== 0) { - throw new Error("Invalid hexadecimal string"); - } - if (!new RegExp(`^[${hexadecimal}]+$`).test(data)) { - throw new Error("Invalid hexadecimal string"); - } - const result = new Uint8Array(data.length / 2); - for (let i = 0; i < data.length; i += 2) { - result[i / 2] = parseInt(data.slice(i, i + 2), 16); - } - return new TextDecoder().decode(result); - } - return new TextDecoder().decode(data); - } -} \ No newline at end of file + encode: (data: string | ArrayBuffer | TypedArray) => { + if (typeof data === "string") { + data = new TextEncoder().encode(data); + } + if (data.byteLength === 0) { + return ""; + } + const buffer = new Uint8Array(data); + let result = ""; + for (const byte of buffer) { + result += byte.toString(16).padStart(2, "0"); + } + return result; + }, + decode: (data: string | ArrayBuffer | TypedArray) => { + if (!data) { + return ""; + } + if (typeof data === "string") { + if (data.length % 2 !== 0) { + throw new Error("Invalid hexadecimal string"); + } + if (!new RegExp(`^[${hexadecimal}]+$`).test(data)) { + throw new Error("Invalid hexadecimal string"); + } + const result = new Uint8Array(data.length / 2); + for (let i = 0; i < data.length; i += 2) { + result[i / 2] = parseInt(data.slice(i, i + 2), 16); + } + return new TextDecoder().decode(result); + } + return new TextDecoder().decode(data); + }, +}; diff --git a/src/otp.test.ts b/src/otp.test.ts index 501d47e..747f8ea 100644 --- a/src/otp.test.ts +++ b/src/otp.test.ts @@ -1,14 +1,13 @@ import { describe, it, expect, vi } from "vitest"; import { createOTP } from "./otp"; - describe("HOTP and TOTP Generation Tests", () => { it("should generate a valid HOTP for a given counter", async () => { const key = "1234567890"; const counter = 1; const digits = 6; const otp = await createOTP(key, { - digits + digits, }).hotp(counter); expect(otp).toBeTypeOf("string"); expect(otp.length).toBe(digits); @@ -20,13 +19,13 @@ describe("HOTP and TOTP Generation Tests", () => { await expect( createOTP(key, { - digits: 9 - }).hotp(counter) + digits: 9, + }).hotp(counter), ).rejects.toThrow("Digits must be between 1 and 8"); await expect( createOTP(key, { - digits: 0 - }).hotp(counter) + digits: 0, + }).hotp(counter), ).rejects.toThrow("Digits must be between 1 and 8"); }); @@ -35,7 +34,7 @@ describe("HOTP and TOTP Generation Tests", () => { const digits = 6; const otp = await createOTP(secret, { - digits + digits, }).totp(); expect(otp).toBeTypeOf("string"); expect(otp.length).toBe(digits); @@ -48,13 +47,13 @@ describe("HOTP and TOTP Generation Tests", () => { const otp1 = await createOTP(secret, { period: seconds, - digits + digits, }).totp(); vi.useFakeTimers(); await vi.advanceTimersByTimeAsync(30000); const otp2 = await createOTP(secret, { period: seconds, - digits + digits, }).totp(); expect(otp1).not.toBe(otp2); }); diff --git a/src/otp.ts b/src/otp.ts index 5b893bc..027d792 100644 --- a/src/otp.ts +++ b/src/otp.ts @@ -1,3 +1,4 @@ +import { base32 } from "./base32"; import { createHMAC } from "./hmac"; import type { SHAFamily } from "./type"; @@ -40,7 +41,7 @@ async function generateTOTP( period?: number; digits?: number; hash?: SHAFamily; - } + }, ) { const digits = options?.digits ?? defaultDigits; const period = options?.period ?? defaultPeriod; @@ -49,7 +50,6 @@ async function generateTOTP( return await generateHOTP(secret, { counter, digits, hash: options?.hash }); } - async function verifyTOTP( otp: string, { @@ -79,23 +79,21 @@ async function verifyTOTP( } /** - * Generate a QR code URL for the OTP secret - */ -function generateQRCode( - { - issuer, - account, - secret, - digits = defaultDigits, - period = defaultPeriod, - }: { - issuer: string, - account: string, - secret: string, - digits?: number, - period?: number, - } -) { + * Generate a QR code URL for the OTP secret + */ +function generateQRCode({ + issuer, + account, + secret, + digits = defaultDigits, + period = defaultPeriod, +}: { + issuer: string; + account: string; + secret: string; + digits?: number; + period?: number; +}) { const url = new URL("otpauth://totp"); url.searchParams.set("secret", secret); url.searchParams.set("issuer", issuer); @@ -110,7 +108,7 @@ export const createOTP = ( opts?: { digits?: number; period?: number; - } + }, ) => { const digits = opts?.digits ?? defaultDigits; const period = opts?.period ?? defaultPeriod; @@ -119,6 +117,7 @@ export const createOTP = ( totp: () => generateTOTP(secret, { digits, period }), verify: (otp: string, options?: { window?: number }) => verifyTOTP(otp, { secret, digits, period, ...options }), - url: (issuer: string, account: string) => generateQRCode({ issuer, account, secret, digits, period }), + url: (issuer: string, account: string) => + generateQRCode({ issuer, account, secret, digits, period }), }; -} \ No newline at end of file +}; diff --git a/src/type.ts b/src/type.ts index 8f51839..3e4eee8 100644 --- a/src/type.ts +++ b/src/type.ts @@ -11,6 +11,11 @@ export type TypedArray = | BigUint64Array; export type SHAFamily = "SHA-1" | "SHA-256" | "SHA-384" | "SHA-512"; -export type EncodingFormat = "hex" | "base64" | "base64url" | "base64urlnopad" | "none" +export type EncodingFormat = + | "hex" + | "base64" + | "base64url" + | "base64urlnopad" + | "none"; export type ECDSACurve = "P-256" | "P-384" | "P-521"; export type ExportKeyFormat = "jwk" | "spki" | "pkcs8" | "raw";