Skip to content

Commit e737029

Browse files
committed
feat(QR Code Decoder): parse otpauth and otp-migration urls
Fix #307
1 parent 443369c commit e737029

7 files changed

Lines changed: 168 additions & 1 deletion

File tree

locales/en.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4787,6 +4787,8 @@ tools:
47874787
email-1: Email
47884788
email-0: Email
47894789
email: Email
4790+
otpauth: OTP Auth
4791+
otpmigration: OTP Migration
47904792
SplashScreen:
47914793
text:
47924794
pomodoro-timer: Pomodoro Timer

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,7 @@
269269
"node-forge": "^1.3.3",
270270
"openai-chat-tokens": "^0.2.8",
271271
"openpgp": "^6.3.0",
272+
"otpauth-migration": "^1.0.0",
272273
"oui-data": "^1.1.476",
273274
"parse-duration": "^2.1.5",
274275
"parse-torrent": "^11.0.19",

pnpm-lock.yaml

Lines changed: 29 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/tools/qr-code-decoder/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export const tool = defineTool({
66
name: t('tools.qr-code-decoder.title'),
77
path: '/qr-code-decoder',
88
description: t('tools.qr-code-decoder.description'),
9-
keywords: ['qrcode', 'qr-code', 'decoder', 'reader'],
9+
keywords: ['qrcode', 'qr-code', 'decoder', 'reader', 'wifi', 'otp', 'parser'],
1010
component: () => import('./qr-code-decoder.vue'),
1111
icon: Qrcode,
1212
createdAt: new Date('2024-09-01'),

src/tools/qr-code-decoder/qr-code-decoder.service.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,63 @@ describe('qr-code-decoder', () => {
6464
password: 'password', // NOSONAR
6565
},
6666
});
67+
expect(parseQRData('otpauth://totp/ACME%20Co:john.doe@email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30')).toEqual({
68+
type: 'OTP Auth',
69+
value: {
70+
label: {
71+
account: 'john.doe@email.com',
72+
issuer: 'ACME Co',
73+
raw: 'ACME Co:john.doe@email.com',
74+
},
75+
params: {
76+
algorithm: 'SHA1',
77+
digits: '6',
78+
issuer: 'ACME Co',
79+
period: '30',
80+
secret: 'HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ',
81+
},
82+
type: 'totp',
83+
uri: 'otpauth://totp/ACME%20Co:john.doe@email.com?secret=HXDMVJECJJWSRB3HWIZR4IFUGFTMXBOZ&issuer=ACME%20Co&algorithm=SHA1&digits=6&period=30',
84+
},
85+
});
86+
expect(parseQRData('otpauth-migration://offline?data=CigKFFVUVURPbmFMMXd1cDlBSVZHOUVjEgRUZXN0GgRUZXN0IAEoAjACCi8KCkhlbGxvId6tvu8SEGFsaWNlQGdvb2dsZS5jb20aB0V4YW1wbGUgASgBMAE4BxABGAEgAA%3D%3D')).toEqual({
87+
type: 'OTP Migration',
88+
value: [
89+
{
90+
label: {
91+
account: 'Test',
92+
issuer: 'Test',
93+
raw: 'Test:Test',
94+
},
95+
params: {
96+
algorithm: 'SHA1',
97+
digits: '8',
98+
issuer: 'Test',
99+
secret: 'KVKFKRCPNZQUYMLXOVYDSQKJKZDTSRLD',
100+
},
101+
type: 'totp',
102+
uri: 'otpauth://totp/Test%3ATest?issuer=Test&algorithm=SHA1&digits=8&secret=KVKFKRCPNZQUYMLXOVYDSQKJKZDTSRLD',
103+
},
104+
{
105+
label: {
106+
account: 'alice@google.com',
107+
issuer: 'Example',
108+
raw: 'Example:alice@google.com',
109+
},
110+
params: {
111+
algorithm: 'SHA1',
112+
counter: '7',
113+
digits: '6',
114+
issuer: 'Example',
115+
secret: 'JBSWY3DPEHPK3PXP',
116+
},
117+
type: 'hotp',
118+
uri: 'otpauth://hotp/Example%3Aalice%40google.com?issuer=Example&algorithm=SHA1&digits=6&counter=7&secret=JBSWY3DPEHPK3PXP',
119+
},
120+
],
121+
},
122+
123+
);
67124
expect(parseQRData('BEGIN:VCALENDAR\nPRODID:-//xyz Corp//NONSGML PDA Calendar Version 1.0//EN\nVERSION:2.0\nBEGIN:VEVENT\nDTSTAMP:19960704T120000Z\nUID:uid1@example.com\nORGANIZER:mailto:jsmith@example.com\nDTSTART:19960918T143000Z\nDTEND:19960920T220000Z\nSTATUS:CONFIRMED\nCATEGORIES:CONFERENCE\nSUMMARY:Networld+Interop Conference\nDESCRIPTION:Networld+Interop Conference\n and Exhibit\\nAtlanta World Congress Center\\n\n Atlanta\\, Georgia\nEND:VEVENT\nEND:VCALENDAR'))
68125
.toEqual({
69126
type: 'iCal',

src/tools/qr-code-decoder/qr-code-decoder.service.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,67 @@
11
import ICAL from 'ical.js';
2+
import { URI as OTPURI } from 'otpauth-migration';
23

34
import { translate as t } from '@/plugins/i18n.plugin';
45

6+
interface OTPAuthURI {
7+
type: string
8+
label: {
9+
issuer?: string
10+
account?: string
11+
raw: string
12+
}
13+
params: Record<string, string>
14+
uri: string
15+
}
16+
17+
function parseOtpAuthUri(uri: string): OTPAuthURI | null {
18+
const url = new URL(uri);
19+
20+
if (url.protocol !== 'otpauth:') {
21+
return null;
22+
}
23+
24+
// url.hostname contains the OTP type (totp, hotp, etc.)
25+
const type = url.hostname;
26+
27+
// url.pathname starts with "/", so strip it
28+
const rawLabel = decodeURIComponent(url.pathname.slice(1));
29+
30+
let issuer: string | undefined;
31+
let account: string | undefined;
32+
33+
const labelParts = rawLabel.split(':');
34+
if (labelParts.length > 1) {
35+
issuer = labelParts[0];
36+
account = labelParts.slice(1).join(':');
37+
}
38+
else {
39+
account = rawLabel;
40+
}
41+
42+
// Extract query parameters
43+
const params: Record<string, string> = {};
44+
url.searchParams.forEach((value, key) => {
45+
params[key] = value;
46+
});
47+
48+
// If issuer missing in label but present in params, use it
49+
if (!issuer && params.issuer) {
50+
issuer = params.issuer;
51+
}
52+
53+
return {
54+
type,
55+
label: {
56+
issuer,
57+
account,
58+
raw: rawLabel,
59+
},
60+
params,
61+
uri,
62+
};
63+
}
64+
565
export function parseQRData(qrContent: string | null) {
666
if (!qrContent) {
767
return { type: t('tools.qr-code-decoder.service.text.unknown'), value: '' };
@@ -72,6 +132,19 @@ export function parseQRData(qrContent: string | null) {
72132
},
73133
};
74134
}
135+
if (qrContent.startsWith('otpauth:')) {
136+
return {
137+
type: t('tools.qr-code-decoder.service.text.otpauth'),
138+
value: parseOtpAuthUri(qrContent),
139+
};
140+
}
141+
if (qrContent.startsWith('otpauth-migration:')) {
142+
const otpauthUris = OTPURI.toOTPAuthURIs(qrContent);
143+
return {
144+
type: t('tools.qr-code-decoder.service.text.otpmigration'),
145+
value: otpauthUris.map(otpauthUri => parseOtpAuthUri(otpauthUri)),
146+
};
147+
}
75148
if (/^(?:https?|ftp):\/\//.test(qrContent)) {
76149
return {
77150
type: t('tools.websocket-tester.texts.label-url'),

vite.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,11 @@ export default defineConfig({
135135
},
136136
test: {
137137
exclude: [...configDefaults.exclude, '**/*.e2e.spec.ts'],
138+
server: {
139+
deps: {
140+
inline: ['otpauth-migration', 'proto'],
141+
},
142+
},
138143
},
139144
build: {
140145
target: 'esnext',

0 commit comments

Comments
 (0)