Merge branch 'IDTEAM-1.26.0' into 'main'
[ProtonMail-WebClient.git] / packages / pass / lib / otp / otp.ts
blob69adccd0927d5fadfeddb710d6bb322a1c6379bb
1 import { TOTP, URI } from 'otpauth';
3 import type { MaybeNull, OtpCode } from '@proton/pass/types';
4 import { merge } from '@proton/pass/utils/object/merge';
5 import { isEmptyString } from '@proton/pass/utils/string/is-empty-string';
6 import { getEpoch } from '@proton/pass/utils/time/epoch';
7 import { isTotpUri } from '@proton/pass/utils/url/utils';
8 import { getSearchParams } from '@proton/shared/lib/helpers/url';
10 import { PatchedSecret } from './patch';
12 type OTPOptions = {
13     secret?: string;
14     issuer?: MaybeNull<string>;
15     label?: MaybeNull<string>;
18 export const OTP_DEFAULTS = {
19     issuer: '',
20     label: 'Proton Pass',
21     algorithm: 'SHA1',
22     digits: 6,
23     period: 30,
26 export const INVALID_SECRET_CHARS = /\s|-|_/g;
28 /** Validates a `totpUri`. If the default parser fails, will attempt to re-build
29  * the totp options by parsing the supplied `totpUri` search parameters.  */
30 export const parseOTPFromURI = (totpUri: string, options: OTPOptions): TOTP => {
31     try {
32         return URI.parse(totpUri) as TOTP;
33     } catch (err) {
34         const url = new URL(totpUri);
35         const params = Object.fromEntries(url.searchParams);
36         const rawSecret = params.secret?.replaceAll(INVALID_SECRET_CHARS, '');
37         const secret = PatchedSecret.fromBase32(rawSecret ?? '');
38         const urlIssuerAndLabel = decodeURIComponent(url.pathname.slice(1)).split(':', 2);
39         const issuer = options.issuer ?? (urlIssuerAndLabel.length === 2 ? urlIssuerAndLabel[0] : null);
40         const label = options.label ?? urlIssuerAndLabel[urlIssuerAndLabel.length - 1];
41         const period = params.period ? parseInt(params.period, 10) : undefined;
42         const digits = params.digits ? parseInt(params.digits, 10) : undefined;
44         const totpOptions = merge(
45             OTP_DEFAULTS,
46             {
47                 ...params,
48                 digits: Number.isInteger(digits) ? digits : undefined,
49                 period: Number.isInteger(period) ? period : undefined,
50                 issuer,
51                 label,
52                 secret,
53             },
54             { excludeEmpty: true }
55         );
57         return new TOTP(totpOptions);
58     }
61 export const parseOTPFromSecret = (rawSecret: string, { issuer, label }: OTPOptions): TOTP => {
62     const base32Secret = decodeURIComponent(rawSecret).replace(INVALID_SECRET_CHARS, '');
63     const secret = PatchedSecret.fromBase32(base32Secret);
64     const totpOptions = merge(OTP_DEFAULTS, { label, issuer, secret }, { excludeEmpty: true });
66     return new TOTP(totpOptions);
69 export const parseOTPValue = (uriOrSecret?: string, options: OTPOptions = {}): string => {
70     try {
71         if (!uriOrSecret || isEmptyString(uriOrSecret)) throw new Error('Invalid parameter');
72         const totp = (isTotpUri(uriOrSecret) ? parseOTPFromURI : parseOTPFromSecret)(uriOrSecret, options);
73         return totp.toString();
74     } catch {
75         return '';
76     }
79 /** Checks if a `totpUri` has default configuration values
80  * - algorithm: SHA1
81  * - digits: 6
82  * - period: 30 */
83 export const hasDefaultOTPOptions = (totpUri: string): boolean => {
84     const keysToCompare = ['algorithm', 'digits', 'period'] as const;
85     const totpUriParams = getSearchParams(totpUri.split('?')?.[1]);
86     return keysToCompare.every((key) => !(key in totpUriParams) || totpUriParams[key] === String(OTP_DEFAULTS[key]));
89 /** Extracts the OTP secret from a `totpUri` string */
90 export const getOTPSecret = (totpUri: string): string => {
91     const params = getSearchParams(totpUri.split('?')?.[1]);
92     return params.secret === undefined ? '' : params.secret;
95 /** If the supplied `totpUri` has the default configuration this will return the secret */
96 export const getSecretOrUri = (totpUri: string): string =>
97     hasDefaultOTPOptions(totpUri) ? getOTPSecret(totpUri) : totpUri;
99 export const generateTOTPCode = (totpUri?: string): MaybeNull<OtpCode> => {
100     try {
101         if (!totpUri) return null;
102         const otp = (isTotpUri(totpUri) ? parseOTPFromURI : parseOTPFromSecret)(totpUri, {});
104         const token = otp.generate();
105         const timestamp = getEpoch();
106         const expiry = timestamp + otp.period - (timestamp % otp.period);
108         return { token, period: otp.period, expiry };
109     } catch {
110         return null;
111     }