Skip to content

Commit

Permalink
Allow otpUrl to generate TOTP/HOTP tokens (#294)
Browse files Browse the repository at this point in the history
Reported by team admin through user support, some credentials with OTP
where not working.

This is due to a new OTP URL format that was not taken into account in
the CLI code.
  • Loading branch information
Corentin Mors authored Nov 19, 2024
1 parent bcfde0b commit f4eb6d2
Show file tree
Hide file tree
Showing 6 changed files with 130 additions and 15 deletions.
32 changes: 23 additions & 9 deletions src/command-handlers/passwords.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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.');
Expand All @@ -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.');
Expand All @@ -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`);
}
}
};

Expand Down
1 change: 1 addition & 0 deletions src/modules/crypto/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { decryptTransaction, decryptTransactions } from './decrypt.js';
export { getLocalConfiguration } from './keychainManager.js';
export * from './otpauth.js';
export * from './xor.js';
79 changes: 79 additions & 0 deletions src/modules/crypto/otpauth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
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 interface GenerateOtpOutput {
token: string;
remainingTime: number | null;
}

export const generateOtpFromUri = (uri: string): GenerateOtpOutput => {
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): GenerateOtpOutput => {
authenticator.resetOptions();
return { token: authenticator.generate(secret), remainingTime: authenticator.timeRemaining() };
};
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion src/utils/secretPath.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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;
Expand Down
18 changes: 13 additions & 5 deletions src/utils/secretTransformation.ts
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down

0 comments on commit f4eb6d2

Please sign in to comment.