aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGravatar Corentin Thomasset <corentin.thomasset74@gmail.com> 2022-08-24 00:10:53 +0200
committerGravatar Corentin Thomasset <corentin.thomasset74@gmail.com> 2022-08-24 00:18:01 +0200
commit5f168859238e9c3a8b8bbaf6b550c4b9bd163e00 (patch)
tree4b1f7b10a21eea538b582660fab67eba344ac33c /src
parentea5e7a7fc7df1a3a912193912a6ab80a8a36a256 (diff)
downloadit-tools-5f168859238e9c3a8b8bbaf6b550c4b9bd163e00.tar.gz
it-tools-5f168859238e9c3a8b8bbaf6b550c4b9bd163e00.tar.zst
it-tools-5f168859238e9c3a8b8bbaf6b550c4b9bd163e00.zip
feat(new-tool): added otp generator
Diffstat (limited to 'src')
-rw-r--r--src/tools/index.ts11
-rw-r--r--src/tools/otp-code-generator-and-validator/index.ts27
-rw-r--r--src/tools/otp-code-generator-and-validator/otp-code-generator-and-validator.vue140
-rw-r--r--src/tools/otp-code-generator-and-validator/otp.service.test.ts124
-rw-r--r--src/tools/otp-code-generator-and-validator/otp.service.ts139
-rw-r--r--src/tools/otp-code-generator-and-validator/token-display.vue66
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>