diff --git a/keepercommander/api.py b/keepercommander/api.py index 2e62303f9..c8a1fb77d 100644 --- a/keepercommander/api.py +++ b/keepercommander/api.py @@ -64,11 +64,12 @@ def login(params, new_login=False): # type: (KeeperParams, bool) -> None logging.info('Logging in to Keeper Commander') + flow = loginv3.LoginV3Flow() try: - loginv3.LoginV3Flow.login(params, new_login=new_login) + flow.login(params, new_login=new_login) except loginv3.InvalidDeviceToken: logging.warning('Registering new device') - loginv3.LoginV3Flow.login(params, new_device=True) + flow.login(params, new_device=True) def accept_account_transfer_consent(params): @@ -636,7 +637,7 @@ def communicate_rest(params, request, endpoint, *, rs_type=None, payload_version api_request_payload.apiVersion = payload_version rs = rest_api.execute_rest(params.rest_context, endpoint, api_request_payload) - if type(rs) == bytes: + if isinstance(rs, bytes): TTK.update_time_of_last_activity() if rs_type: proto_rs = rs_type() @@ -647,7 +648,7 @@ def communicate_rest(params, request, endpoint, *, rs_type=None, payload_version return proto_rs else: return rs - elif type(rs) == dict: + elif isinstance(rs, dict): kae = KeeperApiError(rs['error'], rs['message']) if kae.result_code == 'session_token_expired': params.session_token = None diff --git a/keepercommander/auth/__init__.py b/keepercommander/auth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/keepercommander/auth/console_ui.py b/keepercommander/auth/console_ui.py new file mode 100644 index 000000000..e10bdba2d --- /dev/null +++ b/keepercommander/auth/console_ui.py @@ -0,0 +1,351 @@ +import json +import getpass +import logging +import pyperclip +import re +import webbrowser +from typing import Optional, List + +from . import login_steps +from .. import utils +from ..display import bcolors +from ..error import KeeperApiError + + +class ConsoleLoginUi(login_steps.LoginUi): + def __init__(self): + self._show_device_approval_help = True + self._show_two_factor_help = True + self._show_password_help = True + self._show_sso_redirect_help = True + self._show_sso_data_key_help = True + self._failed_password_attempt = 0 + + def on_device_approval(self, step): + if self._show_device_approval_help: + print("\nDevice Approval Required\n") + + print("Approve by selecting a method below:") + print("\t\"" + bcolors.OKGREEN + "email_send" + bcolors.ENDC + "\" to send email") + print("\t\"" + bcolors.OKGREEN + "email_code=" + bcolors.ENDC + "\" to validate verification code sent via email") + print("\t\"" + bcolors.OKGREEN + "keeper_push" + bcolors.ENDC + "\" to send Keeper Push notification") + print("\t\"" + bcolors.OKGREEN + "2fa_send" + bcolors.ENDC + "\" to send 2FA code") + print("\t\"" + bcolors.OKGREEN + "2fa_code=" + bcolors.ENDC + "\" to validate a code provided by 2FA application") + print("\t\"" + bcolors.OKGREEN + "" + bcolors.ENDC + "\" to resume") + + self._show_device_approval_help = False + else: + print(bcolors.BOLD + "\nWaiting for device approval." + bcolors.ENDC) + print("Check email, SMS message or push notification on the approved device.\n") + + try: + selection = input('Type your selection or to resume: ') + + if selection == "email_send" or selection == "es": + step.send_push(login_steps.DeviceApprovalChannel.Email) + print(bcolors.WARNING + "\nAn email with instructions has been sent to " + step.username + bcolors.WARNING + '\nPress when approved.') + + elif selection.startswith("email_code="): + code = selection.replace("email_code=", "") + step.send_code(login_steps.DeviceApprovalChannel.Email, code) + print("Successfully verified email code.") + + elif selection == "2fa_send" or selection == "2fs": + step.send_push(login_steps.DeviceApprovalChannel.TwoFactor) + print(bcolors.WARNING + "\n2FA code was sent." + bcolors.ENDC) + + elif selection.startswith("2fa_code="): + code = selection.replace("2fa_code=", "") + step.send_code(login_steps.DeviceApprovalChannel.TwoFactor, code) + print("Successfully verified 2FA code.") + + elif selection == "keeper_push" or selection == "kp": + step.send_push(login_steps.DeviceApprovalChannel.KeeperPush) + logging.info('Successfully made a push notification to the approved device.\nPress when approved.') + + elif selection == "": + step.resume() + except KeyboardInterrupt: + step.cancel() + except KeeperApiError as kae: + print() + print(bcolors.WARNING + kae.message + bcolors.ENDC) + pass + + @staticmethod + def two_factor_channel_to_desc(channel): # type: (login_steps.TwoFactorChannel) -> str + if channel == login_steps.TwoFactorChannel.Authenticator: + return 'TOTP (Google and Microsoft Authenticator)' + if channel == login_steps.TwoFactorChannel.TextMessage: + return 'Send SMS Code' + if channel == login_steps.TwoFactorChannel.DuoSecurity: + return 'DUO' + if channel == login_steps.TwoFactorChannel.RSASecurID: + return 'RSA SecurID' + if channel == login_steps.TwoFactorChannel.SecurityKey: + return 'WebAuthN (FIDO2 Security Key)' + if channel == login_steps.TwoFactorChannel.KeeperDNA: + return 'Keeper DNA (Watch)' + if channel == login_steps.TwoFactorChannel.Backup: + return 'Backup Codes' + + def on_two_factor(self, step): + channels = step.get_channels() + + if self._show_two_factor_help: + print("\nThis account requires 2FA Authentication\n") + for i in range(len(channels)): + channel = channels[i] + print(f"{i+1:>3}. {ConsoleLoginUi.two_factor_channel_to_desc(channel.channel_type)} {channel.channel_name} {channel.phone}") + print(f"{'q':>3}. Quit login attempt and return to Commander prompt") + self._show_device_approval_help = False + + channel = None # type: Optional[login_steps.TwoFactorChannelInfo] + while channel is None: + selection = input('Selection: ') + if selection == 'q': + raise KeyboardInterrupt() + + if selection.isnumeric(): + idx = int(selection) + if 1 <= idx <= len(channels): + channel = channels[idx-1] + logging.debug(f"Selected {idx}. {ConsoleLoginUi.two_factor_channel_to_desc(channel.channel_type)}") + else: + print("Invalid entry, additional factors of authentication shown may be configured if not currently enabled.") + else: + print("Invalid entry, additional factors of authentication shown may be configured if not currently enabled.") + + mfa_prompt = False + + if channel.channel_type == login_steps.TwoFactorChannel.Other: + pass + elif channel.channel_type == login_steps.TwoFactorChannel.TextMessage: + mfa_prompt = True + try: + step.send_push(channel.channel_uid, login_steps.TwoFactorPushAction.TextMessage) + print(bcolors.OKGREEN + "\nSuccessfully sent SMS.\n" + bcolors.ENDC) + except KeeperApiError: + print("Was unable to send SMS.") + elif channel.channel_type == login_steps.TwoFactorChannel.SecurityKey: + try: + from ..yubikey.yubikey import yubikey_authenticate + challenge = json.loads(channel.challenge) + response = yubikey_authenticate(challenge) + + if response: + credential_id = response.credential_id + signature = { + "id": utils.base64_url_encode(credential_id), + "rawId": utils.base64_url_encode(credential_id), + "response": { + "authenticatorData": utils.base64_url_encode(response.authenticator_data), + "clientDataJSON": response.client_data.b64, + "signature": utils.base64_url_encode(response.signature), + }, + "type": "public-key", + "clientExtensionResults": response.extension_results or {} + } + step.duration = login_steps.TwoFactorDuration.EveryLogin + step.send_code(channel.channel_uid, json.dumps(signature)) + print(bcolors.OKGREEN + "Verified Security Key." + bcolors.ENDC) + + except ImportError as e: + from ..yubikey import display_fido2_warning + display_fido2_warning() + logging.warning(e) + except KeeperApiError: + print(bcolors.FAIL + "Unable to verify code generated by security key" + bcolors.ENDC) + except Exception as e: + logging.error(e) + + elif channel.channel_type in {login_steps.TwoFactorChannel.Authenticator, + login_steps.TwoFactorChannel.DuoSecurity, + login_steps.TwoFactorChannel.RSASecurID, + login_steps.TwoFactorChannel.KeeperDNA, + login_steps.TwoFactorChannel.Backup}: + mfa_prompt = True + else: + raise NotImplementedError(f"Unhandled channel type {ConsoleLoginUi.two_factor_channel_to_desc(channel.channel_type)}") + + if mfa_prompt: + config_expiration = step.get_max_duration() + mfa_expiration = step.duration + + if mfa_expiration > config_expiration: + mfa_expiration = config_expiration + + allowed_expirations = ['login'] # type: List[str] + if channel.max_expiration >= login_steps.TwoFactorDuration.Every12Hours: + allowed_expirations.append('12_hours') + if channel.max_expiration >= login_steps.TwoFactorDuration.Every24Hours: + allowed_expirations.append('24_hours') + if channel.max_expiration >= login_steps.TwoFactorDuration.Every30Days: + allowed_expirations.append('30_days') + if channel.max_expiration >= login_steps.TwoFactorDuration.Forever: + allowed_expirations.append('forever') + + otp_code = '' + show_duration = True + mfa_pattern = re.compile(r'2fa_duration\s*=\s*(.+)', re.IGNORECASE) + while not otp_code: + if show_duration: + show_duration = False + prompt_exp = '\n2FA Code Duration: {0}.\nTo change duration: 2fa_duration={1}'.format( + 'Require Every Login' if mfa_expiration == login_steps.TwoFactorDuration.EveryLogin else + 'Save on this Device Forever' if mfa_expiration == login_steps.TwoFactorDuration.Forever else + 'Ask Every 12 hours' if mfa_expiration == login_steps.TwoFactorDuration.Every12Hours else + 'Ask Every 24 hours' if mfa_expiration == login_steps.TwoFactorDuration.Every24Hours else + 'Ask Every 30 days', + "|".join(allowed_expirations)) + print(prompt_exp) + + try: + answer = input('\nEnter 2FA Code or Duration: ') + except KeyboardInterrupt: + step.cancel() + return + + m_duration = re.match(mfa_pattern, answer) + if m_duration: + answer = m_duration.group(1).strip().lower() + if answer not in allowed_expirations: + print(f'Invalid 2FA Duration: {answer}') + answer = '' + + if answer == 'login': + show_duration = True + mfa_expiration = login_steps.TwoFactorDuration.EveryLogin + elif answer == '12_hours': + show_duration = True + mfa_expiration = login_steps.TwoFactorDuration.Every12Hours + elif answer == '24_hours': + show_duration = True + mfa_expiration = login_steps.TwoFactorDuration.Every24Hours + elif answer == '30_days': + show_duration = True + mfa_expiration = login_steps.TwoFactorDuration.Every30Days + elif answer == 'forever': + show_duration = True + mfa_expiration = login_steps.TwoFactorDuration.Forever + else: + otp_code = answer + + step.duration = mfa_expiration + try: + step.send_code(channel.channel_uid, otp_code) + print(bcolors.OKGREEN + "Successfully verified 2FA Code." + bcolors.ENDC) + except KeeperApiError: + warning_msg = bcolors.WARNING + f"Unable to verify 2FA code. Regenerate the code and try again." + bcolors.ENDC + print(warning_msg) + + def on_password(self, step): + if self._show_password_help: + print(f'Enter password for {step.username}') + + if self._failed_password_attempt > 0: + print('Forgot password? Type "recover"') + + password = getpass.getpass(prompt='Password: ', stream=None) + if not password: + step.cancel() + elif password == 'recover': + step.forgot_password() + else: + try: + step.verify_password(password) + except KeeperApiError as kae: + print(kae.message) + except KeyboardInterrupt: + step.cancel() + + def on_sso_redirect(self, step): + try: + wb = webbrowser.get() + except: + wb = None + + sp_url = step.sso_login_url + print(f'\nSSO Login URL:\n{sp_url}\n') + if self._show_sso_redirect_help: + print('Navigate to SSO Login URL with your browser and complete login.') + print('Copy a returned SSO Token into clipboard.') + print('Paste that token into Commander') + print('NOTE: To copy SSO Token please click "Copy login token" button on "SSO Connect" page.') + print('') + print(' a. SSO User with a Master Password') + print(' c. Copy SSO Login URL to clipboard') + if wb: + print(' o. Navigate to SSO Login URL with the default web browser') + print(' p. Paste SSO Token from clipboard') + print(' q. Quit SSO login attempt and return to Commander prompt') + self._show_sso_redirect_help = False + + while True: + try: + token = input('Selection: ') + except KeyboardInterrupt: + step.cancel() + return + if token == 'q': + step.cancel() + return + if token == 'a': + step.login_with_password() + return + if token == 'c': + token = None + try: + pyperclip.copy(sp_url) + print('SSO Login URL is copied to clipboard.') + except: + print('Failed to copy SSO Login URL to clipboard.') + elif token == 'o': + token = None + if wb: + try: + wb.open_new_tab(sp_url) + except: + print('Failed to open web browser.') + elif token == 'p': + try: + token = pyperclip.paste() + except: + token = '' + logging.info('Failed to paste from clipboard') + else: + if len(token) < 10: + print(f'Unsupported menu option: {token}') + token = None + if token: + step.set_sso_token(token) + break + + def on_sso_data_key(self, step): + if self._show_sso_data_key_help: + print('\nApprove this device by selecting a method below:') + print(' 1. Keeper Push. Send a push notification to your device.') + print(' 2. Admin Approval. Request your admin to approve this device.') + print('') + print(' r. Resume SSO login after device is approved.') + print(' q. Quit SSO login attempt and return to Commander prompt.') + self._show_sso_data_key_help = False + + while True: + try: + answer = input('Selection: ') + except KeyboardInterrupt: + answer = 'q' + + if answer == 'q': + step.cancel() + break + elif answer == 'r': + step.resume() + break + elif answer == '1': + step.request_data_key(login_steps.DataKeyShareChannel.KeeperPush) + elif answer == '2': + step.request_data_key(login_steps.DataKeyShareChannel.AdminApproval) + else: + print(f'Action \"{answer}\" is not supported.') diff --git a/keepercommander/auth/login_steps.py b/keepercommander/auth/login_steps.py new file mode 100644 index 000000000..74d065934 --- /dev/null +++ b/keepercommander/auth/login_steps.py @@ -0,0 +1,202 @@ +import abc +import enum +from typing import Optional, Sequence + + +class ILoginStep(abc.ABC): + @abc.abstractmethod + def cancel(self): + pass + + def is_final(self): + return False + + +class DeviceApprovalChannel(enum.Enum): + Email = enum.auto() + KeeperPush = enum.auto() + TwoFactor = enum.auto() + + +class TwoFactorDuration(enum.IntEnum): + EveryLogin = enum.auto() + Every12Hours = enum.auto() + Every24Hours = enum.auto() + EveryDay = enum.auto() + Every30Days = enum.auto() + Forever = enum.auto() + + +class TwoFactorChannel(enum.Enum): + Other = enum.auto() + Authenticator = enum.auto() + TextMessage = enum.auto() + DuoSecurity = enum.auto() + RSASecurID = enum.auto() + KeeperDNA = enum.auto() + SecurityKey = enum.auto() + Backup = enum.auto() + + +class TwoFactorPushAction(enum.Enum): + DuoPush = enum.auto() + DuoTextMessage = enum.auto() + DuoVoiceCall = enum.auto() + TextMessage = enum.auto() + KeeperDna = enum.auto() + + +class DataKeyShareChannel(enum.Enum): + KeeperPush = enum.auto() + AdminApproval = enum.auto() + + +class LoginStepDeviceApproval(ILoginStep, abc.ABC): + @property + @abc.abstractmethod + def username(self): + pass + + @abc.abstractmethod + def send_push(self, channel): # type: (DeviceApprovalChannel) -> None + pass + + @abc.abstractmethod + def send_code(self, channel, code): # type: (DeviceApprovalChannel, str) -> None + pass + + @abc.abstractmethod + def resume(self): # type: () -> None + pass + + +class TwoFactorChannelInfo: + def __init__(self) -> None: + self.channel_type: TwoFactorChannel = TwoFactorChannel.Other + self.channel_name = '' + self.channel_uid = b'' + self.phone = None # type: Optional[str] + self.max_expiration: TwoFactorDuration = TwoFactorDuration.EveryLogin + self.challenge = '' + + +class LoginStepTwoFactor(ILoginStep, abc.ABC): + def __init__(self) -> None: + self.duration: TwoFactorDuration = TwoFactorDuration.EveryLogin + + def get_max_duration(self): # type: () -> TwoFactorDuration + return TwoFactorDuration.Forever + + @abc.abstractmethod + def get_channels(self): # type: () -> Sequence[TwoFactorChannelInfo] + pass + + @abc.abstractmethod + def get_channel_push_actions(self, channel_uid): # type: (bytes) -> Sequence[TwoFactorPushAction] + pass + + @abc.abstractmethod + def send_push(self, channel_uid, action): # type: (bytes, TwoFactorPushAction) -> None + pass + + @abc.abstractmethod + def send_code(self, channel_uid: bytes, code: str) -> None: + pass + + @abc.abstractmethod + def resume(self) -> None: + pass + + +class LoginStepSsoDataKey(ILoginStep, abc.ABC): + @staticmethod + def get_channels(): # type: () -> Sequence[DataKeyShareChannel] + return DataKeyShareChannel.KeeperPush, DataKeyShareChannel.AdminApproval + + @abc.abstractmethod + def request_data_key(self, channel): # type: (DataKeyShareChannel) -> None + pass + + @abc.abstractmethod + def resume(self): # type: () -> None + pass + + +class LoginStepPassword(ILoginStep, abc.ABC): + @property + @abc.abstractmethod + def username(self): # type: () -> str + pass + + @abc.abstractmethod + def forgot_password(self): # type: () -> None + pass + + @abc.abstractmethod + def verify_password(self, password): # type: (str) -> None + pass + + @abc.abstractmethod + def verify_biometric_key(self, biometric_key): # type: (bytes) -> None + pass + + +class LoginStepError(ILoginStep): + def __init__(self, code, message): # type: (str, str) -> None + self.code = code + self.message = message + + def is_final(self) -> bool: + return True + + +class LoginStepSsoToken(ILoginStep, abc.ABC): + @abc.abstractmethod + def set_sso_token(self, token): # type: (str) -> None + pass + + @abc.abstractmethod + def login_with_password(self): # type: () -> None + pass + + @property + @abc.abstractmethod + def is_cloud_sso(self): # type: () -> bool + pass + + @property + @abc.abstractmethod + def is_provider_login(self): # type: () -> bool + pass + + @property + @abc.abstractmethod + def login_name(self): # type: () -> str + pass + + @property + @abc.abstractmethod + def sso_login_url(self): # type: () -> str + pass + + +class LoginUi(abc.ABC): + @abc.abstractmethod + def on_device_approval(self, step): # type: (LoginStepDeviceApproval) -> None + pass + + @abc.abstractmethod + def on_two_factor(self, step): # type: (LoginStepTwoFactor) -> None + pass + + @abc.abstractmethod + def on_password(self, step): # type: (LoginStepPassword) -> None + pass + + @abc.abstractmethod + def on_sso_redirect(self, step): # type: (LoginStepSsoToken) -> None + pass + + @abc.abstractmethod + def on_sso_data_key(self, step): # type: (LoginStepSsoDataKey) -> None + pass diff --git a/keepercommander/commands/breachwatch.py b/keepercommander/commands/breachwatch.py index 08231ccee..68ba1b6d8 100644 --- a/keepercommander/commands/breachwatch.py +++ b/keepercommander/commands/breachwatch.py @@ -21,7 +21,7 @@ from ..breachwatch import BreachWatch from ..params import KeeperParams from ..error import CommandError -from ..proto import client_pb2 as client_proto, breachwatch_pb2 as breachwatch_proto +from ..proto import breachwatch_pb2, client_pb2 breachwatch_list_parser = argparse.ArgumentParser(prog='breachwatch-list') @@ -170,16 +170,16 @@ def execute(self, params, **kwargs): # type: (KeeperParams, Any) -> Any if euid: euid_to_delete.append(base64.b64decode(euid)) if record_password in scans: - bwrq = breachwatch_proto.BreachWatchRecordRequest() + bwrq = breachwatch_pb2.BreachWatchRecordRequest() bwrq.recordUid = utils.base64_url_decode(record_uid) - bwrq.breachWatchInfoType = breachwatch_proto.RECORD + bwrq.breachWatchInfoType = breachwatch_pb2.RECORD bwrq.updateUserWhoScanned = True hash_status = scans[record_password] - bw_password = client_proto.BWPassword() + bw_password = client_pb2.BWPassword() bw_password.value = record_password - bw_password.status = client_proto.WEAK if hash_status.breachDetected else client_proto.GOOD + bw_password.status = client_pb2.WEAK if hash_status.breachDetected else client_pb2.GOOD bw_password.euid = hash_status.euid - bw_data = client_proto.BreachWatchData() + bw_data = client_pb2.BreachWatchData() bw_data.passwords.append(bw_password) data = bw_data.SerializeToString() try: @@ -191,10 +191,10 @@ def execute(self, params, **kwargs): # type: (KeeperParams, Any) -> Any while bw_requests: chunk = bw_requests[0:999] bw_requests = bw_requests[999:] - rq = breachwatch_proto.BreachWatchUpdateRequest() + rq = breachwatch_pb2.BreachWatchUpdateRequest() rq.breachWatchRecordRequest.extend(chunk) api.communicate_rest(params, rq, 'breachwatch/update_record_data', - rs_type=breachwatch_proto.BreachWatchUpdateResponse) + rs_type=breachwatch_pb2.BreachWatchUpdateResponse) params.sync_data = True if euid_to_delete: params.breach_watch.delete_euids(params, euid_to_delete) @@ -237,19 +237,19 @@ def execute(self, params, **kwargs): # type: (KeeperParams, any) -> any if record.record_uid not in record_uids: continue record_uids.remove(record.record_uid) - bwrq = breachwatch_proto.BreachWatchRecordRequest() + bwrq = breachwatch_pb2.BreachWatchRecordRequest() bwrq.recordUid = utils.base64_url_decode(record.record_uid) - bwrq.breachWatchInfoType = breachwatch_proto.RECORD + bwrq.breachWatchInfoType = breachwatch_pb2.RECORD bwrq.updateUserWhoScanned = False - bw_password = client_proto.BWPassword() + bw_password = client_pb2.BWPassword() bw_password.value = password.get('value') bw_password.resolved = utils.current_milli_time() - bw_password.status = client_proto.IGNORE + bw_password.status = client_pb2.IGNORE euid = password.get('euid') if euid: bw_password.euid = base64.b64decode(euid) - bw_data = client_proto.BreachWatchData() + bw_data = client_pb2.BreachWatchData() bw_data.passwords.append(bw_password) data = bw_data.SerializeToString() try: @@ -271,10 +271,10 @@ def execute(self, params, **kwargs): # type: (KeeperParams, any) -> any while bw_requests: chunk = bw_requests[0:999] bw_requests = bw_requests[999:] - rq = breachwatch_proto.BreachWatchUpdateRequest() + rq = breachwatch_pb2.BreachWatchUpdateRequest() rq.breachWatchRecordRequest.extend(chunk) rs = api.communicate_rest(params, rq, 'breachwatch/update_record_data', - rs_type=breachwatch_proto.BreachWatchUpdateResponse) + rs_type=breachwatch_pb2.BreachWatchUpdateResponse) for status in rs.breachWatchRecordStatus: logging.info(f'{utils.base64_url_encode(status.recordUid)}: {status.status} {status.reason}') diff --git a/keepercommander/loginv3.py b/keepercommander/loginv3.py index f7dae0af9..5216ceec0 100644 --- a/keepercommander/loginv3.py +++ b/keepercommander/loginv3.py @@ -5,36 +5,33 @@ import logging import os import re -import webbrowser +from collections import namedtuple from sys import platform as _platform -from typing import Optional, List +from typing import Optional, List, Any from urllib.parse import urlparse, urlencode, urlunparse, parse_qsl -import pyperclip -from prompt_toolkit.shortcuts import prompt -from prompt_toolkit.lexers.base import Lexer +from cryptography.hazmat.primitives.asymmetric import ec, rsa +from google.protobuf.json_format import MessageToJson from prompt_toolkit.auto_suggest import AutoSuggest, Suggestion from prompt_toolkit.completion import Completer, Completion -from prompt_toolkit.key_binding import KeyBindings -from prompt_toolkit.formatted_text import FormattedText -from prompt_toolkit.validation import Validator, ValidationError -from prompt_toolkit.filters import completion_is_selected from prompt_toolkit.enums import EditingMode +from prompt_toolkit.filters import completion_is_selected +from prompt_toolkit.formatted_text import FormattedText +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.lexers.base import Lexer from prompt_toolkit.shortcuts import CompleteStyle -from cryptography.hazmat.primitives.asymmetric import ec, rsa -from google.protobuf.json_format import MessageToJson +from prompt_toolkit.shortcuts import prompt +from prompt_toolkit.validation import Validator, ValidationError from . import api, rest_api, utils, crypto, constants, generator +from .auth import login_steps, console_ui from .breachwatch import BreachWatch from .config_storage import loader from .display import bcolors from .error import KeeperApiError from .humps import decamelize from .params import KeeperParams -from .proto import APIRequest_pb2 as proto, AccountSummary_pb2 as proto_as -from .proto import breachwatch_pb2 as breachwatch_proto -from .proto import ssocloud_pb2 as ssocloud -from .proto.enterprise_pb2 import LoginToMcRequest, LoginToMcResponse, DomainPasswordRulesRequest +from .proto import APIRequest_pb2, AccountSummary_pb2, breachwatch_pb2, ssocloud_pb2, enterprise_pb2 permissions_error_msg = "Grant Commander SDK permissions to access Keeper by navigating to Admin Console -> Admin -> " \ "Roles -> [Select User's Role] -> Enforcement Policies -> Platform Restrictions -> Click on " \ @@ -43,8 +40,10 @@ class LoginV3Flow: - @staticmethod - def login(params, new_device=False, new_login=False): # type: (KeeperParams, bool, bool) -> None + def __init__(self, login_ui=None): # type: (login_steps.LoginUi) -> None + self.login_ui = login_ui or console_ui.ConsoleLoginUi() # type: login_steps.LoginUi + + def login(self, params, new_device=False, new_login=False): # type: (KeeperParams, bool, bool) -> None logging.debug("Login v3 Start as '%s'", params.user) @@ -65,56 +64,100 @@ def login(params, new_device=False, new_login=False): # type: (KeeperParams, b is_alternate_login = False while True: - - is_cloud = resp.loginState == proto.REQUIRES_DEVICE_ENCRYPTED_DATA_KEY - - if resp.loginState == proto.DEVICE_APPROVAL_REQUIRED: # client goes to “standard device approval”. - print("\nDevice Approval Required") - - verDevResp = LoginV3Flow.verifyDevice( - params, - encryptedDeviceToken, - resp.encryptedLoginToken - ) - - if verDevResp: + is_cloud = resp.loginState == APIRequest_pb2.REQUIRES_DEVICE_ENCRYPTED_DATA_KEY + + if resp.loginState == APIRequest_pb2.DEVICE_APPROVAL_REQUIRED: # client goes to “standard device approval”. + should_cancel = False + should_resume = False + + class DeviceApproval(login_steps.LoginStepDeviceApproval): + @property + def username(self): + return params.user + + def cancel(self): + nonlocal should_cancel + should_cancel = True + + def send_push(self, channel): + nonlocal should_resume + should_resume = LoginV3Flow.verifyDevice( + params, encryptedDeviceToken, resp.encryptedLoginToken, approval_action='push', approval_channel=channel) + + def send_code(self, channel, code): + nonlocal should_resume + should_resume = LoginV3Flow.verifyDevice( + params, encryptedDeviceToken, resp.encryptedLoginToken, approval_action='code', approval_channel=channel, approval_code=code) + + def resume(self): + nonlocal should_resume + should_resume = True + + self.login_ui.on_device_approval(DeviceApproval()) + if should_cancel: + break + if should_resume: resp = LoginV3API.startLoginMessage(params, encryptedDeviceToken) - if resp.loginState != proto.DEVICE_APPROVAL_REQUIRED: - print(bcolors.OKGREEN + "\nDevice was approved" + bcolors.ENDC + "\n") - - else: - print(bcolors.BOLD + "\nWaiting for device approval." + bcolors.ENDC) - print("Check email, SMS message or push notification on the approved device.\n") - - elif resp.loginState == proto.REQUIRES_2FA: - - encryptedLoginToken = LoginV3Flow.handleTwoFactor(params, resp.encryptedLoginToken, resp) - - if encryptedLoginToken: + elif resp.loginState == APIRequest_pb2.REQUIRES_2FA: + supported_channels = {APIRequest_pb2.TWO_FA_CODE_TOTP, APIRequest_pb2.TWO_FA_CT_SMS, + APIRequest_pb2.TWO_FA_CT_DUO, APIRequest_pb2.TWO_FA_CT_RSA, + APIRequest_pb2.TWO_FA_CT_U2F, APIRequest_pb2.TWO_FA_CT_WEBAUTHN, + APIRequest_pb2.TWO_FA_CT_DNA, APIRequest_pb2.TWO_FA_CT_BACKUP} + channels = [_tfa_channel_info_keeper_to_sdk(x) for x in resp.channels if x.channelType in supported_channels] + channels = [x for x in channels if x.channel_type != login_steps.TwoFactorChannel.Other] + if len(channels) == 0: + backup_code_channel = APIRequest_pb2.TwoFactorChannelInfo() + backup_code_channel.channelType = APIRequest_pb2.TWO_FA_CT_BACKUP + channels.append(backup_code_channel) + + encrypted_login_token = None # type: Optional[bytes] + should_cancel = False + + class TwoFactorApproval(login_steps.LoginStepTwoFactor): + def get_channels(self): + return channels + + def get_channel_push_actions(self, channel_uid): + pass + + def send_push(self, channel_uid, action): + LoginV3API.twoFactorSend2FAPushMessage( + params, encryptedLoginToken, pushType=tfa_action_sdk_to_keeper(action), + channel_uid=channel_uid, expireIn=APIRequest_pb2.TWO_FA_EXP_IMMEDIATELY) + + def send_code(self, channel_uid: bytes, code: str) -> None: + nonlocal encrypted_login_token + channel = next((x for x in channels if x.channel_uid == channel_uid), None) + if channel: + encrypted_login_token = LoginV3API.twoFactorValidateMessage( + params, resp.encryptedLoginToken, code, tfa_expire_in=_duration_sdk_to_keeper(self.duration), + channel_uid=channel_uid, twoFactorValueType=_channel_keeper_value(channel.channel_type)) + + def resume(self) -> None: + nonlocal encrypted_login_token + encrypted_login_token = resp.encryptedLoginToken + + def cancel(self): + nonlocal should_cancel + should_cancel = True + + self.login_ui.on_two_factor(TwoFactorApproval()) + if should_cancel: + break + + if encrypted_login_token: # Successfully completed 2FA. Re-login - login_type = 'ALTERNATE' if is_alternate_login else 'NORMAL' + resp = LoginV3API.resume_login(params, encrypted_login_token, encryptedDeviceToken, loginType=login_type) - resp = LoginV3API.resume_login(params, encryptedLoginToken, encryptedDeviceToken, loginType=login_type) - - elif resp.loginState == proto.REQUIRES_USERNAME: - + elif resp.loginState == APIRequest_pb2.REQUIRES_USERNAME: if not params.user: - params.user = getpass.getpass(prompt='User(Email): ', stream=None) - - while not params.user: - params.user = getpass.getpass(prompt='User(Email): ', stream=None) + raise Exception('Username is required.') + resp = LoginV3API.resume_login(params, resp.encryptedLoginToken, encryptedDeviceToken, clone_code_bytes) - encryptedLoginToken = resp.encryptedLoginToken - if encryptedLoginToken: - # Successfully completed 2FA. Re-login - resp = LoginV3API.resume_login(params, encryptedLoginToken, encryptedDeviceToken, clone_code_bytes) - - # raise Exception('Username is required.') - - elif resp.loginState == proto.REDIRECT_ONSITE_SSO or resp.loginState == proto.REDIRECT_CLOUD_SSO: - encryptedLoginToken = LoginV3Flow.handleSsoRedirect(params, resp.loginState == proto.REDIRECT_CLOUD_SSO, resp.url, resp.encryptedLoginToken) + elif resp.loginState == APIRequest_pb2.REDIRECT_ONSITE_SSO or resp.loginState == APIRequest_pb2.REDIRECT_CLOUD_SSO: + encryptedLoginToken = self.handleSsoRedirect(params, resp.loginState == APIRequest_pb2.REDIRECT_CLOUD_SSO, resp.url, resp.encryptedLoginToken) if encryptedLoginToken: resp = LoginV3API.resume_login(params, encryptedLoginToken, encryptedDeviceToken, loginMethod='AFTER_SSO') else: @@ -123,89 +166,103 @@ def login(params, new_device=False, new_login=False): # type: (KeeperParams, b is_alternate_login = True resp = LoginV3API.startLoginMessage(params, encryptedDeviceToken, loginType='ALTERNATE') - elif resp.loginState == proto.REQUIRES_DEVICE_ENCRYPTED_DATA_KEY: + elif resp.loginState == APIRequest_pb2.REQUIRES_DEVICE_ENCRYPTED_DATA_KEY: encryptedLoginToken = resp.encryptedLoginToken - LoginV3Flow.handleSsoRequestDataKey(params, resp.encryptedLoginToken, encryptedDeviceToken) - resp = LoginV3API.resume_login(params, encryptedLoginToken, encryptedDeviceToken) + should_resume = self.handleSsoRequestDataKey(params, resp.encryptedLoginToken, encryptedDeviceToken) + if should_resume: + resp = LoginV3API.resume_login(params, encryptedLoginToken, encryptedDeviceToken) - elif resp.loginState == proto.REQUIRES_ACCOUNT_CREATION: - # if isSSOAccount: - # return createNewSso - raise Exception('This account need to be created.' % rest_api.CLIENT_VERSION) + elif resp.loginState == APIRequest_pb2.REQUIRES_ACCOUNT_CREATION: + raise Exception('This account needs to be created.' % rest_api.CLIENT_VERSION) - elif resp.loginState == proto.REGION_REDIRECT: + elif resp.loginState == APIRequest_pb2.REGION_REDIRECT: params.server = resp.stateSpecificValue logging.info('Redirecting to region: %s', params.server) LoginV3API.register_device_in_region(params, encryptedDeviceToken) resp = LoginV3API.startLoginMessage(params, encryptedDeviceToken) - elif resp.loginState == proto.REQUIRES_AUTH_HASH: - if len(resp.salt) > 0: - salt = api.get_correct_salt(resp.salt) - - salt_bytes = salt.salt - salt_iterations = salt.iterations - is_entered = False - - while True: - if not params.password and params.sso_login_info: - if 'sso_password' in params.sso_login_info and params.sso_login_info['sso_password']: - params.password = params.sso_login_info['sso_password'].pop() + elif resp.loginState == APIRequest_pb2.REQUIRES_AUTH_HASH: + if len(resp.salt) == 0: + self.handle_account_recovery(params, resp.encryptedLoginToken) + return + salt = api.get_correct_salt(resp.salt) + salt_bytes = salt.salt + salt_iterations = salt.iterations + need_account_recovery = False + verify_password_response = None + should_cancel = False + + class PasswordStep(login_steps.LoginStepPassword): + @property + def username(self): + return params.user + + def forgot_password(self): + nonlocal need_account_recovery + need_account_recovery = True + + def verify_password(self, password): + nonlocal verify_password_response + params.auth_verifier = crypto.derive_keyhash_v1(password, salt_bytes, salt_iterations) + verify_password_response = LoginV3API.validateAuthHashMessage(params, resp.encryptedLoginToken) + if verify_password_response: + params.password = password + + def verify_biometric_key(self, biometric_key): + pass + + def cancel(self): + nonlocal should_cancel + should_cancel = True + + step = PasswordStep() + while True: + if not params.password and params.sso_login_info: + if 'sso_password' in params.sso_login_info and params.sso_login_info['sso_password']: + params.password = params.sso_login_info['sso_password'].pop() + + if params.password: try: - is_entered = CommonHelperMethods.fill_password_with_prompt_if_missing(params, is_entered) - except NeedAccountRecovery: - LoginV3API.handle_account_recovery(params, resp.encryptedLoginToken) + step.verify_password(params.password) + except: + params.password = None + else: + self.login_ui.on_password(step) + if should_cancel: return - - if not params.password: + elif need_account_recovery: + self.handle_account_recovery(params, resp.encryptedLoginToken) return - params.salt = salt_bytes - params.iterations = salt_iterations - params.auth_verifier = crypto.derive_keyhash_v1(params.password, salt_bytes, salt_iterations) - - try: - resp = LoginV3API.validateAuthHashMessage(params, resp.encryptedLoginToken) - break - except KeeperApiError as kae: - if kae.result_code == 'auth_failed': - params.password = None - if not params.sso_login_info: - logging.info(kae) - else: - raise kae - else: - LoginV3API.handle_account_recovery(params, resp.encryptedLoginToken) - return + if verify_password_response: + break - if LoginV3Flow.post_login_processing(params, resp): - return - else: - # Not successfully authenticated, so restart login process - clone_code_bytes = utils.base64_url_decode(params.clone_code) if params.clone_code else None - resp = LoginV3API.startLoginMessage(params, encryptedDeviceToken, cloneCode=clone_code_bytes) + if verify_password_response: + params.salt = salt_bytes + params.iterations = salt_iterations + resp = verify_password_response - elif resp.loginState == proto.DEVICE_ACCOUNT_LOCKED: + elif resp.loginState == APIRequest_pb2.DEVICE_ACCOUNT_LOCKED: params.clear_session() raise Exception('\n*** Device for this account is locked ***\n') - elif resp.loginState == proto.DEVICE_LOCKED: + elif resp.loginState == APIRequest_pb2.DEVICE_LOCKED: params.clear_session() raise Exception('\n*** This device is locked ***\n') - elif resp.loginState == proto.ACCOUNT_LOCKED: + elif resp.loginState == APIRequest_pb2.ACCOUNT_LOCKED: raise Exception('\n*** User account `' + params.user + '` is LOCKED ***\n') - elif resp.loginState == proto.LICENSE_EXPIRED: + elif resp.loginState == APIRequest_pb2.LICENSE_EXPIRED: raise Exception('\n*** Your Keeper license has expired ***\n') - elif resp.loginState == proto.UPGRADE: + elif resp.loginState == APIRequest_pb2.UPGRADE: raise Exception('Application or device is out of date and requires an update.') - elif resp.loginState == proto.LOGGED_IN: + elif resp.loginState == APIRequest_pb2.LOGGED_IN: LoginV3Flow.post_login_processing(params, resp) return else: raise Exception("UNKNOWN LOGIN STATE [%s]" % resp.loginState) @staticmethod - def post_login_processing(params: KeeperParams, resp: proto.LoginResponse): + def post_login_processing(params: KeeperParams, resp: APIRequest_pb2.LoginResponse): """Processing after login Returns True if authentication is successful and False otherwise. @@ -222,23 +279,23 @@ def post_login_processing(params: KeeperParams, resp: proto.LoginResponse): LoginV3Flow.populateAccountSummary(params) - if resp.sessionTokenType != proto.NO_RESTRICTION: + if resp.sessionTokenType != APIRequest_pb2.NO_RESTRICTION: # This is not a happy-path login. Let the user know what's wrong. - if resp.sessionTokenType in (proto.PURCHASE, proto.RESTRICT): + if resp.sessionTokenType in (APIRequest_pb2.PURCHASE, APIRequest_pb2.RESTRICT): params.session_token = None msg = ( 'Your Keeper account has expired. Please open the Keeper app to renew or visit the Web ' 'Vault at https://keepersecurity.com/vault' ) raise Exception(msg) - elif resp.sessionTokenType == proto.ACCOUNT_RECOVERY: + elif resp.sessionTokenType == APIRequest_pb2.ACCOUNT_RECOVERY: print('Your Master Password has expired, you are required to change it before you can login.\n') if LoginV3Flow.change_master_password(params): return False else: params.clear_session() raise Exception('Change password failed') - elif resp.sessionTokenType == proto.SHARE_ACCOUNT: + elif resp.sessionTokenType == APIRequest_pb2.SHARE_ACCOUNT: logging.info('Account transfer required') accepted = api.accept_account_transfer_consent(params) if accepted: @@ -252,7 +309,8 @@ def post_login_processing(params: KeeperParams, resp: proto.LoginResponse): if params.license and 'account_type' in params.license: if params.license['account_type'] == 2: try: - rs = api.communicate_rest(params, None, 'enterprise/get_enterprise_public_key', rs_type=breachwatch_proto.EnterprisePublicKeyResponse) + rs = api.communicate_rest(params, None, 'enterprise/get_enterprise_public_key', + rs_type=breachwatch_pb2.EnterprisePublicKeyResponse) if rs.enterpriseECCPublicKey: params.enterprise_ec_key = crypto.load_ec_public_key(rs.enterpriseECCPublicKey) if rs.enterprisePublicKey: @@ -271,12 +329,12 @@ def post_login_processing(params: KeeperParams, resp: proto.LoginResponse): return True @staticmethod - def get_data_key(params: KeeperParams, resp: proto.LoginResponse): + def get_data_key(params: KeeperParams, resp: APIRequest_pb2.LoginResponse): """Get decrypted data key and store in params.data_key Returns login_type_message which is one of ("Persistent Login", "Password", "Master Password"). """ - if resp.encryptedDataKeyType == proto.BY_DEVICE_PUBLIC_KEY: + if resp.encryptedDataKeyType == APIRequest_pb2.BY_DEVICE_PUBLIC_KEY: private_key = crypto.load_ec_private_key(utils.base64_url_decode(params.device_private_key)) decrypted_data_key = crypto.decrypt_ec(resp.encryptedDataKey, private_key) if params.sso_login_info: @@ -284,18 +342,18 @@ def get_data_key(params: KeeperParams, resp: proto.LoginResponse): else: login_type_message = bcolors.UNDERLINE + "Persistent Login" - elif resp.encryptedDataKeyType == proto.BY_PASSWORD: + elif resp.encryptedDataKeyType == APIRequest_pb2.BY_PASSWORD: decrypted_data_key = \ utils.decrypt_encryption_params(resp.encryptedDataKey, params.password) login_type_message = bcolors.UNDERLINE + "Password" - elif resp.encryptedDataKeyType == proto.BY_ALTERNATE: + elif resp.encryptedDataKeyType == APIRequest_pb2.BY_ALTERNATE: decryption_key = crypto.derive_keyhash_v2('data_key', params.password, params.salt, params.iterations) decrypted_data_key = crypto.decrypt_aes_v2(resp.encryptedDataKey, decryption_key) login_type_message = bcolors.UNDERLINE + "Master Password" - elif resp.encryptedDataKeyType == proto.NO_KEY \ - or resp.encryptedDataKeyType == proto.BY_BIO: + elif resp.encryptedDataKeyType == APIRequest_pb2.NO_KEY \ + or resp.encryptedDataKeyType == APIRequest_pb2.BY_BIO: raise Exception("Data Key type %s decryption not implemented" % resp.encryptedDataKeyType) else: raise Exception("Data Key type %s decryption not implemented" % resp.encryptedDataKeyType) @@ -304,14 +362,14 @@ def get_data_key(params: KeeperParams, resp: proto.LoginResponse): return login_type_message @staticmethod - def get_default_password_rules(params): # type: (KeeperParams) -> (List[proto.PasswordRules], int) - rq = DomainPasswordRulesRequest() + def get_default_password_rules(params): # type: (KeeperParams) -> (List[APIRequest_pb2.PasswordRules], int) + rq = enterprise_pb2.DomainPasswordRulesRequest() rq.username = params.user rs = api.communicate_rest(params, rq, 'authentication/get_domain_password_rules', - rs_type=proto.NewUserMinimumParams) + rs_type=APIRequest_pb2.NewUserMinimumParams) rules = [] for regexp, description in zip(rs.passwordMatchRegex, rs.passwordMatchDescription): - rule = proto.PasswordRules() + rule = APIRequest_pb2.PasswordRules() rule.match = True rule.pattern = regexp rule.description = description @@ -321,7 +379,7 @@ def get_default_password_rules(params): # type: (KeeperParams) -> (List[proto.P @staticmethod def change_master_password(params, password_rules=None, min_iterations=None): - # type: (KeeperParams, Optional[List[proto.PasswordRules]], Optional[int]) -> bool + # type: (KeeperParams, Optional[List[APIRequest_pb2.PasswordRules]], Optional[int]) -> bool """Change the master password when expired Return True if the master password is successfully changed and False otherwise. @@ -419,122 +477,75 @@ def populateAccountSummary(params: KeeperParams): params.prepare_commands = True @staticmethod - def verifyDevice(params: KeeperParams, encryptedDeviceToken: bytes, encryptedLoginToken: bytes): - - print("Approve by selecting a method below:") - - print("\t\"" + bcolors.OKGREEN + "email_send" + bcolors.ENDC + "\" to send email") - print("\t\"" + bcolors.OKGREEN + "email_code=" + bcolors.ENDC + "\" to validate verification code sent via email") - print("\t\"" + bcolors.OKGREEN + "keeper_push" + bcolors.ENDC + "\" to send Keeper Push notification") - print("\t\"" + bcolors.OKGREEN + "2fa_send" + bcolors.ENDC + "\" to send 2FA code") - print("\t\"" + bcolors.OKGREEN + "2fa_code=" + bcolors.ENDC + "\" to validate a code provided by 2FA application") - print("\t\"" + bcolors.OKGREEN + "" + bcolors.ENDC + "\" to resume") - - selection = input('Type your selection or to resume: ') - - if selection == "email_send" or selection == "es": - - rs = LoginV3API.requestDeviceVerificationMessage(params, encryptedDeviceToken, 'email') - - if type(rs) == bytes: - print(bcolors.WARNING + "\nAn email with instructions has been sent to " + params.user + bcolors.WARNING + '\nPress when approved.') - else: - raise KeeperApiError(rs['error'], rs['message']) - - elif selection.startswith("email_code="): - code = selection.replace("email_code=", "") - - rs = LoginV3API.validateDeviceVerificationCodeMessage( - params, - code - ) - - if type(rs) == bytes: - - print("Successfully verified email code.") - return True - else: - print() - print(bcolors.WARNING + rs['message'] + bcolors.ENDC) - - elif selection == "2fa_send" or selection == "2fs": - rs = LoginV3API.twoFactorSend2FAPushMessage( - params, - encryptedLoginToken) - if type(rs) == bytes: - print(bcolors.WARNING + "\n2FA code was sent." + bcolors.ENDC) - else: - raise KeeperApiError(rs['error'], rs['message']) - - elif selection.startswith("2fa_code="): - code = selection.replace("2fa_code=", "") - - rs = LoginV3API.twoFactorValidateMessage(params, encryptedLoginToken, code, proto.TWO_FA_EXP_IMMEDIATELY) - - if type(rs) == bytes: - logging.info("Successfully verified 2FA code.") - return True - else: - raise KeeperApiError(rs['error'], rs['message']) - - elif selection == "keeper_push" or selection == "kp": - - rs = LoginV3API.twoFactorSend2FAPushMessage( - params, - encryptedLoginToken, - pushType=proto.TWO_FA_PUSH_KEEPER) - - if type(rs) == bytes: - logging.info('Successfully made a push notification to the approved device.\nPress when approved.') - else: - raise KeeperApiError(rs['error'], rs['message']) - - elif selection == "": + def verifyDevice(params, # type: KeeperParams + encryptedDeviceToken, # type: bytes + encryptedLoginToken, # type: bytes + *, + approval_action, # type: str + approval_channel, # login_steps.DeviceApprovalChannel + approval_code=None # Optional[str] + ): # type: (...) -> bool + if approval_action == 'push': + if approval_channel == login_steps.DeviceApprovalChannel.Email: + LoginV3API.requestDeviceVerificationMessage(params, encryptedDeviceToken, 'email') + elif approval_channel == login_steps.DeviceApprovalChannel.KeeperPush: + LoginV3API.twoFactorSend2FAPushMessage(params, encryptedLoginToken, pushType=APIRequest_pb2.TWO_FA_PUSH_KEEPER) + elif approval_channel == login_steps.DeviceApprovalChannel.TwoFactor: + LoginV3API.twoFactorSend2FAPushMessage(params, encryptedLoginToken) + return False + + if approval_action == 'code': + if approval_channel == login_steps.DeviceApprovalChannel.Email: + LoginV3API.validateDeviceVerificationCodeMessage(params, approval_code) + elif approval_channel == login_steps.DeviceApprovalChannel.TwoFactor: + LoginV3API.twoFactorValidateMessage(params, encryptedLoginToken, approval_code) return True - @staticmethod - def handleSsoRequestDataKey(params, login_token, device_token): # type: (KeeperParams, bytes, bytes) -> None - print('Approve this device by selecting a method below:') - print(' 1. Keeper Push. Send a push notification to your device.') - print(' 2. Admin Approval. Request your admin to approve this device.') - print('') - print(' r. Resume SSO login after device is approved.') - print(' q. Quit SSO login attempt and return to Commander prompt.') + return False - while True: - answer = input('Selection: ') - if answer == 'q': - raise KeyboardInterrupt() - if answer == 'r': - return - try: - if answer == '1': - rq = proto.TwoFactorSendPushRequest() - rq.pushType = proto.TWO_FA_PUSH_KEEPER - rq.encryptedLoginToken = login_token + def handleSsoRequestDataKey(self, params, login_token, device_token): # type: (KeeperParams, bytes, bytes) -> bool + should_cancel = False + should_resume = False + class SsoDataKeyStep(login_steps.LoginStepSsoDataKey): + def request_data_key(self, channel): + nonlocal should_resume + if channel == login_steps.DataKeyShareChannel.KeeperPush: + rq = APIRequest_pb2.TwoFactorSendPushRequest() + rq.pushType = APIRequest_pb2.TWO_FA_PUSH_KEEPER + rq.encryptedLoginToken = login_token api.communicate_rest(params, rq, "authentication/2fa_send_push") - elif answer == '2': - rq = proto.DeviceVerificationRequest() + elif channel == login_steps.DataKeyShareChannel.AdminApproval: + rq = APIRequest_pb2.DeviceVerificationRequest() rq.username = params.user rq.clientVersion = rest_api.CLIENT_VERSION rq.encryptedDeviceToken = device_token - rs = api.communicate_rest(params, rq, "authentication/request_device_admin_approval", rs_type=proto.DeviceVerificationResponse) - if rs.deviceStatus == proto.DEVICE_OK: - return - elif answer: - logging.info(f'Action \"{answer}\" is not supported.') - except Exception as e: - logging.warning(f'Device approval request failed: {e}') + rs = api.communicate_rest(params, rq, "authentication/request_device_admin_approval", + rs_type=APIRequest_pb2.DeviceVerificationResponse) + if rs.deviceStatus == APIRequest_pb2.DEVICE_OK: + should_resume = True - @staticmethod - def handleSsoRedirect(params, is_cloud, sso_url, login_token): - # type: (KeeperParams, bool, str, bytes) -> Optional[bytes] + def resume(self): + nonlocal should_resume + should_resume = True + + def cancel(self): + nonlocal should_cancel + should_cancel = True + + self.login_ui.on_sso_data_key(SsoDataKeyStep()) + if should_cancel: + raise KeyboardInterrupt() + return should_resume + + def handleSsoRedirect(self, params, is_cloud, sso_url, login_token): # type: (KeeperParams, bool, str, bytes) -> Optional[bytes] sp_url_builder = urlparse(sso_url) sp_url_query = parse_qsl(sp_url_builder.query, keep_blank_values=True) + transmission_key = None # type: Optional[bytes] + rsa_private = None if is_cloud: - sso_rq = ssocloud.SsoCloudRequest() + sso_rq = ssocloud_pb2.SsoCloudRequest() sso_rq.messageSessionUid = crypto.get_random_bytes(16) sso_rq.clientVersion = rest_api.CLIENT_VERSION sso_rq.dest = 'commander' @@ -543,10 +554,10 @@ def handleSsoRedirect(params, is_cloud, sso_url, login_token): sso_rq.detached = True transmission_key = utils.generate_aes_key() - rq_payload = proto.ApiRequestPayload() + rq_payload = APIRequest_pb2.ApiRequestPayload() rq_payload.apiVersion = 3 rq_payload.payload = sso_rq.SerializeToString() - api_rq = proto.ApiRequest() + api_rq = APIRequest_pb2.ApiRequest() api_rq.locale = params.rest_context.locale or 'en_US' server_public_key = rest_api.SERVER_PUBLIC_KEYS[params.rest_context.server_key_id] @@ -567,317 +578,161 @@ def handleSsoRedirect(params, is_cloud, sso_url, login_token): sp_url_query.append(('dest', 'commander')) sp_url_query.append(('embedded', '')) - try: - wb = webbrowser.get() - except: - wb = None sp_url_builder = sp_url_builder._replace(query=urlencode(sp_url_query, doseq=True)) sp_url = urlunparse(sp_url_builder) - print(f'\nSSO Login URL:\n{sp_url}') - print('Navigate to SSO Login URL with your browser and complete login.') - print('Copy a returned SSO Token into clipboard.') - print('Paste that token into Commander') - print('NOTE: To copy SSO Token please click "Copy login token" button on "SSO Connect" page.') - print('') - print(' a. SSO User with a Master Password') - print(' c. Copy SSO Login URL to clipboard') - if wb: - print(' o. Navigate to SSO Login URL with the default web browser') - print(' p. Paste SSO Token from clipboard') - print(' q. Quit SSO login attempt and return to Commander prompt') - while True: - token = input('Selection: ') - if token == 'q': - raise KeyboardInterrupt() - if token == 'a': - return None - if token == 'c': - token = None - try: - pyperclip.copy(sp_url) - print('SSO Login URL is copied to clipboard.') - except: - print('Failed to copy SSO Login URL to clipboard.') - elif token == 'o': - token = None - if wb: - try: - wb.open_new_tab(sp_url) - except: - print('Failed to open web browser.') - elif token == 'p': - try: - token = pyperclip.paste() - except: - token = '' - logging.info('Failed to paste from clipboard') - else: - if len(token) < 10: - print(f'Unsupported menu option: {token}') - token = None - if token: - try: - if is_cloud: - rs_bytes = crypto.decrypt_aes_v2(utils.base64_url_decode(token), transmission_key) - sso_rs = ssocloud.SsoCloudResponse() - sso_rs.ParseFromString(rs_bytes) - params.user = sso_rs.email - params.sso_login_info = { - 'is_cloud': is_cloud, - 'sso_provider': sso_rs.providerName, - 'idp_session_id': sso_rs.idpSessionId, - 'sso_url': sso_url, - } - return sso_rs.encryptedLoginToken - else: - sso_dict = json.loads(token) - if 'email' in sso_dict: - params.user = sso_dict['email'] - - params.sso_login_info = { - 'is_cloud': is_cloud, - 'sso_provider': sso_dict.get('provider_name') or '', - 'idp_session_id': sso_dict.get('session_id') or '', - 'sso_url': sso_url, - 'sso_password': [] - } - if 'password' in sso_dict: - pswd = utils.base64_url_decode(sso_dict['password']) - pswd = crypto.decrypt_rsa(pswd, rsa_private) - params.sso_login_info['sso_password'].append(pswd.decode('utf-8')) - if 'new_password' in sso_dict: - pswd = utils.base64_url_decode(sso_dict['new_password']) - pswd = crypto.decrypt_rsa(pswd, rsa_private) - params.sso_login_info['sso_password'].append(pswd.decode('utf-8')) - - if sso_dict.get('login_token'): - return utils.base64_url_decode(sso_dict.get('login_token')) - else: - return login_token - except Exception as e: - logging.warning(f'SSO Login error: {e}') + should_cancel = False + use_master_password = False + sso_token = None # type: Optional[str] - @staticmethod - def two_factor_channel_to_desc(channel): - if channel == proto.TWO_FA_CT_TOTP: - return 'TOTP (Google and Microsoft Authenticator)' - if channel == proto.TWO_FA_CT_SMS: - return 'Send SMS Code' - if channel == proto.TWO_FA_CT_DUO: - return 'DUO' - if channel == proto.TWO_FA_CT_RSA: - return 'RSA SecurID' - if channel == proto.TWO_FA_CT_U2F: - return 'U2F (FIDO Security Key)' - if channel == proto.TWO_FA_CT_WEBAUTHN: - return 'WebAuthN (FIDO2 Security Key)' - if channel == proto.TWO_FA_CT_DNA: - return 'Keeper DNA (Watch)' - if channel == proto.TWO_FA_CT_BACKUP: - return 'Backup Codes' + class SsoRedirectStep(login_steps.LoginStepSsoToken): + def set_sso_token(self, token): + nonlocal sso_token + sso_token = token - @staticmethod - def handleTwoFactor(params: KeeperParams, encryptedLoginToken, login_resp): - print("This account requires 2FA Authentication") + def login_with_password(self): + nonlocal use_master_password + use_master_password = True - supported_channels = {proto.TWO_FA_CODE_TOTP, proto.TWO_FA_CT_SMS, proto.TWO_FA_CT_DUO, proto.TWO_FA_CT_RSA, - proto.TWO_FA_CT_U2F, proto.TWO_FA_CT_WEBAUTHN, proto.TWO_FA_CT_DNA, - proto.TWO_FA_CT_BACKUP} - channels = [x for x in login_resp.channels if x.channelType in supported_channels] + @property + def is_cloud_sso(self): + return is_cloud - if len(channels) == 0: - backup_code_channel = proto.TwoFactorChannelInfo() - backup_code_channel.channelType = proto.TWO_FA_CT_BACKUP - channels.append(backup_code_channel) + @property + def is_provider_login(self): + return False - for i in range(len(channels)): - channel = channels[i] - print(f"{i+1:>3}. {LoginV3Flow.two_factor_channel_to_desc(channel.channelType)} {channel.channelName} {channel.phoneNumber}") + @property + def login_name(self): + return params.user + + @property + def sso_login_url(self): + return sp_url + + def cancel(self): + nonlocal should_cancel + should_cancel = True + + self.login_ui.on_sso_redirect(SsoRedirectStep()) + if use_master_password: + return None + if should_cancel: + raise KeyboardInterrupt() + if sso_token: + if is_cloud: + rs_bytes = crypto.decrypt_aes_v2(utils.base64_url_decode(sso_token), transmission_key) + sso_rs = ssocloud_pb2.SsoCloudResponse() + sso_rs.ParseFromString(rs_bytes) + params.user = sso_rs.email + params.sso_login_info = { + 'is_cloud': is_cloud, + 'sso_provider': sso_rs.providerName, + 'idp_session_id': sso_rs.idpSessionId, + 'sso_url': sso_url, + } + return sso_rs.encryptedLoginToken + else: + sso_dict = json.loads(sso_token) + if 'email' in sso_dict: + params.user = sso_dict['email'] + + params.sso_login_info = { + 'is_cloud': is_cloud, + 'sso_provider': sso_dict.get('provider_name') or '', + 'idp_session_id': sso_dict.get('session_id') or '', + 'sso_url': sso_url, + 'sso_password': [] + } + if 'password' in sso_dict: + pswd = utils.base64_url_decode(sso_dict['password']) + pswd = crypto.decrypt_rsa(pswd, rsa_private) + params.sso_login_info['sso_password'].append(pswd.decode('utf-8')) + if 'new_password' in sso_dict: + pswd = utils.base64_url_decode(sso_dict['new_password']) + pswd = crypto.decrypt_rsa(pswd, rsa_private) + params.sso_login_info['sso_password'].append(pswd.decode('utf-8')) + + if sso_dict.get('login_token'): + return utils.base64_url_decode(sso_dict.get('login_token')) + else: + return login_token + raise KeyboardInterrupt() - print(f" q. Quit login attempt and return to Commander prompt") + def handle_account_recovery(self, params, encrypted_login_token_bytes): + logging.info('') + logging.info('Password Recovery') + rq = APIRequest_pb2.MasterPasswordRecoveryVerificationRequest() + rq.encryptedLoginToken = encrypted_login_token_bytes try: - selection = input('Selection: ') - if selection == 'q': - raise KeyboardInterrupt() - assert selection.isnumeric() - idx = 1 if not selection else int(selection) - assert 1 <= idx <= len(channels) - channel = channels[idx-1] - logging.debug(f"Selected {idx}. {LoginV3Flow.two_factor_channel_to_desc(channel.channelType)}") - except AssertionError: - print("Invalid entry, additional factors of authentication shown may be configured if not currently enabled.") - return - except EOFError: - exit(1) + api.communicate_rest(params, rq, 'authentication/master_password_recovery_verification_v2') + except KeeperApiError as kae: + if kae.result_code != 'bad_request' and not kae.message.startswith('Email has been sent.'): + raise kae - mfa_prompt = False + logging.info('Please check your email and enter the verification code below:') + verification_code = input('Verification Code: ') + if not verification_code: + return - if channel.channelType == proto.TWO_FA_CODE_NONE: - pass + rq = APIRequest_pb2.GetSecurityQuestionV3Request() + rq.encryptedLoginToken = encrypted_login_token_bytes + rq.verificationCode = verification_code + rs = api.communicate_rest(params, rq, 'authentication/account_recovery_verify_code', + rs_type=APIRequest_pb2.AccountRecoveryVerifyCodeResponse) - elif channel.channelType == proto.TWO_FA_CT_SMS: - rs = LoginV3API.twoFactorSend2FAPushMessage( - params, - encryptedLoginToken, - pushType=proto.TWO_FA_PUSH_SMS, - channel_uid=channel.channel_uid, - expireIn=proto.TWO_FA_EXP_IMMEDIATELY - ) + backup_type = rs.backupKeyType - if type(rs) == bytes: - logging.info(bcolors.OKGREEN + "\nSuccessfully sent SMS.\n" + bcolors.ENDC) - mfa_prompt = True + if backup_type == APIRequest_pb2.BKT_SEC_ANSWER: + print(f'Security Question: {rs.securityQuestion}') + answer = getpass.getpass(prompt='Answer: ', stream=None) + if not answer: + return + recovery_phrase = answer.lower() + auth_hash = crypto.derive_keyhash_v1(recovery_phrase, rs.salt, rs.iterations) + elif backup_type == APIRequest_pb2.BKT_PASSPHRASE_HASH: + p = PassphrasePrompt() + print('Please enter your Recovery Phrase ') + if os.isatty(0): + phrase = prompt('Recovery Phrase: ', lexer=p, completer=p, key_bindings=p.kb, validator=p, + validate_while_typing=False, editing_mode=EditingMode.VI, wrap_lines=True, + complete_style=CompleteStyle.MULTI_COLUMN, complete_while_typing=True, + bottom_toolbar=p.get_word_count_text) else: - logging.error("Was unable to send SMS.") - raise KeeperApiError(rs['error'], rs['message']) - - elif channel.channelType in {proto.TWO_FA_CT_U2F, proto.TWO_FA_CT_WEBAUTHN}: - try: - from .yubikey.yubikey import yubikey_authenticate - challenge = json.loads(channel.challenge) - response = yubikey_authenticate(challenge) - - if response: - if channel.channelType == proto.TWO_FA_CT_U2F: - signature = response - key_value_type = proto.TWO_FA_RESP_U2F - else: - credential_id = response.credential_id - signature = { - "id": utils.base64_url_encode(credential_id), - "rawId": utils.base64_url_encode(credential_id), - "response": { - "authenticatorData": utils.base64_url_encode(response.authenticator_data), - "clientDataJSON": response.client_data.b64, - "signature": utils.base64_url_encode(response.signature), - }, - "type": "public-key", - "clientExtensionResults": response.extension_results or {} - } - key_value_type = proto.TWO_FA_RESP_WEBAUTHN - - rs = LoginV3API.twoFactorValidateMessage(params, encryptedLoginToken, json.dumps(signature), - proto.TWO_FA_EXP_IMMEDIATELY, key_value_type, - channel_uid=channel.channel_uid) - - if type(rs) == bytes: - - print(bcolors.OKGREEN + "Verified 2FA Code." + bcolors.ENDC) - - two_fa_validation_rs = proto.TwoFactorValidateResponse() - two_fa_validation_rs.ParseFromString(rs) - - return two_fa_validation_rs.encryptedLoginToken - else: - print(bcolors.FAIL + "Unable to verify code generated by security key" + bcolors.ENDC) - - except ImportError as e: - from .yubikey import display_fido2_warning - display_fido2_warning() - logging.warning(e) - except Exception as e: - logging.error(e) - - elif channel.channelType in {proto.TWO_FA_CT_TOTP, proto.TWO_FA_CT_DUO, proto.TWO_FA_CT_RSA, - proto.TWO_FA_CT_DNA, proto.TWO_FA_CT_BACKUP}: - mfa_prompt = True + phrase = input('Recovery Phrase: ') + if not phrase: + return + words = [x.strip() for x in phrase.lower().split(' ') if x] + if len(words) != 24: + raise Exception('Recovery phrase should contain 24 words') + recovery_phrase = ' '.join(words) + auth_hash = crypto.generate_hkdf_key('recovery_auth_token', recovery_phrase) else: - raise NotImplementedError(f"Unhandled channel type {channel.channelType}") - - if mfa_prompt: - config_expiration = params.config.get('mfa_duration') or 'login' - mfa_expiration = \ - proto.TWO_FA_EXP_IMMEDIATELY if config_expiration == 'login' else \ - proto.TWO_FA_EXP_NEVER if config_expiration == 'forever' else \ - proto.TWO_FA_EXP_12_HOURS if config_expiration == '12_hours' else \ - proto.TWO_FA_EXP_24_HOURS if config_expiration == '24_hours' else \ - proto.TWO_FA_EXP_30_DAYS - - if mfa_expiration > channel.maxExpiration: - mfa_expiration = channel.maxExpiration - - allowed_expirations = ['login'] # type: List[str] - if channel.maxExpiration >= proto.TWO_FA_EXP_12_HOURS: - allowed_expirations.append('12_hours') - if channel.maxExpiration >= proto.TWO_FA_EXP_24_HOURS: - allowed_expirations.append('24_hours') - if channel.maxExpiration >= proto.TWO_FA_EXP_30_DAYS: - allowed_expirations.append('30_days') - if channel.maxExpiration >= proto.TWO_FA_EXP_NEVER: - allowed_expirations.append('forever') - - otp_code = '' - show_duration = True - mfa_pattern = re.compile(r'2fa_duration\s*=\s*(.+)', re.IGNORECASE) - while not otp_code: - if show_duration: - show_duration = False - prompt_exp = '\n2FA Code Duration: {0}.\nTo change duration: 2fa_duration={1}'.format( - 'Require Every Login' if mfa_expiration == proto.TWO_FA_EXP_IMMEDIATELY else - 'Save on this Device Forever' if mfa_expiration == proto.TWO_FA_EXP_NEVER else - 'Ask Every 12 hours' if mfa_expiration == proto.TWO_FA_EXP_12_HOURS else - 'Ask Every 24 hours' if mfa_expiration == proto.TWO_FA_EXP_24_HOURS else - 'Ask Every 30 days', - "|".join(allowed_expirations)) - print(prompt_exp) - - try: - answer = input('\nEnter 2FA Code or Duration: ') - except KeyboardInterrupt: - return - - m_duration = re.match(mfa_pattern, answer) - if m_duration: - answer = m_duration.group(1).strip().lower() - if answer not in allowed_expirations: - print(f'Invalid 2FA Duration: {answer}') - answer = '' - - if answer == 'login': - show_duration = True - mfa_expiration = proto.TWO_FA_EXP_IMMEDIATELY - elif answer == '12_hours': - show_duration = True - mfa_expiration = proto.TWO_FA_EXP_12_HOURS - elif answer == '24_hours': - show_duration = True - mfa_expiration = proto.TWO_FA_EXP_24_HOURS - elif answer == '30_days': - show_duration = True - mfa_expiration = proto.TWO_FA_EXP_30_DAYS - elif answer == 'forever': - show_duration = True - mfa_expiration = proto.TWO_FA_EXP_NEVER - else: - otp_code = answer - - rs = LoginV3API.twoFactorValidateMessage( - params, - encryptedLoginToken, - otp_code, - mfa_expiration, - channel_uid=channel.channel_uid - ) - - if type(rs) == bytes: - - logging.info(bcolors.OKGREEN + "Successfully verified 2FA Code." + bcolors.ENDC) + logging.info('Unsupported account recovery type') + return - two_fa_validation_rs = proto.TwoFactorValidateResponse() - two_fa_validation_rs.ParseFromString(rs) + rq = APIRequest_pb2.GetDataKeyBackupV3Request() + rq.encryptedLoginToken = encrypted_login_token_bytes + rq.verificationCode = verification_code + rq.securityAnswerHash = auth_hash + rs = api.communicate_rest(params, rq, 'authentication/get_data_key_backup_v3', + rs_type=APIRequest_pb2.GetDataKeyBackupV3Response) + if backup_type == APIRequest_pb2.BKT_SEC_ANSWER: + params.data_key = utils.decrypt_encryption_params(rs.dataKeyBackup, recovery_phrase) + else: + encryption_key = crypto.generate_hkdf_key('recovery_key_aes_gcm_256', recovery_phrase) + params.data_key = crypto.decrypt_aes_v2(rs.dataKeyBackup, encryption_key) + params.session_token = utils.base64_url_encode(rs.encryptedSessionToken) - return two_fa_validation_rs.encryptedLoginToken - else: - warning_msg = bcolors.WARNING + "Unable to verify 2FA code '" + otp_code + "'. Regenerate the code and try again." + bcolors.ENDC - logging.warning(warning_msg) + success = LoginV3Flow.change_master_password(params, list(rs.passwordRules), rs.minimumPbkdf2Iterations) + if success: + self.login(params) class LoginV3API: - @staticmethod def rest_request(params: KeeperParams, api_endpoint: str, rq): - api_request_payload = proto.ApiRequestPayload() + api_request_payload = APIRequest_pb2.ApiRequestPayload() api_request_payload.payload = rq.SerializeToString() rs = rest_api.execute_rest(params.rest_context, api_endpoint, api_request_payload) @@ -899,18 +754,18 @@ def get_device_id(params, new_device=False): # type: (KeeperParams, bool) -> b if not params.device_token: private, public = crypto.generate_ec_key() - rq = proto.DeviceRegistrationRequest() + rq = APIRequest_pb2.DeviceRegistrationRequest() rq.clientVersion = rest_api.CLIENT_VERSION rq.deviceName = CommonHelperMethods.get_device_name() rq.devicePublicKey = crypto.unload_ec_public_key(public) - api_request_payload = proto.ApiRequestPayload() + api_request_payload = APIRequest_pb2.ApiRequestPayload() api_request_payload.payload = rq.SerializeToString() rs = rest_api.execute_rest(params.rest_context, 'authentication/register_device', api_request_payload) if type(rs) == bytes: - register_device_rs = proto.Device() + register_device_rs = APIRequest_pb2.Device() register_device_rs.ParseFromString(rs) # A globally unique device id for each device encrypted by the device token key @@ -927,54 +782,50 @@ def requestDeviceVerificationMessage(params: KeeperParams, encrypted_device_token: bytes, verification_channel: str, message_session_uid: bytes = None): - rq = proto.DeviceVerificationRequest() + rq = APIRequest_pb2.DeviceVerificationRequest() rq.username = params.user.lower() rq.encryptedDeviceToken = encrypted_device_token rq.verificationChannel = verification_channel rq.clientVersion = rest_api.CLIENT_VERSION - rq.messageSessionUid = CommonHelperMethods.url_safe_str_to_bytes(message_session_uid or "") + if message_session_uid: + rq.messageSessionUid = utils.base64_url_encode(message_session_uid) - api_request_payload = proto.ApiRequestPayload() - api_request_payload.payload = rq.SerializeToString() - - return rest_api.execute_rest(params.rest_context, 'authentication/request_device_verification', api_request_payload) + api.communicate_rest(params, rq, 'authentication/request_device_verification', + rs_type=APIRequest_pb2.DeviceVerificationResponse) @staticmethod - def validateDeviceVerificationCodeMessage(params: KeeperParams, verificationCode: str, message_session_uid=None): - - rq = proto.ValidateDeviceVerificationCodeRequest() - + def validateDeviceVerificationCodeMessage(params: KeeperParams, verificationCode: str): + rq = APIRequest_pb2.ValidateDeviceVerificationCodeRequest() rq.username = params.user.lower() rq.clientVersion = rest_api.CLIENT_VERSION # rq.encryptedDeviceToken = encrypted_device_token rq.verificationCode = verificationCode - rq.messageSessionUid = CommonHelperMethods.url_safe_str_to_bytes(message_session_uid or "") - api_request_payload = proto.ApiRequestPayload() + api_request_payload = APIRequest_pb2.ApiRequestPayload() api_request_payload.payload = rq.SerializeToString() - return rest_api.execute_rest(params.rest_context, 'authentication/validate_device_verification_code', api_request_payload) + api.communicate_rest(params, rq, 'authentication/validate_device_verification_code') @staticmethod def resume_login(params: KeeperParams, encryptedLoginToken, encryptedDeviceToken, cloneCode = None, loginType = 'NORMAL', loginMethod='EXISTING_ACCOUNT'): - rq = proto.StartLoginRequest() + rq = APIRequest_pb2.StartLoginRequest() rq.clientVersion = rest_api.CLIENT_VERSION rq.encryptedLoginToken = encryptedLoginToken rq.encryptedDeviceToken = encryptedDeviceToken rq.username = params.user.lower() - rq.loginType = proto.LoginType.Value(loginType) + rq.loginType = APIRequest_pb2.LoginType.Value(loginType) if cloneCode: - rq.loginMethod = proto.LoginMethod.Value(loginMethod) + rq.loginMethod = APIRequest_pb2.LoginMethod.Value(loginMethod) rq.cloneCode = cloneCode - api_request_payload = proto.ApiRequestPayload() + api_request_payload = APIRequest_pb2.ApiRequestPayload() api_request_payload.payload = rq.SerializeToString() rs = rest_api.execute_rest(params.rest_context, 'authentication/start_login', api_request_payload) if type(rs) == bytes: - login_resp = proto.LoginResponse() + login_resp = APIRequest_pb2.LoginResponse() login_resp.ParseFromString(rs) return login_resp @@ -994,25 +845,25 @@ def resume_login(params: KeeperParams, encryptedLoginToken, encryptedDeviceToken @staticmethod def startLoginMessage(params, encryptedDeviceToken, cloneCode = None, loginType = 'NORMAL'): - # type: (KeeperParams, bytes, Optional[bytes], str) -> proto.LoginResponse - rq = proto.StartLoginRequest() + # type: (KeeperParams, bytes, Optional[bytes], str) -> APIRequest_pb2.LoginResponse + rq = APIRequest_pb2.StartLoginRequest() rq.clientVersion = rest_api.CLIENT_VERSION rq.username = params.user.lower() rq.encryptedDeviceToken = encryptedDeviceToken - rq.loginType = proto.LoginType.Value(loginType) - rq.loginMethod = proto.LoginMethod.Value('EXISTING_ACCOUNT') + rq.loginType = APIRequest_pb2.LoginType.Value(loginType) + rq.loginMethod = APIRequest_pb2.LoginMethod.Value('EXISTING_ACCOUNT') if cloneCode: rq.cloneCode = cloneCode rq.username = '' - api_request_payload = proto.ApiRequestPayload() + api_request_payload = APIRequest_pb2.ApiRequestPayload() api_request_payload.payload = rq.SerializeToString() rs = rest_api.execute_rest(params.rest_context, 'authentication/start_login', api_request_payload) if type(rs) == bytes: - login_resp = proto.LoginResponse() + login_resp = APIRequest_pb2.LoginResponse() login_resp.ParseFromString(rs) if not hasattr(login_resp, 'loginState'): @@ -1045,91 +896,22 @@ def startLoginMessage(params, encryptedDeviceToken, cloneCode = None, loginType raise KeeperApiError(rs['error'], err_msg) - @staticmethod - def handle_account_recovery(params, encrypted_login_token_bytes): - logging.info('') - logging.info('Password Recovery') - rq = proto.MasterPasswordRecoveryVerificationRequest() - rq.encryptedLoginToken = encrypted_login_token_bytes - try: - api.communicate_rest(params, rq, 'authentication/master_password_recovery_verification_v2') - except KeeperApiError as kae: - if kae.result_code != 'bad_request' and not kae.message.startswith('Email has been sent.'): - raise kae - - logging.info('Please check your email and enter the verification code below:') - verification_code = input('Verification Code: ') - if not verification_code: - return - - rq = proto.GetSecurityQuestionV3Request() - rq.encryptedLoginToken = encrypted_login_token_bytes - rq.verificationCode = verification_code - rs = api.communicate_rest(params, rq, 'authentication/account_recovery_verify_code', - rs_type=proto.AccountRecoveryVerifyCodeResponse) - - backup_type = rs.backupKeyType - - if backup_type == proto.BKT_SEC_ANSWER: - print(f'Security Question: {rs.securityQuestion}') - answer = getpass.getpass(prompt='Answer: ', stream=None) - if not answer: - return - recovery_phrase = answer.lower() - auth_hash = crypto.derive_keyhash_v1(recovery_phrase, rs.salt, rs.iterations) - elif backup_type == proto.BKT_PASSPHRASE_HASH: - p = PassphrasePrompt() - print('Please enter your Recovery Phrase ') - if os.isatty(0): - phrase = prompt('Recovery Phrase: ', lexer=p, completer=p, key_bindings=p.kb, validator=p, - validate_while_typing=False, editing_mode=EditingMode.VI, wrap_lines=True, - complete_style=CompleteStyle.MULTI_COLUMN, complete_while_typing=True, - bottom_toolbar=p.get_word_count_text) - else: - phrase = input('Recovery Phrase: ') - if not phrase: - return - words = [x.strip() for x in phrase.lower().split(' ') if x] - if len(words) != 24: - raise Exception('Recovery phrase should contain 24 words') - recovery_phrase = ' '.join(words) - auth_hash = crypto.generate_hkdf_key('recovery_auth_token', recovery_phrase) - else: - logging.info('Unsupported account recovery type') - return - - rq = proto.GetDataKeyBackupV3Request() - rq.encryptedLoginToken = encrypted_login_token_bytes - rq.verificationCode = verification_code - rq.securityAnswerHash = auth_hash - rs = api.communicate_rest(params, rq, 'authentication/get_data_key_backup_v3', rs_type=proto.GetDataKeyBackupV3Response) - if backup_type == proto.BKT_SEC_ANSWER: - params.data_key = utils.decrypt_encryption_params(rs.dataKeyBackup, recovery_phrase) - else: - encryption_key = crypto.generate_hkdf_key('recovery_key_aes_gcm_256', recovery_phrase) - params.data_key = crypto.decrypt_aes_v2(rs.dataKeyBackup, encryption_key) - params.session_token = utils.base64_url_encode(rs.encryptedSessionToken) - - success = LoginV3Flow.change_master_password(params, list(rs.passwordRules), rs.minimumPbkdf2Iterations) - if success: - LoginV3Flow.login(params) - @staticmethod def validateAuthHashMessage(params: KeeperParams, encrypted_login_token_bytes): - rq = proto.ValidateAuthHashRequest() - rq.passwordMethod = proto.PasswordMethod.Value("ENTERED") + rq = APIRequest_pb2.ValidateAuthHashRequest() + rq.passwordMethod = APIRequest_pb2.PasswordMethod.Value("ENTERED") rq.authResponse = params.auth_verifier rq.encryptedLoginToken = encrypted_login_token_bytes - api_request_payload = proto.ApiRequestPayload() + api_request_payload = APIRequest_pb2.ApiRequestPayload() api_request_payload.payload = rq.SerializeToString() rs = rest_api.execute_rest(params.rest_context, 'authentication/validate_auth_hash', api_request_payload) if type(rs) == bytes: - login_resp = proto.LoginResponse() + login_resp = APIRequest_pb2.LoginResponse() login_resp.ParseFromString(rs) return login_resp else: @@ -1137,57 +919,54 @@ def validateAuthHashMessage(params: KeeperParams, encrypted_login_token_bytes): raise KeeperApiError(error_code, 'Invalid email or password combination, please re-enter.' if error_code == 'auth_failed' else rs['message'] ) @staticmethod - def twoFactorValidateMessage(params, encryptedLoginToken, otp_code, tfa_expire_in, - twoFactorValueType=None, channel_uid=None): - - rq = proto.TwoFactorValidateRequest() + def twoFactorValidateMessage(params, # type: KeeperParams + encryptedLoginToken, # type: bytes + otp_code, # type: str + *, + tfa_expire_in=None, # type: Any + twoFactorValueType=None, # type: Any + channel_uid=None # type: Any + ): # type: (...) -> Optional[bytes] + + rq = APIRequest_pb2.TwoFactorValidateRequest() rq.encryptedLoginToken = encryptedLoginToken rq.value = otp_code - if twoFactorValueType: rq.valueType = twoFactorValueType if channel_uid: rq.channel_uid = channel_uid - rq.expireIn = tfa_expire_in - api_request_payload = proto.ApiRequestPayload() - api_request_payload.payload = rq.SerializeToString() - - rs = rest_api.execute_rest(params.rest_context, 'authentication/2fa_validate', api_request_payload) - - return rs + rs = api.communicate_rest(params, rq, 'authentication/2fa_validate', + rs_type=APIRequest_pb2.TwoFactorValidateResponse) + if rs: + return rs.encryptedLoginToken @staticmethod def twoFactorSend2FAPushMessage(params: KeeperParams, encryptedLoginToken: bytes, + *, pushType=None, channel_uid=None, expireIn=None): - rq = proto.TwoFactorSendPushRequest() - + rq = APIRequest_pb2.TwoFactorSendPushRequest() rq.encryptedLoginToken = encryptedLoginToken if channel_uid: rq.channel_uid = channel_uid - if expireIn: rq.expireIn = expireIn - if pushType: rq.pushType = pushType - api_request_payload = proto.ApiRequestPayload() - api_request_payload.payload = rq.SerializeToString() - - return rest_api.execute_rest(params.rest_context, 'authentication/2fa_send_push', api_request_payload) + api.communicate_rest(params, rq, 'authentication/2fa_send_push') @staticmethod def rename_device(params: KeeperParams, new_name): - rq = proto.DeviceUpdateRequest() + rq = APIRequest_pb2.DeviceUpdateRequest() rq.clientVersion = rest_api.CLIENT_VERSION - # rq.deviceStatus = proto.DEVICE_OK + # rq.deviceStatus = APIRequest_pb2.DEVICE_OK rq.deviceName = new_name rq.encryptedDeviceToken = LoginV3API.get_device_id(params) @@ -1213,7 +992,7 @@ def change_master_password(params, password, iterations=0): # type: (KeeperPara @staticmethod def register_encrypted_data_key_for_device(params: KeeperParams): device_key = crypto.load_ec_private_key(utils.base64_url_decode(params.device_private_key)) - rq = proto.RegisterDeviceDataKeyRequest() + rq = APIRequest_pb2.RegisterDeviceDataKeyRequest() rq.encryptedDeviceToken = utils.base64_url_decode(params.device_token) rq.encryptedDeviceDataKey = crypto.encrypt_ec(params.data_key, device_key.public_key()) try: @@ -1227,13 +1006,13 @@ def register_encrypted_data_key_for_device(params: KeeperParams): @staticmethod def register_device_in_region(params, encrypted_device_token): # type: (KeeperParams, bytes) -> None - rq = proto.RegisterDeviceInRegionRequest() + rq = APIRequest_pb2.RegisterDeviceInRegionRequest() rq.encryptedDeviceToken = encrypted_device_token rq.clientVersion = rest_api.CLIENT_VERSION rq.deviceName = CommonHelperMethods.get_device_name() device_key = crypto.load_ec_private_key(utils.base64_url_decode(params.device_private_key)) rq.devicePublicKey = crypto.unload_ec_public_key(device_key.public_key()) - api_request_payload = proto.ApiRequestPayload() + api_request_payload = APIRequest_pb2.ApiRequestPayload() api_request_payload.payload = rq.SerializeToString() rs = rest_api.execute_rest(params.rest_context, 'authentication/register_device_in_region', api_request_payload) if isinstance(rs, dict): @@ -1250,7 +1029,7 @@ def set_user_setting(params: KeeperParams, name: str, value: str): # - persistent_login # - ip_disable_auto_approve - rq = proto.UserSettingRequest() + rq = APIRequest_pb2.UserSettingRequest() rq.setting = name rq.value = value @@ -1263,19 +1042,19 @@ def set_user_setting(params: KeeperParams, name: str, value: str): @staticmethod def accountSummary(params: KeeperParams): - rq = proto_as.AccountSummaryRequest() + rq = AccountSummary_pb2.AccountSummaryRequest() rq.summaryVersion = 1 - return api.communicate_rest(params, rq, 'login/account_summary', rs_type=proto_as.AccountSummaryElements) + return api.communicate_rest(params, rq, 'login/account_summary', rs_type=AccountSummary_pb2.AccountSummaryElements) @staticmethod def loginToMc(rest_context, session_token, mc_id): endpoint = 'authentication/login_to_mc' - rq = LoginToMcRequest() + rq = enterprise_pb2.LoginToMcRequest() rq.mcEnterpriseId = mc_id - api_request_payload = proto.ApiRequestPayload() + api_request_payload = APIRequest_pb2.ApiRequestPayload() # api_request_payload.payload = rq.SerializeToString() api_request_payload.encryptedSessionToken = base64.urlsafe_b64decode(session_token + '==') @@ -1288,7 +1067,7 @@ def loginToMc(rest_context, session_token, mc_id): if type(rs) == bytes: - login_to_mc_rs = LoginToMcResponse() + login_to_mc_rs = enterprise_pb2.LoginToMcResponse() login_to_mc_rs.ParseFromString(rs) return login_to_mc_rs @@ -1343,28 +1122,6 @@ def check_int(s): return num_str[1:].isdigit() return num_str.isdigit() - @staticmethod - def fill_password_with_prompt_if_missing(params: KeeperParams, ask_for_recovery=False) -> bool: - while not params.user: - params.user = getpass.getpass(prompt='User(Email): ', stream=None) - - if not params.password: - logging.info('') - if ask_for_recovery: - logging.info('Forgot password? Type "recover"') - logging.info('Enter password for {0}'.format(params.user)) - try: - password = getpass.getpass(prompt='Password: ', stream=None) - if password == 'recover': - raise NeedAccountRecovery() - params.password = password - return True - except KeyboardInterrupt: - print('') - except EOFError: - print('') - return False - class PassphrasePrompt(AutoSuggest, Completer, Lexer, Validator): def __init__(self): @@ -1479,9 +1236,77 @@ def validate(self, document): raise ValidationError(cursor_position=document.cursor_position, message=error) -class NeedAccountRecovery(Exception): +class InvalidDeviceToken(Exception): pass -class InvalidDeviceToken(Exception): - pass +TwoFactorChannelMapping = namedtuple('TwoFactorChannelMapping', ['sdk', 'proto', 'value']) +TwoFactorChannels: List[TwoFactorChannelMapping] = [ + TwoFactorChannelMapping(sdk=login_steps.TwoFactorChannel.Authenticator, proto=APIRequest_pb2.TWO_FA_CT_TOTP, + value=APIRequest_pb2.TWO_FA_CODE_TOTP), + TwoFactorChannelMapping(sdk=login_steps.TwoFactorChannel.TextMessage, proto=APIRequest_pb2.TWO_FA_CT_SMS, + value=APIRequest_pb2.TWO_FA_CODE_SMS), + TwoFactorChannelMapping(sdk=login_steps.TwoFactorChannel.DuoSecurity, proto=APIRequest_pb2.TWO_FA_CT_DUO, + value=APIRequest_pb2.TWO_FA_CODE_DUO), + TwoFactorChannelMapping(sdk=login_steps.TwoFactorChannel.RSASecurID, proto=APIRequest_pb2.TWO_FA_CT_RSA, + value=APIRequest_pb2.TWO_FA_CODE_RSA), + TwoFactorChannelMapping(sdk=login_steps.TwoFactorChannel.SecurityKey, proto=APIRequest_pb2.TWO_FA_CT_WEBAUTHN, + value=APIRequest_pb2.TWO_FA_RESP_WEBAUTHN), + TwoFactorChannelMapping(sdk=login_steps.TwoFactorChannel.KeeperDNA, proto=APIRequest_pb2.TWO_FA_CT_DNA, + value=APIRequest_pb2.TWO_FA_CODE_DNA), + TwoFactorChannelMapping(sdk=login_steps.TwoFactorChannel.Backup, proto=APIRequest_pb2.TWO_FA_CT_BACKUP, + value=APIRequest_pb2.TWO_FA_CODE_NONE), +] + + +def _channel_keeper_to_sdk(channel_proto): # type: (APIRequest_pb2.TwoFactorChannelType) -> login_steps.TwoFactorChannel + return next((x.sdk for x in TwoFactorChannels if x.proto == channel_proto), login_steps.TwoFactorChannel.Other) + + +def _channel_keeper_value(channel_sdk): # type: (login_steps.TwoFactorChannel) -> APIRequest_pb2.TwoFactorValueType + return next((x.value for x in TwoFactorChannels if x.sdk == channel_sdk), APIRequest_pb2.TWO_FA_CODE_NONE) + + +DurationMapping = namedtuple('DurationMapping', ['sdk', 'proto']) +Durations: List[DurationMapping] = [ + DurationMapping(sdk=login_steps.TwoFactorDuration.EveryLogin, proto=APIRequest_pb2.TWO_FA_EXP_IMMEDIATELY), + DurationMapping(sdk=login_steps.TwoFactorDuration.EveryLogin, proto=APIRequest_pb2.TWO_FA_EXP_5_MINUTES), + DurationMapping(sdk=login_steps.TwoFactorDuration.Every12Hours, proto=APIRequest_pb2.TWO_FA_EXP_12_HOURS), + DurationMapping(sdk=login_steps.TwoFactorDuration.Every24Hours, proto=APIRequest_pb2.TWO_FA_EXP_24_HOURS), + DurationMapping(sdk=login_steps.TwoFactorDuration.EveryDay, proto=APIRequest_pb2.TWO_FA_EXP_24_HOURS), + DurationMapping(sdk=login_steps.TwoFactorDuration.Every30Days, proto=APIRequest_pb2.TWO_FA_EXP_30_DAYS), + DurationMapping(sdk=login_steps.TwoFactorDuration.Forever, proto=APIRequest_pb2.TWO_FA_EXP_NEVER), +] + + +def _duration_keeper_to_sdk(duration): # type: (APIRequest_pb2.TwoFactorExpiration) -> login_steps.TwoFactorDuration + return next((x.sdk for x in Durations if x.proto == duration), login_steps.TwoFactorDuration.EveryLogin) + + +def _duration_sdk_to_keeper(duration): # type: (login_steps.TwoFactorDuration) -> APIRequest_pb2.TwoFactorExpiration + return next((x.proto for x in Durations if x.sdk == duration), APIRequest_pb2.TWO_FA_EXP_IMMEDIATELY) + + +def _tfa_channel_info_keeper_to_sdk(channel_info): # type: (APIRequest_pb2.TwoFactorChannelInfo) -> login_steps.TwoFactorChannelInfo + info = login_steps.TwoFactorChannelInfo() + info.channel_type = _channel_keeper_to_sdk(channel_info.channelType) + info.channel_uid = channel_info.channel_uid + info.channel_name = channel_info.channelName + info.phone = channel_info.phoneNumber + info.max_expiration = _duration_keeper_to_sdk(channel_info.maxExpiration) + info.challenge = channel_info.challenge + return info + + +TwoFactorPushMapping = namedtuple('TwoFactorPushMapping', ['sdk', 'proto']) +TwoFactorPushes: List[TwoFactorPushMapping] = [ + TwoFactorPushMapping(sdk=login_steps.TwoFactorPushAction.DuoPush, proto=APIRequest_pb2.TWO_FA_PUSH_DUO_PUSH), + TwoFactorPushMapping(sdk=login_steps.TwoFactorPushAction.DuoTextMessage, proto=APIRequest_pb2.TWO_FA_PUSH_DUO_TEXT), + TwoFactorPushMapping(sdk=login_steps.TwoFactorPushAction.DuoVoiceCall, proto=APIRequest_pb2.TWO_FA_PUSH_DUO_CALL), + TwoFactorPushMapping(sdk=login_steps.TwoFactorPushAction.TextMessage, proto=APIRequest_pb2.TWO_FA_PUSH_SMS), + TwoFactorPushMapping(sdk=login_steps.TwoFactorPushAction.KeeperDna, proto=APIRequest_pb2.TWO_FA_PUSH_KEEPER), +] + + +def tfa_action_sdk_to_keeper(action: login_steps.TwoFactorPushAction) -> APIRequest_pb2.TwoFactorPushType: + return next((x.proto for x in TwoFactorPushes if x.sdk == action), APIRequest_pb2.TWO_FA_PUSH_NONE) diff --git a/keepercommander/params.py b/keepercommander/params.py index c015d871c..1a7edd831 100644 --- a/keepercommander/params.py +++ b/keepercommander/params.py @@ -102,8 +102,6 @@ def __init__(self, config_filename='', config=None, server='keepersecurity.com') self.commands = [] self.plugins = [] self.session_token = None - self.salt = None - self.iterations = 0 self.data_key = None self.client_key = None self.rsa_key = None @@ -165,6 +163,10 @@ def __init__(self, config_filename='', config=None, server='keepersecurity.com') self.ws = None self.tunnel_threads = {} self.tunnel_threads_queue = {} # add ability to tail tunnel process + # TODO check if it can be deleted + self.salt = None + self.iterations = 0 + def clear_session(self): self.auth_verifier = None