Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
imcotton opened this issue Sep 28, 2024 · 5 comments
Open

Comments

@imcotton
Copy link

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
Copy link
Owner

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
Copy link
Author

imcotton commented Sep 28, 2024

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
Copy link
Author

imcotton commented Oct 7, 2024

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
Copy link
Owner

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
Copy link
Author

imcotton commented Oct 8, 2024

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants