diff --git a/src/command-handlers/passwords.ts b/src/command-handlers/passwords.ts index a3a0c64..b2def9b 100644 --- a/src/command-handlers/passwords.ts +++ b/src/command-handlers/passwords.ts @@ -1,13 +1,12 @@ import Database from 'better-sqlite3'; import { Clipboard } from '@napi-rs/clipboard'; -import { authenticator } from 'otplib'; import { AuthentifiantTransactionContent, BackupEditTransaction, LocalConfiguration, VaultCredential, } from '../types.js'; -import { decryptTransactions } from '../modules/crypto/index.js'; +import { decryptTransactions, generateOtpFromSecret, generateOtpFromUri } from '../modules/crypto/index.js'; import { askCredentialChoice, filterMatches } from '../utils/index.js'; import { connectAndPrepare } from '../modules/database/index.js'; import { logger } from '../logger.js'; @@ -28,7 +27,7 @@ export const runPassword = async ( } if (field === 'otp') { - foundCredentials = foundCredentials.filter((credential) => credential.otpSecret); + foundCredentials = foundCredentials.filter((credential) => credential.otpSecret || credential.otpUrl); if (foundCredentials.length === 0) { throw new Error('No credential found with OTP.'); @@ -49,10 +48,15 @@ export const runPassword = async ( result = selectedCredential.password; break; case 'otp': - if (!selectedCredential.otpSecret) { + if (!selectedCredential.otpSecret || !selectedCredential.otpUrl) { throw new Error('No OTP found for this credential.'); } - result = authenticator.generate(selectedCredential.otpSecret); + if (selectedCredential.otpSecret) { + result = generateOtpFromSecret(selectedCredential.otpSecret).token; + } + if (selectedCredential.otpUrl) { + result = generateOtpFromUri(selectedCredential.otpUrl).token; + } break; default: throw new Error('Unable to recognize the field.'); @@ -73,10 +77,20 @@ export const runPassword = async ( `🔓 ${field} for "${selectedCredential.title || selectedCredential.url || 'N/C'}" copied to clipboard!` ); - if (field === 'password' && selectedCredential.otpSecret) { - const token = authenticator.generate(selectedCredential.otpSecret); - const timeRemaining = authenticator.timeRemaining(); - logger.content(`🔢 OTP code: ${token} \u001B[3m(expires in ${timeRemaining} seconds)\u001B[0m`); + if (field === 'password' && (selectedCredential.otpSecret || selectedCredential.otpUrl)) { + let token = ''; + let remainingTime: number | null = null; + if (selectedCredential.otpSecret) { + ({ token, remainingTime } = generateOtpFromSecret(selectedCredential.otpSecret)); + } + if (selectedCredential.otpUrl) { + ({ token, remainingTime } = generateOtpFromUri(selectedCredential.otpUrl)); + } + + logger.content(`🔢 OTP code: ${token}`); + if (remainingTime) { + logger.content(`⏳ Remaining time: ${remainingTime} seconds`); + } } }; diff --git a/src/modules/crypto/index.ts b/src/modules/crypto/index.ts index 13237c5..512cd29 100644 --- a/src/modules/crypto/index.ts +++ b/src/modules/crypto/index.ts @@ -1,3 +1,4 @@ export { decryptTransaction, decryptTransactions } from './decrypt.js'; export { getLocalConfiguration } from './keychainManager.js'; +export * from './otpauth.js'; export * from './xor.js'; diff --git a/src/modules/crypto/otpauth.ts b/src/modules/crypto/otpauth.ts new file mode 100644 index 0000000..de96b10 --- /dev/null +++ b/src/modules/crypto/otpauth.ts @@ -0,0 +1,74 @@ +import { authenticator, hotp } from 'otplib'; + +enum HashAlgorithms { + 'SHA1' = 'sha1', + 'SHA256' = 'sha256', + 'SHA512' = 'sha512', +} + +interface Otpauth { + protocol: string; + type: string; + issuer: string; + secret: string; + algorithm: HashAlgorithms; + digits: number; + period: number; + counter: number; +} + +const matchAlgorithm = (algorithm: string): HashAlgorithms => { + switch (algorithm) { + case 'SHA1': + return HashAlgorithms.SHA1; + case 'SHA256': + return HashAlgorithms.SHA256; + case 'SHA512': + return HashAlgorithms.SHA512; + default: + throw new Error('Invalid algorithm'); + } +}; + +const parseOtpauth = (uri: string): Otpauth => { + const url = new URL(uri); + const searchParams = url.searchParams; + + return { + protocol: url.protocol.slice(0, -1), + type: url.hostname, + issuer: searchParams.get('issuer') ?? '', + secret: searchParams.get('secret') ?? '', + algorithm: matchAlgorithm(searchParams.get('algorithm') ?? 'sha1'), + digits: Number(searchParams.get('digits') ?? 0), + period: Number(searchParams.get('period') ?? 0), + counter: Number(searchParams.get('counter') ?? 0), + }; +}; + +export const generateOtpFromUri = (uri: string): { token: string; remainingTime: number | null } => { + const otpauth = parseOtpauth(uri); + + authenticator.resetOptions(); + hotp.resetOptions(); + + switch (otpauth.type) { + case 'totp': + authenticator.options = { + algorithm: otpauth.algorithm, + digits: otpauth.digits, + period: otpauth.period, + }; + return { token: authenticator.generate(otpauth.secret), remainingTime: authenticator.timeRemaining() }; + case 'hotp': + hotp.options = { algorithm: otpauth.algorithm, digits: otpauth.digits }; + return { token: hotp.generate(otpauth.secret, otpauth.counter), remainingTime: null }; + default: + throw new Error('Invalid OTP type'); + } +}; + +export const generateOtpFromSecret = (secret: string): { token: string; remainingTime: number | null } => { + authenticator.resetOptions(); + return { token: authenticator.generate(secret), remainingTime: authenticator.timeRemaining() }; +}; diff --git a/src/types.ts b/src/types.ts index 63111e1..8c594ff 100644 --- a/src/types.ts +++ b/src/types.ts @@ -166,6 +166,7 @@ export interface VaultCredential { subdomainOnly: 'true' | 'false'; useFixedUrl: 'true' | 'false'; otpSecret?: string; + otpUrl?: string; appMetaData?: string; // info about linked mobile applications status: 'ACCOUNT_NOT_VERIFIED' | 'ACCOUNT_VERIFIED' | 'ACCOUNT_INVALID'; numberUse: string; // number diff --git a/src/utils/secretPath.ts b/src/utils/secretPath.ts index 2eee9ac..39f0657 100644 --- a/src/utils/secretPath.ts +++ b/src/utils/secretPath.ts @@ -1,5 +1,11 @@ import { isUuid } from './strings.js'; -import { transformJsonPath, transformOtp, transformOtpAndExpiry } from './secretTransformation.js'; +import { + transformJsonPath, + transformOtp, + transformOtpAndExpiry, + transformOtpUri, + transformOtpUriAndExpiry, +} from './secretTransformation.js'; import { ParsedPath } from '../types.js'; import { InvalidDashlanePathError } from '../errors.js'; @@ -55,6 +61,12 @@ export const parsePath = (path: string): ParsedPath => { case 'otp+expiry': transformation = transformOtpAndExpiry; break; + case 'otp_uri': + transformation = transformOtpUri; + break; + case 'otp_uri+expiry': + transformation = transformOtpUriAndExpiry; + break; case 'json': transformation = (json: string) => transformJsonPath(json, queryParamValue); break; diff --git a/src/utils/secretTransformation.ts b/src/utils/secretTransformation.ts index 95f12ea..ca295a1 100644 --- a/src/utils/secretTransformation.ts +++ b/src/utils/secretTransformation.ts @@ -1,14 +1,22 @@ import { JSONPath } from 'jsonpath-plus'; -import { authenticator } from 'otplib'; +import { generateOtpFromSecret, generateOtpFromUri } from '../modules/crypto'; export const transformOtp = (secret: string) => { - return authenticator.generate(secret); + return generateOtpFromSecret(secret).token; }; export const transformOtpAndExpiry = (secret: string) => { - const otp = authenticator.generate(secret); - const expiry = authenticator.timeRemaining(); - return `${otp} ${expiry}`; + const { token, remainingTime } = generateOtpFromSecret(secret); + return `${token} ${remainingTime}`; +}; + +export const transformOtpUri = (uri: string) => { + return generateOtpFromUri(uri).token; +}; + +export const transformOtpUriAndExpiry = (uri: string) => { + const { token, remainingTime } = generateOtpFromUri(uri); + return `${token} ${remainingTime}`; }; export const transformJsonPath = (json: string, path: string) => {