diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 81010bce..c9e89555 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -29,6 +29,7 @@ module.exports = { { js: 'never', ts: 'never', + tsx: 'never', }, ], }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 35c1b243..c0d5a8e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,50 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +## [2.12.0](https://github.com/CorentinTh/it-tools/compare/v2.10.3...v2.12.0) (2022-08-23) + + +### Features + +* added colored share card ([ab7483b](https://github.com/CorentinTh/it-tools/commit/ab7483b5c2bd5aee1b8b609597c22b7b7b55606d)) +* **config:** added tsx to allowed extension ([741a3c2](https://github.com/CorentinTh/it-tools/commit/741a3c25a915d8296987b23bda03f2b664d51ba6)) +* **new-tool:** added otp generator ([cc6070a](https://github.com/CorentinTh/it-tools/commit/cc6070a16655bce9de90517bdda3bf6224ba139d)) +* **new-tool:** meta tag generator ([164e32b](https://github.com/CorentinTh/it-tools/commit/164e32b4428b8dfaaddcefa06b767a8af94573a9)) + + +### Bug Fixes + +* **deps:** added missing optional deps ([4975590](https://github.com/CorentinTh/it-tools/commit/49755909bdaea9399e51b67fbd1a6d071acd3182)) +* removed colored card border ([7c449f4](https://github.com/CorentinTh/it-tools/commit/7c449f4f2d491ce58726c5419a74dc295fa92905)) + + +### Refactors + +* **colored-card:** added transition on like hover ([da17696](https://github.com/CorentinTh/it-tools/commit/da17696293270005b1b7ec4aafc0df7496f602c7)) +* **share:** updated share meta ([5222bd5](https://github.com/CorentinTh/it-tools/commit/5222bd5d04ad089ba4cbade399dada55e29dcde5)) +* token generator can use a custom alphabet ([59ec629](https://github.com/CorentinTh/it-tools/commit/59ec6293b65526fe8dc527ac596d0e5af29b1e32)) +* **useQRCode:** switched args to MaybeRef ([a89c9be](https://github.com/CorentinTh/it-tools/commit/a89c9bea42d598f4caba10800becd66a07bbcdc9)) + +## [2.11.0](https://github.com/CorentinTh/it-tools/compare/v2.10.3...v2.11.0) (2022-08-19) + + +### Features + +* added colored share card ([ab7483b](https://github.com/CorentinTh/it-tools/commit/ab7483b5c2bd5aee1b8b609597c22b7b7b55606d)) +* **new-tool:** meta tag generator ([164e32b](https://github.com/CorentinTh/it-tools/commit/164e32b4428b8dfaaddcefa06b767a8af94573a9)) + + +### Bug Fixes + +* **deps:** added missing optional deps ([4975590](https://github.com/CorentinTh/it-tools/commit/49755909bdaea9399e51b67fbd1a6d071acd3182)) +* removed colored card border ([7c449f4](https://github.com/CorentinTh/it-tools/commit/7c449f4f2d491ce58726c5419a74dc295fa92905)) + + +### Refactors + +* **colored-card:** added transition on like hover ([da17696](https://github.com/CorentinTh/it-tools/commit/da17696293270005b1b7ec4aafc0df7496f602c7)) +* **share:** updated share meta ([5222bd5](https://github.com/CorentinTh/it-tools/commit/5222bd5d04ad089ba4cbade399dada55e29dcde5)) + ### [2.10.3](https://github.com/CorentinTh/it-tools/compare/v2.10.2...v2.10.3) (2022-08-14) diff --git a/package.json b/package.json index efe9bea1..8ca37552 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "it-tools", - "version": "2.10.3", + "version": "2.12.0", "description": "Collection of handy online tools for developers, with great UX. ", "keywords": [ "productivity", diff --git a/src/tools/index.ts b/src/tools/index.ts index 80f1618c..e9377e49 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,6 +1,7 @@ import { LockOpen } from '@vicons/tabler'; import type { ToolCategory } from './tool'; +import { tool as otpCodeGeneratorAndValidator } from './otp-code-generator-and-validator'; import { tool as base64FileConverter } from './base64-file-converter'; import { tool as base64StringConverter } from './base64-string-converter'; import { tool as basicAuthGenerator } from './basic-auth-generator'; @@ -56,7 +57,15 @@ export const toolsByCategory: ToolCategory[] = [ { name: 'Web', icon: LockOpen, - components: [urlEncoder, htmlEntities, urlParser, deviceInformation, basicAuthGenerator, metaTagGenerator], + components: [ + urlEncoder, + htmlEntities, + urlParser, + deviceInformation, + basicAuthGenerator, + metaTagGenerator, + otpCodeGeneratorAndValidator, + ], }, { name: 'Images', diff --git a/src/tools/otp-code-generator-and-validator/index.ts b/src/tools/otp-code-generator-and-validator/index.ts new file mode 100644 index 00000000..6ce2d3f4 --- /dev/null +++ b/src/tools/otp-code-generator-and-validator/index.ts @@ -0,0 +1,27 @@ +import { DeviceMobile } from '@vicons/tabler'; +import { defineTool } from '../tool'; + +export const tool = defineTool({ + name: 'OTP code generator', + path: '/otp-code-generator-and-validator', + description: 'Generate and validate time-based OTP (one time password) for multi-factor authentication.', + keywords: [ + 'otp', + 'code', + 'generator', + 'validator', + 'one', + 'time', + 'password', + 'authentication', + 'MFA', + 'mobile', + 'device', + 'security', + 'TOTP', + 'Time', + 'HMAC', + ], + component: () => import('./otp-code-generator-and-validator.vue'), + icon: DeviceMobile, +}); diff --git a/src/tools/otp-code-generator-and-validator/otp-code-generator-and-validator.vue b/src/tools/otp-code-generator-and-validator/otp-code-generator-and-validator.vue new file mode 100644 index 00000000..3521c236 --- /dev/null +++ b/src/tools/otp-code-generator-and-validator/otp-code-generator-and-validator.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/src/tools/otp-code-generator-and-validator/otp.service.test.ts b/src/tools/otp-code-generator-and-validator/otp.service.test.ts new file mode 100644 index 00000000..f2f5449a --- /dev/null +++ b/src/tools/otp-code-generator-and-validator/otp.service.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; +import { + generateHOTP, + hexToBytes, + verifyHOTP, + generateTOTP, + verifyTOTP, + buildKeyUri, + base32toHex, +} from './otp.service'; + +describe('otp functions', () => { + describe('hexToBytes', () => { + it('convert an hexstring to a byte array', () => { + expect(hexToBytes('1')).to.eql([1]); + expect(hexToBytes('ffffff')).to.eql([255, 255, 255]); + expect(hexToBytes('000000000')).to.eql([0, 0, 0, 0, 0]); + expect(hexToBytes('a3218bcef89')).to.eql([163, 33, 139, 206, 248, 9]); + expect(hexToBytes('063679ca')).toEqual([6, 54, 121, 202]); + expect(hexToBytes('0102030405060708090a0b0c0d0e0f')).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]); + }); + }); + describe('base32tohex', () => { + it('convert a base32 to hex string', () => { + expect(base32toHex('ABCDEF')).to.eql('00443205'); + expect(base32toHex('7777')).to.eql('ffff0f'); + expect(base32toHex('JBSWY3DPEHPK3PXP')).to.eql('48656c6c6f21deadbeef'); + }); + }); + + describe('generateHOTP', () => { + it('generates HOTP codes for a given counter', () => { + const key = 'JBSWY3DPEHPK3PXP'; + const hotpCodes = ['282760', '996554', '602287', '143627', '960129']; + + for (const [counter, code] of hotpCodes.entries()) { + expect(generateHOTP({ key, counter })).to.eql(code); + } + }); + }); + + describe('verifyHOTP', () => { + it('validate hotp for a given secret', () => { + const key = 'JBSWY3DPEHPK3PXP'; + const hotpCodes = ['282760', '996554', '602287', '143627', '960129']; + + for (const [counter, token] of hotpCodes.entries()) { + expect(verifyHOTP({ token, key, counter, window: 0 })).to.eql(true); + } + + expect(verifyHOTP({ token: 'INVALID', key })).to.eql(false); + }); + + it('does not validate hotp out of sync', () => { + const key = 'JBSWY3DPEHPK3PXP'; + const token = '282760'; + + expect(verifyHOTP({ token, key, counter: 5, window: 2 })).to.eql(false); + expect(verifyHOTP({ token, key, counter: 5, window: 5 })).to.eql(true); + }); + }); + + describe('generateTOTP', () => { + it('generates TOTP codes', () => { + const key = 'JBSWY3DPEHPK3PXP'; + + const codes = [ + { token: '282760', now: 0 }, + { token: '341128', now: 1465324707000 }, + { token: '089029', now: 1365324707000 }, + ]; + + for (const { token, now } of codes) { + expect(generateTOTP({ key, now })).to.eql(token); + } + }); + }); + + describe('verifyTOTP', () => { + it('verify TOTP in sync codes against a key', () => { + const key = 'JBSWY3DPEHPK3PXP'; + + const codes = [ + { token: '282760', now: 0 }, + { token: '341128', now: 1465324707000 }, + { token: '089029', now: 1365324707000 }, + ]; + + for (const { token, now } of codes) { + expect(verifyTOTP({ key, token, now })).to.eql(true); + } + }); + + it('does not validate totp out of sync', () => { + const key = 'JBSWY3DPEHPK3PXP'; + const token = '635183'; + const now = 1661266455000; + + expect(verifyTOTP({ key, token, now, window: 2 })).to.eql(true); + expect(verifyTOTP({ key, token, now, window: 1 })).to.eql(false); + }); + }); + + describe('buildKeyUri', () => { + it('build a key uri string', () => { + expect(buildKeyUri({ secret: 'JBSWY3DPEHPK3PXP' })).to.eql( + 'otpauth://totp/IT-Tools:demo-user?issuer=IT-Tools&secret=JBSWY3DPEHPK3PXP&algorithm=SHA1&digits=6&period=30', + ); + + expect( + buildKeyUri({ + secret: 'JBSWY3DPEHPK3PXP', + app: 'app-name', + account: 'account', + algorithm: 'algo', + digits: 7, + period: 10, + }), + ).to.eql( + 'otpauth://totp/app-name:account?issuer=app-name&secret=JBSWY3DPEHPK3PXP&algorithm=algo&digits=7&period=10', + ); + }); + }); +}); diff --git a/src/tools/otp-code-generator-and-validator/otp.service.ts b/src/tools/otp-code-generator-and-validator/otp.service.ts new file mode 100644 index 00000000..7b8c49ad --- /dev/null +++ b/src/tools/otp-code-generator-and-validator/otp.service.ts @@ -0,0 +1,139 @@ +import { enc, HmacSHA1 } from 'crypto-js'; +import _ from 'lodash'; +import { createToken } from '../token-generator/token-generator.service'; + +export { + generateHOTP, + hexToBytes, + verifyHOTP, + generateTOTP, + verifyTOTP, + buildKeyUri, + generateSecret, + base32toHex, + getCounterFromTime, +}; + +function hexToBytes(hex: string) { + return (hex.match(/.{1,2}/g) ?? []).map((char) => parseInt(char, 16)); +} + +function computeHMACSha1(message: string, key: string) { + return HmacSHA1(enc.Hex.parse(message), enc.Hex.parse(base32toHex(key))).toString(enc.Hex); +} + +function base32toHex(base32: string) { + const base32Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + + const bits = base32 + .replace(/=+$/, '') + .split('') + .map((value) => base32Chars.indexOf(value).toString(2).padStart(5, '0')) + .join(''); + + const hex = (bits.match(/.{1,8}/g) ?? []).map((chunk) => parseInt(chunk, 2).toString(16).padStart(2, '0')).join(''); + + return hex; +} + +function generateHOTP({ key, counter = 0 }: { key: string; counter?: number }) { + // Compute HMACdigest + const digest = computeHMACSha1(counter.toString(16).padStart(16, '0'), key); + + // Get byte array + const bytes = hexToBytes(digest); + + // Truncate + const offset = bytes[19] & 0xf; + const v = + ((bytes[offset] & 0x7f) << 24) | + ((bytes[offset + 1] & 0xff) << 16) | + ((bytes[offset + 2] & 0xff) << 8) | + (bytes[offset + 3] & 0xff); + + const code = String(v % 1000000).padStart(6, '0'); + + return code; +} + +function verifyHOTP({ + token, + key, + window = 0, + counter = 0, +}: { + token: string; + key: string; + window?: number; + counter?: number; +}) { + for (let i = counter - window; i <= counter + window; ++i) { + if (generateHOTP({ key, counter: i }) === token) { + return true; + } + } + + return false; +} + +function getCounterFromTime({ now, timeStep }: { now: number; timeStep: number }) { + return Math.floor(now / 1000 / timeStep); +} + +function generateTOTP({ key, now = Date.now(), timeStep = 30 }: { key: string; now?: number; timeStep?: number }) { + const counter = getCounterFromTime({ now, timeStep }); + + return generateHOTP({ key, counter }); +} + +function verifyTOTP({ + key, + token, + window = 0, + now = Date.now(), + timeStep = 30, +}: { + token: string; + key: string; + window?: number; + now?: number; + timeStep?: number; +}) { + const counter = getCounterFromTime({ now, timeStep }); + + return verifyHOTP({ token, key, window, counter }); +} + +function buildKeyUri({ + secret, + app = 'IT-Tools', + account = 'demo-user', + algorithm = 'SHA1', + digits = 6, + period = 30, +}: { + secret: string; + app?: string; + account?: string; + algorithm?: string; + digits?: number; + period?: number; +}) { + const params = { + issuer: app, + secret, + algorithm, + digits, + period, + }; + + const paramsString = _(params) + .map((value, key) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`) + .join('&'); + + return `otpauth://totp/${encodeURIComponent(app)}:${encodeURIComponent(account)}?${paramsString}`; +} + +function generateSecret() { + return createToken({ length: 16, alphabet: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' }); +} diff --git a/src/tools/otp-code-generator-and-validator/token-display.vue b/src/tools/otp-code-generator-and-validator/token-display.vue new file mode 100644 index 00000000..ad3b64bd --- /dev/null +++ b/src/tools/otp-code-generator-and-validator/token-display.vue @@ -0,0 +1,66 @@ + + + + + diff --git a/src/tools/qr-code-generator/useQRCode.ts b/src/tools/qr-code-generator/useQRCode.ts index 38a9d314..44b72a72 100644 --- a/src/tools/qr-code-generator/useQRCode.ts +++ b/src/tools/qr-code-generator/useQRCode.ts @@ -1,5 +1,6 @@ +import { get, type MaybeRef } from '@vueuse/core'; import QRCode, { type QRCodeErrorCorrectionLevel, type QRCodeToDataURLOptions } from 'qrcode'; -import { ref, watch, type Ref } from 'vue'; +import { ref, watch, isRef } from 'vue'; export function useQRCode({ text, @@ -7,24 +8,24 @@ export function useQRCode({ errorCorrectionLevel, options, }: { - text: Ref; - color: { foreground: Ref; background: Ref }; - errorCorrectionLevel: Ref; + text: MaybeRef; + color: { foreground: MaybeRef; background: MaybeRef }; + errorCorrectionLevel?: MaybeRef; options?: QRCodeToDataURLOptions; }) { const qrcode = ref(''); watch( - [text, background, foreground, errorCorrectionLevel], + [text, background, foreground, errorCorrectionLevel].filter(isRef), async () => { - if (text.value) - qrcode.value = await QRCode.toDataURL(text.value, { + if (get(text)) + qrcode.value = await QRCode.toDataURL(get(text), { color: { - dark: foreground.value, - light: background.value, + dark: get(foreground), + light: get(background), ...options?.color, }, - errorCorrectionLevel: errorCorrectionLevel.value, + errorCorrectionLevel: get(errorCorrectionLevel) ?? 'M', ...options, }); }, diff --git a/src/tools/token-generator/token-generator.service.ts b/src/tools/token-generator/token-generator.service.ts index bf6d9ac3..f48a4deb 100644 --- a/src/tools/token-generator/token-generator.service.ts +++ b/src/tools/token-generator/token-generator.service.ts @@ -6,19 +6,23 @@ export function createToken({ withNumbers = true, withSymbols = false, length = 64, + alphabet, }: { withUppercase?: boolean; withLowercase?: boolean; withNumbers?: boolean; withSymbols?: boolean; length?: number; + alphabet?: string; }) { - const alphabet = [ - ...(withUppercase ? 'ABCDEFGHIJKLMOPQRSTUVWXYZ' : ''), - ...(withLowercase ? 'abcdefghijklmopqrstuvwxyz' : ''), - ...(withNumbers ? '0123456789' : ''), - ...(withSymbols ? '.,;:!?./-"\'#{([-|\\@)]=}*+' : ''), - ].join(''); + const allAlphabet = + alphabet ?? + [ + ...(withUppercase ? 'ABCDEFGHIJKLMOPQRSTUVWXYZ' : ''), + ...(withLowercase ? 'abcdefghijklmopqrstuvwxyz' : ''), + ...(withNumbers ? '0123456789' : ''), + ...(withSymbols ? '.,;:!?./-"\'#{([-|\\@)]=}*+' : ''), + ].join(''); - return shuffleString(alphabet.repeat(length)).substring(0, length); + return shuffleString(allAlphabet.repeat(length)).substring(0, length); }