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';
14 issuer?: MaybeNull<string>;
15 label?: MaybeNull<string>;
18 export const OTP_DEFAULTS = {
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 => {
32 return URI.parse(totpUri) as TOTP;
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(
48 digits: Number.isInteger(digits) ? digits : undefined,
49 period: Number.isInteger(period) ? period : undefined,
54 { excludeEmpty: true }
57 return new TOTP(totpOptions);
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 => {
71 if (!uriOrSecret || isEmptyString(uriOrSecret)) throw new Error('Invalid parameter');
72 const totp = (isTotpUri(uriOrSecret) ? parseOTPFromURI : parseOTPFromSecret)(uriOrSecret, options);
73 return totp.toString();
79 /** Checks if a `totpUri` has default configuration values
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> => {
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 };