Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,20 @@ const qrCodeUrl = createOTP(secret).url("my-app", "user@email.com");
```


### Using a raw-byte secret (RFC 6238 migration)

`createOTP` also accepts the secret as raw bytes (`Uint8Array` / `ArrayBuffer`) or a `CryptoKey`, not just a string. Most authenticator libraries (and `otpauth://` URIs) treat the secret as **raw bytes**, with base32 only as the transport encoding. To verify or generate codes for a secret created by such a library, decode the base32 once and pass the bytes:

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

// `otpSecret` is the base32 string from the other library / otpauth:// URI
const isValid = createOTP(base32.decode(otpSecret)).verify(code);
```

Passing a `string` keeps the previous behaviour (it is encoded as UTF-8 before the key is derived).

## Base64

Base64 utilities provide a simple interface to encode and decode data in base64 format.
Expand Down
156 changes: 156 additions & 0 deletions src/otp.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,37 @@
import { describe, it, expect, vi } from "vitest";
import { createOTP } from "./otp";
import { base32 } from "./base32";

/**
* Independent HOTP reference: imports the key as RAW HMAC-SHA1 bytes (the RFC way)
* and applies the standard dynamic truncation. A passing comparison proves createOTP
* derives its key from the raw bytes too — not via TextEncoder.
*/
async function referenceHOTP(
keyBytes: Uint8Array,
counter: number,
digits = 6,
): Promise<string> {
const key = await globalThis.crypto.subtle.importKey(
"raw",
keyBytes,
{ name: "HMAC", hash: "SHA-1" },
false,
["sign"],
);
const buf = new ArrayBuffer(8);
new DataView(buf).setBigUint64(0, BigInt(counter), false);
const sig = new Uint8Array(
await globalThis.crypto.subtle.sign("HMAC", key, buf),
);
const offset = sig[sig.length - 1] & 0x0f;
const truncated =
((sig[offset] & 0x7f) << 24) |
((sig[offset + 1] & 0xff) << 16) |
((sig[offset + 2] & 0xff) << 8) |
(sig[offset + 3] & 0xff);
return (truncated % 10 ** digits).toString().padStart(digits, "0");
}

describe("HOTP and TOTP Generation Tests", () => {
it("should generate a valid HOTP for a given counter", async () => {
Expand Down Expand Up @@ -125,3 +157,127 @@ describe("HOTP and TOTP Generation Tests", () => {
expect(url).toContain("otpauth://totp");
});
});

describe("OTP raw-byte secret support (RFC 6238 migration)", () => {
// RFC 4226 Appendix D test vectors. Secret is the ASCII string "12345678901234567890".
const RFC4226_HOTP = [
"755224",
"287082",
"359152",
"969429",
"338314",
"254676",
"287922",
"162583",
"399871",
"520489",
];

it("matches the RFC 4226 HOTP test vectors when the secret is raw bytes", async () => {
const keyBytes = new TextEncoder().encode("12345678901234567890");
for (let counter = 0; counter < RFC4226_HOTP.length; counter++) {
const otp = await createOTP(keyBytes, { digits: 6 }).hotp(counter);
expect(otp).toBe(RFC4226_HOTP[counter]);
}
});

it("derives the key from raw bytes, not via TextEncoder (handles bytes >= 0x80)", async () => {
// A realistic 20-byte key: roughly half the bytes are >= 0x80.
const keyBytes = new Uint8Array(20);
for (let i = 0; i < keyBytes.length; i++) {
keyBytes[i] = (i * 17 + 0x80) & 0xff;
}

// Raw-byte path must match an independent raw HMAC computation.
const reference = await referenceHOTP(keyBytes, 1);
expect(await createOTP(keyBytes, { digits: 6 }).hotp(1)).toBe(reference);

// The old latin-1-string path runs through UTF-8 TextEncoder, which corrupts
// bytes >= 0x80 — so it must produce a different (wrong) result. This is the bug.
const latin1 = String.fromCharCode(...keyBytes);
expect(await createOTP(latin1, { digits: 6 }).hotp(1)).not.toBe(reference);
});

it("generates and verifies a TOTP round-trip with a raw-byte secret", async () => {
const keyBytes = new Uint8Array(20);
for (let i = 0; i < keyBytes.length; i++) {
keyBytes[i] = (i * 13 + 200) & 0xff;
}
const totp = await createOTP(keyBytes).totp();
expect(await createOTP(keyBytes).verify(totp)).toBe(true);
});

it("treats a raw ArrayBuffer identically to a Uint8Array", async () => {
const keyBytes = new Uint8Array(20);
for (let i = 0; i < keyBytes.length; i++) {
keyBytes[i] = (i * 11 + 130) & 0xff;
}
expect(await createOTP(keyBytes.buffer).hotp(3)).toBe(
await createOTP(keyBytes).hotp(3),
);
});

it("round-trips a raw-byte secret through the otpauth URL (QR and sign agree)", async () => {
const keyBytes = new Uint8Array(20);
for (let i = 0; i < keyBytes.length; i++) {
keyBytes[i] = (i * 19 + 0x88) & 0xff;
}
// Pull the base32 secret out of the QR URL and decode it back to bytes, exactly as
// an authenticator app would, then confirm the resulting code verifies. Guards the
// QR path and the sign path against keying on different bytes.
const url = createOTP(keyBytes).url("my-site.com", "account");
const secretParam = new URL(url).searchParams.get("secret");
expect(secretParam).toBe(base32.encode(keyBytes, { padding: false }));
const decoded = base32.decode(secretParam ?? "");
const code = await createOTP(decoded).totp();
expect(await createOTP(keyBytes).verify(code)).toBe(true);
});

it("keys consistently on a multi-byte TypedArray view (sign and QR agree)", async () => {
// Elements > 255: if one path read raw bytes and the other truncated per element,
// the QR a user scans would never verify. Both must read the same underlying bytes.
const view = new Uint16Array([0x0141, 0x0242, 0x0343, 0x0444, 0x0545]);
const url = createOTP(view).url("my-site.com", "account");
const secretParam = new URL(url).searchParams.get("secret");
const decoded = base32.decode(secretParam ?? "");
const code = await createOTP(decoded).totp();
expect(await createOTP(view).verify(code)).toBe(true);
});

it("accepts an already-imported CryptoKey and matches the raw-byte result", async () => {
const keyBytes = new Uint8Array(20);
for (let i = 0; i < keyBytes.length; i++) {
keyBytes[i] = (i * 7 + 0x90) & 0xff;
}
const cryptoKey = await globalThis.crypto.subtle.importKey(
"raw",
keyBytes,
{ name: "HMAC", hash: "SHA-1" },
false,
["sign"],
);
expect(await createOTP(cryptoKey).hotp(5)).toBe(
await createOTP(keyBytes).hotp(5),
);
});

it("builds an otpauth URL from raw bytes and rejects a CryptoKey secret", async () => {
const keyBytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
const url = createOTP(keyBytes).url("my-site.com", "account");
expect(url).toContain("otpauth://totp");
expect(url).toContain(
`secret=${base32.encode(keyBytes, { padding: false })}`,
);

const cryptoKey = await globalThis.crypto.subtle.importKey(
"raw",
keyBytes,
{ name: "HMAC", hash: "SHA-1" },
false,
["sign"],
);
expect(() => createOTP(cryptoKey).url("my-site.com", "account")).toThrow(
/CryptoKey/,
);
});
});
86 changes: 77 additions & 9 deletions src/otp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,61 @@ import type { SHAFamily } from "./type";
const defaultPeriod = 30;
const defaultDigits = 6;

/**
* The secret used to derive the HMAC key for HOTP/TOTP.
*
* - `string`: encoded as UTF-8 bytes (default, backwards compatible).
* - `ArrayBuffer` / `Uint8Array`: used directly as the raw HMAC key bytes. This matches
* the RFC 4226 / RFC 6238 convention used by most authenticator libraries, where the
* secret is raw bytes and base32 is only the transport encoding. Decode the base32
* secret once and pass the bytes here so secrets migrated from those libraries verify
* correctly.
* - `CryptoKey`: an already-imported HMAC key, used as-is. Its algorithm must match the
* OTP hash (SHA-1), otherwise the generated code silently differs. A `CryptoKey` has no
* extractable bytes, so it cannot be used with `url()`.
*/
export type OTPSecret = string | ArrayBuffer | Uint8Array | CryptoKey;

/**
* Normalises any byte source to a `Uint8Array` over its EXACT underlying bytes.
*
* Both the HMAC key derivation and the base32 transport (QR URL) must read the same
* bytes. A `TypedArray` whose elements are wider than one byte (e.g. `Uint16Array`) is
* structurally assignable to `Uint8Array` in TypeScript, so the type alone can't keep it
* out — and the two paths would otherwise disagree (raw buffer bytes vs element-wise
* truncation), silently locking the user out. Going through the underlying buffer makes
* both paths agree for every view, including `subarray` views with a non-zero offset.
*/
function toBytes(secret: ArrayBuffer | ArrayBufferView): Uint8Array {
return secret instanceof ArrayBuffer
? new Uint8Array(secret)
: new Uint8Array(secret.buffer, secret.byteOffset, secret.byteLength);
}

/**
* Resolves an {@link OTPSecret} into a value that `createHMAC().sign` accepts.
*
* A `string` is passed through so `sign` encodes it as UTF-8 (unchanged behaviour).
* Raw bytes are imported as the HMAC key directly — crucially WITHOUT going through
* `TextEncoder`, which would expand any byte >= 0x80 into a 2-byte UTF-8 sequence and
* produce the wrong key. A `CryptoKey` is returned untouched.
*
* Bytes are detected with `ArrayBuffer.isView` rather than `instanceof CryptoKey`, so this
* never dereferences the `CryptoKey` global (which is not bound in every runtime).
*/
async function resolveHmacKey(
secret: OTPSecret,
hash: SHAFamily,
): Promise<string | CryptoKey> {
if (typeof secret === "string") {
return secret;
}
if (secret instanceof ArrayBuffer || ArrayBuffer.isView(secret)) {
return createHMAC(hash).importKey(toBytes(secret), "sign");
}
return secret;
}

/**
* loops over `expected.length` so timing never depends on input length
*
Expand All @@ -19,7 +74,7 @@ function constantTimeEqualOTP(input: string, expected: string): boolean {
}

async function generateHOTP(
secret: string,
secret: OTPSecret,
{
counter,
digits,
Expand All @@ -37,7 +92,8 @@ async function generateHOTP(
const buffer = new ArrayBuffer(8);
new DataView(buffer).setBigUint64(0, BigInt(counter), false);
const bytes = new Uint8Array(buffer);
const hmacResult = new Uint8Array(await createHMAC(hash).sign(secret, bytes));
const key = await resolveHmacKey(secret, hash);
const hmacResult = new Uint8Array(await createHMAC(hash).sign(key, bytes));
const offset = hmacResult[hmacResult.length - 1] & 0x0f;
const truncated =
((hmacResult[offset] & 0x7f) << 24) |
Expand All @@ -49,7 +105,7 @@ async function generateHOTP(
}

async function generateTOTP(
secret: string,
secret: OTPSecret,
options?: {
period?: number;
digits?: number;
Expand All @@ -74,7 +130,7 @@ async function verifyTOTP(
period?: number;
window?: number;
digits?: number;
secret: string;
secret: OTPSecret;
},
) {
const milliseconds = period * 1000;
Expand Down Expand Up @@ -102,17 +158,29 @@ function generateQRCode({
}: {
issuer: string;
account: string;
secret: string;
secret: OTPSecret;
digits?: number;
period?: number;
}) {
if (
typeof secret !== "string" &&
!(secret instanceof ArrayBuffer) &&
!ArrayBuffer.isView(secret)
) {
throw new TypeError(
"Cannot build an otpauth:// URL from a CryptoKey secret; pass the raw secret bytes or a string instead.",
);
}
const encodedIssuer = encodeURIComponent(issuer);
const encodedAccountName = encodeURIComponent(account);
const baseURI = `otpauth://totp/${encodedIssuer}:${encodedAccountName}`;
const params = new URLSearchParams({
secret: base32.encode(secret, {
padding: false,
}),
secret: base32.encode(
typeof secret === "string" ? secret : toBytes(secret),
{
padding: false,
},
),
issuer,
});

Expand All @@ -126,7 +194,7 @@ function generateQRCode({
}

export const createOTP = (
secret: string,
secret: OTPSecret,
opts?: {
digits?: number;
period?: number;
Expand Down