From 733802eff050cc512378209df5ea1618524e7511 Mon Sep 17 00:00:00 2001 From: Tristan P Date: Thu, 4 Jul 2024 18:27:55 +0200 Subject: [PATCH] feat: account recovery --- src/command-handlers/logout.ts | 39 +-------- src/command-handlers/recovery.ts | 37 +++++++++ src/commands/index.ts | 6 ++ src/endpoints/getEncryptedVaultKey.ts | 19 +++++ src/modules/auth/getAuthenticationTickets.ts | 74 +++++++++++++++++ src/modules/auth/index.ts | 3 + src/modules/auth/logout.ts | 40 ++++++++++ .../getMasterpasswordFromArkPrompt.ts | 34 ++++++++ src/modules/auth/registerDevice.ts | 79 ++----------------- src/modules/crypto/keychainManager.ts | 66 +++++++++++----- src/modules/database/connectAndPrepare.ts | 17 ++-- src/utils/dialogs.ts | 25 ++++++ 12 files changed, 304 insertions(+), 135 deletions(-) create mode 100644 src/command-handlers/recovery.ts create mode 100644 src/endpoints/getEncryptedVaultKey.ts create mode 100644 src/modules/auth/getAuthenticationTickets.ts create mode 100644 src/modules/auth/logout.ts create mode 100644 src/modules/auth/recovery/getMasterpasswordFromArkPrompt.ts diff --git a/src/command-handlers/logout.ts b/src/command-handlers/logout.ts index ca19cbc..722c9b9 100644 --- a/src/command-handlers/logout.ts +++ b/src/command-handlers/logout.ts @@ -1,46 +1,11 @@ -import { Database } from 'better-sqlite3'; -import { deactivateDevices } from '../endpoints/index.js'; -import { connectAndPrepare, connect, reset } from '../modules/database/index.js'; -import { LocalConfiguration, DeviceConfiguration } from '../types.js'; import { askConfirmReset } from '../utils/index.js'; -import { logger } from '../logger.js'; +import { logout } from '../modules/auth/logout.js'; export const runLogout = async (options: { ignoreRevocation: boolean }) => { - if (options.ignoreRevocation) { - logger.info("The device credentials won't be revoked on Dashlane's servers."); - } - const resetConfirmation = await askConfirmReset(); if (!resetConfirmation) { return; } - let db: Database; - let localConfiguration: LocalConfiguration | undefined; - let deviceConfiguration: DeviceConfiguration | null | undefined; - try { - ({ db, localConfiguration, deviceConfiguration } = await connectAndPrepare({ - autoSync: false, - failIfNoDB: true, - })); - } catch (error) { - let errorMessage = 'unknown error'; - if (error instanceof Error) { - errorMessage = error.message; - } - logger.debug(`Unable to read device configuration during logout: ${errorMessage}`); - - db = connect(); - db.serialize(); - } - if (localConfiguration && deviceConfiguration && !options.ignoreRevocation) { - await deactivateDevices({ - deviceIds: [deviceConfiguration.accessKey], - login: deviceConfiguration.login, - localConfiguration, - }).catch((error) => logger.error('Unable to deactivate the device', error)); - } - reset({ db, localConfiguration }); - logger.success('The local Dashlane local storage has been reset and you have been logged out.'); - db.close(); + await logout({ ignoreRevocation: options.ignoreRevocation }); }; diff --git a/src/command-handlers/recovery.ts b/src/command-handlers/recovery.ts new file mode 100644 index 0000000..ee3bfbf --- /dev/null +++ b/src/command-handlers/recovery.ts @@ -0,0 +1,37 @@ +import { logger } from '../logger'; +import { logout } from '../modules/auth'; +import { connectAndPrepare } from '../modules/database/connectAndPrepare'; +import { askConfirmRecovery, askConfirmShowMp } from '../utils'; +import { sync } from './sync'; + +export const runAccountRecovery = async () => { + const doRecovery = await askConfirmRecovery(); + if (!doRecovery) { + return; + } + const shouldShowMp = await askConfirmShowMp(); + + await logout({ ignoreRevocation: false }); + const { db, localConfiguration, deviceConfiguration } = await connectAndPrepare({ + autoSync: false, + recoveryOptions: { + promptForArk: true, + displayMasterpassword: shouldShowMp, + }, + }); + + try { + await sync({ db, localConfiguration, deviceConfiguration }); + logger.success('Account recovered! you can use the CLI to export its data.'); + } catch (error) { + logger.error( + 'Sync failed. It probably means that the masterpassword locked behind the recovery key\n' + + 'is not matching. Try running the command again and show the masterpassword. Maybe you will\n' + + 'remember the one you initially set up' + ); + + throw error; + } finally { + db.close(); + } +}; diff --git a/src/commands/index.ts b/src/commands/index.ts index 70062a3..488d24f 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -15,6 +15,7 @@ import { runBackup, runSecret, } from '../command-handlers/index.js'; +import { runAccountRecovery } from '../command-handlers/recovery.js'; export const rootCommands = (params: { program: Command }) => { const { program } = params; @@ -113,6 +114,11 @@ export const rootCommands = (params: { program: Command }) => { .description('Backup your local vault (will use the current directory by default)') .action(runBackup); + program + .command('recover-account') + .description('interactively login to an account with a recovery key') + .action(runAccountRecovery); + program .command('lock') .description('Lock the vault, next commands will request the master password to unlock it)') diff --git a/src/endpoints/getEncryptedVaultKey.ts b/src/endpoints/getEncryptedVaultKey.ts new file mode 100644 index 0000000..0d5a901 --- /dev/null +++ b/src/endpoints/getEncryptedVaultKey.ts @@ -0,0 +1,19 @@ +import { requestAppApi } from '../requestApi.js'; + +interface GetEncryptedVaultKeyParams { + login: string; + authTicket: string; +} + +export interface GetEncryptedVaultKeyResult { + encryptedVaultKey: string; +} + +export const getEncryptedVaultKey = ({ authTicket, login }: GetEncryptedVaultKeyParams) => + requestAppApi({ + path: 'accountrecovery/GetEncryptedVaultKey', + payload: { + authTicket, + login, + }, + }); diff --git a/src/modules/auth/getAuthenticationTickets.ts b/src/modules/auth/getAuthenticationTickets.ts new file mode 100644 index 0000000..ac83c64 --- /dev/null +++ b/src/modules/auth/getAuthenticationTickets.ts @@ -0,0 +1,74 @@ +import { + performDashlaneAuthenticatorVerification, + performDuoPushVerification, + performEmailTokenVerification, + performTotpVerification, +} from '../../endpoints'; +import { getAuthenticationMethodsForDevice } from '../../endpoints/getAuthenticationMethodsForDevice'; +import { logger } from '../../logger'; +import { askOtp, askToken, askVerificationMethod } from '../../utils/dialogs'; +import { doConfidentialSSOVerification } from './confidential-sso'; +import { doSSOVerification } from './sso'; + +export const getAuthenticationTickets = async (login: string) => { + logger.debug('Registering the device...'); + + // Log in via a compatible verification method + const { verifications, accountType } = await getAuthenticationMethodsForDevice({ login }); + + if (accountType === 'invisibleMasterPassword') { + throw new Error('Master password-less is currently not supported'); + } + + const nonEmptyVerifications = verifications.filter((method) => method.type); + + const selectedVerificationMethod = + nonEmptyVerifications.length > 1 + ? await askVerificationMethod(nonEmptyVerifications) + : nonEmptyVerifications[0]; + + let authTicket: string; + let ssoSpKey: string | null = null; + if (!selectedVerificationMethod || Object.keys(selectedVerificationMethod).length === 0) { + throw new Error('No verification method selected'); + } + + if (selectedVerificationMethod.type === 'duo_push') { + logger.info('Please accept the Duo push notification on your phone.'); + ({ authTicket } = await performDuoPushVerification({ login })); + } else if (selectedVerificationMethod.type === 'dashlane_authenticator') { + logger.info('Please accept the Dashlane Authenticator push notification on your phone.'); + ({ authTicket } = await performDashlaneAuthenticatorVerification({ login })); + } else if (selectedVerificationMethod.type === 'totp') { + const otp = await askOtp(); + ({ authTicket } = await performTotpVerification({ + login, + otp, + })); + } else if (selectedVerificationMethod.type === 'email_token') { + const urlEncodedLogin = encodeURIComponent(login); + logger.info( + `Please open the following URL in your browser: https://www.dashlane.com/cli-device-registration?login=${urlEncodedLogin}` + ); + const token = await askToken(); + ({ authTicket } = await performEmailTokenVerification({ + login, + token, + })); + } else if (selectedVerificationMethod.type === 'sso') { + if (selectedVerificationMethod.ssoInfo.isNitroProvider) { + ({ authTicket, ssoSpKey } = await doConfidentialSSOVerification({ + requestedLogin: login, + })); + } else { + ({ authTicket, ssoSpKey } = await doSSOVerification({ + requestedLogin: login, + serviceProviderURL: selectedVerificationMethod.ssoInfo.serviceProviderUrl, + })); + } + } else { + throw new Error('Auth verification method not supported: ' + selectedVerificationMethod.type); + } + + return { ssoSpKey, authTicket }; +}; diff --git a/src/modules/auth/index.ts b/src/modules/auth/index.ts index 506e858..2b771ab 100644 --- a/src/modules/auth/index.ts +++ b/src/modules/auth/index.ts @@ -1,3 +1,6 @@ export * from './perform2FAVerification.js'; export * from './registerDevice.js'; export * from './userPresenceVerification.js'; +export * from './getAuthenticationTickets.js'; +export * from './recovery/getMasterpasswordFromArkPrompt.js'; +export * from './logout.js'; diff --git a/src/modules/auth/logout.ts b/src/modules/auth/logout.ts new file mode 100644 index 0000000..26a378d --- /dev/null +++ b/src/modules/auth/logout.ts @@ -0,0 +1,40 @@ +import { Database } from 'better-sqlite3'; +import { deactivateDevices } from '../../endpoints'; +import { logger } from '../../logger'; +import { LocalConfiguration, DeviceConfiguration } from '../../types'; +import { connectAndPrepare, connect, reset } from '../database'; + +export const logout = async (options: { ignoreRevocation: boolean }) => { + if (options.ignoreRevocation) { + logger.info("The device credentials won't be revoked on Dashlane's servers."); + } + + let db: Database; + let localConfiguration: LocalConfiguration | undefined; + let deviceConfiguration: DeviceConfiguration | null | undefined; + try { + ({ db, localConfiguration, deviceConfiguration } = await connectAndPrepare({ + autoSync: false, + failIfNoDB: true, + })); + } catch (error) { + let errorMessage = 'unknown error'; + if (error instanceof Error) { + errorMessage = error.message; + } + logger.debug(`Unable to read device configuration during logout: ${errorMessage}`); + + db = connect(); + db.serialize(); + } + if (localConfiguration && deviceConfiguration && !options.ignoreRevocation) { + await deactivateDevices({ + deviceIds: [deviceConfiguration.accessKey], + login: deviceConfiguration.login, + localConfiguration, + }).catch((error) => logger.error('Unable to deactivate the device', error)); + } + reset({ db, localConfiguration }); + logger.success('The local Dashlane local storage has been reset and you have been logged out.'); + db.close(); +}; diff --git a/src/modules/auth/recovery/getMasterpasswordFromArkPrompt.ts b/src/modules/auth/recovery/getMasterpasswordFromArkPrompt.ts new file mode 100644 index 0000000..b9d2a1f --- /dev/null +++ b/src/modules/auth/recovery/getMasterpasswordFromArkPrompt.ts @@ -0,0 +1,34 @@ +import { getEncryptedVaultKey } from '../../../endpoints/getEncryptedVaultKey'; +import { logger } from '../../../logger'; +import { askAccountRecoveryKey } from '../../../utils/dialogs'; +import { decryptAesCbcHmac256, getDerivateUsingParametersFromEncryptedData } from '../../crypto/decrypt'; +import { deserializeEncryptedData } from '../../crypto/encryptedDataDeserialization'; + +function cleanArk(input: string): string { + return input.toUpperCase().replaceAll(/[^A-Z0-9]/g, ''); +} +export async function getMasterpasswordFromArkPrompt(authTicket: string, login: string): Promise { + const { encryptedVaultKey: encryptedVaultKeyBase64 } = await getEncryptedVaultKey({ + authTicket, + login, + }); + + const ark = cleanArk(await askAccountRecoveryKey()); + + const buffer = Buffer.from(encryptedVaultKeyBase64, 'base64'); + const decodedBase64 = buffer.toString('ascii'); + const { encryptedData } = deserializeEncryptedData(decodedBase64, buffer); + const derivatedKey = await getDerivateUsingParametersFromEncryptedData(ark, encryptedData); + + try { + const decryptedVaultKey = decryptAesCbcHmac256({ + cipherData: encryptedData.cipherData, + originalKey: derivatedKey, + inflatedKey: encryptedData.cipherConfig.cipherMode === 'cbchmac64', + }); + return decryptedVaultKey.toString('utf-8'); + } catch (e) { + logger.error('Failed to decrypt MP. Maybe the ARK entered is incorrect'); + throw e; + } +} diff --git a/src/modules/auth/registerDevice.ts b/src/modules/auth/registerDevice.ts index 4406b5d..95e199a 100644 --- a/src/modules/auth/registerDevice.ts +++ b/src/modules/auth/registerDevice.ts @@ -1,84 +1,15 @@ -import { doSSOVerification } from './sso/index.js'; -import { doConfidentialSSOVerification } from './confidential-sso/index.js'; -import { - completeDeviceRegistration, - performDashlaneAuthenticatorVerification, - performDuoPushVerification, - performEmailTokenVerification, - performTotpVerification, -} from '../../endpoints/index.js'; -import { askOtp, askToken, askVerificationMethod } from '../../utils/index.js'; -import { getAuthenticationMethodsForDevice } from '../../endpoints/getAuthenticationMethodsForDevice.js'; -import { logger } from '../../logger.js'; +import { completeDeviceRegistration } from '../../endpoints/index.js'; +import { getAuthenticationTickets } from './getAuthenticationTickets.js'; interface RegisterDevice { login: string; deviceName: string; } -export const registerDevice = async (params: RegisterDevice) => { - const { login, deviceName } = params; - logger.debug('Registering the device...'); - - // Log in via a compatible verification method - const { verifications, accountType } = await getAuthenticationMethodsForDevice({ login }); - - if (accountType === 'invisibleMasterPassword') { - throw new Error('Master password-less is currently not supported'); - } - - const nonEmptyVerifications = verifications.filter((method) => method.type); - - const selectedVerificationMethod = - nonEmptyVerifications.length > 1 - ? await askVerificationMethod(nonEmptyVerifications) - : nonEmptyVerifications[0]; - - let authTicket: string; - let ssoSpKey: string | null = null; - if (!selectedVerificationMethod || Object.keys(selectedVerificationMethod).length === 0) { - throw new Error('No verification method selected'); - } - - if (selectedVerificationMethod.type === 'duo_push') { - logger.info('Please accept the Duo push notification on your phone.'); - ({ authTicket } = await performDuoPushVerification({ login })); - } else if (selectedVerificationMethod.type === 'dashlane_authenticator') { - logger.info('Please accept the Dashlane Authenticator push notification on your phone.'); - ({ authTicket } = await performDashlaneAuthenticatorVerification({ login })); - } else if (selectedVerificationMethod.type === 'totp') { - const otp = await askOtp(); - ({ authTicket } = await performTotpVerification({ - login, - otp, - })); - } else if (selectedVerificationMethod.type === 'email_token') { - const urlEncodedLogin = encodeURIComponent(login); - logger.info( - `Please open the following URL in your browser: https://www.dashlane.com/cli-device-registration?login=${urlEncodedLogin}` - ); - const token = await askToken(); - ({ authTicket } = await performEmailTokenVerification({ - login, - token, - })); - } else if (selectedVerificationMethod.type === 'sso') { - if (selectedVerificationMethod.ssoInfo.isNitroProvider) { - ({ authTicket, ssoSpKey } = await doConfidentialSSOVerification({ - requestedLogin: login, - })); - } else { - ({ authTicket, ssoSpKey } = await doSSOVerification({ - requestedLogin: login, - serviceProviderURL: selectedVerificationMethod.ssoInfo.serviceProviderUrl, - })); - } - } else { - throw new Error('Auth verification method not supported: ' + selectedVerificationMethod.type); - } +export const registerDevice = async ({ deviceName, login }: RegisterDevice) => { + const { authTicket, ssoSpKey } = await getAuthenticationTickets(login); // Complete the device registration and save the result const completeDeviceRegistrationResponse = await completeDeviceRegistration({ login, deviceName, authTicket }); - - return { ...completeDeviceRegistrationResponse, ssoSpKey }; + return { ...completeDeviceRegistrationResponse, ssoSpKey, authTicket }; }; diff --git a/src/modules/crypto/keychainManager.ts b/src/modules/crypto/keychainManager.ts index f93704b..52d8264 100644 --- a/src/modules/crypto/keychainManager.ts +++ b/src/modules/crypto/keychainManager.ts @@ -8,7 +8,12 @@ import { sha512 } from './hash.js'; import { EncryptedData } from './types.js'; import { decryptSsoRemoteKey } from './buildSsoRemoteKey.js'; import { CLI_VERSION, cliVersionToString } from '../../cliVersion.js'; -import { perform2FAVerification, registerDevice } from '../auth/index.js'; +import { + getAuthenticationTickets, + getMasterpasswordFromArkPrompt, + perform2FAVerification, + registerDevice, +} from '../auth/index.js'; import { DeviceConfiguration, LocalConfiguration } from '../../types.js'; import { askEmailAddress, askMasterPassword } from '../../utils/dialogs.js'; import { get2FAStatusUnauthenticated } from '../../endpoints/get2FAStatusUnauthenticated.js'; @@ -83,25 +88,33 @@ const getDerivationParametersForLocalKey = (login: string): EncryptedData => { const getLocalConfigurationWithoutDB = async ( db: Database, login: string, - shouldNotSaveMasterPassword: boolean + shouldNotSaveMasterPassword: boolean, + recoveryOptions?: { + /** Allow prompting for ARK instead of MP */ + promptForArk?: boolean; + displayMasterpassword?: boolean; + } ): Promise => { const localKey = generateLocalKey(); // Register the user's device const deviceCredentials = getDeviceCredentials(); - const { deviceAccessKey, deviceSecretKey, serverKey, ssoServerKey, ssoSpKey, remoteKeys } = deviceCredentials - ? { - deviceAccessKey: deviceCredentials.accessKey, - deviceSecretKey: deviceCredentials.secretKey, - serverKey: undefined, - ssoServerKey: undefined, - ssoSpKey: undefined, - remoteKeys: [], - } - : await registerDevice({ - login, - deviceName: `${os.hostname()} - ${os.platform()}-${os.arch()}`, - }); + const deviceName = `${os.hostname()} - ${os.platform()}-${os.arch()}`; + const { deviceAccessKey, deviceSecretKey, serverKey, ssoServerKey, ssoSpKey, remoteKeys, authTicket } = + deviceCredentials + ? { + deviceAccessKey: deviceCredentials.accessKey, + deviceSecretKey: deviceCredentials.secretKey, + serverKey: undefined, + ssoServerKey: undefined, + ssoSpKey: undefined, + remoteKeys: [], + authTicket: undefined, + } + : await registerDevice({ + login, + deviceName, + }); // Get the authentication type (mainly to identify if the user is with OTP2) // if non-interactive device, we consider it as email_token, so we don't need to call the API @@ -116,7 +129,15 @@ const getLocalConfigurationWithoutDB = async ( if (isSSO) { masterPassword = decryptSsoRemoteKey({ ssoServerKey, ssoSpKey, remoteKeys }); } else { - masterPassword = masterPasswordEnv ?? (await askMasterPassword()); + if (recoveryOptions?.promptForArk) { + const freshAuthTicket = authTicket ?? (await getAuthenticationTickets(login)).authTicket; + masterPassword = await getMasterpasswordFromArkPrompt(freshAuthTicket, login); + if (recoveryOptions.displayMasterpassword) { + logger.info(`Recovered masterpassword ${masterPassword}`); + } + } else { + masterPassword = masterPasswordEnv ?? (await askMasterPassword()); + } // In case of OTP2 if (type === 'totp_login' && serverKey) { @@ -281,10 +302,19 @@ export const replaceMasterPassword = async ( }; }; +export interface GetLocalConfigurationConfiguration { + shouldNotSaveMasterPasswordIfNoDeviceKeys?: boolean; + shouldAskForArk?: boolean; + recoveryOptions?: { + /** Allow prompting for ARK instead of MP */ + promptForArk?: boolean; + displayMasterpassword?: boolean; + }; +} export const getLocalConfiguration = async ( db: Database, deviceConfiguration: DeviceConfiguration | null, - shouldNotSaveMasterPasswordIfNoDeviceKeys = false + { shouldNotSaveMasterPasswordIfNoDeviceKeys = false, recoveryOptions }: GetLocalConfigurationConfiguration = {} ): Promise => { let login: string; if (deviceConfiguration) { @@ -295,7 +325,7 @@ export const getLocalConfiguration = async ( // If there are no configuration and secrets in the DB if (!deviceConfiguration) { - return getLocalConfigurationWithoutDB(db, login, shouldNotSaveMasterPasswordIfNoDeviceKeys); + return getLocalConfigurationWithoutDB(db, login, shouldNotSaveMasterPasswordIfNoDeviceKeys, recoveryOptions); } let localKey: Buffer | undefined = undefined; diff --git a/src/modules/database/connectAndPrepare.ts b/src/modules/database/connectAndPrepare.ts index b37bd94..ad6490f 100644 --- a/src/modules/database/connectAndPrepare.ts +++ b/src/modules/database/connectAndPrepare.ts @@ -27,6 +27,12 @@ export interface ConnectAndPrepareParams { /* Force the synchronization of the vault */ forceSync?: boolean; + + recoveryOptions?: { + /** Allow prompting for ARK instead of MP */ + promptForArk?: boolean; + displayMasterpassword?: boolean; + }; } export const connectAndPrepare = async ( @@ -36,7 +42,7 @@ export const connectAndPrepare = async ( localConfiguration: LocalConfiguration; deviceConfiguration: DeviceConfiguration | null; }> => { - const { autoSync, shouldNotSaveMasterPasswordIfNoDeviceKeys, failIfNoDB, forceSync } = params; + const { autoSync, shouldNotSaveMasterPasswordIfNoDeviceKeys, failIfNoDB, forceSync, recoveryOptions } = params; const db = connect(); db.serialize(); @@ -52,11 +58,10 @@ export const connectAndPrepare = async ( process.exit(1); }); - const localConfiguration = await getLocalConfiguration( - db, - deviceConfiguration, - shouldNotSaveMasterPasswordIfNoDeviceKeys - ); + const localConfiguration = await getLocalConfiguration(db, deviceConfiguration, { + shouldNotSaveMasterPasswordIfNoDeviceKeys, + recoveryOptions, + }); // if the device was created for the first time we need to get the device credentials again if (!deviceConfiguration) { diff --git a/src/utils/dialogs.ts b/src/utils/dialogs.ts index a0f4f55..17628af 100644 --- a/src/utils/dialogs.ts +++ b/src/utils/dialogs.ts @@ -72,6 +72,19 @@ export const askConfirmReset = async () => { }); return response; }; +export const askConfirmRecovery = async () => { + const response = await confirm({ + message: 'Doing a recovery will delete all local data from this app. Continue?', + }); + return response; +}; + +export const askConfirmShowMp = async () => { + const response = await confirm({ + message: 'Do you want to see the masterpassword that will be unlocked by this recovery key?', + }); + return response; +}; export const askCredentialChoice = async (params: { matchedCredentials: VaultCredential[]; hasFilters: boolean }) => { const message = params.hasFilters @@ -141,6 +154,18 @@ export const askToken = async () => { return response; }; +export const askAccountRecoveryKey = async () => { + const response = input({ + message: 'Please enter your account recovery key:', + validate(input: string) { + // 28 characters alpha numeric, capslock, + // can contain dashes between groups of 4 symbols + return /^(([A-Z0-9]{4}-*){7})$/.test(input) ? true : 'Not a valid recovery key'; + }, + }); + return response; +}; + export const askVerificationMethod = async ( verificationMethods: GetAuthenticationMethodsForDeviceResult['verifications'] ) => {