From 0a54b9eb571e7f4a2a827154d20c02e35c9f3144 Mon Sep 17 00:00:00 2001 From: holistis Date: Sun, 21 Jun 2026 22:32:20 +0200 Subject: [PATCH] feat(otp): accept raw-byte and CryptoKey secrets in createOTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createOTP/HOTP/TOTP only accepted a string secret, run through TextEncoder (UTF-8) to derive the HMAC key. That corrupts any byte >= 0x80, so secrets from RFC 4226 / RFC 6238 libraries (Lucia, Auth.js, oslo, otplib, ...) — which treat base32 as transport for RAW key bytes — cannot be verified after migration. See better-auth/better-auth#9944. Accept string | ArrayBuffer | Uint8Array | CryptoKey for the secret: - string: unchanged (UTF-8 via TextEncoder), backwards compatible - ArrayBuffer / Uint8Array: imported directly as the raw HMAC key (no TextEncoder) - CryptoKey: used as-is (must match the OTP hash; not usable with url()) Byte detection uses ArrayBuffer.isView, never 'instanceof CryptoKey', so it does not assume a CryptoKey global (matching the lib's getWebcryptoSubtle discipline). Tests: RFC 4226 Appendix D vectors via raw bytes; a >=0x80 key matches an independent raw HMAC and differs from the latin-1 string path; ArrayBuffer parity; full otpauth-URL round-trip (base32 decode -> verify) proving the QR and sign paths agree. README: raw-byte migration example. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 14 +++++ src/otp.test.ts | 156 ++++++++++++++++++++++++++++++++++++++++++++++++ src/otp.ts | 86 +++++++++++++++++++++++--- 3 files changed, 247 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index cf541d6..9457284 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/otp.test.ts b/src/otp.test.ts index aa488c6..aea3064 100644 --- a/src/otp.test.ts +++ b/src/otp.test.ts @@ -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 { + 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 () => { @@ -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/, + ); + }); +}); diff --git a/src/otp.ts b/src/otp.ts index ef23122..011e3c0 100644 --- a/src/otp.ts +++ b/src/otp.ts @@ -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 { + 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 * @@ -19,7 +74,7 @@ function constantTimeEqualOTP(input: string, expected: string): boolean { } async function generateHOTP( - secret: string, + secret: OTPSecret, { counter, digits, @@ -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) | @@ -49,7 +105,7 @@ async function generateHOTP( } async function generateTOTP( - secret: string, + secret: OTPSecret, options?: { period?: number; digits?: number; @@ -74,7 +130,7 @@ async function verifyTOTP( period?: number; window?: number; digits?: number; - secret: string; + secret: OTPSecret; }, ) { const milliseconds = period * 1000; @@ -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, }); @@ -126,7 +194,7 @@ function generateQRCode({ } export const createOTP = ( - secret: string, + secret: OTPSecret, opts?: { digits?: number; period?: number;