diff --git a/package.json b/package.json index 1ee1a65..0c79fd2 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,27 @@ "require": "./dist/crypto.web.cjs", "import": "./dist/crypto.web.mjs", "types": "./dist/crypto.web.d.ts" + }, + "./otp": { + "browser": "./dist/otp.mjs", + "bun": "./dist/otp.mjs", + "deno": "./dist/otp.mjs", + "edge-light": "./dist/otp.mjs", + "edge-routine": "./dist/otp.mjs", + "lagon": "./dist/otp.mjs", + "netlify": "./dist/otp.mjs", + "react-native": "./dist/otp.mjs", + "wintercg": "./dist/otp.mjs", + "worker": "./dist/otp.mjs", + "workerd": "./dist/otp.mjs", + "node": { + "require": "./dist/otp.cjs", + "import": "./dist/otp.mjs", + "types": "./dist/otp.d.ts" + }, + "require": "./dist/otp.cjs", + "import": "./dist/otp.mjs", + "types": "./dist/otp.d.ts" } }, "main": "./dist/crypto.node.cjs", @@ -56,5 +77,10 @@ "unbuild": "^1.2.1", "vitest": "^0.34.5" }, - "packageManager": "pnpm@8.8.0" -} \ No newline at end of file + "packageManager": "pnpm@8.8.0", + "unbuild": { + "externals": [ + "uncrypto" + ] + } +} diff --git a/src/otp/base32.ts b/src/otp/base32.ts new file mode 100644 index 0000000..2672d55 --- /dev/null +++ b/src/otp/base32.ts @@ -0,0 +1,151 @@ +/*! + * base-32.js + * Copyright(c) 2024 Reaper + * MIT Licensed + */ + +// Simple implementation based of RFC 4648 for base32 encoding and decoding + +const pad = "="; +const base32alphaMap = [ + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z", + "2", + "3", + "4", + "5", + "6", + "7", +]; + +export const encode = (str: string) => { + const splits = [...str]; + + if (splits.length === 0) { + return ""; + } + + const binaryGroup = []; + let bitText = ""; + + for (const c of splits) { + bitText += toBinary(c); + + if (bitText.length === 40) { + binaryGroup.push(bitText); + bitText = ""; + } + } + + if (bitText.length > 0) { + binaryGroup.push(bitText); + bitText = ""; + } + + return binaryGroup + .map((x) => { + const fiveBitGrouping = []; + let lex = ""; + const bitOn = x; + + for (const d of bitOn) { + lex += d; + if (lex.length === 5) { + fiveBitGrouping.push(lex); + lex = ""; + } + } + + if (lex.length > 0) { + fiveBitGrouping.push(lex.padEnd(5, "0")); + lex = ""; + } + + let paddedArray = [...fiveBitGrouping]; + paddedArray.length = 8; + paddedArray = paddedArray.fill("-1", fiveBitGrouping.length, 8); + + return paddedArray + .map((f) => { + if (f === "-1") { + return pad; + } + const key = Number.parseInt(f, 2).toString( + 10 + ) as unknown as keyof typeof base32alphaMap; + return base32alphaMap[key]; + }) + .join(""); + }) + .join(""); +}; + +export const decode = (str: string) => { + const overallBinary = [...str] + .map((x) => { + if (x === pad) { + return "00000"; + } + const decodePoint = base32alphaMap.indexOf(x); + const binary = decodePoint.toString(2); + return binary.padStart(5, "0"); + }) + .join(""); + + const characterBitGrouping = chunk([...overallBinary], 8); + return characterBitGrouping + .map((x) => { + const binaryL = x.join(""); + const str = String.fromCodePoint( + +Number.parseInt(binaryL, 2).toString(10) + ); + return str.replace("\u0000", ""); + }) + .join(""); +}; + +const toBinary = (char: string, padLimit = 8) => { + // need ascii values + // eslint-disable-next-line unicorn/prefer-code-point + const binary = String(char).charCodeAt(0).toString(2); + return binary.padStart(padLimit, "0"); +}; + +const chunk = ( + arr: Array, + chunkSize = 1, + cache: Array> = [] +) => { + const tmp = [...arr]; + if (chunkSize <= 0) { + return cache; + } + while (tmp.length > 0) { + cache.push(tmp.splice(0, chunkSize)); + } + return cache; +}; diff --git a/src/otp/endian.ts b/src/otp/endian.ts new file mode 100644 index 0000000..183de23 --- /dev/null +++ b/src/otp/endian.ts @@ -0,0 +1,5 @@ +export function bigEndian64(hash: bigint) { + const buf = Buffer.allocUnsafe(64 / 8); + buf.writeBigInt64BE(hash, 0); + return buf; +} diff --git a/src/otp/hmac.ts b/src/otp/hmac.ts new file mode 100644 index 0000000..f778fd4 --- /dev/null +++ b/src/otp/hmac.ts @@ -0,0 +1,37 @@ +import subtle from "uncrypto"; + +const algoMap = { + sha1: "SHA-1", + sha256: "SHA-256", + sha512: "SHA-512", +}; + +export type AlgoEnum = "sha1" | "sha256" | "sha512"; + +export async function createHmac( + algorithm: AlgoEnum, + secret: string, + data: Buffer +) { + // let enc + // if (TextEncoder.constructor.length == 1) { + // // @ts-ignore + // enc = new TextEncoder('utf-8') + // } else { + // enc = new TextEncoder() + // } + + const key = await subtle.importKey( + "raw", // raw format of the key - should be Uint8Array + secret, + { + // algorithm details + name: "HMAC", + hash: { name: algoMap[algorithm] }, + }, + false, // export = false + ["sign", "verify"] // what this key can do + ); + const signature = await subtle.sign("HMAC", key, data); + return Buffer.from(signature); +} diff --git a/src/otp/otp.ts b/src/otp/otp.ts new file mode 100644 index 0000000..3d5ddd7 --- /dev/null +++ b/src/otp/otp.ts @@ -0,0 +1,85 @@ +import { decode, encode } from "./base32.js"; +import { AlgoEnum, createHmac } from "./hmac.js"; +import { bigEndian64 } from "./endian"; +import { getRandomValues } from "uncrypto"; + +interface TOTPURLOptions { + company: string; + email: string; +} + +const { floor } = Math; + +/** + * @param {string} secret - the secret to be used, needs to be a base32 encoded string + * @param {number} when - point of time in seconds (default: Date.now()/1000) + * @param {object} [options] + * @param {number} [options.period] in seconds (eg: 30 => 30 seconds) + * @param {import("./hmac.js").AlgoEnum} [options.algorithm] (default: sha512) + * @returns {Promise} + */ +export async function totp( + secret: string, + when = floor(Date.now() / 1000), + options = {} +) { + const _options = Object.assign( + { + period: 30, + algorithm: "sha512" as AlgoEnum, + }, + options + ); + const now = floor(when / _options.period); + const key = decode(secret); + const buff = bigEndian64(BigInt(now)); + const hmac = await createHmac(_options.algorithm, key, buff); + const offset = hmac.at(-1) & 0xf; + const truncatedHash = hmac.subarray(offset, offset + 4); + const otp = ( + (truncatedHash.readInt32BE() & 0x7f_ff_ff_ff) % + 1_000_000 + ).toString(10); + return otp.length < 6 ? `${otp}`.padStart(6, "0") : otp; +} + +/** + * @param {string} secret - the secret to be used, needs to be a base32 encoded string + * @param {string} totpToken - the totp token + * @param {object} [options] + * @param {number} [options.period] in seconds (eg: 30 => 30 seconds) + * @param {import("./hmac.js").AlgoEnum} [options.algorithm] (default: sha512) + * @returns {Promise} + */ +export async function isTOTPValid( + secret: string, + totpToken: string, + options = {} +) { + const _options = Object.assign({ period: 30, algorithm: "sha512" }, options); + for (let index = -2; index < 3; index += 1) { + const fromSys = await totp(secret, Date.now() / 1000 + index, _options); + const valid = fromSys === totpToken; + if (valid) { + return true; + } + } + return false; +} + +export function generateTOTPURL(secret: string, options: TOTPURLOptions) { + const parameters = new URLSearchParams(); + parameters.append("secret", secret); + parameters.append("issuer", options.company); + parameters.append("digits", "6"); + const url = `otpauth://totp/${options.company}:${ + options.email + }?${parameters.toString()}`; + return new URL(url).toString(); +} + +export function generateTOTPSecret(num = 32) { + const array = new Uint32Array(num); + const vals = getRandomValues(array); + return encode(Buffer.from(vals).toString("ascii")); +} diff --git a/test/otp.test.ts b/test/otp.test.ts new file mode 100644 index 0000000..c11d920 --- /dev/null +++ b/test/otp.test.ts @@ -0,0 +1,27 @@ +import { expect, it, describe } from "vitest"; +import { generateTOTPSecret, isTOTPValid, totp } from "../src/otp/otp"; + +describe("uncrypto:otp", () => { + it("will generate 2 different secrets", () => { + const secret = generateTOTPSecret(); + const secret2 = generateTOTPSecret(); + expect(secret).not.eq(secret2); + }); + + it("dynamic isValid", async () => { + const secret = generateTOTPSecret(); + const period = 60; + const opts = { + period, + }; + const otp = await totp(secret, undefined, opts); + expect(await isTOTPValid(secret, otp, opts)).toBe(true); + }); + + it("static is valid", async () => { + const secret = "JFLVYRQGJ5ZFOLSYO5HVOWIZGAYHOCTEGNLE2JYMNAMTCET3A5VQ===="; + const d = 1_704_875_845_134; + const otp = await totp(secret, d / 1000); + expect(otp).is.eq("" + 881_718); + }); +});