Open imcotton opened 1 month ago
Thus letting end user to adjust their seed format as needed, instead of jumping in-between of base32 padding / non-padding as currently.
Could you provide a snippet of what you'd like the API to look like ?
Currently there are a lot of conversions indeed, but it's mostly because it's expected that users would want to work with strings (either to store in db, to generate the qr code, to display to user, etc.)
I'd like to visualise your use-case better, I confess this lib was mostly done for 2FA with an authenticator app but maybe there are others usage for it that the current api make impractical ?
Btw, this feature suggestion is breaking change as semver major, but I think it'd worthwhile.
It's ok the versioning process is entirely automated and I don't mind increasing major anyways
Please checkout the draft in diff below:
diff --git a/crypto/totp.ts b/crypto/totp.ts
index e9f6f5a..6742c75 100644
--- a/crypto/totp.ts
+++ b/crypto/totp.ts
@@ -12,11 +12,11 @@
*
* @example
* ```ts
- * import { otpauth, otpsecret, verify } from "./totp.ts"
+ * import { otpauth, otpsecret, verify, readBase32 } from "./totp.ts"
* import { qrcode } from "jsr:@libs/qrcode"
*
* // Issue a new TOTP secret
- * const secret = otpsecret()
+ * const secret = readBase32(otpsecret())
* const url = otpauth({ issuer: "example.com", account: "alice", secret })
* console.log(`Please scan the following QR Code:`)
* qrcode(url.href, { output: "console" })
@@ -43,10 +43,10 @@ import { decodeBase32, encodeBase32 } from "@std/encoding/base32"
/**
* Returns a HMAC-based OTP.
*/
-async function htop(secret: string, counter: bigint): Promise<string> {
+async function htop(secret: BufferSource, counter: bigint): Promise<string> {
const buffer = new DataView(new ArrayBuffer(8))
buffer.setBigUint64(0, counter, false)
- const key = await crypto.subtle.importKey("raw", decodeBase32(`${secret}${"=".repeat((8 - (secret.length % 8)) % 8)}`), { name: "HMAC", hash: "SHA-1" }, false, ["sign"])
+ const key = await crypto.subtle.importKey("raw", secret, { name: "HMAC", hash: "SHA-1" }, false, ["sign"])
const hmac = new Uint8Array(await crypto.subtle.sign("HMAC", key, buffer))
const offset = hmac[hmac.length - 1] & 0xf
const code = (hmac[offset] & 0x7f) << 24 | (hmac[offset + 1] & 0xff) << 16 | (hmac[offset + 2] & 0xff) << 8 | (hmac[offset + 3] & 0xff)
@@ -58,12 +58,12 @@ async function htop(secret: string, counter: bigint): Promise<string> {
*
* @example
* ```ts
- * import { totp, otpsecret } from "./totp.ts"
- * const secret = otpsecret()
+ * import { totp, otpsecret, readBase32 } from "./totp.ts"
+ * const secret = readBase32(otpsecret())
* console.log(totp(secret, { t: Date.now() }))
* ```
*/
-export async function totp(secret: string, { t = Date.now(), dt = 0 } = {}): Promise<string> {
+export async function totp(secret: BufferSource, { t = Date.now(), dt = 0 } = {}): Promise<string> {
return await htop(secret, BigInt(Math.floor(t / 1000 / 30) + dt))
}
@@ -78,7 +78,18 @@ export async function totp(secret: string, { t = Date.now(), dt = 0 } = {}): Pro
* ```
*/
export function otpsecret(length = 20): string {
- return encodeBase32(crypto.getRandomValues(new Uint8Array(length))).replaceAll("=", "")
+ return encodeBase32NoPadding(crypto.getRandomValues(new Uint8Array(length)))
+}
+
+export function encodeBase32NoPadding(source: BufferSource): string {
+ const data = ArrayBuffer.isView(source) ? source.buffer : new Uint8Array(source)
+ return encodeBase32(data).replaceAll("=", "")
+}
+
+export function readBase32(source: string): Uint8Array {
+ const left = source.length % 8
+ const full = left <= 0 ? source : source.concat("=".repeat(8 - left))
+ return decodeBase32(full)
}
/**
@@ -93,12 +104,13 @@ export function otpsecret(length = 20): string {
* qrcode(url.href, { output: "console" })
* ```
*/
-export function otpauth({ issuer, account, secret = otpsecret(), image }: { issuer: string; account: string; secret?: string; image?: string }): URL {
+export function otpauth({ issuer, account, secret, image }: { issuer: string; account: string; secret?: BufferSource; image?: string }): URL {
if ((issuer.includes(":")) || (account.includes(":"))) {
throw new RangeError("Label may not contain a colon character")
}
+ const base32 = secret ? encodeBase32NoPadding(secret) : otpsecret()
const label = encodeURIComponent(`${issuer}:${account}`)
- const params = new URLSearchParams({ secret, issuer, algorithm: "SHA1", digits: "6", period: "30" })
+ const params = new URLSearchParams({ secret: base32, issuer, algorithm: "SHA1", digits: "6", period: "30" })
if (image) {
params.set("image", image)
}
@@ -111,12 +123,12 @@ export function otpauth({ issuer, account, secret = otpsecret(), image }: { issu
*
* @example
* ```ts
- * import { verify } from "./totp.ts"
- * console.assert(await verify({ secret: "JBSWY3DPEHPK3PXP", token: 152125, t: 1708671725109 }))
- * console.assert(!await verify({ secret: "JBSWY3DPEHPK3PXP", token: 0, t: 1708671725109 }))
+ * import { verify, readBase32 } from "./totp.ts"
+ * console.assert(await verify({ secret: readBase32("JBSWY3DPEHPK3PXP"), token: 152125, t: 1708671725109 }))
+ * console.assert(!await verify({ secret: readBase32("JBSWY3DPEHPK3PXP"), token: 0, t: 1708671725109 }))
* ```
*/
-export async function verify({ secret, token, t = Date.now(), tolerance = 1 }: { secret: string; token: string | number; t?: number; tolerance?: number }): Promise<boolean> {
+export async function verify({ secret, token, t = Date.now(), tolerance = 1 }: { secret: BufferSource; token: string | number; t?: number; tolerance?: number }): Promise<boolean> {
for (let dt = -tolerance; dt <= tolerance; dt++) {
if (Number(await totp(secret, { t, dt })) === Number(token)) {
return true
User now need extra help readBase32
to supply the secret
, but this way they're free to use UUID
or sha256(seed)
as needed.
Hey, shall I close the ticket if it's not ideal to your current aiming? I don't want to put too much of maintaining labor to you since I can get around for my own need here.
It's fine to leave it open, no worries
I'm working on other projects right now but I'll eventually take a look at it when I have some spare time
Got it, this is low priority to me, nothing urgent as well.
Thus letting end user to adjust their seed format as needed, instead of jumping in-between of base32 padding / non-padding as currently.
BufferSource
as input type ofcrypto.subtle.importKey("raw", _)
, which is both OK forUint8Array
orArrayBuffer
.Btw, this feature suggestion is breaking change as semver major, but I think it'd worthwhile.