feat(otp): accept raw-byte and CryptoKey secrets in createOTP#27
Open
holistis wants to merge 1 commit into
Open
feat(otp): accept raw-byte and CryptoKey secrets in createOTP#27holistis wants to merge 1 commit into
holistis wants to merge 1 commit into
Conversation
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) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
createOTP(and the HOTP/TOTP/verify/url it returns) currently only accepts astringsecret, which is run throughTextEncoder(UTF-8) to derive the HMAC key. That's fine for an ASCII secret, but it corrupts any byte>= 0x80(it expands into a 2-byte UTF-8 sequence), so a raw key can't be used.This adds support for passing the secret as raw bytes (
Uint8Array/ArrayBuffer) or an already-importedCryptoKey, alongside the existingstring.Why
Most authenticator libraries (Auth.js, Lucia, oslo, otplib, speakeasy, …) and the
otpauth://URI itself treat the secret as raw bytes, with base32 only as the transport encoding. Today there's no way to feed those raw bytes in — you'd have to pass the base32 string, which then gets UTF-8 encoded and produces the wrong HMAC key for roughly half of all real keys (any with a byte>= 0x80).That blocks migrating existing TOTP enrollments into anything built on
@better-auth/utils. It's the root cause behind better-auth/better-auth#9944.With this change you decode the base32 once and pass the bytes:
Notes
stringsecrets are unchanged (UTF-8), so this is fully backwards compatible.TextEncoder). The sign path and the QR /url()path both read the secret through its underlyingArrayBufferbytes, so they always agree — including for views with a non-zerobyteOffset.ArrayBuffer.isViewrather thaninstanceof CryptoKey, to avoid assuming aCryptoKeyglobal (consistent with thegetWebcryptoSubtleapproach already used here).CryptoKeyhas no extractable bytes, sourl()throws a clearTypeErrorfor that case.Tests
>= 0x80matches an independent raw HMAC computation and differs from the old latin-1 string path (the bug).otpauth://URL round-trip (build url → decode the base32 secret → verify), so the QR and sign paths can't silently drift apart.ArrayBufferparity and a multi-byte-view regression test.