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 @@ + + + + +