Skip to content

Commit

Permalink
improve hash and otp api
Browse files Browse the repository at this point in the history
  • Loading branch information
Bekacru committed Dec 10, 2024
1 parent 8678991 commit 857f4be
Show file tree
Hide file tree
Showing 5 changed files with 103 additions and 68 deletions.
37 changes: 23 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,50 +296,59 @@ It's implemented based on [RFC 4226](https://tools.ietf.org/html/rfc4226) and [R
HOTP generates a one-time password based on a counter value and a secret key. The counter should be incremented for each new OTP.

```ts
import { generateHOTP } from "@better-auth/utils/otp";
import { createOTP } from "@better-auth/utils/otp";
const secret = "my-super-secret-key";
const counter = 1234;
const otp = generateHOTP(secret, counter);
const otp = createOTP(secret, {
digits: 6,
}).hotp(counter);
```

### Generating TOTP

TOTP generates a one-time password based on the current time and a secret key. The time step is typically 30 seconds.

```ts
import { generateTOTP } from "@better-auth/utils/otp";
import { createOTP } from "@better-auth/utils/otp";
const secret = "my-super-secret-key"
const otp = generateTOTP(secret);
const otp = createOTP(secret, {
digits: 6,
period: 30,
}).totp();
```

### Verifying TOTP

Verify a TOTP against the secret key and a specified time window. The default time window is 30 seconds.

```ts
import { verifyTOTP } from "@better-auth/utils/otp";
import { createOTP } from "@better-auth/utils/otp";
const secret = "my-super-secret-key"
const isValid = verifyTOTP(secret, otp);
const isValid = createOTP(secret, {
digits: 6,
period: 30,
}).verify(otp);
```

You can also specify the time window in seconds.

```ts
import { verifyTOTP } from "@better-auth/utils";
const isValid = verifyTOTP(secret, otp, { window: 60 });
import { createOTP } from "@better-auth/utils";
const isValid = createOTP(secret).verify(otp, { window: 60 });
```

### Generate QR Code
### Generate URL for Authenticator App

Generate a QR code URL for provisioning a TOTP secret key in an authenticator app.
Generate a URL for provisioning a TOTP secret key in an authenticator app.

- `issuer` - The name of the service or app.
- `account` - The user's email or username.

```ts
import { generateQRCode } from "@better-auth/utils/otp";
import { createOTP } from "@better-auth/utils/otp";

const secret = "my-super-secret-key";
const label = "My Account";

const qrCodeUrl = generateQRCode(secret, label);
const qrCodeUrl = createOTP(secret).url("my-app", "[email protected]");
```

## License
Expand Down
18 changes: 9 additions & 9 deletions src/hash.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,52 +7,52 @@ describe("digest", () => {

describe("SHA algorithms", () => {
it("computes SHA-256 hash in raw format", async () => {
const hash = await createHash("SHA-256")(inputString);
const hash = await createHash("SHA-256").digest(inputString);
expect(hash).toBeInstanceOf(ArrayBuffer);
});

it("computes SHA-512 hash in raw format", async () => {
const hash = await createHash("SHA-512")(inputBuffer);
const hash = await createHash("SHA-512").digest(inputBuffer);
expect(hash).toBeInstanceOf(ArrayBuffer);
});

it("computes SHA-256 hash in hex encoding", async () => {
const hash = await createHash("SHA-256", "hex")(inputString);
const hash = await createHash("SHA-256", "hex").digest(inputString);
expect(typeof hash).toBe("string");
expect(hash).toMatch(/^[a-f0-9]{64}$/);
});

it("computes SHA-512 hash in hex encoding", async () => {
const hash = await createHash("SHA-512", "hex")(inputBuffer);
const hash = await createHash("SHA-512", "hex").digest(inputBuffer);
expect(typeof hash).toBe("string");
expect(hash).toMatch(/^[a-f0-9]{128}$/);
});
});

describe("Input variations", () => {
it("handles input as a string", async () => {
const hash = await createHash("SHA-256")(inputString);
const hash = await createHash("SHA-256").digest(inputString);
expect(hash).toBeInstanceOf(ArrayBuffer);
});

it("handles input as an ArrayBuffer", async () => {
const hash = await createHash("SHA-256")(inputBuffer.buffer);
const hash = await createHash("SHA-256").digest(inputBuffer.buffer);
expect(hash).toBeInstanceOf(ArrayBuffer);
});

it("handles input as an ArrayBufferView", async () => {
const hash = await createHash("SHA-256")(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)(inputString)).rejects.toThrow();
await expect(createHash("SHA-10" as any).digest(inputString)).rejects.toThrow();
});

it("throws an error for invalid input types", async () => {
await expect(createHash("SHA-256")({} as any)).rejects.toThrow();
await expect(createHash("SHA-256").digest({} as any)).rejects.toThrow();
});
});
});
38 changes: 20 additions & 18 deletions src/hash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,28 @@ export function createHash<Encoding extends EncodingFormat = "none">(
algorithm: SHAFamily,
encoding?: Encoding,
) {
return async (input: string | ArrayBuffer | TypedArray,): Promise<Encoding extends "none" ? ArrayBuffer : string> => {
const encoder = new TextEncoder();
const data = typeof input === "string" ? encoder.encode(input) : input;
const hashBuffer = await crypto.subtle.digest(algorithm, data);
return {
digest: async (input: string | ArrayBuffer | TypedArray,): Promise<Encoding extends "none" ? ArrayBuffer : string> => {
const encoder = new TextEncoder();
const data = typeof input === "string" ? encoder.encode(input) : input;
const hashBuffer = await crypto.subtle.digest(algorithm, data);

if (encoding === "hex") {
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return hashHex as any;
}
if (encoding === "hex") {
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return hashHex as any;
}

if (encoding === "base64" || encoding === "base64url" || encoding === "base64urlnopad") {
const hashBase64 = base64.encode(hashBuffer, {
urlSafe: encoding !== "base64",
padding: encoding !== "base64urlnopad",
});
return hashBase64 as any;
if (encoding === "base64" || encoding === "base64url" || encoding === "base64urlnopad") {
const hashBase64 = base64.encode(hashBuffer, {
urlSafe: encoding !== "base64",
padding: encoding !== "base64urlnopad",
});
return hashBase64 as any;
}
return hashBuffer as any;
}
return hashBuffer as any;
}
}
52 changes: 29 additions & 23 deletions src/otp.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { describe, it, expect, vi } from "vitest";
import { generateHOTP, generateTOTP, verifyTOTP } from "./otp";
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 generateHOTP(key, {
counter,
});
const otp = await createOTP(key, {
digits
}).hotp(counter);
expect(otp).toBeTypeOf("string");
expect(otp.length).toBe(digits);
});
Expand All @@ -19,24 +19,24 @@ describe("HOTP and TOTP Generation Tests", () => {
const counter = 1;

await expect(
generateHOTP(key, {
counter,
digits: 9,
}),
createOTP(key, {
digits: 9
}).hotp(counter)
).rejects.toThrow("Digits must be between 1 and 8");
await expect(
generateHOTP(key, {
counter,
digits: 0,
}),
createOTP(key, {
digits: 0
}).hotp(counter)
).rejects.toThrow("Digits must be between 1 and 8");
});

it("should generate a valid TOTP based on current time", async () => {
const secret = "1234567890";
const digits = 6;

const otp = await generateTOTP(secret, { digits });
const otp = await createOTP(secret, {
digits
}).totp();
expect(otp).toBeTypeOf("string");
expect(otp.length).toBe(digits);
});
Expand All @@ -46,40 +46,46 @@ describe("HOTP and TOTP Generation Tests", () => {
const seconds = 30;
const digits = 6;

const otp1 = await generateTOTP(secret, { digits, period: seconds });
const otp1 = await createOTP(secret, {
period: seconds,
digits
}).totp();
vi.useFakeTimers();
await vi.advanceTimersByTimeAsync(30000);
const otp2 = await generateTOTP(secret, { digits });
const otp2 = await createOTP(secret, {
period: seconds,
digits
}).totp();
expect(otp1).not.toBe(otp2);
});

it("should verify correct TOTP against generated value", async () => {
const secret = "1234567890";
const totp = await generateTOTP(secret, { digits: 6 });
const isValid = await verifyTOTP(totp, { secret });
const totp = await createOTP(secret).totp();
const isValid = await createOTP(secret).verify(totp);
expect(isValid).toBe(true);
});

it("should return false for incorrect TOTP", async () => {
const secret = "1234567890";
const invalidTOTP = "000000";

const isValid = await verifyTOTP(invalidTOTP, { secret });
const isValid = await createOTP(secret).verify(invalidTOTP);
console.log(isValid);
expect(isValid).toBe(false);
});

it("should verify TOTP within the window", async () => {
const secret = "1234567890";
const totp = await generateTOTP(secret, { digits: 6 });
const isValid = await verifyTOTP(totp, { secret, window: 1 });
const totp = await createOTP(secret).totp();
const isValid = await createOTP(secret).verify(totp, { window: 1 });
expect(isValid).toBe(true);
});

it("should return false for TOTP outside the window", async () => {
const secret = "1234567890";
const totp = await generateTOTP(secret, { digits: 6 });
const isValid = await verifyTOTP(totp, { secret, window: -1 });
const totp = await createOTP(secret).totp();
const isValid = await createOTP(secret).verify(totp, { window: -1 });
expect(isValid).toBe(false);
});
});
26 changes: 22 additions & 4 deletions src/otp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { SHAFamily } from "./type";
const defaultPeriod = 30;
const defaultDigits = 6;

export async function generateHOTP(
async function generateHOTP(
secret: string,
{
counter,
Expand Down Expand Up @@ -34,7 +34,7 @@ export async function generateHOTP(
return otp.toString().padStart(_digits, "0");
}

export async function generateTOTP(
async function generateTOTP(
secret: string,
options?: {
period?: number;
Expand All @@ -50,7 +50,7 @@ export async function generateTOTP(
}


export async function verifyTOTP(
async function verifyTOTP(
otp: string,
{
window = 1,
Expand Down Expand Up @@ -81,7 +81,7 @@ export async function verifyTOTP(
/**
* Generate a QR code URL for the OTP secret
*/
export function generateQRCode(
function generateQRCode(
{
issuer,
account,
Expand All @@ -103,4 +103,22 @@ export function generateQRCode(
url.searchParams.set("digits", digits.toString());
url.searchParams.set("period", period.toString());
return url.toString();
}

export const createOTP = (
secret: string,
opts?: {
digits?: number;
period?: number;
}
) => {
const digits = opts?.digits ?? defaultDigits;
const period = opts?.period ?? defaultPeriod;
return {
hotp: (counter: number) => generateHOTP(secret, { counter, digits }),
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 }),
};
}

0 comments on commit 857f4be

Please sign in to comment.