lowlighter / libs

🍱 Collection of carefully crafted TypeScript standalone libraries. Minimal, unbloated, convenient.
https://jsr.io/@libs
MIT License
122 stars 11 forks source link

feat(crypto): h/totp to accept `secret` in type of `BufferSource` only. #81

Open imcotton opened 1 month ago

imcotton commented 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.

BufferSource as input type of crypto.subtle.importKey("raw", _), which is both OK for Uint8Array or ArrayBuffer.

Btw, this feature suggestion is breaking change as semver major, but I think it'd worthwhile.

lowlighter commented 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

imcotton commented 1 month ago

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.

imcotton commented 1 month ago

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.

lowlighter commented 1 month ago

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

imcotton commented 1 month ago

Got it, this is low priority to me, nothing urgent as well.