diff options
author | 2022-08-24 00:10:53 +0200 | |
---|---|---|
committer | 2022-08-24 00:18:01 +0200 | |
commit | 5f168859238e9c3a8b8bbaf6b550c4b9bd163e00 (patch) | |
tree | 4b1f7b10a21eea538b582660fab67eba344ac33c /src | |
parent | ea5e7a7fc7df1a3a912193912a6ab80a8a36a256 (diff) | |
download | it-tools-5f168859238e9c3a8b8bbaf6b550c4b9bd163e00.tar.gz it-tools-5f168859238e9c3a8b8bbaf6b550c4b9bd163e00.tar.zst it-tools-5f168859238e9c3a8b8bbaf6b550c4b9bd163e00.zip |
feat(new-tool): added otp generator
Diffstat (limited to 'src')
6 files changed, 506 insertions, 1 deletions
diff --git a/src/tools/index.ts b/src/tools/index.ts index 80f1618..e9377e4 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 0000000..6ce2d3f --- /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 0000000..3521c23 --- /dev/null +++ b/src/tools/otp-code-generator-and-validator/otp-code-generator-and-validator.vue @@ -0,0 +1,140 @@ +<template> + <div style="max-width: 350px"> + <n-form-item label="Secret" v-bind="secretValidationAttrs"> + <n-input v-model:value="secret" placeholder="Paste your TOTP secret..."> + <template #suffix> + <n-tooltip trigger="hover"> + <template #trigger> + <n-button quaternary circle @click="refreshSecret"> + <n-icon :component="Refresh" /> + </n-button> + </template> + Generate secret token + </n-tooltip> + </template> + </n-input> + </n-form-item> + + <div> + <token-display :tokens="tokens" style="margin-top: 2px" /> + + <n-progress :percentage="(100 * interval) / 30" :color="theme.primaryColor" :show-indicator="false" /> + <div style="text-align: center">Next in {{ String(Math.floor(30 - interval)).padStart(2, '0') }}s</div> + </div> + <n-space justify="center" vertical align="center" style="margin-top: 10px"> + <n-image :src="qrcode"></n-image> + <n-button secondary tag="a" :href="keyUri" target="_blank">Open Key URI in new tab</n-button> + </n-space> + </div> + <div style="max-width: 350px"> + <n-form-item label="Secret in hexadecimal"> + <input-copyable :value="base32toHex(secret)" readonly placeholder="Secret in hex will be displayed here" /> + </n-form-item> + + <n-form-item label="Epoch"> + <input-copyable + :value="Math.floor(now / 1000).toString()" + readonly + placeholder="Epoch in sec will be displayed here" + /> + </n-form-item> + <n-form-item label="Iteration" :show-feedback="false"> + <n-input-group> + <n-input-group-label style="width: 110px">Count:</n-input-group-label> + <input-copyable + :value="String(getCounterFromTime({ now, timeStep: 30 }))" + readonly + placeholder="Iteration count will be displayed here" + /> + </n-input-group> + </n-form-item> + + <n-form-item label="Iteration" :show-label="false" style="margin-top: 5px"> + <n-input-group> + <n-input-group-label style="width: 110px">Padded hex:</n-input-group-label> + <input-copyable + :value="getCounterFromTime({ now, timeStep: 30 }).toString(16).padStart(16, '0')" + readonly + placeholder="Iteration count in hex will be displayed here" + /> + </n-input-group> + </n-form-item> + </div> +</template> + +<script setup lang="ts"> +import { computed, ref, watch } from 'vue'; +import { Refresh } from '@vicons/tabler'; +import { useTimestamp, whenever } from '@vueuse/core'; +import { useThemeVars } from 'naive-ui'; +import { useStyleStore } from '@/stores/style.store'; +import InputCopyable from '@/components/InputCopyable.vue'; +import { useValidation } from '@/composable/validation'; +import { generateTOTP, buildKeyUri, generateSecret, base32toHex, getCounterFromTime } from './otp.service'; +import { useQRCode } from '../qr-code-generator/useQRCode'; +import TokenDisplay from './token-display.vue'; + +const now = useTimestamp(); +const interval = computed(() => (now.value / 1000) % 30); +const theme = useThemeVars(); +const styleStore = useStyleStore(); +const secret = ref(generateSecret()); +const tokens = ref(buildTokens()); +const keyUri = computed(() => buildKeyUri({ secret: secret.value })); + +const { qrcode } = useQRCode({ + text: keyUri, + color: { background: '#00000000', foreground: computed(() => (styleStore.isDarkTheme ? '#ffffff' : '#000000')) }, + options: { width: 210 }, +}); + +const { attrs: secretValidationAttrs } = useValidation({ + source: secret, + rules: [ + { + message: 'Secret should be a base32 string', + validator: (value) => value.match(/^[A-Z234567]+$/), + }, + { + message: 'Please set a secret', + validator: (value) => value !== '', + }, + ], +}); + +// watch + whenever to prevent token to be refresh every raf +watch([secret], refreshToken); +whenever(() => Math.floor(interval.value) === 0, refreshToken); + +function refreshSecret() { + secret.value = generateSecret(); +} + +function refreshToken() { + tokens.value = buildTokens(); +} + +function buildTokens() { + return { + previous: generateTOTP({ key: secret.value, now: now.value - 30000 }), + current: generateTOTP({ key: secret.value, now: now.value }), + next: generateTOTP({ key: secret.value, now: now.value + 30000 }), + }; +} +</script> + +<style lang="less" scoped> +.n-progress { + margin-top: 10px; + ::v-deep(.n-progress-graph-line-fill) { + transition-duration: 0.05s !important; + } +} + +.token { + text-align: center; + &.token-current { + font-size: 20px; + } +} +</style> 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 0000000..f2f5449 --- /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 0000000..7b8c49a --- /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 0000000..ad3b64b --- /dev/null +++ b/src/tools/otp-code-generator-and-validator/token-display.vue @@ -0,0 +1,66 @@ +<template> + <div> + <n-space class="labels" item-style="flex: 1 1 0" style="width: 100%" align="center"> + <div style="text-align: left">Previous</div> + <div style="text-align: center">Current OTP</div> + <div style="text-align: right">Next</div> + </n-space> + <n-input-group> + <n-tooltip trigger="hover" placement="bottom"> + <template #trigger> + <n-button secondary @click.prevent="copyPrevious(tokens.previous)">{{ tokens.previous }}</n-button> + </template> + <div>{{ previousCopied ? 'Copied !' : 'Copy previous OTP' }}</div> + </n-tooltip> + <n-tooltip trigger="hover" placement="bottom"> + <template #trigger> + <n-button tertiary type="primary" class="current-otp" @click.prevent="copyCurrent(tokens.current)"> + {{ tokens.current }} + </n-button> + </template> + <div>{{ currentCopied ? 'Copied !' : 'Copy current OTP' }}</div> + </n-tooltip> + <n-tooltip trigger="hover" placement="bottom"> + <template #trigger> + <n-button secondary @click.prevent="copyNext(tokens.next)">{{ tokens.next }}</n-button> + </template> + <div>{{ nextCopied ? 'Copied !' : 'Copy next OTP' }}</div> + </n-tooltip> + </n-input-group> + </div> +</template> + +<script setup lang="ts"> +import { useClipboard } from '@vueuse/core'; +import { toRefs } from 'vue'; + +const { copy: copyPrevious, copied: previousCopied } = useClipboard(); +const { copy: copyCurrent, copied: currentCopied } = useClipboard(); +const { copy: copyNext, copied: nextCopied } = useClipboard(); + +const props = defineProps<{ tokens: { previous: string; current: string; next: string } }>(); +const { tokens } = toRefs(props); +</script> + +<style scoped lang="less"> +.current-otp { + font-size: 22px; + flex: 1 0 35% !important; +} + +.n-button { + height: 45px; +} + +.labels { + div { + text-align: center; + padding: 0 2px 6px 2px; + line-height: 1.25; + } +} + +.n-input-group > * { + flex: 1 0 0; +} +</style> |