diff --git a/examples/sdk_examples/device_management/account_lock_device.py b/examples/sdk_examples/device_management/account_lock_device.py new file mode 100644 index 00000000..1dd4f76e --- /dev/null +++ b/examples/sdk_examples/device_management/account_lock_device.py @@ -0,0 +1,540 @@ +import getpass +import sqlite3 +import json +import logging +from typing import Dict, Optional + +import fido2 +import webbrowser + +from keepersdk import errors, utils +from keepersdk.authentication import ( + configuration, + endpoint, + keeper_auth, + login_auth, +) +from keepersdk.authentication.yubikey import ( + IKeeperUserInteraction, + yubikey_authenticate, +) +from keepersdk.constants import KEEPER_PUBLIC_HOSTS +from keepersdk.vault import sqlite_storage, vault_online, ksm_management + +try: + import pyperclip +except ImportError: + pyperclip = None + +logger = utils.get_logger() +logger.setLevel(logging.INFO) +if not logger.handlers: + _handler = logging.StreamHandler() + _handler.setLevel(logging.INFO) + _handler.setFormatter( + logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") + ) + logger.addHandler(_handler) + + +class FidoCliInteraction(fido2.client.UserInteraction, IKeeperUserInteraction): + def output_text(self, text: str) -> None: + print(text) + + def prompt_up(self) -> None: + print( + "\nTouch the flashing Security key to authenticate or " + "press Ctrl-C to resume with the primary two factor authentication..." + ) + + def request_pin(self, permissions, rd_id): + return getpass.getpass("Enter Security Key PIN: ") + + def request_uv(self, permissions, rd_id): + print("User Verification required.") + return True + + +# Two-factor duration codes (used by LoginFlow) +_TWO_FACTOR_DURATION_CODES: Dict[login_auth.TwoFactorDuration, str] = { + login_auth.TwoFactorDuration.EveryLogin: "login", + login_auth.TwoFactorDuration.Every12Hours: "12_hours", + login_auth.TwoFactorDuration.EveryDay: "24_hours", + login_auth.TwoFactorDuration.Every30Days: "30_days", + login_auth.TwoFactorDuration.Forever: "forever", +} + + +class LoginFlow: + """ + Handles the full login process: server selection, username, password, + device approval, 2FA, SSO data key, and SSO token. + """ + + def __init__(self) -> None: + self._config = configuration.JsonConfigurationStorage() + self._logged_in_with_persistent = True + self._endpoint: Optional[endpoint.KeeperEndpoint] = None + + @property + def endpoint(self) -> Optional[endpoint.KeeperEndpoint]: + return self._endpoint + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + def run(self) -> Optional[keeper_auth.KeeperAuth]: + """ + Run the login flow. + + Returns: + Authenticated Keeper context, or None if login fails. + """ + server = self._ensure_server() + keeper_endpoint = endpoint.KeeperEndpoint(self._config, server) + self._endpoint = keeper_endpoint + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = self._config.get().last_login or input("Enter username: ") + login_auth_context.resume_session = True + login_auth_context.login(username) + + while not login_auth_context.login_step.is_final(): + step = login_auth_context.login_step + if isinstance(step, login_auth.LoginStepDeviceApproval): + self._handle_device_approval(step) + elif isinstance(step, login_auth.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(step) + elif isinstance(step, login_auth.LoginStepError): + print(f"Login error: ({step.code}) {step.message}") + return None + else: + raise NotImplementedError( + f"Unsupported login step type: {type(step).__name__}" + ) + self._logged_in_with_persistent = False + + if self._logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + def _ensure_server(self) -> str: + if not self._config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = ( + input("Enter server (default: keepersecurity.com): ").strip() + or "keepersecurity.com" + ) + self._config.get().last_server = server + else: + server = self._config.get().last_server + return server + + def _handle_device_approval( + self, step: login_auth.LoginStepDeviceApproval + ) -> None: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + def _handle_two_factor(self, step: login_auth.LoginStepTwoFactor) -> None: + channels = [ + x + for x in step.get_channels() + if x.channel_type != login_auth.TwoFactorChannel.Other + ] + menu = [] + for i, channel in enumerate(channels): + desc = self._two_factor_channel_desc(channel.channel_type) + menu.append( + ( + str(i + 1), + f"{desc} {channel.channel_name} {channel.phone}", + ) + ) + menu.append(("q", "Quit authentication attempt and return to Commander prompt.")) + + lines = ["", "This account requires 2FA Authentication"] + lines.extend(f" {a}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + selection = input("Selection: ") + if selection is None: + return + if selection in ("q", "Q"): + raise KeyboardInterrupt() + try: + assert selection.isnumeric() + idx = 1 if not selection else int(selection) + assert 1 <= idx <= len(channels) + channel = channels[idx - 1] + desc = self._two_factor_channel_desc(channel.channel_type) + print(f"Selected {idx}. {desc}") + except AssertionError: + print( + "Invalid entry, additional factors of authentication shown " + "may be configured if not currently enabled." + ) + continue + + if channel.channel_type in ( + login_auth.TwoFactorChannel.TextMessage, + login_auth.TwoFactorChannel.KeeperDNA, + login_auth.TwoFactorChannel.DuoSecurity, + ): + action = next( + ( + x + for x in step.get_channel_push_actions(channel.channel_uid) + if x + in ( + login_auth.TwoFactorPushAction.TextMessage, + login_auth.TwoFactorPushAction.KeeperDna, + ) + ), + None, + ) + if action: + step.send_push(channel.channel_uid, action) + + if channel.channel_type == login_auth.TwoFactorChannel.SecurityKey: + try: + challenge = json.loads(channel.challenge) + signature = yubikey_authenticate(challenge, FidoCliInteraction()) + if signature: + print("Verified Security Key.") + step.send_code(channel.channel_uid, signature) + return + except Exception as e: + logger.error(e) + continue + + # 2FA code path + step.duration = min(step.duration, channel.max_expiration) + available_dura = sorted( + x for x in _TWO_FACTOR_DURATION_CODES if x <= channel.max_expiration + ) + available_codes = [ + _TWO_FACTOR_DURATION_CODES.get(x) or "login" for x in available_dura + ] + + while True: + mfa_desc = self._two_factor_duration_desc(step.duration) + prompt_exp = ( + f"\n2FA Code Duration: {mfa_desc}.\n" + f"To change duration: 2fa_duration={'|'.join(available_codes)}" + ) + print(prompt_exp) + + selection = input("\nEnter 2FA Code or Duration: ") + if not selection: + return + if selection in available_codes: + step.duration = self._two_factor_code_to_duration(selection) + elif selection.startswith("2fa_duration="): + code = selection[len("2fa_duration=") :] + if code in available_codes: + step.duration = self._two_factor_code_to_duration(code) + else: + print(f"Invalid 2FA duration: {code}") + else: + try: + step.send_code(channel.channel_uid, selection) + print("Successfully verified 2FA Code.") + return + except errors.KeeperApiError as kae: + print(f"Invalid 2FA code: ({kae.result_code}) {kae.message}") + + def _handle_sso_data_key( + self, step: login_auth.LoginStepSsoDataKey + ) -> None: + menu = [ + ("1", "Keeper Push. Send a push notification to your device."), + ("2", "Admin Approval. Request your admin to approve this device."), + ("r", "Resume SSO authentication after device is approved."), + ("q", "Quit SSO authentication attempt and return to Commander prompt."), + ] + lines = ["Approve this device by selecting a method below:"] + lines.extend(f" {cmd:>3}. {text}" for cmd, text in menu) + print("\n".join(lines)) + + while True: + answer = input("Selection: ") + if answer is None: + return + if answer == "q": + raise KeyboardInterrupt() + if answer == "r": + step.resume() + break + if answer in ("1", "2"): + step.request_data_key( + login_auth.DataKeyShareChannel.KeeperPush + if answer == "1" + else login_auth.DataKeyShareChannel.AdminApproval + ) + else: + print(f'Action "{answer}" is not supported.') + + def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: + menu = [ + ("a", "SSO User with a Master Password."), + ] + if pyperclip: + menu.append(("c", "Copy SSO Login URL to clipboard.")) + else: + menu.append(("u", "Show SSO Login URL.")) + try: + wb = webbrowser.get() + menu.append(("o", "Navigate to SSO Login URL with the default web browser.")) + except Exception: + wb = None + if pyperclip: + menu.append(("p", "Paste SSO Token from clipboard.")) + menu.append(("t", "Enter SSO Token manually.")) + menu.append(("q", "Quit SSO authentication attempt and return to Commander prompt.")) + + lines = [ + "", + "SSO Login URL:", + step.sso_login_url, + "Navigate to SSO Login URL with your browser and complete authentication.", + "Copy a returned SSO Token into clipboard." + + (" Paste that token into Commander." if pyperclip else " Then use option 't' to enter the token manually."), + 'NOTE: To copy SSO Token please click "Copy authentication token" ' + 'button on "SSO Connect" page.', + "", + ] + lines.extend(f" {a:>3}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + token = input("Selection: ") + if token == "q": + raise KeyboardInterrupt() + if token == "a": + step.login_with_password() + return + if token == "c": + token = None + if pyperclip: + try: + pyperclip.copy(step.sso_login_url) + print("SSO Login URL is copied to clipboard.") + except Exception: + print("Failed to copy SSO Login URL to clipboard.") + else: + print("Clipboard not available (install pyperclip).") + elif token == "u": + token = None + if not pyperclip: + print("\nSSO Login URL:", step.sso_login_url, "\n") + else: + print("Unsupported menu option (use 'c' to copy URL).") + elif token == "o": + token = None + if wb: + try: + wb.open_new_tab(step.sso_login_url) + except Exception: + print("Failed to open web browser.") + elif token == "p": + if pyperclip: + try: + token = pyperclip.paste() + except Exception: + token = "" + print("Failed to paste from clipboard") + else: + token = None + print("Clipboard not available (use 't' to enter token manually).") + elif token == "t": + token = getpass.getpass("Enter SSO Token: ").strip() + else: + if len(token) < 10: + print(f"Unsupported menu option: {token}") + continue + + if token: + try: + step.set_sso_token(token) + break + except errors.KeeperApiError as kae: + print(f"SSO Login error: ({kae.result_code}) {kae.message}") + + @staticmethod + def _two_factor_channel_desc( + channel_type: login_auth.TwoFactorChannel, + ) -> str: + return { + login_auth.TwoFactorChannel.Authenticator: "TOTP (Google and Microsoft Authenticator)", + login_auth.TwoFactorChannel.TextMessage: "Send SMS Code", + login_auth.TwoFactorChannel.DuoSecurity: "DUO", + login_auth.TwoFactorChannel.RSASecurID: "RSA SecurID", + login_auth.TwoFactorChannel.SecurityKey: "WebAuthN (FIDO2 Security Key)", + login_auth.TwoFactorChannel.KeeperDNA: "Keeper DNA (Watch)", + login_auth.TwoFactorChannel.Backup: "Backup Code", + }.get(channel_type, "Not Supported") + + @staticmethod + def _two_factor_duration_desc( + duration: login_auth.TwoFactorDuration, + ) -> str: + return { + login_auth.TwoFactorDuration.EveryLogin: "Require Every Login", + login_auth.TwoFactorDuration.Forever: "Save on this Device Forever", + login_auth.TwoFactorDuration.Every12Hours: "Ask Every 12 hours", + login_auth.TwoFactorDuration.EveryDay: "Ask Every 24 hours", + login_auth.TwoFactorDuration.Every30Days: "Ask Every 30 days", + }.get(duration, "Require Every Login") + + @staticmethod + def _two_factor_code_to_duration( + text: str, + ) -> login_auth.TwoFactorDuration: + for dura, code in _TWO_FACTOR_DURATION_CODES.items(): + if code == text: + return dura + return login_auth.TwoFactorDuration.EveryLogin + + +def enable_persistent_login(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + """ + Enable persistent login and register data key for device. + Sets persistent_login to on and logout_timer to 30 days. + """ + keeper_auth.set_user_setting(keeper_auth_context, 'persistent_login', '1') + keeper_auth.register_data_key_for_device(keeper_auth_context) + mins_per_day = 60 * 24 + timeout_in_minutes = mins_per_day * 30 # 30 days + keeper_auth.set_user_setting(keeper_auth_context, 'logout_timer', str(timeout_in_minutes)) + print("Persistent login turned on successfully and device registered") + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps (device approval, password, 2FA + with channel selection and Security Key, SSO data key, SSO token). + + Returns: + tuple: (keeper_auth_context, keeper_endpoint) on success, or (None, None) if login fails. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint + +from keepersdk.authentication import device_management + + +def print_devices_table(devices): + if not devices: + print('\nNo devices found.') + return + print(f'\nUser Devices ({len(devices)} found)') + print('=' * 100) + print(f"{'ID':<4} {'Device Name':<22} {'Client Type':<13} {'Login Status':<14} {'Last Accessed':<19}") + print('-' * 4 + ' ' + '-' * 22 + ' ' + '-' * 13 + ' ' + '-' * 14 + ' ' + '-' * 19) + for d in devices: + last = d.last_accessed.strftime('%Y-%m-%d %H:%M:%S') if d.last_accessed else 'N/A' + print( + f"{d.list_index:<4} {d.name[:21]:<22} " + f"{d.client_type[:12]:<13} {d.login_status[:13]:<14} {last:<19}" + ) + print('-' * 4 + ' ' + '-' * 22 + ' ' + '-' * 13 + ' ' + '-' * 14 + ' ' + '-' * 19) + + +def main(): + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + + # Fill in your values here. + device_identifiers = [''] + + try: + print(f'Account-locking {len(device_identifiers)} device(s)...') + for name in device_management.account_lock_user_devices( + keeper_auth_context, device_identifiers + ): + print(f"Device '{name}' successfully account locked") + print('\nUpdated device list:') + print_devices_table(device_management.list_user_devices(keeper_auth_context)) + except Exception as e: + print(f'Error account-locking devices: {e}') + finally: + keeper_auth_context.close() + + +if __name__ == '__main__': + main() diff --git a/examples/sdk_examples/device_management/account_unlock_device.py b/examples/sdk_examples/device_management/account_unlock_device.py new file mode 100644 index 00000000..a306397f --- /dev/null +++ b/examples/sdk_examples/device_management/account_unlock_device.py @@ -0,0 +1,540 @@ +import getpass +import sqlite3 +import json +import logging +from typing import Dict, Optional + +import fido2 +import webbrowser + +from keepersdk import errors, utils +from keepersdk.authentication import ( + configuration, + endpoint, + keeper_auth, + login_auth, +) +from keepersdk.authentication.yubikey import ( + IKeeperUserInteraction, + yubikey_authenticate, +) +from keepersdk.constants import KEEPER_PUBLIC_HOSTS +from keepersdk.vault import sqlite_storage, vault_online, ksm_management + +try: + import pyperclip +except ImportError: + pyperclip = None + +logger = utils.get_logger() +logger.setLevel(logging.INFO) +if not logger.handlers: + _handler = logging.StreamHandler() + _handler.setLevel(logging.INFO) + _handler.setFormatter( + logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") + ) + logger.addHandler(_handler) + + +class FidoCliInteraction(fido2.client.UserInteraction, IKeeperUserInteraction): + def output_text(self, text: str) -> None: + print(text) + + def prompt_up(self) -> None: + print( + "\nTouch the flashing Security key to authenticate or " + "press Ctrl-C to resume with the primary two factor authentication..." + ) + + def request_pin(self, permissions, rd_id): + return getpass.getpass("Enter Security Key PIN: ") + + def request_uv(self, permissions, rd_id): + print("User Verification required.") + return True + + +# Two-factor duration codes (used by LoginFlow) +_TWO_FACTOR_DURATION_CODES: Dict[login_auth.TwoFactorDuration, str] = { + login_auth.TwoFactorDuration.EveryLogin: "login", + login_auth.TwoFactorDuration.Every12Hours: "12_hours", + login_auth.TwoFactorDuration.EveryDay: "24_hours", + login_auth.TwoFactorDuration.Every30Days: "30_days", + login_auth.TwoFactorDuration.Forever: "forever", +} + + +class LoginFlow: + """ + Handles the full login process: server selection, username, password, + device approval, 2FA, SSO data key, and SSO token. + """ + + def __init__(self) -> None: + self._config = configuration.JsonConfigurationStorage() + self._logged_in_with_persistent = True + self._endpoint: Optional[endpoint.KeeperEndpoint] = None + + @property + def endpoint(self) -> Optional[endpoint.KeeperEndpoint]: + return self._endpoint + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + def run(self) -> Optional[keeper_auth.KeeperAuth]: + """ + Run the login flow. + + Returns: + Authenticated Keeper context, or None if login fails. + """ + server = self._ensure_server() + keeper_endpoint = endpoint.KeeperEndpoint(self._config, server) + self._endpoint = keeper_endpoint + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = self._config.get().last_login or input("Enter username: ") + login_auth_context.resume_session = True + login_auth_context.login(username) + + while not login_auth_context.login_step.is_final(): + step = login_auth_context.login_step + if isinstance(step, login_auth.LoginStepDeviceApproval): + self._handle_device_approval(step) + elif isinstance(step, login_auth.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(step) + elif isinstance(step, login_auth.LoginStepError): + print(f"Login error: ({step.code}) {step.message}") + return None + else: + raise NotImplementedError( + f"Unsupported login step type: {type(step).__name__}" + ) + self._logged_in_with_persistent = False + + if self._logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + def _ensure_server(self) -> str: + if not self._config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = ( + input("Enter server (default: keepersecurity.com): ").strip() + or "keepersecurity.com" + ) + self._config.get().last_server = server + else: + server = self._config.get().last_server + return server + + def _handle_device_approval( + self, step: login_auth.LoginStepDeviceApproval + ) -> None: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + def _handle_two_factor(self, step: login_auth.LoginStepTwoFactor) -> None: + channels = [ + x + for x in step.get_channels() + if x.channel_type != login_auth.TwoFactorChannel.Other + ] + menu = [] + for i, channel in enumerate(channels): + desc = self._two_factor_channel_desc(channel.channel_type) + menu.append( + ( + str(i + 1), + f"{desc} {channel.channel_name} {channel.phone}", + ) + ) + menu.append(("q", "Quit authentication attempt and return to Commander prompt.")) + + lines = ["", "This account requires 2FA Authentication"] + lines.extend(f" {a}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + selection = input("Selection: ") + if selection is None: + return + if selection in ("q", "Q"): + raise KeyboardInterrupt() + try: + assert selection.isnumeric() + idx = 1 if not selection else int(selection) + assert 1 <= idx <= len(channels) + channel = channels[idx - 1] + desc = self._two_factor_channel_desc(channel.channel_type) + print(f"Selected {idx}. {desc}") + except AssertionError: + print( + "Invalid entry, additional factors of authentication shown " + "may be configured if not currently enabled." + ) + continue + + if channel.channel_type in ( + login_auth.TwoFactorChannel.TextMessage, + login_auth.TwoFactorChannel.KeeperDNA, + login_auth.TwoFactorChannel.DuoSecurity, + ): + action = next( + ( + x + for x in step.get_channel_push_actions(channel.channel_uid) + if x + in ( + login_auth.TwoFactorPushAction.TextMessage, + login_auth.TwoFactorPushAction.KeeperDna, + ) + ), + None, + ) + if action: + step.send_push(channel.channel_uid, action) + + if channel.channel_type == login_auth.TwoFactorChannel.SecurityKey: + try: + challenge = json.loads(channel.challenge) + signature = yubikey_authenticate(challenge, FidoCliInteraction()) + if signature: + print("Verified Security Key.") + step.send_code(channel.channel_uid, signature) + return + except Exception as e: + logger.error(e) + continue + + # 2FA code path + step.duration = min(step.duration, channel.max_expiration) + available_dura = sorted( + x for x in _TWO_FACTOR_DURATION_CODES if x <= channel.max_expiration + ) + available_codes = [ + _TWO_FACTOR_DURATION_CODES.get(x) or "login" for x in available_dura + ] + + while True: + mfa_desc = self._two_factor_duration_desc(step.duration) + prompt_exp = ( + f"\n2FA Code Duration: {mfa_desc}.\n" + f"To change duration: 2fa_duration={'|'.join(available_codes)}" + ) + print(prompt_exp) + + selection = input("\nEnter 2FA Code or Duration: ") + if not selection: + return + if selection in available_codes: + step.duration = self._two_factor_code_to_duration(selection) + elif selection.startswith("2fa_duration="): + code = selection[len("2fa_duration=") :] + if code in available_codes: + step.duration = self._two_factor_code_to_duration(code) + else: + print(f"Invalid 2FA duration: {code}") + else: + try: + step.send_code(channel.channel_uid, selection) + print("Successfully verified 2FA Code.") + return + except errors.KeeperApiError as kae: + print(f"Invalid 2FA code: ({kae.result_code}) {kae.message}") + + def _handle_sso_data_key( + self, step: login_auth.LoginStepSsoDataKey + ) -> None: + menu = [ + ("1", "Keeper Push. Send a push notification to your device."), + ("2", "Admin Approval. Request your admin to approve this device."), + ("r", "Resume SSO authentication after device is approved."), + ("q", "Quit SSO authentication attempt and return to Commander prompt."), + ] + lines = ["Approve this device by selecting a method below:"] + lines.extend(f" {cmd:>3}. {text}" for cmd, text in menu) + print("\n".join(lines)) + + while True: + answer = input("Selection: ") + if answer is None: + return + if answer == "q": + raise KeyboardInterrupt() + if answer == "r": + step.resume() + break + if answer in ("1", "2"): + step.request_data_key( + login_auth.DataKeyShareChannel.KeeperPush + if answer == "1" + else login_auth.DataKeyShareChannel.AdminApproval + ) + else: + print(f'Action "{answer}" is not supported.') + + def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: + menu = [ + ("a", "SSO User with a Master Password."), + ] + if pyperclip: + menu.append(("c", "Copy SSO Login URL to clipboard.")) + else: + menu.append(("u", "Show SSO Login URL.")) + try: + wb = webbrowser.get() + menu.append(("o", "Navigate to SSO Login URL with the default web browser.")) + except Exception: + wb = None + if pyperclip: + menu.append(("p", "Paste SSO Token from clipboard.")) + menu.append(("t", "Enter SSO Token manually.")) + menu.append(("q", "Quit SSO authentication attempt and return to Commander prompt.")) + + lines = [ + "", + "SSO Login URL:", + step.sso_login_url, + "Navigate to SSO Login URL with your browser and complete authentication.", + "Copy a returned SSO Token into clipboard." + + (" Paste that token into Commander." if pyperclip else " Then use option 't' to enter the token manually."), + 'NOTE: To copy SSO Token please click "Copy authentication token" ' + 'button on "SSO Connect" page.', + "", + ] + lines.extend(f" {a:>3}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + token = input("Selection: ") + if token == "q": + raise KeyboardInterrupt() + if token == "a": + step.login_with_password() + return + if token == "c": + token = None + if pyperclip: + try: + pyperclip.copy(step.sso_login_url) + print("SSO Login URL is copied to clipboard.") + except Exception: + print("Failed to copy SSO Login URL to clipboard.") + else: + print("Clipboard not available (install pyperclip).") + elif token == "u": + token = None + if not pyperclip: + print("\nSSO Login URL:", step.sso_login_url, "\n") + else: + print("Unsupported menu option (use 'c' to copy URL).") + elif token == "o": + token = None + if wb: + try: + wb.open_new_tab(step.sso_login_url) + except Exception: + print("Failed to open web browser.") + elif token == "p": + if pyperclip: + try: + token = pyperclip.paste() + except Exception: + token = "" + print("Failed to paste from clipboard") + else: + token = None + print("Clipboard not available (use 't' to enter token manually).") + elif token == "t": + token = getpass.getpass("Enter SSO Token: ").strip() + else: + if len(token) < 10: + print(f"Unsupported menu option: {token}") + continue + + if token: + try: + step.set_sso_token(token) + break + except errors.KeeperApiError as kae: + print(f"SSO Login error: ({kae.result_code}) {kae.message}") + + @staticmethod + def _two_factor_channel_desc( + channel_type: login_auth.TwoFactorChannel, + ) -> str: + return { + login_auth.TwoFactorChannel.Authenticator: "TOTP (Google and Microsoft Authenticator)", + login_auth.TwoFactorChannel.TextMessage: "Send SMS Code", + login_auth.TwoFactorChannel.DuoSecurity: "DUO", + login_auth.TwoFactorChannel.RSASecurID: "RSA SecurID", + login_auth.TwoFactorChannel.SecurityKey: "WebAuthN (FIDO2 Security Key)", + login_auth.TwoFactorChannel.KeeperDNA: "Keeper DNA (Watch)", + login_auth.TwoFactorChannel.Backup: "Backup Code", + }.get(channel_type, "Not Supported") + + @staticmethod + def _two_factor_duration_desc( + duration: login_auth.TwoFactorDuration, + ) -> str: + return { + login_auth.TwoFactorDuration.EveryLogin: "Require Every Login", + login_auth.TwoFactorDuration.Forever: "Save on this Device Forever", + login_auth.TwoFactorDuration.Every12Hours: "Ask Every 12 hours", + login_auth.TwoFactorDuration.EveryDay: "Ask Every 24 hours", + login_auth.TwoFactorDuration.Every30Days: "Ask Every 30 days", + }.get(duration, "Require Every Login") + + @staticmethod + def _two_factor_code_to_duration( + text: str, + ) -> login_auth.TwoFactorDuration: + for dura, code in _TWO_FACTOR_DURATION_CODES.items(): + if code == text: + return dura + return login_auth.TwoFactorDuration.EveryLogin + + +def enable_persistent_login(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + """ + Enable persistent login and register data key for device. + Sets persistent_login to on and logout_timer to 30 days. + """ + keeper_auth.set_user_setting(keeper_auth_context, 'persistent_login', '1') + keeper_auth.register_data_key_for_device(keeper_auth_context) + mins_per_day = 60 * 24 + timeout_in_minutes = mins_per_day * 30 # 30 days + keeper_auth.set_user_setting(keeper_auth_context, 'logout_timer', str(timeout_in_minutes)) + print("Persistent login turned on successfully and device registered") + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps (device approval, password, 2FA + with channel selection and Security Key, SSO data key, SSO token). + + Returns: + tuple: (keeper_auth_context, keeper_endpoint) on success, or (None, None) if login fails. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint + +from keepersdk.authentication import device_management + + +def print_devices_table(devices): + if not devices: + print('\nNo devices found.') + return + print(f'\nUser Devices ({len(devices)} found)') + print('=' * 100) + print(f"{'ID':<4} {'Device Name':<22} {'Client Type':<13} {'Login Status':<14} {'Last Accessed':<19}") + print('-' * 4 + ' ' + '-' * 22 + ' ' + '-' * 13 + ' ' + '-' * 14 + ' ' + '-' * 19) + for d in devices: + last = d.last_accessed.strftime('%Y-%m-%d %H:%M:%S') if d.last_accessed else 'N/A' + print( + f"{d.list_index:<4} {d.name[:21]:<22} " + f"{d.client_type[:12]:<13} {d.login_status[:13]:<14} {last:<19}" + ) + print('-' * 4 + ' ' + '-' * 22 + ' ' + '-' * 13 + ' ' + '-' * 14 + ' ' + '-' * 19) + + +def main(): + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + + # Fill in your values here. + device_identifiers = [''] + + try: + print(f'Account-unlocking {len(device_identifiers)} device(s)...') + for name in device_management.account_unlock_user_devices( + keeper_auth_context, device_identifiers + ): + print(f"Device '{name}' successfully account unlocked") + print('\nUpdated device list:') + print_devices_table(device_management.list_user_devices(keeper_auth_context)) + except Exception as e: + print(f'Error account-unlocking devices: {e}') + finally: + keeper_auth_context.close() + + +if __name__ == '__main__': + main() diff --git a/examples/sdk_examples/device_management/admin_list_devices.py b/examples/sdk_examples/device_management/admin_list_devices.py new file mode 100644 index 00000000..b92a2b43 --- /dev/null +++ b/examples/sdk_examples/device_management/admin_list_devices.py @@ -0,0 +1,540 @@ +import getpass +import sqlite3 +import json +import logging +from typing import Dict, Optional + +import fido2 +import webbrowser + +from keepersdk import errors, utils +from keepersdk.authentication import ( + configuration, + device_management, + endpoint, + keeper_auth, + login_auth, +) +from keepersdk.authentication.yubikey import ( + IKeeperUserInteraction, + yubikey_authenticate, +) +from keepersdk.constants import KEEPER_PUBLIC_HOSTS +from keepersdk.vault import sqlite_storage, vault_online, ksm_management + +try: + import pyperclip +except ImportError: + pyperclip = None + +logger = utils.get_logger() +logger.setLevel(logging.INFO) +if not logger.handlers: + _handler = logging.StreamHandler() + _handler.setLevel(logging.INFO) + _handler.setFormatter( + logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") + ) + logger.addHandler(_handler) + + +class FidoCliInteraction(fido2.client.UserInteraction, IKeeperUserInteraction): + def output_text(self, text: str) -> None: + print(text) + + def prompt_up(self) -> None: + print( + "\nTouch the flashing Security key to authenticate or " + "press Ctrl-C to resume with the primary two factor authentication..." + ) + + def request_pin(self, permissions, rd_id): + return getpass.getpass("Enter Security Key PIN: ") + + def request_uv(self, permissions, rd_id): + print("User Verification required.") + return True + + +# Two-factor duration codes (used by LoginFlow) +_TWO_FACTOR_DURATION_CODES: Dict[login_auth.TwoFactorDuration, str] = { + login_auth.TwoFactorDuration.EveryLogin: "login", + login_auth.TwoFactorDuration.Every12Hours: "12_hours", + login_auth.TwoFactorDuration.EveryDay: "24_hours", + login_auth.TwoFactorDuration.Every30Days: "30_days", + login_auth.TwoFactorDuration.Forever: "forever", +} + + +class LoginFlow: + """ + Handles the full login process: server selection, username, password, + device approval, 2FA, SSO data key, and SSO token. + """ + + def __init__(self) -> None: + self._config = configuration.JsonConfigurationStorage() + self._logged_in_with_persistent = True + self._endpoint: Optional[endpoint.KeeperEndpoint] = None + + @property + def endpoint(self) -> Optional[endpoint.KeeperEndpoint]: + return self._endpoint + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + def run(self) -> Optional[keeper_auth.KeeperAuth]: + """ + Run the login flow. + + Returns: + Authenticated Keeper context, or None if login fails. + """ + server = self._ensure_server() + keeper_endpoint = endpoint.KeeperEndpoint(self._config, server) + self._endpoint = keeper_endpoint + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = self._config.get().last_login or input("Enter username: ") + login_auth_context.resume_session = True + login_auth_context.login(username) + + while not login_auth_context.login_step.is_final(): + step = login_auth_context.login_step + if isinstance(step, login_auth.LoginStepDeviceApproval): + self._handle_device_approval(step) + elif isinstance(step, login_auth.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(step) + elif isinstance(step, login_auth.LoginStepError): + print(f"Login error: ({step.code}) {step.message}") + return None + else: + raise NotImplementedError( + f"Unsupported login step type: {type(step).__name__}" + ) + self._logged_in_with_persistent = False + + if self._logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + def _ensure_server(self) -> str: + if not self._config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = ( + input("Enter server (default: keepersecurity.com): ").strip() + or "keepersecurity.com" + ) + self._config.get().last_server = server + else: + server = self._config.get().last_server + return server + + def _handle_device_approval( + self, step: login_auth.LoginStepDeviceApproval + ) -> None: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + def _handle_two_factor(self, step: login_auth.LoginStepTwoFactor) -> None: + channels = [ + x + for x in step.get_channels() + if x.channel_type != login_auth.TwoFactorChannel.Other + ] + menu = [] + for i, channel in enumerate(channels): + desc = self._two_factor_channel_desc(channel.channel_type) + menu.append( + ( + str(i + 1), + f"{desc} {channel.channel_name} {channel.phone}", + ) + ) + menu.append(("q", "Quit authentication attempt and return to Commander prompt.")) + + lines = ["", "This account requires 2FA Authentication"] + lines.extend(f" {a}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + selection = input("Selection: ") + if selection is None: + return + if selection in ("q", "Q"): + raise KeyboardInterrupt() + try: + assert selection.isnumeric() + idx = 1 if not selection else int(selection) + assert 1 <= idx <= len(channels) + channel = channels[idx - 1] + desc = self._two_factor_channel_desc(channel.channel_type) + print(f"Selected {idx}. {desc}") + except AssertionError: + print( + "Invalid entry, additional factors of authentication shown " + "may be configured if not currently enabled." + ) + continue + + if channel.channel_type in ( + login_auth.TwoFactorChannel.TextMessage, + login_auth.TwoFactorChannel.KeeperDNA, + login_auth.TwoFactorChannel.DuoSecurity, + ): + action = next( + ( + x + for x in step.get_channel_push_actions(channel.channel_uid) + if x + in ( + login_auth.TwoFactorPushAction.TextMessage, + login_auth.TwoFactorPushAction.KeeperDna, + ) + ), + None, + ) + if action: + step.send_push(channel.channel_uid, action) + + if channel.channel_type == login_auth.TwoFactorChannel.SecurityKey: + try: + challenge = json.loads(channel.challenge) + signature = yubikey_authenticate(challenge, FidoCliInteraction()) + if signature: + print("Verified Security Key.") + step.send_code(channel.channel_uid, signature) + return + except Exception as e: + logger.error(e) + continue + + # 2FA code path + step.duration = min(step.duration, channel.max_expiration) + available_dura = sorted( + x for x in _TWO_FACTOR_DURATION_CODES if x <= channel.max_expiration + ) + available_codes = [ + _TWO_FACTOR_DURATION_CODES.get(x) or "login" for x in available_dura + ] + + while True: + mfa_desc = self._two_factor_duration_desc(step.duration) + prompt_exp = ( + f"\n2FA Code Duration: {mfa_desc}.\n" + f"To change duration: 2fa_duration={'|'.join(available_codes)}" + ) + print(prompt_exp) + + selection = input("\nEnter 2FA Code or Duration: ") + if not selection: + return + if selection in available_codes: + step.duration = self._two_factor_code_to_duration(selection) + elif selection.startswith("2fa_duration="): + code = selection[len("2fa_duration=") :] + if code in available_codes: + step.duration = self._two_factor_code_to_duration(code) + else: + print(f"Invalid 2FA duration: {code}") + else: + try: + step.send_code(channel.channel_uid, selection) + print("Successfully verified 2FA Code.") + return + except errors.KeeperApiError as kae: + print(f"Invalid 2FA code: ({kae.result_code}) {kae.message}") + + def _handle_sso_data_key( + self, step: login_auth.LoginStepSsoDataKey + ) -> None: + menu = [ + ("1", "Keeper Push. Send a push notification to your device."), + ("2", "Admin Approval. Request your admin to approve this device."), + ("r", "Resume SSO authentication after device is approved."), + ("q", "Quit SSO authentication attempt and return to Commander prompt."), + ] + lines = ["Approve this device by selecting a method below:"] + lines.extend(f" {cmd:>3}. {text}" for cmd, text in menu) + print("\n".join(lines)) + + while True: + answer = input("Selection: ") + if answer is None: + return + if answer == "q": + raise KeyboardInterrupt() + if answer == "r": + step.resume() + break + if answer in ("1", "2"): + step.request_data_key( + login_auth.DataKeyShareChannel.KeeperPush + if answer == "1" + else login_auth.DataKeyShareChannel.AdminApproval + ) + else: + print(f'Action "{answer}" is not supported.') + + def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: + menu = [ + ("a", "SSO User with a Master Password."), + ] + if pyperclip: + menu.append(("c", "Copy SSO Login URL to clipboard.")) + else: + menu.append(("u", "Show SSO Login URL.")) + try: + wb = webbrowser.get() + menu.append(("o", "Navigate to SSO Login URL with the default web browser.")) + except Exception: + wb = None + if pyperclip: + menu.append(("p", "Paste SSO Token from clipboard.")) + menu.append(("t", "Enter SSO Token manually.")) + menu.append(("q", "Quit SSO authentication attempt and return to Commander prompt.")) + + lines = [ + "", + "SSO Login URL:", + step.sso_login_url, + "Navigate to SSO Login URL with your browser and complete authentication.", + "Copy a returned SSO Token into clipboard." + + (" Paste that token into Commander." if pyperclip else " Then use option 't' to enter the token manually."), + 'NOTE: To copy SSO Token please click "Copy authentication token" ' + 'button on "SSO Connect" page.', + "", + ] + lines.extend(f" {a:>3}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + token = input("Selection: ") + if token == "q": + raise KeyboardInterrupt() + if token == "a": + step.login_with_password() + return + if token == "c": + token = None + if pyperclip: + try: + pyperclip.copy(step.sso_login_url) + print("SSO Login URL is copied to clipboard.") + except Exception: + print("Failed to copy SSO Login URL to clipboard.") + else: + print("Clipboard not available (install pyperclip).") + elif token == "u": + token = None + if not pyperclip: + print("\nSSO Login URL:", step.sso_login_url, "\n") + else: + print("Unsupported menu option (use 'c' to copy URL).") + elif token == "o": + token = None + if wb: + try: + wb.open_new_tab(step.sso_login_url) + except Exception: + print("Failed to open web browser.") + elif token == "p": + if pyperclip: + try: + token = pyperclip.paste() + except Exception: + token = "" + print("Failed to paste from clipboard") + else: + token = None + print("Clipboard not available (use 't' to enter token manually).") + elif token == "t": + token = getpass.getpass("Enter SSO Token: ").strip() + else: + if len(token) < 10: + print(f"Unsupported menu option: {token}") + continue + + if token: + try: + step.set_sso_token(token) + break + except errors.KeeperApiError as kae: + print(f"SSO Login error: ({kae.result_code}) {kae.message}") + + @staticmethod + def _two_factor_channel_desc( + channel_type: login_auth.TwoFactorChannel, + ) -> str: + return { + login_auth.TwoFactorChannel.Authenticator: "TOTP (Google and Microsoft Authenticator)", + login_auth.TwoFactorChannel.TextMessage: "Send SMS Code", + login_auth.TwoFactorChannel.DuoSecurity: "DUO", + login_auth.TwoFactorChannel.RSASecurID: "RSA SecurID", + login_auth.TwoFactorChannel.SecurityKey: "WebAuthN (FIDO2 Security Key)", + login_auth.TwoFactorChannel.KeeperDNA: "Keeper DNA (Watch)", + login_auth.TwoFactorChannel.Backup: "Backup Code", + }.get(channel_type, "Not Supported") + + @staticmethod + def _two_factor_duration_desc( + duration: login_auth.TwoFactorDuration, + ) -> str: + return { + login_auth.TwoFactorDuration.EveryLogin: "Require Every Login", + login_auth.TwoFactorDuration.Forever: "Save on this Device Forever", + login_auth.TwoFactorDuration.Every12Hours: "Ask Every 12 hours", + login_auth.TwoFactorDuration.EveryDay: "Ask Every 24 hours", + login_auth.TwoFactorDuration.Every30Days: "Ask Every 30 days", + }.get(duration, "Require Every Login") + + @staticmethod + def _two_factor_code_to_duration( + text: str, + ) -> login_auth.TwoFactorDuration: + for dura, code in _TWO_FACTOR_DURATION_CODES.items(): + if code == text: + return dura + return login_auth.TwoFactorDuration.EveryLogin + + +def enable_persistent_login(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + """ + Enable persistent login and register data key for device. + Sets persistent_login to on and logout_timer to 30 days. + """ + keeper_auth.set_user_setting(keeper_auth_context, 'persistent_login', '1') + keeper_auth.register_data_key_for_device(keeper_auth_context) + mins_per_day = 60 * 24 + timeout_in_minutes = mins_per_day * 30 # 30 days + keeper_auth.set_user_setting(keeper_auth_context, 'logout_timer', str(timeout_in_minutes)) + print("Persistent login turned on successfully and device registered") + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps (device approval, password, 2FA + with channel selection and Security Key, SSO data key, SSO token). + + Returns: + tuple: (keeper_auth_context, keeper_endpoint) on success, or (None, None) if login fails. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint + + +def print_admin_devices_table(devices): + if not devices: + print('\nNo devices found.') + return + print(f'\nAdmin Device List ({len(devices)} devices found)') + print('=' * 120) + print( + f"{'ID':<4} {'Enterprise User ID':<20} {'Device Name':<22} " + f"{'UI Category':<18} {'Device Status':<16} {'Login Status':<14} {'Last Accessed':<20}" + ) + print('-' * 120) + for d in devices: + last = d.last_accessed.strftime('%Y-%m-%d %H:%M:%S') if d.last_accessed else 'N/A' + print( + f"{d.list_index:<4} {d.enterprise_user_id:<20} {d.name[:21]:<22} " + f"{d.ui_category[:17]:<18} {d.device_status[:15]:<16} " + f"{d.login_status[:13]:<14} {last:<20}" + ) + print('-' * 120) + + +def main(): + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + + # Fill in your values here (enterprise admin required). + enterprise_user_ids = [0] + + try: + devices = device_management.list_admin_devices( + keeper_auth_context, enterprise_user_ids + ) + print_admin_devices_table(devices) + except Exception as e: + print(f'Error listing admin devices: {e}') + finally: + keeper_auth_context.close() + + +if __name__ == '__main__': + main() diff --git a/examples/sdk_examples/device_management/admin_logout_device.py b/examples/sdk_examples/device_management/admin_logout_device.py new file mode 100644 index 00000000..4838b10a --- /dev/null +++ b/examples/sdk_examples/device_management/admin_logout_device.py @@ -0,0 +1,549 @@ +import getpass +import sqlite3 +import json +import logging +from typing import Dict, Optional + +import fido2 +import webbrowser + +from keepersdk import errors, utils +from keepersdk.authentication import ( + configuration, + device_management, + endpoint, + keeper_auth, + login_auth, +) +from keepersdk.authentication.yubikey import ( + IKeeperUserInteraction, + yubikey_authenticate, +) +from keepersdk.constants import KEEPER_PUBLIC_HOSTS +from keepersdk.vault import sqlite_storage, vault_online, ksm_management + +try: + import pyperclip +except ImportError: + pyperclip = None + +logger = utils.get_logger() +logger.setLevel(logging.INFO) +if not logger.handlers: + _handler = logging.StreamHandler() + _handler.setLevel(logging.INFO) + _handler.setFormatter( + logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") + ) + logger.addHandler(_handler) + + +class FidoCliInteraction(fido2.client.UserInteraction, IKeeperUserInteraction): + def output_text(self, text: str) -> None: + print(text) + + def prompt_up(self) -> None: + print( + "\nTouch the flashing Security key to authenticate or " + "press Ctrl-C to resume with the primary two factor authentication..." + ) + + def request_pin(self, permissions, rd_id): + return getpass.getpass("Enter Security Key PIN: ") + + def request_uv(self, permissions, rd_id): + print("User Verification required.") + return True + + +# Two-factor duration codes (used by LoginFlow) +_TWO_FACTOR_DURATION_CODES: Dict[login_auth.TwoFactorDuration, str] = { + login_auth.TwoFactorDuration.EveryLogin: "login", + login_auth.TwoFactorDuration.Every12Hours: "12_hours", + login_auth.TwoFactorDuration.EveryDay: "24_hours", + login_auth.TwoFactorDuration.Every30Days: "30_days", + login_auth.TwoFactorDuration.Forever: "forever", +} + + +class LoginFlow: + """ + Handles the full login process: server selection, username, password, + device approval, 2FA, SSO data key, and SSO token. + """ + + def __init__(self) -> None: + self._config = configuration.JsonConfigurationStorage() + self._logged_in_with_persistent = True + self._endpoint: Optional[endpoint.KeeperEndpoint] = None + + @property + def endpoint(self) -> Optional[endpoint.KeeperEndpoint]: + return self._endpoint + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + def run(self) -> Optional[keeper_auth.KeeperAuth]: + """ + Run the login flow. + + Returns: + Authenticated Keeper context, or None if login fails. + """ + server = self._ensure_server() + keeper_endpoint = endpoint.KeeperEndpoint(self._config, server) + self._endpoint = keeper_endpoint + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = self._config.get().last_login or input("Enter username: ") + login_auth_context.resume_session = True + login_auth_context.login(username) + + while not login_auth_context.login_step.is_final(): + step = login_auth_context.login_step + if isinstance(step, login_auth.LoginStepDeviceApproval): + self._handle_device_approval(step) + elif isinstance(step, login_auth.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(step) + elif isinstance(step, login_auth.LoginStepError): + print(f"Login error: ({step.code}) {step.message}") + return None + else: + raise NotImplementedError( + f"Unsupported login step type: {type(step).__name__}" + ) + self._logged_in_with_persistent = False + + if self._logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + def _ensure_server(self) -> str: + if not self._config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = ( + input("Enter server (default: keepersecurity.com): ").strip() + or "keepersecurity.com" + ) + self._config.get().last_server = server + else: + server = self._config.get().last_server + return server + + def _handle_device_approval( + self, step: login_auth.LoginStepDeviceApproval + ) -> None: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + def _handle_two_factor(self, step: login_auth.LoginStepTwoFactor) -> None: + channels = [ + x + for x in step.get_channels() + if x.channel_type != login_auth.TwoFactorChannel.Other + ] + menu = [] + for i, channel in enumerate(channels): + desc = self._two_factor_channel_desc(channel.channel_type) + menu.append( + ( + str(i + 1), + f"{desc} {channel.channel_name} {channel.phone}", + ) + ) + menu.append(("q", "Quit authentication attempt and return to Commander prompt.")) + + lines = ["", "This account requires 2FA Authentication"] + lines.extend(f" {a}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + selection = input("Selection: ") + if selection is None: + return + if selection in ("q", "Q"): + raise KeyboardInterrupt() + try: + assert selection.isnumeric() + idx = 1 if not selection else int(selection) + assert 1 <= idx <= len(channels) + channel = channels[idx - 1] + desc = self._two_factor_channel_desc(channel.channel_type) + print(f"Selected {idx}. {desc}") + except AssertionError: + print( + "Invalid entry, additional factors of authentication shown " + "may be configured if not currently enabled." + ) + continue + + if channel.channel_type in ( + login_auth.TwoFactorChannel.TextMessage, + login_auth.TwoFactorChannel.KeeperDNA, + login_auth.TwoFactorChannel.DuoSecurity, + ): + action = next( + ( + x + for x in step.get_channel_push_actions(channel.channel_uid) + if x + in ( + login_auth.TwoFactorPushAction.TextMessage, + login_auth.TwoFactorPushAction.KeeperDna, + ) + ), + None, + ) + if action: + step.send_push(channel.channel_uid, action) + + if channel.channel_type == login_auth.TwoFactorChannel.SecurityKey: + try: + challenge = json.loads(channel.challenge) + signature = yubikey_authenticate(challenge, FidoCliInteraction()) + if signature: + print("Verified Security Key.") + step.send_code(channel.channel_uid, signature) + return + except Exception as e: + logger.error(e) + continue + + # 2FA code path + step.duration = min(step.duration, channel.max_expiration) + available_dura = sorted( + x for x in _TWO_FACTOR_DURATION_CODES if x <= channel.max_expiration + ) + available_codes = [ + _TWO_FACTOR_DURATION_CODES.get(x) or "login" for x in available_dura + ] + + while True: + mfa_desc = self._two_factor_duration_desc(step.duration) + prompt_exp = ( + f"\n2FA Code Duration: {mfa_desc}.\n" + f"To change duration: 2fa_duration={'|'.join(available_codes)}" + ) + print(prompt_exp) + + selection = input("\nEnter 2FA Code or Duration: ") + if not selection: + return + if selection in available_codes: + step.duration = self._two_factor_code_to_duration(selection) + elif selection.startswith("2fa_duration="): + code = selection[len("2fa_duration=") :] + if code in available_codes: + step.duration = self._two_factor_code_to_duration(code) + else: + print(f"Invalid 2FA duration: {code}") + else: + try: + step.send_code(channel.channel_uid, selection) + print("Successfully verified 2FA Code.") + return + except errors.KeeperApiError as kae: + print(f"Invalid 2FA code: ({kae.result_code}) {kae.message}") + + def _handle_sso_data_key( + self, step: login_auth.LoginStepSsoDataKey + ) -> None: + menu = [ + ("1", "Keeper Push. Send a push notification to your device."), + ("2", "Admin Approval. Request your admin to approve this device."), + ("r", "Resume SSO authentication after device is approved."), + ("q", "Quit SSO authentication attempt and return to Commander prompt."), + ] + lines = ["Approve this device by selecting a method below:"] + lines.extend(f" {cmd:>3}. {text}" for cmd, text in menu) + print("\n".join(lines)) + + while True: + answer = input("Selection: ") + if answer is None: + return + if answer == "q": + raise KeyboardInterrupt() + if answer == "r": + step.resume() + break + if answer in ("1", "2"): + step.request_data_key( + login_auth.DataKeyShareChannel.KeeperPush + if answer == "1" + else login_auth.DataKeyShareChannel.AdminApproval + ) + else: + print(f'Action "{answer}" is not supported.') + + def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: + menu = [ + ("a", "SSO User with a Master Password."), + ] + if pyperclip: + menu.append(("c", "Copy SSO Login URL to clipboard.")) + else: + menu.append(("u", "Show SSO Login URL.")) + try: + wb = webbrowser.get() + menu.append(("o", "Navigate to SSO Login URL with the default web browser.")) + except Exception: + wb = None + if pyperclip: + menu.append(("p", "Paste SSO Token from clipboard.")) + menu.append(("t", "Enter SSO Token manually.")) + menu.append(("q", "Quit SSO authentication attempt and return to Commander prompt.")) + + lines = [ + "", + "SSO Login URL:", + step.sso_login_url, + "Navigate to SSO Login URL with your browser and complete authentication.", + "Copy a returned SSO Token into clipboard." + + (" Paste that token into Commander." if pyperclip else " Then use option 't' to enter the token manually."), + 'NOTE: To copy SSO Token please click "Copy authentication token" ' + 'button on "SSO Connect" page.', + "", + ] + lines.extend(f" {a:>3}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + token = input("Selection: ") + if token == "q": + raise KeyboardInterrupt() + if token == "a": + step.login_with_password() + return + if token == "c": + token = None + if pyperclip: + try: + pyperclip.copy(step.sso_login_url) + print("SSO Login URL is copied to clipboard.") + except Exception: + print("Failed to copy SSO Login URL to clipboard.") + else: + print("Clipboard not available (install pyperclip).") + elif token == "u": + token = None + if not pyperclip: + print("\nSSO Login URL:", step.sso_login_url, "\n") + else: + print("Unsupported menu option (use 'c' to copy URL).") + elif token == "o": + token = None + if wb: + try: + wb.open_new_tab(step.sso_login_url) + except Exception: + print("Failed to open web browser.") + elif token == "p": + if pyperclip: + try: + token = pyperclip.paste() + except Exception: + token = "" + print("Failed to paste from clipboard") + else: + token = None + print("Clipboard not available (use 't' to enter token manually).") + elif token == "t": + token = getpass.getpass("Enter SSO Token: ").strip() + else: + if len(token) < 10: + print(f"Unsupported menu option: {token}") + continue + + if token: + try: + step.set_sso_token(token) + break + except errors.KeeperApiError as kae: + print(f"SSO Login error: ({kae.result_code}) {kae.message}") + + @staticmethod + def _two_factor_channel_desc( + channel_type: login_auth.TwoFactorChannel, + ) -> str: + return { + login_auth.TwoFactorChannel.Authenticator: "TOTP (Google and Microsoft Authenticator)", + login_auth.TwoFactorChannel.TextMessage: "Send SMS Code", + login_auth.TwoFactorChannel.DuoSecurity: "DUO", + login_auth.TwoFactorChannel.RSASecurID: "RSA SecurID", + login_auth.TwoFactorChannel.SecurityKey: "WebAuthN (FIDO2 Security Key)", + login_auth.TwoFactorChannel.KeeperDNA: "Keeper DNA (Watch)", + login_auth.TwoFactorChannel.Backup: "Backup Code", + }.get(channel_type, "Not Supported") + + @staticmethod + def _two_factor_duration_desc( + duration: login_auth.TwoFactorDuration, + ) -> str: + return { + login_auth.TwoFactorDuration.EveryLogin: "Require Every Login", + login_auth.TwoFactorDuration.Forever: "Save on this Device Forever", + login_auth.TwoFactorDuration.Every12Hours: "Ask Every 12 hours", + login_auth.TwoFactorDuration.EveryDay: "Ask Every 24 hours", + login_auth.TwoFactorDuration.Every30Days: "Ask Every 30 days", + }.get(duration, "Require Every Login") + + @staticmethod + def _two_factor_code_to_duration( + text: str, + ) -> login_auth.TwoFactorDuration: + for dura, code in _TWO_FACTOR_DURATION_CODES.items(): + if code == text: + return dura + return login_auth.TwoFactorDuration.EveryLogin + + +def enable_persistent_login(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + """ + Enable persistent login and register data key for device. + Sets persistent_login to on and logout_timer to 30 days. + """ + keeper_auth.set_user_setting(keeper_auth_context, 'persistent_login', '1') + keeper_auth.register_data_key_for_device(keeper_auth_context) + mins_per_day = 60 * 24 + timeout_in_minutes = mins_per_day * 30 # 30 days + keeper_auth.set_user_setting(keeper_auth_context, 'logout_timer', str(timeout_in_minutes)) + print("Persistent login turned on successfully and device registered") + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps (device approval, password, 2FA + with channel selection and Security Key, SSO data key, SSO token). + + Returns: + tuple: (keeper_auth_context, keeper_endpoint) on success, or (None, None) if login fails. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint + + +def print_admin_devices_table(devices): + if not devices: + print('\nNo devices found.') + return + print(f'\nAdmin Device List ({len(devices)} devices found)') + print('=' * 120) + print( + f"{'ID':<4} {'Enterprise User ID':<20} {'Device Name':<22} " + f"{'UI Category':<18} {'Device Status':<16} {'Login Status':<14} {'Last Accessed':<20}" + ) + print('-' * 120) + for d in devices: + last = d.last_accessed.strftime('%Y-%m-%d %H:%M:%S') if d.last_accessed else 'N/A' + print( + f"{d.list_index:<4} {d.enterprise_user_id:<20} {d.name[:21]:<22} " + f"{d.ui_category[:17]:<18} {d.device_status[:15]:<16} " + f"{d.login_status[:13]:<14} {last:<20}" + ) + print('-' * 120) + + +def main(): + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + + # Fill in your values here (enterprise admin required). + enterprise_user_id = 0 + device_identifiers = [''] + + try: + print(f'Logging out {len(device_identifiers)} device(s) for user {enterprise_user_id}...') + for name in device_management.logout_admin_user_devices( + keeper_auth_context, enterprise_user_id, device_identifiers + ): + print( + f"Device action successfully completed: '{name}' logged out " + f'for user {enterprise_user_id}' + ) + print(f'\nUpdated device list for user {enterprise_user_id}:') + print_admin_devices_table( + device_management.list_admin_devices(keeper_auth_context, [enterprise_user_id]) + ) + except Exception as e: + print(f'Error logging out admin devices: {e}') + finally: + keeper_auth_context.close() + + +if __name__ == '__main__': + main() diff --git a/examples/sdk_examples/device_management/admin_remove_device.py b/examples/sdk_examples/device_management/admin_remove_device.py new file mode 100644 index 00000000..8d0e1113 --- /dev/null +++ b/examples/sdk_examples/device_management/admin_remove_device.py @@ -0,0 +1,549 @@ +import getpass +import sqlite3 +import json +import logging +from typing import Dict, Optional + +import fido2 +import webbrowser + +from keepersdk import errors, utils +from keepersdk.authentication import ( + configuration, + device_management, + endpoint, + keeper_auth, + login_auth, +) +from keepersdk.authentication.yubikey import ( + IKeeperUserInteraction, + yubikey_authenticate, +) +from keepersdk.constants import KEEPER_PUBLIC_HOSTS +from keepersdk.vault import sqlite_storage, vault_online, ksm_management + +try: + import pyperclip +except ImportError: + pyperclip = None + +logger = utils.get_logger() +logger.setLevel(logging.INFO) +if not logger.handlers: + _handler = logging.StreamHandler() + _handler.setLevel(logging.INFO) + _handler.setFormatter( + logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") + ) + logger.addHandler(_handler) + + +class FidoCliInteraction(fido2.client.UserInteraction, IKeeperUserInteraction): + def output_text(self, text: str) -> None: + print(text) + + def prompt_up(self) -> None: + print( + "\nTouch the flashing Security key to authenticate or " + "press Ctrl-C to resume with the primary two factor authentication..." + ) + + def request_pin(self, permissions, rd_id): + return getpass.getpass("Enter Security Key PIN: ") + + def request_uv(self, permissions, rd_id): + print("User Verification required.") + return True + + +# Two-factor duration codes (used by LoginFlow) +_TWO_FACTOR_DURATION_CODES: Dict[login_auth.TwoFactorDuration, str] = { + login_auth.TwoFactorDuration.EveryLogin: "login", + login_auth.TwoFactorDuration.Every12Hours: "12_hours", + login_auth.TwoFactorDuration.EveryDay: "24_hours", + login_auth.TwoFactorDuration.Every30Days: "30_days", + login_auth.TwoFactorDuration.Forever: "forever", +} + + +class LoginFlow: + """ + Handles the full login process: server selection, username, password, + device approval, 2FA, SSO data key, and SSO token. + """ + + def __init__(self) -> None: + self._config = configuration.JsonConfigurationStorage() + self._logged_in_with_persistent = True + self._endpoint: Optional[endpoint.KeeperEndpoint] = None + + @property + def endpoint(self) -> Optional[endpoint.KeeperEndpoint]: + return self._endpoint + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + def run(self) -> Optional[keeper_auth.KeeperAuth]: + """ + Run the login flow. + + Returns: + Authenticated Keeper context, or None if login fails. + """ + server = self._ensure_server() + keeper_endpoint = endpoint.KeeperEndpoint(self._config, server) + self._endpoint = keeper_endpoint + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = self._config.get().last_login or input("Enter username: ") + login_auth_context.resume_session = True + login_auth_context.login(username) + + while not login_auth_context.login_step.is_final(): + step = login_auth_context.login_step + if isinstance(step, login_auth.LoginStepDeviceApproval): + self._handle_device_approval(step) + elif isinstance(step, login_auth.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(step) + elif isinstance(step, login_auth.LoginStepError): + print(f"Login error: ({step.code}) {step.message}") + return None + else: + raise NotImplementedError( + f"Unsupported login step type: {type(step).__name__}" + ) + self._logged_in_with_persistent = False + + if self._logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + def _ensure_server(self) -> str: + if not self._config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = ( + input("Enter server (default: keepersecurity.com): ").strip() + or "keepersecurity.com" + ) + self._config.get().last_server = server + else: + server = self._config.get().last_server + return server + + def _handle_device_approval( + self, step: login_auth.LoginStepDeviceApproval + ) -> None: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + def _handle_two_factor(self, step: login_auth.LoginStepTwoFactor) -> None: + channels = [ + x + for x in step.get_channels() + if x.channel_type != login_auth.TwoFactorChannel.Other + ] + menu = [] + for i, channel in enumerate(channels): + desc = self._two_factor_channel_desc(channel.channel_type) + menu.append( + ( + str(i + 1), + f"{desc} {channel.channel_name} {channel.phone}", + ) + ) + menu.append(("q", "Quit authentication attempt and return to Commander prompt.")) + + lines = ["", "This account requires 2FA Authentication"] + lines.extend(f" {a}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + selection = input("Selection: ") + if selection is None: + return + if selection in ("q", "Q"): + raise KeyboardInterrupt() + try: + assert selection.isnumeric() + idx = 1 if not selection else int(selection) + assert 1 <= idx <= len(channels) + channel = channels[idx - 1] + desc = self._two_factor_channel_desc(channel.channel_type) + print(f"Selected {idx}. {desc}") + except AssertionError: + print( + "Invalid entry, additional factors of authentication shown " + "may be configured if not currently enabled." + ) + continue + + if channel.channel_type in ( + login_auth.TwoFactorChannel.TextMessage, + login_auth.TwoFactorChannel.KeeperDNA, + login_auth.TwoFactorChannel.DuoSecurity, + ): + action = next( + ( + x + for x in step.get_channel_push_actions(channel.channel_uid) + if x + in ( + login_auth.TwoFactorPushAction.TextMessage, + login_auth.TwoFactorPushAction.KeeperDna, + ) + ), + None, + ) + if action: + step.send_push(channel.channel_uid, action) + + if channel.channel_type == login_auth.TwoFactorChannel.SecurityKey: + try: + challenge = json.loads(channel.challenge) + signature = yubikey_authenticate(challenge, FidoCliInteraction()) + if signature: + print("Verified Security Key.") + step.send_code(channel.channel_uid, signature) + return + except Exception as e: + logger.error(e) + continue + + # 2FA code path + step.duration = min(step.duration, channel.max_expiration) + available_dura = sorted( + x for x in _TWO_FACTOR_DURATION_CODES if x <= channel.max_expiration + ) + available_codes = [ + _TWO_FACTOR_DURATION_CODES.get(x) or "login" for x in available_dura + ] + + while True: + mfa_desc = self._two_factor_duration_desc(step.duration) + prompt_exp = ( + f"\n2FA Code Duration: {mfa_desc}.\n" + f"To change duration: 2fa_duration={'|'.join(available_codes)}" + ) + print(prompt_exp) + + selection = input("\nEnter 2FA Code or Duration: ") + if not selection: + return + if selection in available_codes: + step.duration = self._two_factor_code_to_duration(selection) + elif selection.startswith("2fa_duration="): + code = selection[len("2fa_duration=") :] + if code in available_codes: + step.duration = self._two_factor_code_to_duration(code) + else: + print(f"Invalid 2FA duration: {code}") + else: + try: + step.send_code(channel.channel_uid, selection) + print("Successfully verified 2FA Code.") + return + except errors.KeeperApiError as kae: + print(f"Invalid 2FA code: ({kae.result_code}) {kae.message}") + + def _handle_sso_data_key( + self, step: login_auth.LoginStepSsoDataKey + ) -> None: + menu = [ + ("1", "Keeper Push. Send a push notification to your device."), + ("2", "Admin Approval. Request your admin to approve this device."), + ("r", "Resume SSO authentication after device is approved."), + ("q", "Quit SSO authentication attempt and return to Commander prompt."), + ] + lines = ["Approve this device by selecting a method below:"] + lines.extend(f" {cmd:>3}. {text}" for cmd, text in menu) + print("\n".join(lines)) + + while True: + answer = input("Selection: ") + if answer is None: + return + if answer == "q": + raise KeyboardInterrupt() + if answer == "r": + step.resume() + break + if answer in ("1", "2"): + step.request_data_key( + login_auth.DataKeyShareChannel.KeeperPush + if answer == "1" + else login_auth.DataKeyShareChannel.AdminApproval + ) + else: + print(f'Action "{answer}" is not supported.') + + def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: + menu = [ + ("a", "SSO User with a Master Password."), + ] + if pyperclip: + menu.append(("c", "Copy SSO Login URL to clipboard.")) + else: + menu.append(("u", "Show SSO Login URL.")) + try: + wb = webbrowser.get() + menu.append(("o", "Navigate to SSO Login URL with the default web browser.")) + except Exception: + wb = None + if pyperclip: + menu.append(("p", "Paste SSO Token from clipboard.")) + menu.append(("t", "Enter SSO Token manually.")) + menu.append(("q", "Quit SSO authentication attempt and return to Commander prompt.")) + + lines = [ + "", + "SSO Login URL:", + step.sso_login_url, + "Navigate to SSO Login URL with your browser and complete authentication.", + "Copy a returned SSO Token into clipboard." + + (" Paste that token into Commander." if pyperclip else " Then use option 't' to enter the token manually."), + 'NOTE: To copy SSO Token please click "Copy authentication token" ' + 'button on "SSO Connect" page.', + "", + ] + lines.extend(f" {a:>3}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + token = input("Selection: ") + if token == "q": + raise KeyboardInterrupt() + if token == "a": + step.login_with_password() + return + if token == "c": + token = None + if pyperclip: + try: + pyperclip.copy(step.sso_login_url) + print("SSO Login URL is copied to clipboard.") + except Exception: + print("Failed to copy SSO Login URL to clipboard.") + else: + print("Clipboard not available (install pyperclip).") + elif token == "u": + token = None + if not pyperclip: + print("\nSSO Login URL:", step.sso_login_url, "\n") + else: + print("Unsupported menu option (use 'c' to copy URL).") + elif token == "o": + token = None + if wb: + try: + wb.open_new_tab(step.sso_login_url) + except Exception: + print("Failed to open web browser.") + elif token == "p": + if pyperclip: + try: + token = pyperclip.paste() + except Exception: + token = "" + print("Failed to paste from clipboard") + else: + token = None + print("Clipboard not available (use 't' to enter token manually).") + elif token == "t": + token = getpass.getpass("Enter SSO Token: ").strip() + else: + if len(token) < 10: + print(f"Unsupported menu option: {token}") + continue + + if token: + try: + step.set_sso_token(token) + break + except errors.KeeperApiError as kae: + print(f"SSO Login error: ({kae.result_code}) {kae.message}") + + @staticmethod + def _two_factor_channel_desc( + channel_type: login_auth.TwoFactorChannel, + ) -> str: + return { + login_auth.TwoFactorChannel.Authenticator: "TOTP (Google and Microsoft Authenticator)", + login_auth.TwoFactorChannel.TextMessage: "Send SMS Code", + login_auth.TwoFactorChannel.DuoSecurity: "DUO", + login_auth.TwoFactorChannel.RSASecurID: "RSA SecurID", + login_auth.TwoFactorChannel.SecurityKey: "WebAuthN (FIDO2 Security Key)", + login_auth.TwoFactorChannel.KeeperDNA: "Keeper DNA (Watch)", + login_auth.TwoFactorChannel.Backup: "Backup Code", + }.get(channel_type, "Not Supported") + + @staticmethod + def _two_factor_duration_desc( + duration: login_auth.TwoFactorDuration, + ) -> str: + return { + login_auth.TwoFactorDuration.EveryLogin: "Require Every Login", + login_auth.TwoFactorDuration.Forever: "Save on this Device Forever", + login_auth.TwoFactorDuration.Every12Hours: "Ask Every 12 hours", + login_auth.TwoFactorDuration.EveryDay: "Ask Every 24 hours", + login_auth.TwoFactorDuration.Every30Days: "Ask Every 30 days", + }.get(duration, "Require Every Login") + + @staticmethod + def _two_factor_code_to_duration( + text: str, + ) -> login_auth.TwoFactorDuration: + for dura, code in _TWO_FACTOR_DURATION_CODES.items(): + if code == text: + return dura + return login_auth.TwoFactorDuration.EveryLogin + + +def enable_persistent_login(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + """ + Enable persistent login and register data key for device. + Sets persistent_login to on and logout_timer to 30 days. + """ + keeper_auth.set_user_setting(keeper_auth_context, 'persistent_login', '1') + keeper_auth.register_data_key_for_device(keeper_auth_context) + mins_per_day = 60 * 24 + timeout_in_minutes = mins_per_day * 30 # 30 days + keeper_auth.set_user_setting(keeper_auth_context, 'logout_timer', str(timeout_in_minutes)) + print("Persistent login turned on successfully and device registered") + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps (device approval, password, 2FA + with channel selection and Security Key, SSO data key, SSO token). + + Returns: + tuple: (keeper_auth_context, keeper_endpoint) on success, or (None, None) if login fails. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint + + +def print_admin_devices_table(devices): + if not devices: + print('\nNo devices found.') + return + print(f'\nAdmin Device List ({len(devices)} devices found)') + print('=' * 120) + print( + f"{'ID':<4} {'Enterprise User ID':<20} {'Device Name':<22} " + f"{'UI Category':<18} {'Device Status':<16} {'Login Status':<14} {'Last Accessed':<20}" + ) + print('-' * 120) + for d in devices: + last = d.last_accessed.strftime('%Y-%m-%d %H:%M:%S') if d.last_accessed else 'N/A' + print( + f"{d.list_index:<4} {d.enterprise_user_id:<20} {d.name[:21]:<22} " + f"{d.ui_category[:17]:<18} {d.device_status[:15]:<16} " + f"{d.login_status[:13]:<14} {last:<20}" + ) + print('-' * 120) + + +def main(): + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + + # Fill in your values here (enterprise admin required). + enterprise_user_id = 0 + device_identifiers = [''] + + try: + print(f'Removing {len(device_identifiers)} device(s) for user {enterprise_user_id}...') + for name in device_management.remove_admin_user_devices( + keeper_auth_context, enterprise_user_id, device_identifiers + ): + print( + f"Device action successfully completed: '{name}' removed " + f'for user {enterprise_user_id}' + ) + print(f'\nUpdated device list for user {enterprise_user_id}:') + print_admin_devices_table( + device_management.list_admin_devices(keeper_auth_context, [enterprise_user_id]) + ) + except Exception as e: + print(f'Error removing admin devices: {e}') + finally: + keeper_auth_context.close() + + +if __name__ == '__main__': + main() diff --git a/examples/sdk_examples/device_management/list_devices.py b/examples/sdk_examples/device_management/list_devices.py index 5a8b31cb..f8ef5291 100644 --- a/examples/sdk_examples/device_management/list_devices.py +++ b/examples/sdk_examples/device_management/list_devices.py @@ -503,15 +503,15 @@ def print_devices_table(devices): return print(f'\nUser Devices ({len(devices)} found)') print('=' * 100) - print(f"{'ID':<4} {'Name':<24} {'Client Type':<14} {'Login Status':<14} {'Last Accessed':<20}") - print('-' * 100) + print(f"{'ID':<4} {'Device Name':<22} {'Client Type':<13} {'Login Status':<14} {'Last Accessed':<19}") + print('-' * 4 + ' ' + '-' * 22 + ' ' + '-' * 13 + ' ' + '-' * 14 + ' ' + '-' * 19) for d in devices: last = d.last_accessed.strftime('%Y-%m-%d %H:%M:%S') if d.last_accessed else 'N/A' print( - f"{d.list_index:<4} {d.name[:23]:<24} " - f"{d.client_type[:13]:<14} {d.login_status[:13]:<14} {last:<20}" + f"{d.list_index:<4} {d.name[:21]:<22} " + f"{d.client_type[:12]:<13} {d.login_status[:13]:<14} {last:<19}" ) - print('-' * 100) + print('-' * 4 + ' ' + '-' * 22 + ' ' + '-' * 13 + ' ' + '-' * 14 + ' ' + '-' * 19) def list_user_devices_example(keeper_auth_context): diff --git a/examples/sdk_examples/device_management/lock_device.py b/examples/sdk_examples/device_management/lock_device.py new file mode 100644 index 00000000..7726c8db --- /dev/null +++ b/examples/sdk_examples/device_management/lock_device.py @@ -0,0 +1,540 @@ +import getpass +import sqlite3 +import json +import logging +from typing import Dict, Optional + +import fido2 +import webbrowser + +from keepersdk import errors, utils +from keepersdk.authentication import ( + configuration, + endpoint, + keeper_auth, + login_auth, +) +from keepersdk.authentication.yubikey import ( + IKeeperUserInteraction, + yubikey_authenticate, +) +from keepersdk.constants import KEEPER_PUBLIC_HOSTS +from keepersdk.vault import sqlite_storage, vault_online, ksm_management + +try: + import pyperclip +except ImportError: + pyperclip = None + +logger = utils.get_logger() +logger.setLevel(logging.INFO) +if not logger.handlers: + _handler = logging.StreamHandler() + _handler.setLevel(logging.INFO) + _handler.setFormatter( + logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") + ) + logger.addHandler(_handler) + + +class FidoCliInteraction(fido2.client.UserInteraction, IKeeperUserInteraction): + def output_text(self, text: str) -> None: + print(text) + + def prompt_up(self) -> None: + print( + "\nTouch the flashing Security key to authenticate or " + "press Ctrl-C to resume with the primary two factor authentication..." + ) + + def request_pin(self, permissions, rd_id): + return getpass.getpass("Enter Security Key PIN: ") + + def request_uv(self, permissions, rd_id): + print("User Verification required.") + return True + + +# Two-factor duration codes (used by LoginFlow) +_TWO_FACTOR_DURATION_CODES: Dict[login_auth.TwoFactorDuration, str] = { + login_auth.TwoFactorDuration.EveryLogin: "login", + login_auth.TwoFactorDuration.Every12Hours: "12_hours", + login_auth.TwoFactorDuration.EveryDay: "24_hours", + login_auth.TwoFactorDuration.Every30Days: "30_days", + login_auth.TwoFactorDuration.Forever: "forever", +} + + +class LoginFlow: + """ + Handles the full login process: server selection, username, password, + device approval, 2FA, SSO data key, and SSO token. + """ + + def __init__(self) -> None: + self._config = configuration.JsonConfigurationStorage() + self._logged_in_with_persistent = True + self._endpoint: Optional[endpoint.KeeperEndpoint] = None + + @property + def endpoint(self) -> Optional[endpoint.KeeperEndpoint]: + return self._endpoint + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + def run(self) -> Optional[keeper_auth.KeeperAuth]: + """ + Run the login flow. + + Returns: + Authenticated Keeper context, or None if login fails. + """ + server = self._ensure_server() + keeper_endpoint = endpoint.KeeperEndpoint(self._config, server) + self._endpoint = keeper_endpoint + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = self._config.get().last_login or input("Enter username: ") + login_auth_context.resume_session = True + login_auth_context.login(username) + + while not login_auth_context.login_step.is_final(): + step = login_auth_context.login_step + if isinstance(step, login_auth.LoginStepDeviceApproval): + self._handle_device_approval(step) + elif isinstance(step, login_auth.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(step) + elif isinstance(step, login_auth.LoginStepError): + print(f"Login error: ({step.code}) {step.message}") + return None + else: + raise NotImplementedError( + f"Unsupported login step type: {type(step).__name__}" + ) + self._logged_in_with_persistent = False + + if self._logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + def _ensure_server(self) -> str: + if not self._config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = ( + input("Enter server (default: keepersecurity.com): ").strip() + or "keepersecurity.com" + ) + self._config.get().last_server = server + else: + server = self._config.get().last_server + return server + + def _handle_device_approval( + self, step: login_auth.LoginStepDeviceApproval + ) -> None: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + def _handle_two_factor(self, step: login_auth.LoginStepTwoFactor) -> None: + channels = [ + x + for x in step.get_channels() + if x.channel_type != login_auth.TwoFactorChannel.Other + ] + menu = [] + for i, channel in enumerate(channels): + desc = self._two_factor_channel_desc(channel.channel_type) + menu.append( + ( + str(i + 1), + f"{desc} {channel.channel_name} {channel.phone}", + ) + ) + menu.append(("q", "Quit authentication attempt and return to Commander prompt.")) + + lines = ["", "This account requires 2FA Authentication"] + lines.extend(f" {a}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + selection = input("Selection: ") + if selection is None: + return + if selection in ("q", "Q"): + raise KeyboardInterrupt() + try: + assert selection.isnumeric() + idx = 1 if not selection else int(selection) + assert 1 <= idx <= len(channels) + channel = channels[idx - 1] + desc = self._two_factor_channel_desc(channel.channel_type) + print(f"Selected {idx}. {desc}") + except AssertionError: + print( + "Invalid entry, additional factors of authentication shown " + "may be configured if not currently enabled." + ) + continue + + if channel.channel_type in ( + login_auth.TwoFactorChannel.TextMessage, + login_auth.TwoFactorChannel.KeeperDNA, + login_auth.TwoFactorChannel.DuoSecurity, + ): + action = next( + ( + x + for x in step.get_channel_push_actions(channel.channel_uid) + if x + in ( + login_auth.TwoFactorPushAction.TextMessage, + login_auth.TwoFactorPushAction.KeeperDna, + ) + ), + None, + ) + if action: + step.send_push(channel.channel_uid, action) + + if channel.channel_type == login_auth.TwoFactorChannel.SecurityKey: + try: + challenge = json.loads(channel.challenge) + signature = yubikey_authenticate(challenge, FidoCliInteraction()) + if signature: + print("Verified Security Key.") + step.send_code(channel.channel_uid, signature) + return + except Exception as e: + logger.error(e) + continue + + # 2FA code path + step.duration = min(step.duration, channel.max_expiration) + available_dura = sorted( + x for x in _TWO_FACTOR_DURATION_CODES if x <= channel.max_expiration + ) + available_codes = [ + _TWO_FACTOR_DURATION_CODES.get(x) or "login" for x in available_dura + ] + + while True: + mfa_desc = self._two_factor_duration_desc(step.duration) + prompt_exp = ( + f"\n2FA Code Duration: {mfa_desc}.\n" + f"To change duration: 2fa_duration={'|'.join(available_codes)}" + ) + print(prompt_exp) + + selection = input("\nEnter 2FA Code or Duration: ") + if not selection: + return + if selection in available_codes: + step.duration = self._two_factor_code_to_duration(selection) + elif selection.startswith("2fa_duration="): + code = selection[len("2fa_duration=") :] + if code in available_codes: + step.duration = self._two_factor_code_to_duration(code) + else: + print(f"Invalid 2FA duration: {code}") + else: + try: + step.send_code(channel.channel_uid, selection) + print("Successfully verified 2FA Code.") + return + except errors.KeeperApiError as kae: + print(f"Invalid 2FA code: ({kae.result_code}) {kae.message}") + + def _handle_sso_data_key( + self, step: login_auth.LoginStepSsoDataKey + ) -> None: + menu = [ + ("1", "Keeper Push. Send a push notification to your device."), + ("2", "Admin Approval. Request your admin to approve this device."), + ("r", "Resume SSO authentication after device is approved."), + ("q", "Quit SSO authentication attempt and return to Commander prompt."), + ] + lines = ["Approve this device by selecting a method below:"] + lines.extend(f" {cmd:>3}. {text}" for cmd, text in menu) + print("\n".join(lines)) + + while True: + answer = input("Selection: ") + if answer is None: + return + if answer == "q": + raise KeyboardInterrupt() + if answer == "r": + step.resume() + break + if answer in ("1", "2"): + step.request_data_key( + login_auth.DataKeyShareChannel.KeeperPush + if answer == "1" + else login_auth.DataKeyShareChannel.AdminApproval + ) + else: + print(f'Action "{answer}" is not supported.') + + def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: + menu = [ + ("a", "SSO User with a Master Password."), + ] + if pyperclip: + menu.append(("c", "Copy SSO Login URL to clipboard.")) + else: + menu.append(("u", "Show SSO Login URL.")) + try: + wb = webbrowser.get() + menu.append(("o", "Navigate to SSO Login URL with the default web browser.")) + except Exception: + wb = None + if pyperclip: + menu.append(("p", "Paste SSO Token from clipboard.")) + menu.append(("t", "Enter SSO Token manually.")) + menu.append(("q", "Quit SSO authentication attempt and return to Commander prompt.")) + + lines = [ + "", + "SSO Login URL:", + step.sso_login_url, + "Navigate to SSO Login URL with your browser and complete authentication.", + "Copy a returned SSO Token into clipboard." + + (" Paste that token into Commander." if pyperclip else " Then use option 't' to enter the token manually."), + 'NOTE: To copy SSO Token please click "Copy authentication token" ' + 'button on "SSO Connect" page.', + "", + ] + lines.extend(f" {a:>3}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + token = input("Selection: ") + if token == "q": + raise KeyboardInterrupt() + if token == "a": + step.login_with_password() + return + if token == "c": + token = None + if pyperclip: + try: + pyperclip.copy(step.sso_login_url) + print("SSO Login URL is copied to clipboard.") + except Exception: + print("Failed to copy SSO Login URL to clipboard.") + else: + print("Clipboard not available (install pyperclip).") + elif token == "u": + token = None + if not pyperclip: + print("\nSSO Login URL:", step.sso_login_url, "\n") + else: + print("Unsupported menu option (use 'c' to copy URL).") + elif token == "o": + token = None + if wb: + try: + wb.open_new_tab(step.sso_login_url) + except Exception: + print("Failed to open web browser.") + elif token == "p": + if pyperclip: + try: + token = pyperclip.paste() + except Exception: + token = "" + print("Failed to paste from clipboard") + else: + token = None + print("Clipboard not available (use 't' to enter token manually).") + elif token == "t": + token = getpass.getpass("Enter SSO Token: ").strip() + else: + if len(token) < 10: + print(f"Unsupported menu option: {token}") + continue + + if token: + try: + step.set_sso_token(token) + break + except errors.KeeperApiError as kae: + print(f"SSO Login error: ({kae.result_code}) {kae.message}") + + @staticmethod + def _two_factor_channel_desc( + channel_type: login_auth.TwoFactorChannel, + ) -> str: + return { + login_auth.TwoFactorChannel.Authenticator: "TOTP (Google and Microsoft Authenticator)", + login_auth.TwoFactorChannel.TextMessage: "Send SMS Code", + login_auth.TwoFactorChannel.DuoSecurity: "DUO", + login_auth.TwoFactorChannel.RSASecurID: "RSA SecurID", + login_auth.TwoFactorChannel.SecurityKey: "WebAuthN (FIDO2 Security Key)", + login_auth.TwoFactorChannel.KeeperDNA: "Keeper DNA (Watch)", + login_auth.TwoFactorChannel.Backup: "Backup Code", + }.get(channel_type, "Not Supported") + + @staticmethod + def _two_factor_duration_desc( + duration: login_auth.TwoFactorDuration, + ) -> str: + return { + login_auth.TwoFactorDuration.EveryLogin: "Require Every Login", + login_auth.TwoFactorDuration.Forever: "Save on this Device Forever", + login_auth.TwoFactorDuration.Every12Hours: "Ask Every 12 hours", + login_auth.TwoFactorDuration.EveryDay: "Ask Every 24 hours", + login_auth.TwoFactorDuration.Every30Days: "Ask Every 30 days", + }.get(duration, "Require Every Login") + + @staticmethod + def _two_factor_code_to_duration( + text: str, + ) -> login_auth.TwoFactorDuration: + for dura, code in _TWO_FACTOR_DURATION_CODES.items(): + if code == text: + return dura + return login_auth.TwoFactorDuration.EveryLogin + + +def enable_persistent_login(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + """ + Enable persistent login and register data key for device. + Sets persistent_login to on and logout_timer to 30 days. + """ + keeper_auth.set_user_setting(keeper_auth_context, 'persistent_login', '1') + keeper_auth.register_data_key_for_device(keeper_auth_context) + mins_per_day = 60 * 24 + timeout_in_minutes = mins_per_day * 30 # 30 days + keeper_auth.set_user_setting(keeper_auth_context, 'logout_timer', str(timeout_in_minutes)) + print("Persistent login turned on successfully and device registered") + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps (device approval, password, 2FA + with channel selection and Security Key, SSO data key, SSO token). + + Returns: + tuple: (keeper_auth_context, keeper_endpoint) on success, or (None, None) if login fails. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint + +from keepersdk.authentication import device_management + + +def print_devices_table(devices): + if not devices: + print('\nNo devices found.') + return + print(f'\nUser Devices ({len(devices)} found)') + print('=' * 100) + print(f"{'ID':<4} {'Device Name':<22} {'Client Type':<13} {'Login Status':<14} {'Last Accessed':<19}") + print('-' * 4 + ' ' + '-' * 22 + ' ' + '-' * 13 + ' ' + '-' * 14 + ' ' + '-' * 19) + for d in devices: + last = d.last_accessed.strftime('%Y-%m-%d %H:%M:%S') if d.last_accessed else 'N/A' + print( + f"{d.list_index:<4} {d.name[:21]:<22} " + f"{d.client_type[:12]:<13} {d.login_status[:13]:<14} {last:<19}" + ) + print('-' * 4 + ' ' + '-' * 22 + ' ' + '-' * 13 + ' ' + '-' * 14 + ' ' + '-' * 19) + + +def main(): + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + + # Fill in your values here. + device_identifiers = [''] + + try: + print(f'Locking {len(device_identifiers)} device(s)...') + for name in device_management.lock_user_devices( + keeper_auth_context, device_identifiers + ): + print(f"Device '{name}' successfully locked") + print('\nUpdated device list:') + print_devices_table(device_management.list_user_devices(keeper_auth_context)) + except Exception as e: + print(f'Error locking devices: {e}') + finally: + keeper_auth_context.close() + + +if __name__ == '__main__': + main() diff --git a/examples/sdk_examples/device_management/logout_device.py b/examples/sdk_examples/device_management/logout_device.py index ef82f1df..0d96c010 100644 --- a/examples/sdk_examples/device_management/logout_device.py +++ b/examples/sdk_examples/device_management/logout_device.py @@ -503,15 +503,15 @@ def print_devices_table(devices): return print(f'\nUser Devices ({len(devices)} found)') print('=' * 100) - print(f"{'ID':<4} {'Name':<24} {'Client Type':<14} {'Login Status':<14} {'Last Accessed':<20}") - print('-' * 100) + print(f"{'ID':<4} {'Device Name':<22} {'Client Type':<13} {'Login Status':<14} {'Last Accessed':<19}") + print('-' * 4 + ' ' + '-' * 22 + ' ' + '-' * 13 + ' ' + '-' * 14 + ' ' + '-' * 19) for d in devices: last = d.last_accessed.strftime('%Y-%m-%d %H:%M:%S') if d.last_accessed else 'N/A' print( - f"{d.list_index:<4} {d.name[:23]:<24} " - f"{d.client_type[:13]:<14} {d.login_status[:13]:<14} {last:<20}" + f"{d.list_index:<4} {d.name[:21]:<22} " + f"{d.client_type[:12]:<13} {d.login_status[:13]:<14} {last:<19}" ) - print('-' * 100) + print('-' * 4 + ' ' + '-' * 22 + ' ' + '-' * 13 + ' ' + '-' * 14 + ' ' + '-' * 19) def main(): diff --git a/examples/sdk_examples/device_management/remove_device.py b/examples/sdk_examples/device_management/remove_device.py index fae1e308..2bf3c55a 100644 --- a/examples/sdk_examples/device_management/remove_device.py +++ b/examples/sdk_examples/device_management/remove_device.py @@ -503,15 +503,15 @@ def print_devices_table(devices): return print(f'\nUser Devices ({len(devices)} found)') print('=' * 100) - print(f"{'ID':<4} {'Name':<24} {'Client Type':<14} {'Login Status':<14} {'Last Accessed':<20}") - print('-' * 100) + print(f"{'ID':<4} {'Device Name':<22} {'Client Type':<13} {'Login Status':<14} {'Last Accessed':<19}") + print('-' * 4 + ' ' + '-' * 22 + ' ' + '-' * 13 + ' ' + '-' * 14 + ' ' + '-' * 19) for d in devices: last = d.last_accessed.strftime('%Y-%m-%d %H:%M:%S') if d.last_accessed else 'N/A' print( - f"{d.list_index:<4} {d.name[:23]:<24} " - f"{d.client_type[:13]:<14} {d.login_status[:13]:<14} {last:<20}" + f"{d.list_index:<4} {d.name[:21]:<22} " + f"{d.client_type[:12]:<13} {d.login_status[:13]:<14} {last:<19}" ) - print('-' * 100) + print('-' * 4 + ' ' + '-' * 22 + ' ' + '-' * 13 + ' ' + '-' * 14 + ' ' + '-' * 19) def main(): diff --git a/examples/sdk_examples/device_management/rename_device.py b/examples/sdk_examples/device_management/rename_device.py index a9baa35f..4d3194cf 100644 --- a/examples/sdk_examples/device_management/rename_device.py +++ b/examples/sdk_examples/device_management/rename_device.py @@ -503,15 +503,15 @@ def print_devices_table(devices): return print(f'\nUser Devices ({len(devices)} found)') print('=' * 100) - print(f"{'ID':<4} {'Name':<24} {'Client Type':<14} {'Login Status':<14} {'Last Accessed':<20}") - print('-' * 100) + print(f"{'ID':<4} {'Device Name':<22} {'Client Type':<13} {'Login Status':<14} {'Last Accessed':<19}") + print('-' * 4 + ' ' + '-' * 22 + ' ' + '-' * 13 + ' ' + '-' * 14 + ' ' + '-' * 19) for d in devices: last = d.last_accessed.strftime('%Y-%m-%d %H:%M:%S') if d.last_accessed else 'N/A' print( - f"{d.list_index:<4} {d.name[:23]:<24} " - f"{d.client_type[:13]:<14} {d.login_status[:13]:<14} {last:<20}" + f"{d.list_index:<4} {d.name[:21]:<22} " + f"{d.client_type[:12]:<13} {d.login_status[:13]:<14} {last:<19}" ) - print('-' * 100) + print('-' * 4 + ' ' + '-' * 22 + ' ' + '-' * 13 + ' ' + '-' * 14 + ' ' + '-' * 19) def main(): diff --git a/examples/sdk_examples/device_management/unlock_device.py b/examples/sdk_examples/device_management/unlock_device.py new file mode 100644 index 00000000..aa1dc821 --- /dev/null +++ b/examples/sdk_examples/device_management/unlock_device.py @@ -0,0 +1,540 @@ +import getpass +import sqlite3 +import json +import logging +from typing import Dict, Optional + +import fido2 +import webbrowser + +from keepersdk import errors, utils +from keepersdk.authentication import ( + configuration, + endpoint, + keeper_auth, + login_auth, +) +from keepersdk.authentication.yubikey import ( + IKeeperUserInteraction, + yubikey_authenticate, +) +from keepersdk.constants import KEEPER_PUBLIC_HOSTS +from keepersdk.vault import sqlite_storage, vault_online, ksm_management + +try: + import pyperclip +except ImportError: + pyperclip = None + +logger = utils.get_logger() +logger.setLevel(logging.INFO) +if not logger.handlers: + _handler = logging.StreamHandler() + _handler.setLevel(logging.INFO) + _handler.setFormatter( + logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s") + ) + logger.addHandler(_handler) + + +class FidoCliInteraction(fido2.client.UserInteraction, IKeeperUserInteraction): + def output_text(self, text: str) -> None: + print(text) + + def prompt_up(self) -> None: + print( + "\nTouch the flashing Security key to authenticate or " + "press Ctrl-C to resume with the primary two factor authentication..." + ) + + def request_pin(self, permissions, rd_id): + return getpass.getpass("Enter Security Key PIN: ") + + def request_uv(self, permissions, rd_id): + print("User Verification required.") + return True + + +# Two-factor duration codes (used by LoginFlow) +_TWO_FACTOR_DURATION_CODES: Dict[login_auth.TwoFactorDuration, str] = { + login_auth.TwoFactorDuration.EveryLogin: "login", + login_auth.TwoFactorDuration.Every12Hours: "12_hours", + login_auth.TwoFactorDuration.EveryDay: "24_hours", + login_auth.TwoFactorDuration.Every30Days: "30_days", + login_auth.TwoFactorDuration.Forever: "forever", +} + + +class LoginFlow: + """ + Handles the full login process: server selection, username, password, + device approval, 2FA, SSO data key, and SSO token. + """ + + def __init__(self) -> None: + self._config = configuration.JsonConfigurationStorage() + self._logged_in_with_persistent = True + self._endpoint: Optional[endpoint.KeeperEndpoint] = None + + @property + def endpoint(self) -> Optional[endpoint.KeeperEndpoint]: + return self._endpoint + + @property + def logged_in_with_persistent(self) -> bool: + """True if login succeeded by resuming an existing persistent session (no step loop).""" + return self._logged_in_with_persistent + + def run(self) -> Optional[keeper_auth.KeeperAuth]: + """ + Run the login flow. + + Returns: + Authenticated Keeper context, or None if login fails. + """ + server = self._ensure_server() + keeper_endpoint = endpoint.KeeperEndpoint(self._config, server) + self._endpoint = keeper_endpoint + login_auth_context = login_auth.LoginAuth(keeper_endpoint) + + username = self._config.get().last_login or input("Enter username: ") + login_auth_context.resume_session = True + login_auth_context.login(username) + + while not login_auth_context.login_step.is_final(): + step = login_auth_context.login_step + if isinstance(step, login_auth.LoginStepDeviceApproval): + self._handle_device_approval(step) + elif isinstance(step, login_auth.LoginStepTwoFactor): + self._handle_two_factor(step) + elif isinstance(step, login_auth.LoginStepPassword): + self._handle_password(step) + elif isinstance(step, login_auth.LoginStepSsoToken): + self._handle_sso_token(step) + elif isinstance(step, login_auth.LoginStepSsoDataKey): + self._handle_sso_data_key(step) + elif isinstance(step, login_auth.LoginStepError): + print(f"Login error: ({step.code}) {step.message}") + return None + else: + raise NotImplementedError( + f"Unsupported login step type: {type(step).__name__}" + ) + self._logged_in_with_persistent = False + + if self._logged_in_with_persistent: + print("Successfully logged in with persistent login") + + if isinstance(login_auth_context.login_step, login_auth.LoginStepConnected): + return login_auth_context.login_step.take_keeper_auth() + + return None + + def _ensure_server(self) -> str: + if not self._config.get().last_server: + print("Available server options:") + for region, host in KEEPER_PUBLIC_HOSTS.items(): + print(f" {region}: {host}") + server = ( + input("Enter server (default: keepersecurity.com): ").strip() + or "keepersecurity.com" + ) + self._config.get().last_server = server + else: + server = self._config.get().last_server + return server + + def _handle_device_approval( + self, step: login_auth.LoginStepDeviceApproval + ) -> None: + """Device approval: same options as keepercli verify_device (email, keeper push, 2FA, resume).""" + menu = [ + ("email_send", "to send email"), + ("email_code=", "to validate verification code sent via email"), + ("keeper_push", "to send Keeper Push notification"), + ("2fa_send", "to send 2FA code"), + ("2fa_code=", "to validate a code provided by 2FA application"), + ("", "to resume"), + ] + lines = ["Approve by selecting a method below"] + lines.extend(f" {cmd} {desc}" for cmd, desc in menu) + print("\n".join(lines)) + + selection = input("Type your selection or to resume: ").strip() + if selection is None: + return + if selection in ("email_send", "es"): + step.send_push(channel=login_auth.DeviceApprovalChannel.Email) + print("An email with instructions has been sent. Press when approved.") + elif selection.startswith("email_code="): + code = selection[len("email_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.Email, code=code) + print("Successfully verified email code.") + elif selection in ("keeper_push", "kp"): + step.send_push(channel=login_auth.DeviceApprovalChannel.KeeperPush) + print( + "Successfully made a push notification to the approved device. " + "Press when approved." + ) + elif selection in ("2fa_send", "2fs"): + step.send_push(channel=login_auth.DeviceApprovalChannel.TwoFactor) + print("2FA code was sent.") + elif selection.startswith("2fa_code="): + code = selection[len("2fa_code=") :] + step.send_code(channel=login_auth.DeviceApprovalChannel.TwoFactor, code=code) + print("Successfully verified 2FA code.") + else: + step.resume() + + def _handle_password(self, step: login_auth.LoginStepPassword) -> None: + """Password step: prompt for password and retry on auth_failed (aligned with keepercli handle_verify_password).""" + print(f"\nEnter password for {step.username}") + while True: + password = getpass.getpass("Password: ") + if not password: + raise KeyboardInterrupt() + try: + step.verify_password(password) + break + except errors.KeeperApiError as kae: + print( + "Invalid email or password combination, please re-enter." + if kae.result_code == "auth_failed" + else kae.message + ) + + def _handle_two_factor(self, step: login_auth.LoginStepTwoFactor) -> None: + channels = [ + x + for x in step.get_channels() + if x.channel_type != login_auth.TwoFactorChannel.Other + ] + menu = [] + for i, channel in enumerate(channels): + desc = self._two_factor_channel_desc(channel.channel_type) + menu.append( + ( + str(i + 1), + f"{desc} {channel.channel_name} {channel.phone}", + ) + ) + menu.append(("q", "Quit authentication attempt and return to Commander prompt.")) + + lines = ["", "This account requires 2FA Authentication"] + lines.extend(f" {a}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + selection = input("Selection: ") + if selection is None: + return + if selection in ("q", "Q"): + raise KeyboardInterrupt() + try: + assert selection.isnumeric() + idx = 1 if not selection else int(selection) + assert 1 <= idx <= len(channels) + channel = channels[idx - 1] + desc = self._two_factor_channel_desc(channel.channel_type) + print(f"Selected {idx}. {desc}") + except AssertionError: + print( + "Invalid entry, additional factors of authentication shown " + "may be configured if not currently enabled." + ) + continue + + if channel.channel_type in ( + login_auth.TwoFactorChannel.TextMessage, + login_auth.TwoFactorChannel.KeeperDNA, + login_auth.TwoFactorChannel.DuoSecurity, + ): + action = next( + ( + x + for x in step.get_channel_push_actions(channel.channel_uid) + if x + in ( + login_auth.TwoFactorPushAction.TextMessage, + login_auth.TwoFactorPushAction.KeeperDna, + ) + ), + None, + ) + if action: + step.send_push(channel.channel_uid, action) + + if channel.channel_type == login_auth.TwoFactorChannel.SecurityKey: + try: + challenge = json.loads(channel.challenge) + signature = yubikey_authenticate(challenge, FidoCliInteraction()) + if signature: + print("Verified Security Key.") + step.send_code(channel.channel_uid, signature) + return + except Exception as e: + logger.error(e) + continue + + # 2FA code path + step.duration = min(step.duration, channel.max_expiration) + available_dura = sorted( + x for x in _TWO_FACTOR_DURATION_CODES if x <= channel.max_expiration + ) + available_codes = [ + _TWO_FACTOR_DURATION_CODES.get(x) or "login" for x in available_dura + ] + + while True: + mfa_desc = self._two_factor_duration_desc(step.duration) + prompt_exp = ( + f"\n2FA Code Duration: {mfa_desc}.\n" + f"To change duration: 2fa_duration={'|'.join(available_codes)}" + ) + print(prompt_exp) + + selection = input("\nEnter 2FA Code or Duration: ") + if not selection: + return + if selection in available_codes: + step.duration = self._two_factor_code_to_duration(selection) + elif selection.startswith("2fa_duration="): + code = selection[len("2fa_duration=") :] + if code in available_codes: + step.duration = self._two_factor_code_to_duration(code) + else: + print(f"Invalid 2FA duration: {code}") + else: + try: + step.send_code(channel.channel_uid, selection) + print("Successfully verified 2FA Code.") + return + except errors.KeeperApiError as kae: + print(f"Invalid 2FA code: ({kae.result_code}) {kae.message}") + + def _handle_sso_data_key( + self, step: login_auth.LoginStepSsoDataKey + ) -> None: + menu = [ + ("1", "Keeper Push. Send a push notification to your device."), + ("2", "Admin Approval. Request your admin to approve this device."), + ("r", "Resume SSO authentication after device is approved."), + ("q", "Quit SSO authentication attempt and return to Commander prompt."), + ] + lines = ["Approve this device by selecting a method below:"] + lines.extend(f" {cmd:>3}. {text}" for cmd, text in menu) + print("\n".join(lines)) + + while True: + answer = input("Selection: ") + if answer is None: + return + if answer == "q": + raise KeyboardInterrupt() + if answer == "r": + step.resume() + break + if answer in ("1", "2"): + step.request_data_key( + login_auth.DataKeyShareChannel.KeeperPush + if answer == "1" + else login_auth.DataKeyShareChannel.AdminApproval + ) + else: + print(f'Action "{answer}" is not supported.') + + def _handle_sso_token(self, step: login_auth.LoginStepSsoToken) -> None: + menu = [ + ("a", "SSO User with a Master Password."), + ] + if pyperclip: + menu.append(("c", "Copy SSO Login URL to clipboard.")) + else: + menu.append(("u", "Show SSO Login URL.")) + try: + wb = webbrowser.get() + menu.append(("o", "Navigate to SSO Login URL with the default web browser.")) + except Exception: + wb = None + if pyperclip: + menu.append(("p", "Paste SSO Token from clipboard.")) + menu.append(("t", "Enter SSO Token manually.")) + menu.append(("q", "Quit SSO authentication attempt and return to Commander prompt.")) + + lines = [ + "", + "SSO Login URL:", + step.sso_login_url, + "Navigate to SSO Login URL with your browser and complete authentication.", + "Copy a returned SSO Token into clipboard." + + (" Paste that token into Commander." if pyperclip else " Then use option 't' to enter the token manually."), + 'NOTE: To copy SSO Token please click "Copy authentication token" ' + 'button on "SSO Connect" page.', + "", + ] + lines.extend(f" {a:>3}. {t}" for a, t in menu) + print("\n".join(lines)) + + while True: + token = input("Selection: ") + if token == "q": + raise KeyboardInterrupt() + if token == "a": + step.login_with_password() + return + if token == "c": + token = None + if pyperclip: + try: + pyperclip.copy(step.sso_login_url) + print("SSO Login URL is copied to clipboard.") + except Exception: + print("Failed to copy SSO Login URL to clipboard.") + else: + print("Clipboard not available (install pyperclip).") + elif token == "u": + token = None + if not pyperclip: + print("\nSSO Login URL:", step.sso_login_url, "\n") + else: + print("Unsupported menu option (use 'c' to copy URL).") + elif token == "o": + token = None + if wb: + try: + wb.open_new_tab(step.sso_login_url) + except Exception: + print("Failed to open web browser.") + elif token == "p": + if pyperclip: + try: + token = pyperclip.paste() + except Exception: + token = "" + print("Failed to paste from clipboard") + else: + token = None + print("Clipboard not available (use 't' to enter token manually).") + elif token == "t": + token = getpass.getpass("Enter SSO Token: ").strip() + else: + if len(token) < 10: + print(f"Unsupported menu option: {token}") + continue + + if token: + try: + step.set_sso_token(token) + break + except errors.KeeperApiError as kae: + print(f"SSO Login error: ({kae.result_code}) {kae.message}") + + @staticmethod + def _two_factor_channel_desc( + channel_type: login_auth.TwoFactorChannel, + ) -> str: + return { + login_auth.TwoFactorChannel.Authenticator: "TOTP (Google and Microsoft Authenticator)", + login_auth.TwoFactorChannel.TextMessage: "Send SMS Code", + login_auth.TwoFactorChannel.DuoSecurity: "DUO", + login_auth.TwoFactorChannel.RSASecurID: "RSA SecurID", + login_auth.TwoFactorChannel.SecurityKey: "WebAuthN (FIDO2 Security Key)", + login_auth.TwoFactorChannel.KeeperDNA: "Keeper DNA (Watch)", + login_auth.TwoFactorChannel.Backup: "Backup Code", + }.get(channel_type, "Not Supported") + + @staticmethod + def _two_factor_duration_desc( + duration: login_auth.TwoFactorDuration, + ) -> str: + return { + login_auth.TwoFactorDuration.EveryLogin: "Require Every Login", + login_auth.TwoFactorDuration.Forever: "Save on this Device Forever", + login_auth.TwoFactorDuration.Every12Hours: "Ask Every 12 hours", + login_auth.TwoFactorDuration.EveryDay: "Ask Every 24 hours", + login_auth.TwoFactorDuration.Every30Days: "Ask Every 30 days", + }.get(duration, "Require Every Login") + + @staticmethod + def _two_factor_code_to_duration( + text: str, + ) -> login_auth.TwoFactorDuration: + for dura, code in _TWO_FACTOR_DURATION_CODES.items(): + if code == text: + return dura + return login_auth.TwoFactorDuration.EveryLogin + + +def enable_persistent_login(keeper_auth_context: keeper_auth.KeeperAuth) -> None: + """ + Enable persistent login and register data key for device. + Sets persistent_login to on and logout_timer to 30 days. + """ + keeper_auth.set_user_setting(keeper_auth_context, 'persistent_login', '1') + keeper_auth.register_data_key_for_device(keeper_auth_context) + mins_per_day = 60 * 24 + timeout_in_minutes = mins_per_day * 30 # 30 days + keeper_auth.set_user_setting(keeper_auth_context, 'logout_timer', str(timeout_in_minutes)) + print("Persistent login turned on successfully and device registered") + + +def login(): + """ + Handle the login process including server selection, authentication, + and multi-factor authentication steps (device approval, password, 2FA + with channel selection and Security Key, SSO data key, SSO token). + + Returns: + tuple: (keeper_auth_context, keeper_endpoint) on success, or (None, None) if login fails. + """ + flow = LoginFlow() + keeper_auth_context = flow.run() + if keeper_auth_context and not flow.logged_in_with_persistent: + enable_persistent_login(keeper_auth_context) + keeper_endpoint = flow.endpoint if keeper_auth_context else None + return keeper_auth_context, keeper_endpoint + +from keepersdk.authentication import device_management + + +def print_devices_table(devices): + if not devices: + print('\nNo devices found.') + return + print(f'\nUser Devices ({len(devices)} found)') + print('=' * 100) + print(f"{'ID':<4} {'Device Name':<22} {'Client Type':<13} {'Login Status':<14} {'Last Accessed':<19}") + print('-' * 4 + ' ' + '-' * 22 + ' ' + '-' * 13 + ' ' + '-' * 14 + ' ' + '-' * 19) + for d in devices: + last = d.last_accessed.strftime('%Y-%m-%d %H:%M:%S') if d.last_accessed else 'N/A' + print( + f"{d.list_index:<4} {d.name[:21]:<22} " + f"{d.client_type[:12]:<13} {d.login_status[:13]:<14} {last:<19}" + ) + print('-' * 4 + ' ' + '-' * 22 + ' ' + '-' * 13 + ' ' + '-' * 14 + ' ' + '-' * 19) + + +def main(): + keeper_auth_context, _ = login() + if not keeper_auth_context: + return + + # Fill in your values here. + device_identifiers = [''] + + try: + print(f'Unlocking {len(device_identifiers)} device(s)...') + for name in device_management.unlock_user_devices( + keeper_auth_context, device_identifiers + ): + print(f"Device '{name}' successfully unlocked") + print('\nUpdated device list:') + print_devices_table(device_management.list_user_devices(keeper_auth_context)) + except Exception as e: + print(f'Error unlocking devices: {e}') + finally: + keeper_auth_context.close() + + +if __name__ == '__main__': + main() diff --git a/examples/sdk_examples/nested_shared_folders/nsf_list.py b/examples/sdk_examples/nested_shared_folders/nsf_list.py index 5f024726..5bdd63f5 100644 --- a/examples/sdk_examples/nested_shared_folders/nsf_list.py +++ b/examples/sdk_examples/nested_shared_folders/nsf_list.py @@ -528,7 +528,7 @@ def nsf_list(vault: vault_online.VaultOnline) -> None: for row in rows: print( f"{row.item_type:<8} {row.uid:<28} {row.title[:28]:<30} " - f"{(row.record_type or '')[:15]:<15} {row.parent_or_folder or ''}" + f"{(row.record_type or '')[:15]:<15}" ) print(f"\nTotal items: {len(rows)}") diff --git a/keepercli-package/src/keepercli/__init__.py b/keepercli-package/src/keepercli/__init__.py index e1b6e353..fa3bfd75 100644 --- a/keepercli-package/src/keepercli/__init__.py +++ b/keepercli-package/src/keepercli/__init__.py @@ -9,5 +9,5 @@ # Contact: commander@keepersecurity.com # -__version__ = '1.2.1' +__version__ = '1.2.2' diff --git a/keepercli-package/src/keepercli/commands/device_management.py b/keepercli-package/src/keepercli/commands/device_management.py index b19e5d3d..319d4322 100644 --- a/keepercli-package/src/keepercli/commands/device_management.py +++ b/keepercli-package/src/keepercli/commands/device_management.py @@ -1,7 +1,8 @@ import argparse +import json +import shlex from datetime import datetime -from tkinter.constants import S -from typing import List, Optional +from typing import Callable, Dict, List, Optional from keepersdk.authentication import device_management @@ -13,6 +14,18 @@ logger = api.get_logger() +DEVICE_LIST_TABLE_HEADERS = [ + 'ID', 'Device Name', 'Client Type', 'Login Status', 'Last Accessed', +] + +ADMIN_DEVICE_TABLE_HEADERS = [ + 'ID', 'Enterprise User ID', 'Device Name', 'UI Category', + 'Device Status', 'Login Status', 'Last Accessed', +] +DEVICE_IDENTIFIER_HELP = ( + 'Device ID (from device-list) or exact device name (case-insensitive match)' +) + def _format_timestamp(dt: Optional[datetime]) -> str: if not dt: @@ -24,7 +37,181 @@ def _sdk_error(exc: Exception) -> base.CommandError: return base.CommandError(str(exc)) +def _run_device_action_command( + context: KeeperParams, + device_identifiers: List[str], + action_fn: Callable, + success_message: str, +) -> None: + base.require_login(context) + try: + for name in action_fn(context.auth, device_identifiers): + logger.info(success_message, name) + except ValueError as e: + raise _sdk_error(e) from e + + +def _display_user_devices(context: KeeperParams, title_prefix: str = '') -> None: + devices = device_management.list_user_devices(context.auth) + if not devices: + logger.info('No devices found.') + return + + headers = DEVICE_LIST_TABLE_HEADERS + rows: List[List] = [] + for d in devices: + rows.append([ + d.list_index, + d.name, + d.client_type, + d.login_status, + _format_timestamp(d.last_accessed), + ]) + + title = f'{title_prefix}User Devices ({len(rows)} found)'.strip() + report_utils.dump_report_data(rows, headers, fmt='table', title=title) + + +def _display_admin_devices( + context: KeeperParams, + enterprise_user_ids: List[int], +) -> None: + """Fetch and print the admin device list table for the given enterprise user IDs.""" + try: + devices = device_management.list_admin_devices(context.auth, enterprise_user_ids) + except ValueError as e: + raise _sdk_error(e) from e + + if not devices: + logger.info('No devices found.') + return + + rows: List[List] = [] + for d in devices: + rows.append([ + d.list_index, + d.enterprise_user_id, + d.name, + d.ui_category, + d.device_status, + d.login_status, + _format_timestamp(d.last_accessed), + ]) + + title = f'Admin Device List ({len(rows)} devices found)' + report_utils.dump_report_data(rows, ADMIN_DEVICE_TABLE_HEADERS, fmt='table', title=title) + + +DEVICE_ACTION_DEFINITIONS: Dict[str, Dict] = { + 'logout': { + 'description': 'Logout the user from the device', + 'min_devices': 1, + 'handler': device_management.logout_user_devices, + 'success_message': "Device '%s' successfully logged out", + }, + 'remove': { + 'description': 'Logout and remove the user from that device', + 'min_devices': 1, + 'handler': device_management.remove_user_devices, + 'success_message': "Device '%s' successfully removed", + }, + 'lock': { + 'description': ( + 'Lock the device for all users on the devices and linked devices; ' + 'logout all users from the device' + ), + 'min_devices': 1, + 'handler': device_management.lock_user_devices, + 'success_message': "Device '%s' successfully locked", + }, + 'unlock': { + 'description': 'Unlock the devices and linked devices for the calling user', + 'min_devices': 1, + 'handler': device_management.unlock_user_devices, + 'success_message': "Device '%s' successfully unlocked", + }, + 'account-lock': { + 'description': ( + 'Lock the device for the calling user only; ' + 'if logged in, logout the calling user' + ), + 'min_devices': 1, + 'handler': device_management.account_lock_user_devices, + 'success_message': "Device '%s' successfully account locked", + }, + 'account-unlock': { + 'description': 'Unlock the device for the calling user', + 'min_devices': 1, + 'handler': device_management.account_unlock_user_devices, + 'success_message': "Device '%s' successfully account unlocked", + }, + 'link': { + 'description': 'Link devices for the calling user (requires persistent login)', + 'min_devices': 2, + 'handler': device_management.link_user_devices, + 'success_message': "Device '%s' successfully linked", + }, + 'unlink': { + 'description': 'Unlink devices for the calling user', + 'min_devices': 2, + 'handler': device_management.unlink_user_devices, + 'success_message': "Device '%s' successfully unlinked", + }, +} + +DEVICE_ACTION_CHOICES = list(DEVICE_ACTION_DEFINITIONS.keys()) + +_device_action_parsers: Dict[str, argparse.ArgumentParser] = {} +for _action, _config in DEVICE_ACTION_DEFINITIONS.items(): + _parser = argparse.ArgumentParser( + prog=f'device-action {_action}', + description=_config['description'], + ) + _parser.add_argument( + 'devices', + nargs='+', + help=DEVICE_IDENTIFIER_HELP, + ) + _device_action_parsers[_action] = _parser + + +DEVICE_ADMIN_ACTION_DEFINITIONS: Dict[str, Dict] = { + 'logout': { + 'description': 'Logout the user from the device', + 'handler': device_management.logout_admin_user_devices, + 'action_verb': 'logged out', + }, + 'remove': { + 'description': 'Logout & Remove the user from that device', + 'handler': device_management.remove_admin_user_devices, + 'action_verb': 'removed', + }, +} + +DEVICE_ADMIN_ACTION_CHOICES = list(DEVICE_ADMIN_ACTION_DEFINITIONS.keys()) + +_device_admin_action_parsers: Dict[str, argparse.ArgumentParser] = {} +for _action, _config in DEVICE_ADMIN_ACTION_DEFINITIONS.items(): + _parser = argparse.ArgumentParser( + prog=f'device-admin-action {_action}', + description=_config['description'], + ) + _parser.add_argument( + 'enterprise_user_id', + type=int, + help='Enterprise User ID whose devices to act on', + ) + _parser.add_argument( + 'devices', + nargs='+', + help='Device IDs (1, 2, 3...) or device names', + ) + _device_admin_action_parsers[_action] = _parser + + class DeviceListCommand(base.ArgparseCommand): + """List all active devices for the current user.""" + def __init__(self): parser = argparse.ArgumentParser( prog='device-list', @@ -33,13 +220,14 @@ def __init__(self): ) DeviceListCommand.add_arguments_to_parser(parser) super().__init__(parser) - + @staticmethod def add_arguments_to_parser(parser: argparse.ArgumentParser): parser.error = base.ArgparseCommand.raise_parse_exception parser.exit = base.ArgparseCommand.suppress_exit def execute(self, context: KeeperParams, **kwargs): + """Display user devices in table or JSON format.""" base.require_login(context) try: devices = device_management.list_user_devices(context.auth) @@ -53,7 +241,21 @@ def execute(self, context: KeeperParams, **kwargs): fmt = kwargs.get('format') or 'table' output = kwargs.get('output') - headers = ['id', 'name', 'client_type', 'login_status', 'last_accessed'] + if fmt == 'json': + device_list = [{ + 'id': d.list_index, + 'deviceName': d.name, + 'clientType': d.client_type, + 'loginStatus': d.login_status, + 'lastAccessedTimestamp': _format_timestamp(d.last_accessed), + } for d in devices] + report = json.dumps({'devices': device_list}, indent=2) + if output: + with open(output, 'w', encoding='utf-8') as fd: + fd.write(report) + return report + + headers = DEVICE_LIST_TABLE_HEADERS rows: List[List] = [] for d in devices: rows.append([ @@ -70,6 +272,8 @@ def execute(self, context: KeeperParams, **kwargs): class DeviceRenameCommand(base.ArgparseCommand): + """Rename a device for the current user.""" + def __init__(self): parser = argparse.ArgumentParser( prog='device-rename', @@ -77,15 +281,16 @@ def __init__(self): ) DeviceRenameCommand.add_arguments_to_parser(parser) super().__init__(parser) - + @staticmethod def add_arguments_to_parser(parser: argparse.ArgumentParser): - parser.add_argument('device', help='Device ID (from device-list) or device name substring') + parser.add_argument('device', help=DEVICE_IDENTIFIER_HELP) parser.add_argument('new_name', help='New name for the device') parser.error = base.ArgparseCommand.raise_parse_exception parser.exit = base.ArgparseCommand.suppress_exit def execute(self, context: KeeperParams, **kwargs): + """Rename the specified device and log the old and new names.""" base.require_login(context) device_identifier = (kwargs.get('device') or '').strip() new_name = (kwargs.get('new_name') or '').strip() @@ -95,55 +300,214 @@ def execute(self, context: KeeperParams, **kwargs): context.auth, device_identifier, new_name ) logger.info("Device name updated from '%s' to '%s'", old_name, updated_name) + logger.info('') + _display_user_devices(context, title_prefix='Updated ') except ValueError as e: raise _sdk_error(e) from e -class DeviceRemoveCommand(base.ArgparseCommand): +class DeviceActionCommand(base.ArgparseCommand): + """Perform actions on user devices (logout, remove, lock, unlock, link, unlink, etc.).""" + def __init__(self): parser = argparse.ArgumentParser( - prog='device-remove', - description='Logout and remove the current user from one or more devices', + prog='device-action', + description='Perform actions on user devices', ) - DeviceRemoveCommand.add_arguments_to_parser(parser) + DeviceActionCommand.add_arguments_to_parser(parser) super().__init__(parser) - + @staticmethod def add_arguments_to_parser(parser: argparse.ArgumentParser): - parser.add_argument('devices', nargs='+', help='Device ID (from device-list) or device name substring') + parser.add_argument( + 'action', + choices=DEVICE_ACTION_CHOICES, + help='Action to perform on devices', + ) + parser.add_argument( + 'devices', + nargs='+', + help=DEVICE_IDENTIFIER_HELP, + ) parser.error = base.ArgparseCommand.raise_parse_exception parser.exit = base.ArgparseCommand.suppress_exit + def execute_args(self, context: KeeperParams, args, **kwargs): + args = '' if args is None else args + args = base.expand_cmd_args(args, context.environment_variables) + args = base.normalize_output_param(args) + try: + parsed_args = shlex.split(args) + if len(parsed_args) >= 2 and parsed_args[1] in ('--help', '-h'): + action_parser = _device_action_parsers.get(parsed_args[0]) + if action_parser: + action_parser.print_help() + return + except base.ParseError as e: + logger.warning(str(e)) + return + return super().execute_args(context, args, **kwargs) + def execute(self, context: KeeperParams, **kwargs): - base.require_login(context) - device_identifiers = kwargs.get('devices') or [] + action = kwargs.get('action') + devices = kwargs.get('devices') or [] + config = DEVICE_ACTION_DEFINITIONS.get(action or '') + if not config: + raise _sdk_error(ValueError(f"Invalid action: '{action}'")) + + min_devices = config['min_devices'] + if len(devices) < min_devices: + if min_devices == 1: + raise _sdk_error(ValueError('At least one device must be specified')) + raise _sdk_error(ValueError( + f'{action} action requires at least {min_devices} devices' + )) + + _run_device_action_command( + context, + devices, + config['handler'], + config['success_message'], + ) + logger.info('') + _display_user_devices(context, title_prefix='Updated ') + + +class DeviceAdminListCommand(base.ArgparseCommand): + """List devices across enterprise users that the admin can manage.""" + + def __init__(self): + parser = argparse.ArgumentParser( + prog='device-admin-list', + description='List all devices across users that the Admin has control of', + parents=[base.json_output_parser], + ) + DeviceAdminListCommand.add_arguments_to_parser(parser) + super().__init__(parser) + + @staticmethod + def add_arguments_to_parser(parser: argparse.ArgumentParser): + parser.add_argument( + 'enterprise_user_ids', + nargs='+', + type=int, + help='List of Enterprise User IDs (required). You can get enterprise user IDs by running "ei --users" command', + ) + parser.error = base.ArgparseCommand.raise_parse_exception + parser.exit = base.ArgparseCommand.suppress_exit + + def execute(self, context: KeeperParams, **kwargs): + """Display admin device list in table or JSON format for the given enterprise user IDs.""" + base.require_enterprise_admin(context) + enterprise_user_ids = kwargs.get('enterprise_user_ids') or [] + try: - for name in device_management.remove_user_devices(context.auth, device_identifiers): - logger.info("Device '%s' successfully removed", name) + devices = device_management.list_admin_devices(context.auth, enterprise_user_ids) except ValueError as e: raise _sdk_error(e) from e + if not devices: + logger.info('No devices found.') + return + + fmt = kwargs.get('format') or 'table' + output = kwargs.get('output') + + rows: List[List] = [] + for d in devices: + rows.append([ + d.list_index, + d.enterprise_user_id, + d.name, + d.ui_category, + d.device_status, + d.login_status, + _format_timestamp(d.last_accessed), + ]) + + return report_utils.dump_report_data( + rows, ADMIN_DEVICE_TABLE_HEADERS, fmt=fmt, filename=output, + title=f'Admin Device List ({len(rows)} devices found)', + ) + + +class DeviceAdminActionCommand(base.ArgparseCommand): + """Perform admin actions (logout, remove) on devices for an enterprise user.""" -class DeviceLogoutCommand(base.ArgparseCommand): def __init__(self): parser = argparse.ArgumentParser( - prog='device-logout', - description='Logout the current user from one or more devices', + prog='device-admin-action', + description='Perform various action on one or more devices that the Admin has control of.', ) - DeviceLogoutCommand.add_arguments_to_parser(parser) + DeviceAdminActionCommand.add_arguments_to_parser(parser) super().__init__(parser) - + @staticmethod def add_arguments_to_parser(parser: argparse.ArgumentParser): - parser.add_argument('devices', nargs='+', help='Device ID (from device-list) or device name substring') + parser.add_argument( + 'action', + choices=DEVICE_ADMIN_ACTION_CHOICES, + help='Action to perform on devices', + ) + parser.add_argument( + 'enterprise_user_id', + type=int, + help='Enterprise User ID whose devices to act on', + ) + parser.add_argument( + 'devices', + nargs='+', + help='Device IDs or devicenames', + ) parser.error = base.ArgparseCommand.raise_parse_exception parser.exit = base.ArgparseCommand.suppress_exit + def execute_args(self, context: KeeperParams, args, **kwargs): + """Route per-action --help to the action-specific parser when requested.""" + args = '' if args is None else args + args = base.expand_cmd_args(args, context.environment_variables) + args = base.normalize_output_param(args) + try: + parsed_args = shlex.split(args) + if len(parsed_args) >= 2 and parsed_args[1] in ('--help', '-h'): + action_parser = _device_admin_action_parsers.get(parsed_args[0]) + if action_parser: + action_parser.print_help() + return + if len(parsed_args) >= 3 and parsed_args[2] in ('--help', '-h'): + action_parser = _device_admin_action_parsers.get(parsed_args[0]) + if action_parser: + action_parser.print_help() + return + except base.ParseError as e: + logger.warning(str(e)) + return + return super().execute_args(context, args, **kwargs) + def execute(self, context: KeeperParams, **kwargs): - base.require_login(context) - device_identifiers = kwargs.get('devices') or [] + """Run the requested admin device action and refresh the device list.""" + base.require_enterprise_admin(context) + action = kwargs.get('action') + enterprise_user_id = kwargs.get('enterprise_user_id') + devices = kwargs.get('devices') or [] + config = DEVICE_ADMIN_ACTION_DEFINITIONS.get(action or '') + if not config: + raise _sdk_error(ValueError(f"Invalid action: '{action}'")) + + if not devices: + raise _sdk_error(ValueError('At least one device must be specified')) + + handler: Callable = config['handler'] + action_verb: str = config['action_verb'] try: - for name in device_management.logout_user_devices(context.auth, device_identifiers): - logger.info("Device '%s' successfully logged out", name) + names = handler(context.auth, enterprise_user_id, devices) + for name in names: + logger.info( + "Device action successfully completed: '%s' %s for user %s", + name, action_verb, enterprise_user_id, + ) except ValueError as e: raise _sdk_error(e) from e + + logger.info('Updated device list for user %s:', enterprise_user_id) + _display_admin_devices(context, [enterprise_user_id]) diff --git a/keepercli-package/src/keepercli/commands/enterprise_utils.py b/keepercli-package/src/keepercli/commands/enterprise_utils.py index 81a21471..5050fe6a 100644 --- a/keepercli-package/src/keepercli/commands/enterprise_utils.py +++ b/keepercli-package/src/keepercli/commands/enterprise_utils.py @@ -295,11 +295,21 @@ def get_user_status_text(user: enterprise_types.User) -> str: @staticmethod def get_user_transfer_status_text(user: enterprise_types.User) -> str: + ta_status = user.transfer_acceptance_status or 0 + if ta_status == enterprise_pb2.NOT_REQUIRED: + return 'Not required' + if ta_status == enterprise_pb2.NOT_ACCEPTED: + return 'Pending transfer' + if ta_status == enterprise_pb2.PARTIALLY_ACCEPTED: + return 'Partially accepted' + if ta_status == enterprise_pb2.ACCEPTED: + return 'Transfer accepted' + if isinstance(user.account_share_expiration, int) and user.account_share_expiration > 0: expire_at = datetime.datetime.fromtimestamp(user.account_share_expiration / 1000.0) if expire_at < datetime.datetime.now(): return 'Blocked' - return 'Pending Transfer' + return 'Pending transfer' return '' diff --git a/keepercli-package/src/keepercli/commands/nsf_commands.py b/keepercli-package/src/keepercli/commands/nsf_commands.py index ca0694ac..fbcc0fc4 100644 --- a/keepercli-package/src/keepercli/commands/nsf_commands.py +++ b/keepercli-package/src/keepercli/commands/nsf_commands.py @@ -1279,7 +1279,7 @@ def execute(self, context: KeeperParams, **kwargs): vault, kwargs.get('folder'), action=action, role=role, recursive=kwargs.get('recursive', False), current_user=login)) - if not plan.updates and not plan.creates and not plan.revokes: + if not plan.updates and not plan.creates and not plan.revokes and not plan.denies: if plan.skipped: logger.warning('No permission changes can be made (see skipped entries).') else: @@ -1309,7 +1309,7 @@ def execute(self, context: KeeperParams, **kwargs): def _print_plan(plan) -> None: for label, items in ( ('SKIP', plan.skipped), ('GRANT/UPDATE', plan.updates + plan.creates), - ('REVOKE', plan.revokes)): + ('REVOKE', plan.revokes), ('DENY INHERITED', plan.denies)): if not items: continue logger.info(f'\n{label}:') diff --git a/keepercli-package/src/keepercli/commands/pam/discovery/__init__.py b/keepercli-package/src/keepercli/commands/pam/discovery/__init__.py index 61382748..f941005d 100644 --- a/keepercli-package/src/keepercli/commands/pam/discovery/__init__.py +++ b/keepercli-package/src/keepercli/commands/pam/discovery/__init__.py @@ -200,7 +200,10 @@ def from_gateway(vault: vault_online.VaultOnline, gateway: str, configuration_ui application_id = utils.base64_url_encode(found_gateway.applicationUid) application = vault.vault_data.load_record(application_id) if application is None: - logger.debug(f"cannot find application for gateway {gateway}, skipping.") + logger.warning( + f"KSM application for gateway {gateway} is not in the vault " + f"(record {application_id}); discovery may still work via the router." + ) if (utils.base64_url_encode(found_gateway.controllerUid) == gateway or found_gateway.controllerName.lower() == gateway.lower()): diff --git a/keepercli-package/src/keepercli/commands/pam/discovery/discover.py b/keepercli-package/src/keepercli/commands/pam/discovery/discover.py index f65f2775..118b4fa9 100644 --- a/keepercli-package/src/keepercli/commands/pam/discovery/discover.py +++ b/keepercli-package/src/keepercli/commands/pam/discovery/discover.py @@ -18,7 +18,7 @@ from keepersdk.helpers.pam_user_record_facade import PamUserRecordFacade from keepersdk.helpers.keeper_dag.jobs import Jobs -from keepersdk.helpers.keeper_dag.dag_types import (CredentialBase, DiscoveryDelta, DiscoveryObject, JobItem, UserAcl, DirectoryInfo, +from keepersdk.helpers.keeper_dag.dag_types import (CredentialBase, DiscoveryDelta, DiscoveryObject, JobItem, Settings, UserAcl, DirectoryInfo, BulkRecordConvert, BulkRecordAdd, BulkRecordSuccess, BulkProcessResults, NormalizedRecord, BulkRecordFail, PromptResult, PromptActionEnum, RecordField) from keepersdk.helpers.keeper_dag.dag_vertex import DAGVertex @@ -152,7 +152,7 @@ def print_job_detail(vault: vault_online.VaultOnline, job_id: str): def _find_job(configuration_record) -> Optional[Dict]: - jobs_obj = Jobs(record=configuration_record) + jobs_obj = Jobs(record=configuration_record, vault=vault) job_item = jobs_obj.get_job(job_id) if job_item is not None: return { @@ -167,7 +167,7 @@ def _find_job(configuration_record) -> Optional[Dict]: if gateway_context is not None: jobs = payload["jobs"] job = jobs.get_job(job_id) - infra = Infrastructure(record=gateway_context.configuration) + infra = Infrastructure(record=gateway_context.configuration, vault=vault) status = "RUNNING" if job.end_ts is not None and not job.error: @@ -296,7 +296,7 @@ def execute(self, context: KeeperParams, **kwargs): if len(gateway_context.gateway_name) > max_gateway_name: max_gateway_name = len(gateway_context.gateway_name) - jobs = Jobs(record=configuration_record) + jobs = Jobs(record=configuration_record, vault=vault) if show_history is True: job_list = reversed(jobs.history) else: @@ -391,7 +391,7 @@ def execute(self, context: KeeperParams, **kwargs): multi_conf_msg(gateway, err) return - jobs = Jobs(record=gateway_context.configuration) + jobs = Jobs(record=gateway_context.configuration, vault=vault) current_job_item = jobs.current_job removed_prior_job = None if current_job_item is not None: @@ -467,15 +467,20 @@ def execute(self, context: KeeperParams, **kwargs): setattr(c, key, obj[key]) credentials.append(c.model_dump()) + user_map_entries = self.make_protobuf_user_map( + context=context, + gateway_context=gateway_context + ) + if len(user_map_entries) == 0: + logger.info( + "No pamUser records are linked to this configuration; " + "discovery will run without an existing user map." + ) + action_inputs = GatewayActionDiscoverJobStartInputs( configuration_uid=gateway_context.configuration_uid, resource_uid=kwargs.get('resource_uid'), - user_map=gateway_context.encrypt( - self.make_protobuf_user_map( - context=context, - gateway_context=gateway_context - )[0] - ), + user_map=gateway_context.encrypt({"users": user_map_entries}), shared_folder_uid=gateway_context.default_shared_folder_uid, languages=[kwargs.get('language')], @@ -507,16 +512,39 @@ def execute(self, context: KeeperParams, **kwargs): logger.error(f"The router returned a failure.") return + discovery_settings = Settings( + credentials=[CredentialBase(**c) for c in credentials], + default_shared_folder_uid=gateway_context.default_shared_folder_uid, + include_azure_aadds=kwargs.get('include_azure_aadds', False), + skip_rules=kwargs.get('skip_rules', False), + skip_machines=kwargs.get('skip_machines', False), + skip_databases=kwargs.get('skip_databases', False), + skip_directories=kwargs.get('skip_directories', False), + skip_cloud_users=kwargs.get('skip_cloud_users', False), + user_map=user_map_entries or None, + ) + job_id = jobs.start( + settings=discovery_settings, + resource_uid=kwargs.get('resource_uid'), + conversation_id=conversation_id, + ) + jobs.close() + if "has been queued" in data.get("Response", ""): if removed_prior_job is None: - logger.info("The discovery job is currently running.") + logger.info(f"Discovery job {job_id} is running.") else: - logger.info(f"Active discovery job {removed_prior_job} has been removed and new discovery job is running.") + logger.info( + f"Active discovery job {removed_prior_job} has been removed; " + f"discovery job {job_id} is running." + ) logger.info(f"To check the status, use the command 'pam action discover status'.") - logger.info(f"To stop and remove the current job, use the command 'pam action discover remove -j '.") + logger.info(f"To stop and remove the current job, use the command 'pam action discover remove -j {job_id}'.") else: router_utils.print_router_response(router_response, "job_info", conversation_id, gateway_uid=gateway_context.gateway_uid) + logger.info(f"Discovery job {job_id} was recorded on the configuration.") + logger.info(f"To check the status, use the command 'pam action discover status -j {job_id}'.") @staticmethod def make_protobuf_user_map(context: KeeperParams, gateway_context: GatewayContext) -> List[dict]: @@ -580,7 +608,7 @@ def execute(self, context: KeeperParams, **kwargs): all_gateways = GatewayContext.all_gateways(vault) def _find_job(configuration_record) -> Optional[Dict]: - jobs_obj = Jobs(record=configuration_record) + jobs_obj = Jobs(record=configuration_record, vault=vault) job_item = jobs_obj.get_job(job_id) if job_item is not None: return { @@ -1775,7 +1803,7 @@ def _get_directory_info(domain: str, def remove_job(context: KeeperParams, configuration_record: vault_record.KeeperRecord, job_id: str): try: - jobs = Jobs(record=configuration_record, context=context) + jobs = Jobs(record=configuration_record, vault=context.vault) jobs.cancel(job_id) logger.info(f"No items left to process. Removing completed discovery job.") except Exception as err: @@ -1786,7 +1814,7 @@ def preview(self, job_item: JobItem, context: KeeperParams, gateway_context: Gat sync_point = job_item.sync_point infra = Infrastructure(record=gateway_context.configuration, - context=context, + vault=context.vault, logger=logger, debug_level=debug_level) infra.load(sync_point) @@ -1941,7 +1969,7 @@ def execute(self, context: KeeperParams, **kwargs): # Get the current job. # There can only be one active job. - jobs = Jobs(record=configuration_record, context=context, logger=logger, debug_level=debug_level) + jobs = Jobs(record=configuration_record, vault=vault, logger=logger, debug_level=debug_level) job_item = jobs.current_job if job_item is None: continue diff --git a/keepercli-package/src/keepercli/commands/pam/pam_gateway_action.py b/keepercli-package/src/keepercli/commands/pam/pam_gateway_action.py index 777ce710..6299f7cd 100644 --- a/keepercli-package/src/keepercli/commands/pam/pam_gateway_action.py +++ b/keepercli-package/src/keepercli/commands/pam/pam_gateway_action.py @@ -255,7 +255,10 @@ def record_rotate(self, context: KeeperParams, record_uid, slient:bool = False): config_uid = facade.controller_uid if not resource_uid: - tmp_dag = tunnel_graph.TunnelDAG(vault, encrypted_session_token, encrypted_transmission_key, record.record_uid) + tmp_dag = tunnel_graph.TunnelDAG( + vault, encrypted_session_token, encrypted_transmission_key, record.record_uid, + transmission_key=transmission_key, + ) resource_uid = tmp_dag.get_resource_uid(record_uid) if not resource_uid: is_noop = False diff --git a/keepercli-package/src/keepercli/commands/pam/pam_rotation.py b/keepercli-package/src/keepercli/commands/pam/pam_rotation.py index a47bd3cc..c303be5b 100644 --- a/keepercli-package/src/keepercli/commands/pam/pam_rotation.py +++ b/keepercli-package/src/keepercli/commands/pam/pam_rotation.py @@ -295,7 +295,8 @@ def execute(self, context: KeeperParams, **kwargs): def config_resource(_dag, target_record, target_config_uid, silent=None): if not _dag.linking_dag.has_graph: if target_config_uid: - _dag = TunnelDAG(vault, encrypted_session_token, encrypted_transmission_key, target_config_uid) + _dag = TunnelDAG(vault, encrypted_session_token, encrypted_transmission_key, target_config_uid, + transmission_key=transmission_key) _dag.edit_tunneling_config(rotation=True) else: raise base.CommandError(f'Resource "{target_record.record_uid}" is not associated ' @@ -305,7 +306,7 @@ def config_resource(_dag, target_record, target_config_uid, silent=None): resource_dag = None if not _dag.resource_belongs_to_config(target_record.record_uid): resource_dag = TunnelDAG(vault, encrypted_session_token, encrypted_transmission_key, - target_record.record_uid) + target_record.record_uid, transmission_key=transmission_key) _dag.link_resource_to_config(target_record.record_uid) admin = kwargs.get('admin') @@ -401,10 +402,12 @@ def config_iam_aad_user(_dag, target_record, target_iam_aad_config_uid): return if _dag and not _dag.linking_dag.has_graph: - _dag = TunnelDAG(vault, encrypted_session_token, encrypted_transmission_key, target_iam_aad_config_uid) + _dag = TunnelDAG(vault, encrypted_session_token, encrypted_transmission_key, target_iam_aad_config_uid, + transmission_key=transmission_key) if not _dag or not _dag.linking_dag.has_graph: _dag.edit_tunneling_config(rotation=True) - old_dag = TunnelDAG(vault, encrypted_session_token, encrypted_transmission_key, target_record.record_uid) + old_dag = TunnelDAG(vault, encrypted_session_token, encrypted_transmission_key, target_record.record_uid, + transmission_key=transmission_key) if old_dag.linking_dag.has_graph and old_dag.record.record_uid != target_iam_aad_config_uid: old_dag.remove_from_dag(target_record.record_uid) @@ -621,7 +624,8 @@ def config_user(_dag, target_record, target_resource_uid, target_config_uid=None return if isinstance(target_resource_uid, str) and len(target_resource_uid) > 0: - _dag = TunnelDAG(vault, encrypted_session_token, encrypted_transmission_key, target_resource_uid) + _dag = TunnelDAG(vault, encrypted_session_token, encrypted_transmission_key, target_resource_uid, + transmission_key=transmission_key) if not _dag or not _dag.linking_dag.has_graph: if target_config_uid and target_resource_uid: config_resource(_dag, target_record, target_config_uid, silent=silent) @@ -639,7 +643,8 @@ def config_user(_dag, target_record, target_resource_uid, target_config_uid=None current_record_rotation = context.get_record_rotation(target_record.record_uid) if not _dag or not _dag.linking_dag.has_graph: - _dag = TunnelDAG(vault, encrypted_session_token, encrypted_transmission_key, target_resource_uid) + _dag = TunnelDAG(vault, encrypted_session_token, encrypted_transmission_key, target_resource_uid, + transmission_key=transmission_key) if not _dag.linking_dag.has_graph: raise base.CommandError(f'Resource "{target_resource_uid}" is not associated ' f'with any configuration. ' @@ -824,6 +829,8 @@ def config_user(_dag, target_record, target_resource_uid, target_config_uid=None if record_name: if record_name in vault.vault_data._records: record_uids.add(record_name) + elif vault.vault_data.load_record(record_name): + record_uids.add(record_name) else: rs = folder_utils.try_resolve_path(context, record_name) if rs is not None: @@ -866,7 +873,10 @@ def add_folders(folder: vault_types.Folder): if folder_uids: regex = re.compile(fnmatch.translate(record_pattern), re.IGNORECASE).match if record_pattern else None for folder_uid in folder_uids: - folder_records = vault.vault_data.get_folder(folder_uid).records + folder = vault.vault_data.get_folder(folder_uid) + if not folder: + continue + folder_records = folder.records if not folder_records: continue if record_pattern and record_pattern in folder_records: @@ -957,7 +967,8 @@ def add_folders(folder: vault_types.Folder): r_requests = [] for _record in pam_records: - tmp_dag = TunnelDAG(vault, encrypted_session_token, encrypted_transmission_key, _record.record_uid) + tmp_dag = TunnelDAG(vault, encrypted_session_token, encrypted_transmission_key, _record.record_uid, + transmission_key=transmission_key) if _record.record_type in ['pamMachine', 'pamDatabase', 'pamDirectory', 'pamRemoteBrowser']: config_resource(tmp_dag, _record, config_uid, silent=kwargs.get('silent')) elif _record.record_type == 'pamUser': @@ -1108,7 +1119,7 @@ def is_resource_ok(resource_id, vault, configuration_uid): logger.info(f"Is Rotation Disabled: {rri.disabled}") rq = pam_pb2.PAMGenericUidsRequest() - schedules_proto = router_utils.router_get_rotation_schedules(context, rq) + schedules_proto = router_utils.router_get_rotation_schedules(vault, rq) if schedules_proto: schedules = list(schedules_proto.schedules) for s in schedules: diff --git a/keepercli-package/src/keepercli/commands/shares.py b/keepercli-package/src/keepercli/commands/shares.py index 223f337e..f1107516 100644 --- a/keepercli-package/src/keepercli/commands/shares.py +++ b/keepercli-package/src/keepercli/commands/shares.py @@ -110,6 +110,10 @@ def add_arguments_to_parser(parser: argparse.ArgumentParser): metavar='[(mi)nutes|(h)ours|(d)ays|(mo)nths|(y)ears]', help='share expiration: never or period' ) + parser.add_argument( + '-roe', '--rotate-on-expiration', dest='rotate_on_expiration', action='store_true', + help='Rotate pamUser password when share access expires (requires positive expiration)' + ) parser.add_argument( 'record', nargs='?', type=str, action='store', help='record/shared folder path/UID' ) @@ -136,22 +140,31 @@ def execute(self, context: KeeperParams, **kwargs) -> None: vault.sync_down() return - share_expiration = share_management_utils.get_share_expiration( - kwargs.get('expire_at'), kwargs.get('expire_in') - ) + rotate_on_expiration = kwargs.get('rotate_on_expiration') is True + try: + share_expiration = share_management_utils.get_share_expiration( + kwargs.get('expire_at'), kwargs.get('expire_in') + ) + share_management_utils.validate_rotate_on_expiration(share_expiration, rotate_on_expiration) + except share_management_utils.ShareValidationError as err: + raise base.CommandError(str(err)) from err - request = RecordShares.prep_request( - vault=vault, - enterprise=context.enterprise_data, - uid_or_name=uid_or_name, - emails=emails, - share_expiration=share_expiration, - action=action, - dry_run=kwargs.get('dry_run', False), - can_edit=kwargs.get('can_edit'), - can_share=kwargs.get('can_share'), - recursive=kwargs.get('recursive') - ) + try: + request = RecordShares.prep_request( + vault=vault, + enterprise=context.enterprise_data, + uid_or_name=uid_or_name, + emails=emails, + share_expiration=share_expiration, + action=action, + dry_run=kwargs.get('dry_run', False), + can_edit=kwargs.get('can_edit'), + can_share=kwargs.get('can_share'), + recursive=kwargs.get('recursive'), + rotate_on_expiration=rotate_on_expiration, + ) + except (share_management_utils.ShareValidationError, ValueError) as err: + raise base.CommandError(str(err)) from err if request: success_responses, failed_responses = RecordShares.send_requests(vault, [request]) if success_responses: @@ -261,6 +274,10 @@ def add_arguments_to_parser(parser: argparse.ArgumentParser): '--expire-in', dest='expire_in', action='store', metavar='PERIOD', help='share expiration: never or period ([(y)ears|(mo)nths|(d)ays|(h)ours(mi)nutes]' ) + parser.add_argument( + '-roe', '--rotate-on-expiration', dest='rotate_on_expiration', action='store_true', + help='Rotate pamUser passwords when share access expires (requires positive expiration)' + ) parser.add_argument( 'folder', nargs='+', type=str, action='store', help='shared folder path or UID' ) @@ -277,7 +294,19 @@ def execute(self, context: KeeperParams, **kwargs) -> None: raise ValueError('Enter name of at least one existing folder') action = kwargs.get('action') or ShareAction.GRANT.value - share_expiration = self._get_share_expiration(action, kwargs) + rotate_on_expiration = kwargs.get('rotate_on_expiration') is True + try: + share_expiration = self._get_share_expiration(action, kwargs) + share_management_utils.validate_rotate_on_expiration(share_expiration, rotate_on_expiration) + if rotate_on_expiration and action != ShareAction.GRANT.value: + raise share_management_utils.ShareValidationError( + '--rotate-on-expiration is only valid with --action grant' + ) + share_management_utils.validate_folder_shares_rotate_on_expiration( + vault, shared_folder_uids, rotate_on_expiration + ) + except share_management_utils.ShareValidationError as err: + raise base.CommandError(str(err)) from err user_data = self._parse_user_arguments(vault, kwargs) record_data = self._parse_record_arguments(vault, kwargs) @@ -287,8 +316,8 @@ def execute(self, context: KeeperParams, **kwargs) -> None: return rq_groups = self._prepare_request_groups( - vault, shared_folder_uids, user_data, record_data, - action, share_expiration, kwargs + vault, shared_folder_uids, user_data, record_data, + action, share_expiration, kwargs, rotate_on_expiration ) success_responses, failed_responses = FolderShares.send_requests(vault=vault, partitioned_requests=rq_groups) if success_responses: @@ -523,15 +552,16 @@ def _is_nothing_to_do(self, user_data: Dict, record_data: Dict) -> bool: def _prepare_request_groups(self, vault: vault_online.VaultOnline, shared_folder_uids: Set, user_data: Dict, record_data: Dict, action: str, - share_expiration, kwargs: Dict) -> List: + share_expiration, kwargs: Dict, + rotate_on_expiration: bool = False) -> List: """Prepare request groups for all shared folders.""" rq_groups = [] shared_folder_cache = {x.shared_folder_uid: x for x in vault.vault_data.shared_folders()} for sf_uid in shared_folder_uids: folder_requests = self._prepare_folder_requests( - vault, sf_uid, shared_folder_cache, user_data, - record_data, action, share_expiration, kwargs + vault, sf_uid, shared_folder_cache, user_data, + record_data, action, share_expiration, kwargs, rotate_on_expiration ) rq_groups.extend(folder_requests) @@ -540,7 +570,7 @@ def _prepare_request_groups(self, vault: vault_online.VaultOnline, shared_folder def _prepare_folder_requests(self, vault: vault_online.VaultOnline, sf_uid: str, shared_folder_cache: Dict, user_data: Dict, record_data: Dict, action: str, share_expiration, - kwargs: Dict) -> List: + kwargs: Dict, rotate_on_expiration: bool = False) -> List: """Prepare requests for a single shared folder.""" sf_users = user_data['users'].copy() sf_teams = user_data['teams'].copy() @@ -557,7 +587,8 @@ def _prepare_folder_requests(self, vault: vault_online.VaultOnline, sf_uid: str, return self._chunk_and_prepare_requests( vault, kwargs, sh_fol, sf_uid, sf_users, sf_teams, sf_records, - record_data['default_record'], user_data['default_account'], share_expiration + record_data['default_record'], user_data['default_account'], share_expiration, + rotate_on_expiration, ) def _load_or_create_shared_folder(self, vault: vault_online.VaultOnline, sf_uid: str, shared_folder_cache: Dict, @@ -613,7 +644,7 @@ def _update_from_existing_folder(self, sh_fol, auth: keeper_auth.KeeperAuth, def _chunk_and_prepare_requests(self, vault: vault_online.VaultOnline, kwargs: Dict, sh_fol, sf_uid: str, sf_users: Set, sf_teams: Set, sf_records: Set, default_record: bool, default_account: bool, - share_expiration) -> List: + share_expiration, rotate_on_expiration: bool = False) -> List: """Chunk records and users, then prepare requests.""" rec_list = list(sf_records) user_list = list(sf_users) @@ -644,7 +675,8 @@ def _chunk_and_prepare_requests(self, vault: vault_online.VaultOnline, kwargs: D vault, kwargs, sf_info, u_chunk, sf_teams, r_chunk, default_record=default_record, default_account=default_account, - share_expiration=share_expiration + share_expiration=share_expiration, + rotate_on_expiration=rotate_on_expiration, ) rq_groups[group_idx].append(request) group_idx += 1 diff --git a/keepercli-package/src/keepercli/helpers/report_utils.py b/keepercli-package/src/keepercli/helpers/report_utils.py index 0e4f6395..c1969d88 100644 --- a/keepercli-package/src/keepercli/helpers/report_utils.py +++ b/keepercli-package/src/keepercli/helpers/report_utils.py @@ -118,6 +118,9 @@ def dump_report_data(data: List[List[Any]], # sort_desc: bool - Descending Sort # right_align: Sequence[int] - Force right align + if filename: + filename = os.path.expanduser(filename) + append = kwargs.get('append') is True title = kwargs.get('title') sort_by = kwargs.get('sort_by') diff --git a/keepercli-package/src/keepercli/register_commands.py b/keepercli-package/src/keepercli/register_commands.py index 10bae416..b7e87e23 100644 --- a/keepercli-package/src/keepercli/register_commands.py +++ b/keepercli-package/src/keepercli/register_commands.py @@ -24,9 +24,8 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS commands.register_command('logout', account_commands.LogoutCommand(), base.CommandScope.Account) commands.register_command('this-device', account_commands.ThisDeviceCommand(), base.CommandScope.Account) commands.register_command('device-list', device_management.DeviceListCommand(), base.CommandScope.Account) + commands.register_command('device-action', device_management.DeviceActionCommand(), base.CommandScope.Account) commands.register_command('device-rename', device_management.DeviceRenameCommand(), base.CommandScope.Account) - commands.register_command('device-remove', device_management.DeviceRemoveCommand(), base.CommandScope.Account) - commands.register_command('device-logout', device_management.DeviceLogoutCommand(), base.CommandScope.Account) commands.register_command('whoami', account_commands.WhoamiCommand(), base.CommandScope.Account) commands.register_command('reset-password', account_commands.ResetPasswordCommand(), base.CommandScope.Account) commands.register_command('2fa', two_fa.TwoFaCommand(), base.CommandScope.Account) @@ -110,7 +109,8 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS if not scopes or bool(scopes & base.CommandScope.Enterprise): from .commands import (enterprise_info, enterprise_node, enterprise_role, enterprise_team, enterprise_user, enterprise_create_user, importer_commands, audit_report, audit_alert, audit_log, transfer_account, pedm_admin, msp, user_report, - aging_report, action_report, security_audit_report, enterprise_push, compliance, ext_shares_report) + aging_report, action_report, security_audit_report, enterprise_push, compliance, ext_shares_report, + device_management) from .commands.pam import keeper_pam commands.register_command('create-user', enterprise_create_user.CreateEnterpriseUserCommand(), base.CommandScope.Enterprise, 'ecu') @@ -127,6 +127,8 @@ def register_commands(commands: base.CliCommands, scopes: Optional[base.CommandS commands.register_command('download-membership', importer_commands.DownloadMembershipCommand(), base.CommandScope.Enterprise) commands.register_command('apply-membership', importer_commands.ApplyMembershipCommand(), base.CommandScope.Enterprise) commands.register_command('device-approve', enterprise_user.EnterpriseDeviceApprovalCommand(), base.CommandScope.Enterprise) + commands.register_command('device-admin-list', device_management.DeviceAdminListCommand(), base.CommandScope.Enterprise) + commands.register_command('device-admin-action', device_management.DeviceAdminActionCommand(), base.CommandScope.Enterprise) commands.register_command('pedm', pedm_admin.PedmCommand(), base.CommandScope.Enterprise) commands.register_command('msp-down', msp.MspDownCommand(), base.CommandScope.Enterprise, 'md') commands.register_command('msp-info', msp.MspInfoCommand(), base.CommandScope.Enterprise, 'mi') diff --git a/keepersdk-package/src/keepersdk/__init__.py b/keepersdk-package/src/keepersdk/__init__.py index 10439ade..f807aa9f 100644 --- a/keepersdk-package/src/keepersdk/__init__.py +++ b/keepersdk-package/src/keepersdk/__init__.py @@ -10,6 +10,6 @@ # from . import background -__version__ = '1.2.1' +__version__ = '1.2.2' background.init() diff --git a/keepersdk-package/src/keepersdk/authentication/device_management.py b/keepersdk-package/src/keepersdk/authentication/device_management.py index a829597f..871d4114 100644 --- a/keepersdk-package/src/keepersdk/authentication/device_management.py +++ b/keepersdk-package/src/keepersdk/authentication/device_management.py @@ -4,20 +4,25 @@ # |_|\_\___\___| .__/\___|_| # |_| # -# Keeper SDK for Python — user device management (list, rename, logout, remove). +# Keeper SDK for Python — user and admin device management. # import re from dataclasses import dataclass from datetime import datetime -from typing import List, Optional, Tuple +from typing import Callable, List, Optional, Tuple +from .. import utils from ..proto import APIRequest_pb2, DeviceManagement_pb2 from . import keeper_auth +logger = utils.get_logger() + URL_DEVICE_USER_LIST = 'dm/device_user_list' URL_DEVICE_USER_RENAME = 'dm/device_user_rename' URL_DEVICE_USER_ACTION = 'dm/device_user_action' +URL_DEVICE_ADMIN_LIST = 'dm/device_admin_list' +URL_DEVICE_ADMIN_ACTION = 'dm/device_admin_action' @dataclass(frozen=True) @@ -31,6 +36,19 @@ class UserDeviceInfo: last_accessed: Optional[datetime] +@dataclass(frozen=True) +class AdminDeviceInfo: + """A device for an enterprise user (sorted by last access, newest first).""" + + list_index: int + enterprise_user_id: int + name: str + ui_category: str + device_status: str + login_status: str + last_accessed: Optional[datetime] + + def list_user_devices(auth: keeper_auth.KeeperAuth) -> List[UserDeviceInfo]: """Return all devices for the current user, sorted by last access (newest first).""" devices = _fetch_devices(auth) @@ -43,7 +61,8 @@ def rename_user_device( new_name: str, ) -> Tuple[str, str]: """ - Rename a device by list index (from list_user_devices) or unique name substring. + Rename a device by list index (from list_user_devices) or device name. + All-digit identifiers (e.g. '01') are treated as list indices, not names. Returns: (old_name, new_name) on success. @@ -60,11 +79,12 @@ def rename_user_device( if not devices: raise ValueError('No devices found') - resolved = _resolve_device(devices, device_identifier) - if not resolved: - raise ValueError('No matching device found (or ambiguous device name)') + resolved_device = _resolve_single_device(devices, device_identifier) + if not resolved_device: + raise ValueError('No matching devices found.') - device_token, device = resolved + device_token = resolved_device.encryptedDeviceToken + device = resolved_device old_name = device.deviceName or 'N/A' rq = DeviceManagement_pb2.DeviceRenameRequest() @@ -86,8 +106,6 @@ def rename_user_device( status = DeviceManagement_pb2.DeviceActionStatus.Name(r.deviceActionStatus) raise ValueError(f'Device rename failed: {status}') - raise ValueError('No response returned from device rename') - def logout_user_devices( auth: keeper_auth.KeeperAuth, @@ -97,7 +115,8 @@ def logout_user_devices( Log out the current user from one or more devices. Args: - device_identifiers: List index strings ('1', '2', ...) or unique name substrings. + device_identifiers: List index strings ('1', '2', ...) or device names. + All-digit values (including '01') are list indices, not names. Returns: Names of devices successfully logged out. @@ -124,6 +143,167 @@ def remove_user_devices( return _execute_device_action(auth, device_identifiers, DeviceManagement_pb2.DA_REMOVE) +def list_admin_devices( + auth: keeper_auth.KeeperAuth, + enterprise_user_ids: List[int], +) -> List[AdminDeviceInfo]: + """ + List devices for one or more enterprise users (enterprise admin). + + Args: + enterprise_user_ids: Enterprise user IDs to query. + + Returns: + Devices sorted by last access (newest first), with list indices assigned after sort. + + Raises: + ValueError: validation or empty result when IDs are invalid. + """ + if not enterprise_user_ids: + raise ValueError( + 'Enterprise User ID is required. You can get enterprise user IDs by running: ei --users' + ) + for user_id in enterprise_user_ids: + _validate_enterprise_user_id(user_id) + + entries = _fetch_admin_device_entries(auth, enterprise_user_ids) + entries.sort(key=lambda e: e[1].lastModifiedTime or 0, reverse=True) + return [ + _to_admin_device_info(i, enterprise_user_id, device) + for i, (enterprise_user_id, device) in enumerate(entries, start=1) + ] + + +def logout_admin_user_devices( + auth: keeper_auth.KeeperAuth, + enterprise_user_id: int, + device_identifiers: List[str], +) -> List[str]: + """Log out an enterprise user from one or more devices (enterprise admin).""" + return _execute_admin_device_action( + auth, enterprise_user_id, device_identifiers, DeviceManagement_pb2.DA_LOGOUT + ) + + +def remove_admin_user_devices( + auth: keeper_auth.KeeperAuth, + enterprise_user_id: int, + device_identifiers: List[str], +) -> List[str]: + """Log out and remove an enterprise user from one or more devices (enterprise admin).""" + return _execute_admin_device_action( + auth, enterprise_user_id, device_identifiers, DeviceManagement_pb2.DA_REMOVE + ) + + +def lock_user_devices( + auth: keeper_auth.KeeperAuth, + device_identifiers: List[str], +) -> List[str]: + """ + Lock one or more devices for all users (and linked devices). Logs out all users. + + Returns: + Names of devices successfully locked. + + Raises: + ValueError: validation, not found, or API failure. + """ + return _execute_device_action(auth, device_identifiers, DeviceManagement_pb2.DA_LOCK) + + +def unlock_user_devices( + auth: keeper_auth.KeeperAuth, + device_identifiers: List[str], +) -> List[str]: + """ + Unlock one or more devices (and linked devices) for the calling user. + + Returns: + Names of devices successfully unlocked. + + Raises: + ValueError: validation, not found, or API failure. + """ + return _execute_device_action(auth, device_identifiers, DeviceManagement_pb2.DA_UNLOCK) + + +def account_lock_user_devices( + auth: keeper_auth.KeeperAuth, + device_identifiers: List[str], +) -> List[str]: + """ + Lock one or more devices for the current user only (logs out if logged in). + + Returns: + Names of devices successfully account-locked. + + Raises: + ValueError: validation, not found, or API failure. + """ + return _execute_device_action( + auth, device_identifiers, DeviceManagement_pb2.DA_DEVICE_ACCOUNT_LOCK + ) + + +def account_unlock_user_devices( + auth: keeper_auth.KeeperAuth, + device_identifiers: List[str], +) -> List[str]: + """ + Unlock one or more devices for the current user. + + Returns: + Names of devices successfully account-unlocked. + + Raises: + ValueError: validation, not found, or API failure. + """ + return _execute_device_action( + auth, device_identifiers, DeviceManagement_pb2.DA_DEVICE_ACCOUNT_UNLOCK + ) + + +def link_user_devices( + auth: keeper_auth.KeeperAuth, + device_identifiers: List[str], +) -> List[str]: + """ + Link two or more devices so logging into one can resume sessions on the others + when persistent login is enabled. + + Returns: + Names of devices successfully linked. + + Raises: + ValueError: validation, not found, or API failure. + """ + _validate_link_unlink_identifiers(device_identifiers) + return _execute_device_action(auth, device_identifiers, DeviceManagement_pb2.DA_LINK) + + +def unlink_user_devices( + auth: keeper_auth.KeeperAuth, + device_identifiers: List[str], +) -> List[str]: + """ + Unlink two or more previously linked devices for the current user. + + Returns: + Names of devices successfully unlinked. + + Raises: + ValueError: validation, not found, or API failure. + """ + _validate_link_unlink_identifiers(device_identifiers) + return _execute_device_action(auth, device_identifiers, DeviceManagement_pb2.DA_UNLINK) + + +def _validate_link_unlink_identifiers(device_identifiers: List[str]) -> None: + if len(device_identifiers) < 2: + raise ValueError('At least two device identifiers are required for link/unlink') + + def _fetch_devices(auth: keeper_auth.KeeperAuth) -> List[DeviceManagement_pb2.Device]: rs = auth.execute_auth_rest( rest_endpoint=URL_DEVICE_USER_LIST, @@ -149,6 +329,61 @@ def _to_user_device_info(index: int, device: DeviceManagement_pb2.Device) -> Use ) +def _to_admin_device_info( + index: int, + enterprise_user_id: int, + device: DeviceManagement_pb2.Device, +) -> AdminDeviceInfo: + return AdminDeviceInfo( + list_index=index, + enterprise_user_id=enterprise_user_id, + name=device.deviceName or 'N/A', + ui_category=_ui_category_name(device), + device_status=_device_status_name(device.deviceStatus), + login_status=_login_state_name(device.loginState), + last_accessed=_timestamp_to_datetime(device.lastModifiedTime), + ) + + +def _fetch_admin_device_entries( + auth: keeper_auth.KeeperAuth, + enterprise_user_ids: List[int], +) -> List[Tuple[int, DeviceManagement_pb2.Device]]: + rq = DeviceManagement_pb2.DeviceAdminRequest() + rq.enterpriseUserIds.extend(enterprise_user_ids) + rs = auth.execute_auth_rest( + rest_endpoint=URL_DEVICE_ADMIN_LIST, + request=rq, + response_type=DeviceManagement_pb2.DeviceAdminResponse, + ) + if not rs: + return [] + entries: List[Tuple[int, DeviceManagement_pb2.Device]] = [] + for device_user_group in rs.deviceUserList: + enterprise_user_id = device_user_group.enterpriseUserId + for device_group in device_user_group.deviceGroups: + for device in device_group.devices: + entries.append((enterprise_user_id, device)) + return entries + + +def _fetch_admin_devices_for_user( + auth: keeper_auth.KeeperAuth, + enterprise_user_id: int, +) -> List[DeviceManagement_pb2.Device]: + entries = _fetch_admin_device_entries(auth, [enterprise_user_id]) + devices = [device for user_id, device in entries if user_id == enterprise_user_id] + devices.sort(key=lambda d: d.lastModifiedTime or 0, reverse=True) + return devices + + +def _validate_enterprise_user_id(user_id: int) -> None: + if type(user_id) is not int: + raise ValueError(f'Invalid enterprise user ID: {user_id}') + if user_id < 1: + raise ValueError(f'Invalid enterprise user ID: {user_id}') + + def _validate_identifier(identifier: str) -> None: if not identifier or not identifier.strip(): raise ValueError('Device identifier cannot be empty') @@ -160,22 +395,72 @@ def _sanitize_device_name(name: str) -> str: return re.sub(r'[<>"\'\x00-\x1f\x7f-\x9f]', '', name).strip() -def _resolve_device( +def _device_list_index( + devices: List[DeviceManagement_pb2.Device], device: DeviceManagement_pb2.Device +) -> int: + for index, candidate in enumerate(devices, start=1): + if candidate.encryptedDeviceToken == device.encryptedDeviceToken: + return index + return 0 + + +def _find_matching_devices( devices: List[DeviceManagement_pb2.Device], identifier: str -) -> Optional[Tuple[bytes, DeviceManagement_pb2.Device]]: +) -> List[DeviceManagement_pb2.Device]: + """ + Resolve a device identifier to its token and device record. + + Resolution order: + 1. If the identifier contains only digits (``str.isdigit()``), it is treated as a + 1-based list index from ``list_user_devices`` / ``device-list`` (``int`` is applied, + so ``"01"`` resolves to the first device, not a device named ``"01"``). + 2. Otherwise, match by case-insensitive device name (exact or substring per caller). + """ ident = identifier.strip() + # All-digit strings are list IDs, not names ("01" -> index 1 via int(), not name "01"). if ident.isdigit(): idx = int(ident) if 1 <= idx <= len(devices): - d = devices[idx - 1] - return d.encryptedDeviceToken, d - return None + return [devices[idx - 1]] + return [] + ident_l = ident.lower() - matches = [d for d in devices if (d.deviceName or '').lower().find(ident_l) >= 0] - if len(matches) == 1: - d = matches[0] - return d.encryptedDeviceToken, d - return None + return [d for d in devices if (d.deviceName or '').lower() == ident_l] + + +def _report_multiple_matches( + devices: List[DeviceManagement_pb2.Device], + identifier: str, + matched_devices: List[DeviceManagement_pb2.Device], +) -> None: + logger.warning("Warning: Multiple devices found matching '%s':", identifier) + for device in matched_devices: + device_id = _device_list_index(devices, device) + logger.info(' - ID %s: %s', device_id, device.deviceName or 'N/A') + logger.info( + 'Mutiple device with same name found, please use device ID instead' + ) + + +def _resolve_single_device( + devices: List[DeviceManagement_pb2.Device], + identifier: str, + *, + allow_multiple: bool = False, +) -> Optional[DeviceManagement_pb2.Device]: + matched_devices = _find_matching_devices(devices, identifier) + if not matched_devices: + logger.warning("Warning: No device found matching '%s'", identifier) + return None + if len(matched_devices) > 1 and not allow_multiple: + _report_multiple_matches(devices, identifier, matched_devices) + return None + if len(matched_devices) > 1: + logger.warning( + "Warning: Multiple devices found matching '%s'. Using first match.", + identifier, + ) + return matched_devices[0] def _resolve_devices( @@ -184,14 +469,20 @@ def _resolve_devices( if not identifiers: raise ValueError('At least one device identifier is required') resolved: List[Tuple[bytes, DeviceManagement_pb2.Device]] = [] + seen_tokens: set[bytes] = set() for identifier in identifiers: _validate_identifier(identifier) - match = _resolve_device(devices, identifier) - if not match: + device = _resolve_single_device(devices, identifier) + if not device: + raise ValueError('No matching devices found') + token = device.encryptedDeviceToken + if token in seen_tokens: raise ValueError( - f'No matching device found for "{identifier}" (or ambiguous device name)' + f'Duplicate device specified: "{identifier}" resolves to a device ' + 'already included' ) - resolved.append(match) + seen_tokens.add(token) + resolved.append((token, device)) return resolved @@ -205,6 +496,10 @@ def _execute_device_action( raise ValueError('No devices found') resolved = _resolve_devices(devices, device_identifiers) + if action_type in (DeviceManagement_pb2.DA_LINK, DeviceManagement_pb2.DA_UNLINK): + tokens = [token for token, _ in resolved] + if len(set(tokens)) < 2: + raise ValueError('Link/unlink requires at least two different devices') token_to_device = {token: device for token, device in resolved} rq = DeviceManagement_pb2.DeviceActionRequest() @@ -239,6 +534,133 @@ def _execute_device_action( return succeeded +def _execute_admin_device_action( + auth: keeper_auth.KeeperAuth, + enterprise_user_id: int, + device_identifiers: List[str], + action_type: int, +) -> List[str]: + _validate_enterprise_user_id(enterprise_user_id) + if not device_identifiers: + raise ValueError('At least one device must be specified') + + devices = _fetch_admin_devices_for_user(auth, enterprise_user_id) + if not devices: + raise ValueError('No devices found') + + resolved = _resolve_devices(devices, device_identifiers) + token_to_device = {token: device for token, device in resolved} + + rq = DeviceManagement_pb2.DeviceAdminActionRequest() + admin_action = rq.deviceAdminAction.add() + admin_action.deviceActionType = action_type + admin_action.enterpriseUserId = enterprise_user_id + admin_action.encryptedDeviceToken.extend(list(token_to_device.keys())) + + rs = auth.execute_auth_rest( + rest_endpoint=URL_DEVICE_ADMIN_ACTION, + request=rq, + response_type=DeviceManagement_pb2.DeviceAdminActionResponse, + ) + if not rs or not rs.deviceAdminActionResults: + raise ValueError('No response returned from device admin action') + + succeeded: List[str] = [] + for result in rs.deviceAdminActionResults: + for token in result.encryptedDeviceToken: + device = token_to_device.get(token) + device_name = (device.deviceName if device else None) or 'Unknown Device' + if result.deviceActionStatus == DeviceManagement_pb2.SUCCESS: + succeeded.append(device_name) + else: + status_name = DeviceManagement_pb2.DeviceActionStatus.Name( + result.deviceActionStatus + ) + if result.deviceActionStatus == DeviceManagement_pb2.NOT_ALLOWED: + msg = 'Operation not allowed' + else: + msg = f'Action failed ({status_name})' + raise ValueError(f"Device '{device_name}': {msg}") + return succeeded + + +_UI_CATEGORY_RULES: List[Tuple[Callable[[DeviceManagement_pb2.Device], bool], str]] = [ + (lambda d: d.clientTypeCategory == DeviceManagement_pb2.CAT_EXTENSION, 'Browser Extension'), + (lambda d: d.clientTypeCategory == DeviceManagement_pb2.CAT_DESKTOP, 'Desktop'), + (lambda d: d.clientTypeCategory == DeviceManagement_pb2.CAT_WEB_VAULT, 'Web Vault'), + ( + lambda d: ( + d.clientType == DeviceManagement_pb2.ENTERPRISE_MANAGEMENT_CONSOLE + and d.clientTypeCategory == DeviceManagement_pb2.CAT_ADMIN + ), + 'Admin Console', + ), + ( + lambda d: ( + d.clientType == DeviceManagement_pb2.COMMANDER + and d.clientTypeCategory == DeviceManagement_pb2.CAT_ADMIN + ), + 'Commander CLI', + ), + ( + lambda d: ( + d.clientType == DeviceManagement_pb2.IOS + and d.clientTypeCategory == DeviceManagement_pb2.CAT_MOBILE + ), + 'iOS App', + ), + ( + lambda d: ( + d.clientType == DeviceManagement_pb2.ANDROID + and d.clientTypeCategory == DeviceManagement_pb2.CAT_MOBILE + ), + 'Android App', + ), + ( + lambda d: ( + d.clientTypeCategory == DeviceManagement_pb2.CAT_MOBILE + and d.clientFormFactor == APIRequest_pb2.FF_PHONE + ), + 'Mobile', + ), + ( + lambda d: ( + d.clientTypeCategory == DeviceManagement_pb2.CAT_MOBILE + and d.clientFormFactor == APIRequest_pb2.FF_TABLET + ), + 'Tablet', + ), + ( + lambda d: ( + d.clientTypeCategory == DeviceManagement_pb2.CAT_MOBILE + and d.clientFormFactor == APIRequest_pb2.FF_WATCH + ), + 'Wear OS', + ), +] + +_DEVICE_STATUS_NAMES = { + APIRequest_pb2.DEVICE_NEEDS_APPROVAL: 'NEEDS_APPROVAL', + APIRequest_pb2.DEVICE_OK: 'OK', + APIRequest_pb2.DEVICE_DISABLED_BY_USER: 'DISABLED_BY_USER', + APIRequest_pb2.DEVICE_LOCKED_BY_ADMIN: 'LOCKED_BY_ADMIN', +} + + +def _ui_category_name(device: DeviceManagement_pb2.Device) -> str: + try: + for rule_check, category_name in _UI_CATEGORY_RULES: + if rule_check(device): + return category_name + return 'Unknown Device' + except (AttributeError, TypeError): + return 'Unknown Device' + + +def _device_status_name(device_status: int) -> str: + return _DEVICE_STATUS_NAMES.get(device_status, f'UNKNOWN_STATUS_{device_status}') + + def _timestamp_to_datetime(timestamp: Optional[int]) -> Optional[datetime]: if not timestamp: return None diff --git a/keepersdk-package/src/keepersdk/authentication/keeper_auth.py b/keepersdk-package/src/keepersdk/authentication/keeper_auth.py index 4b3d560e..5fbad726 100644 --- a/keepersdk-package/src/keepersdk/authentication/keeper_auth.py +++ b/keepersdk-package/src/keepersdk/authentication/keeper_auth.py @@ -5,7 +5,7 @@ import json import logging import time -from typing import Optional, Dict, Any, List, Type, Set, Iterable, Union +from typing import Optional, Dict, Any, List, Type, Set, Iterable, Union, Tuple import attrs from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePrivateKey, EllipticCurvePublicKey @@ -47,6 +47,35 @@ class UserKeys: ec: Optional[bytes] = None +def parse_team_asymmetric_key_entry(team_key_entry: dict) -> Tuple[bytes, bytes]: + """Return RSA/EC public key bytes from a ``team_get_keys`` entry.""" + rsa = b'' + ec = b'' + team_pub = team_key_entry.get('team_public_key') + team_pub_type = team_key_entry.get('team_public_key_type') + if team_pub: + try: + pub_bytes = utils.base64_url_decode(team_pub) + if team_pub_type == -1: + ec = pub_bytes + elif team_pub_type == -3: + rsa = pub_bytes + except Exception: + pass + if not rsa and not ec and 'key' in team_key_entry: + key_type = team_key_entry.get('type') + if key_type in (-1, -3): + try: + key_bytes = utils.base64_url_decode(team_key_entry['key']) + if key_type == -1: + ec = key_bytes + elif key_type == -3: + rsa = key_bytes + except Exception: + pass + return rsa, ec + + class AuthContext: def __init__(self) -> None: self.username = '' @@ -278,31 +307,37 @@ def load_team_keys(self, team_uids: Iterable[str]) -> None: rs = self.execute_auth_command(rq) if 'keys' in rs: for tk in rs['keys']: - if 'key' in tk: - team_uid = tk['team_uid'] - try: - aes: Optional[bytes] = None - rsa: Optional[bytes] = None - ec: Optional[bytes] = None - encrypted_key = utils.base64_url_decode(tk['key']) - key_type = tk['type'] - if key_type == 1: - aes = crypto.decrypt_aes_v1(encrypted_key, self.auth_context.data_key) - elif key_type == 2: - assert self.auth_context.rsa_private_key is not None - aes = crypto.decrypt_rsa(encrypted_key, self.auth_context.rsa_private_key) - elif key_type == 3: - aes = crypto.decrypt_aes_v2(encrypted_key, self.auth_context.data_key) - elif key_type == 4: - assert self.auth_context.ec_private_key is not None - aes = crypto.decrypt_ec(encrypted_key, self.auth_context.ec_private_key) - elif key_type == -3: - rsa = encrypted_key - elif key_type == -1: - ec = encrypted_key - self._key_cache[team_uid] = UserKeys(aes=aes,rsa=rsa, ec=ec) - except Exception as e: - utils.get_logger().debug(e) + if 'key' not in tk: + continue + team_uid = tk['team_uid'] + try: + aes: Optional[bytes] = None + rsa: Optional[bytes] = None + ec: Optional[bytes] = None + encrypted_key = utils.base64_url_decode(tk['key']) + key_type = tk['type'] + if key_type == 1: + aes = crypto.decrypt_aes_v1(encrypted_key, self.auth_context.data_key) + elif key_type == 2: + assert self.auth_context.rsa_private_key is not None + aes = crypto.decrypt_rsa(encrypted_key, self.auth_context.rsa_private_key) + elif key_type == 3: + aes = crypto.decrypt_aes_v2(encrypted_key, self.auth_context.data_key) + elif key_type == 4: + assert self.auth_context.ec_private_key is not None + aes = crypto.decrypt_ec(encrypted_key, self.auth_context.ec_private_key) + elif key_type == -3: + rsa = encrypted_key + elif key_type == -1: + ec = encrypted_key + pub_rsa, pub_ec = parse_team_asymmetric_key_entry(tk) + if pub_rsa: + rsa = pub_rsa + if pub_ec: + ec = pub_ec + self._key_cache[team_uid] = UserKeys(aes=aes, rsa=rsa, ec=ec) + except Exception as e: + utils.get_logger().debug(e) def get_user_keys(self, username: str) -> Optional[UserKeys]: if self._key_cache: diff --git a/keepersdk-package/src/keepersdk/helpers/keeper_dag/dag.py b/keepersdk-package/src/keepersdk/helpers/keeper_dag/dag.py index 152a743e..e2360f5d 100644 --- a/keepersdk-package/src/keepersdk/helpers/keeper_dag/dag.py +++ b/keepersdk-package/src/keepersdk/helpers/keeper_dag/dag.py @@ -9,7 +9,7 @@ from typing import Any, List, Optional, Tuple, Union from . import dag_utils, dag_crypto -from .dag_types import EdgeType, Ref, RefType, ENDPOINT_TO_GRAPH_ID_MAP, DAGData +from .dag_types import EdgeType, Ref, RefType, ENDPOINT_TO_GRAPH_ID_MAP, DAGData, endpoint_for_graph_id from .dag_vertex import DAGVertex from .struct.protobuf import DataStruct as ProtobufDataStruct from .struct.default import DataStruct as DefaultDataStruct @@ -171,6 +171,15 @@ def __init__(self, self.conn = conn + if self.conn.use_write_protobuf and self.write_endpoint is None and self.graph_id is not None: + mapped_endpoint = endpoint_for_graph_id(self.graph_id) + if mapped_endpoint is not None: + self.write_endpoint = mapped_endpoint + if self.conn.use_read_protobuf and self.read_endpoint is None and self.graph_id is not None: + mapped_endpoint = endpoint_for_graph_id(self.graph_id) + if mapped_endpoint is not None: + self.read_endpoint = mapped_endpoint + self.read_struct_obj: Union[ProtobufDataStruct, DefaultDataStruct] = ProtobufDataStruct() \ if conn.use_read_protobuf else DefaultDataStruct() self.write_struct_obj: Union[ProtobufDataStruct, DefaultDataStruct] = ProtobufDataStruct() \ diff --git a/keepersdk-package/src/keepersdk/helpers/keeper_dag/dag_types.py b/keepersdk-package/src/keepersdk/helpers/keeper_dag/dag_types.py index bbe7f192..627811a8 100644 --- a/keepersdk-package/src/keepersdk/helpers/keeper_dag/dag_types.py +++ b/keepersdk-package/src/keepersdk/helpers/keeper_dag/dag_types.py @@ -49,6 +49,15 @@ class PamEndpoints(BaseEnum): } +def endpoint_for_graph_id(graph_id: Union[int, "PamGraphId", Enum]) -> Optional[str]: + if isinstance(graph_id, Enum): + graph_id = graph_id.value + for endpoint, gid in ENDPOINT_TO_GRAPH_ID_MAP.items(): + if gid == graph_id: + return endpoint + return None + + class SyncQuery(BaseModel): streamId: Optional[str] = None # base64 of a user's ID who is syncing. deviceId: Optional[str] = None diff --git a/keepersdk-package/src/keepersdk/helpers/keeper_dag/dag_utils.py b/keepersdk-package/src/keepersdk/helpers/keeper_dag/dag_utils.py index 72d1d6d4..c7e084a8 100644 --- a/keepersdk-package/src/keepersdk/helpers/keeper_dag/dag_utils.py +++ b/keepersdk-package/src/keepersdk/helpers/keeper_dag/dag_utils.py @@ -33,6 +33,10 @@ def get_connection(**kwargs): return kwargs.get("connection") vault = kwargs.get("vault") + if vault is None: + context = kwargs.get("context") + if context is not None: + vault = getattr(context, "vault", None) logger = kwargs.get("logger") if value_to_boolean(os.environ.get("USE_LOCAL_DAG")): from ..keeper_dag.connection.local import Connection @@ -40,6 +44,12 @@ def get_connection(**kwargs): else: use_read_protobuf = kwargs.get("use_read_protobuf") use_write_protobuf = kwargs.get("use_write_protobuf") + if use_read_protobuf is None: + env_val = os.environ.get("GS_USE_READ_PROTOBUF") + use_read_protobuf = True if env_val is None else value_to_boolean(env_val) + if use_write_protobuf is None: + env_val = os.environ.get("GS_USE_WRITE_PROTOBUF") + use_write_protobuf = True if env_val is None else value_to_boolean(env_val) if vault is not None: from ..keeper_dag.connection.commander import Connection diff --git a/keepersdk-package/src/keepersdk/helpers/keeper_dag/jobs.py b/keepersdk-package/src/keepersdk/helpers/keeper_dag/jobs.py index eb24648a..c15d65e9 100644 --- a/keepersdk-package/src/keepersdk/helpers/keeper_dag/jobs.py +++ b/keepersdk-package/src/keepersdk/helpers/keeper_dag/jobs.py @@ -32,10 +32,11 @@ def __init__(self, record: Any, logger: Optional[Any] = None, debug_level: int = log_prefix: str = "GS Jobs", save_batch_count: int = 200, agent: Optional[str] = None, **kwargs): - self.conn = get_connection(logger=logger, **kwargs) - self.record = record self._dag = None + self.conn = None + + self.conn = get_connection(logger=logger, **kwargs) if logger is None: logger = logging.getLogger() logger.propagate = False @@ -89,7 +90,7 @@ def close(self): Clean up resources held by this Jobs instance. Releases the DAG instance and connection to prevent memory leaks. """ - if self._dag is not None: + if getattr(self, "_dag", None) is not None: self._dag = None self.conn = None diff --git a/keepersdk-package/src/keepersdk/helpers/keeper_dag/record_link.py b/keepersdk-package/src/keepersdk/helpers/keeper_dag/record_link.py index 9cbb7f43..29e8c1d8 100644 --- a/keepersdk-package/src/keepersdk/helpers/keeper_dag/record_link.py +++ b/keepersdk-package/src/keepersdk/helpers/keeper_dag/record_link.py @@ -24,13 +24,12 @@ def __init__(self, use_write_protobuf: bool = True, **kwargs): + self.record = record + self._dag = None self.conn = get_connection(logger=logger, use_read_protobuf=use_read_protobuf, use_write_protobuf=use_write_protobuf, **kwargs) - - self.record = record - self._dag = None if logger is None: logger = logging.getLogger() self.logger = logger @@ -81,9 +80,10 @@ def close(self): Clean up resources held by this RecordLink instance. Releases the DAG instance and connection to prevent memory leaks. """ - if self._dag is not None: + if getattr(self, "_dag", None) is not None: self._dag = None - self.conn = None + if getattr(self, "conn", None) is not None: + self.conn = None def __enter__(self): """Context manager entry.""" diff --git a/keepersdk-package/src/keepersdk/helpers/keeper_dag/struct/protobuf.py b/keepersdk-package/src/keepersdk/helpers/keeper_dag/struct/protobuf.py index b584d3a0..28e9a2fa 100644 --- a/keepersdk-package/src/keepersdk/helpers/keeper_dag/struct/protobuf.py +++ b/keepersdk-package/src/keepersdk/helpers/keeper_dag/struct/protobuf.py @@ -109,11 +109,16 @@ def get_sync_result(results: bytes) -> SyncData: data_list: List[SyncDataItem] = [] for item in message.data: + raw_content = item.data.content + if raw_content: + encoded_content = dag_crypto.bytes_to_urlsafe_str(raw_content) + else: + encoded_content = None data_list.append( SyncDataItem( type=DataStruct.PB_TO_DATA_MAP.get(item.data.type), - content=item.data.content, - content_is_base64=False, + content=encoded_content, + content_is_base64=True, ref=Ref( type=DataStruct.PB_TO_REF_MAP.get(item.data.ref.type), value=dag_crypto.bytes_to_urlsafe_str(item.data.ref.value), diff --git a/keepersdk-package/src/keepersdk/vault/nsf_common.py b/keepersdk-package/src/keepersdk/vault/nsf_common.py index 625526c4..428a216e 100644 --- a/keepersdk-package/src/keepersdk/vault/nsf_common.py +++ b/keepersdk-package/src/keepersdk/vault/nsf_common.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import Any, Dict, Optional, Tuple +import json +from typing import Any, Dict, List, Optional, Tuple from .. import crypto, utils from ..proto import folder_pb2, record_pb2, record_sharing_pb2 @@ -97,6 +98,10 @@ def encrypt_record_key_for_folder( return crypto.encrypt_aes_v2(record_key, encryption_key), folder_pb2.encrypted_by_data_key_gcm +def _valid_team_aes_key(aes: Optional[bytes]) -> bool: + return aes is not None and len(aes) == 32 + + def encrypt_for_team( plaintext_key: bytes, team_keys, @@ -111,8 +116,10 @@ def encrypt_for_team( if ec_bytes: ec_key = crypto.load_ec_public_key(ec_bytes) return crypto.encrypt_ec(plaintext_key, ec_key), folder_pb2.encrypted_by_public_key_ecc - if aes: - return crypto.encrypt_aes_v2(plaintext_key, aes), folder_pb2.encrypted_by_data_key_gcm + if _valid_team_aes_key(aes): + if forbid_rsa: + return crypto.encrypt_aes_v2(plaintext_key, aes), folder_pb2.encrypted_by_data_key_gcm + return crypto.encrypt_aes_v1(plaintext_key, aes), folder_pb2.encrypted_by_data_key raise ValueError('No public key found for team') @@ -248,6 +255,47 @@ def resolve_team_uid_bytes(vault: VaultOnline, team_identifier: str) -> Optional return None +def get_team_keys(vault: VaultOnline, team_uid_b64: str): + """Return cached team keys, loading asymmetric public keys if needed.""" + from ..authentication.keeper_auth import UserKeys, parse_team_asymmetric_key_entry + + auth = vault.keeper_auth + auth.load_team_keys([team_uid_b64]) + keys = auth.get_team_keys(team_uid_b64) + + has_asym = bool(keys and (keys.rsa or keys.ec)) + if not has_asym: + try: + rq = {'command': 'team_get_keys', 'teams': [team_uid_b64]} + rs = auth.execute_auth_command(rq) + existing_aes = keys.aes if keys else None + rsa_pub = b'' + ec_pub = b'' + for tk in (rs or {}).get('keys', []): + if tk.get('team_uid') != team_uid_b64: + continue + pub_rsa, pub_ec = parse_team_asymmetric_key_entry(tk) + if pub_rsa: + rsa_pub = pub_rsa + if pub_ec: + ec_pub = pub_ec + if rsa_pub or ec_pub: + if auth._key_cache is None: + auth._key_cache = {} + auth._key_cache[team_uid_b64] = UserKeys( + aes=existing_aes, + rsa=rsa_pub or (keys.rsa if keys else None), + ec=ec_pub or (keys.ec if keys else None)) + keys = auth._key_cache[team_uid_b64] + except Exception as exc: + utils.get_logger().debug( + 'team_get_keys fallback failed for %s: %s', team_uid_b64, exc) + + if not keys: + raise ValueError(f'Team key not found for team {team_uid_b64}') + return keys + + def resolve_team_identifier(vault: VaultOnline, team_identifier: str) -> Optional[Tuple[str, bytes]]: uid_bytes = resolve_team_uid_bytes(vault, team_identifier) if not uid_bytes: @@ -320,6 +368,292 @@ def folder_access_role_label( return 'unknown' +_PERMISSION_CAMEL_KEYS: Dict[str, str] = { + 'can_update_access': 'canUpdateAccess', + 'can_update_setting': 'canUpdateSetting', + 'can_delete': 'canDelete', + 'can_change_ownership': 'canChangeOwnership', + 'can_edit': 'canEdit', + 'can_view': 'canView', + 'can_list_access': 'canListAccess', +} + + +def _current_user_account_uid_b64(vault: VaultOnline) -> str: + account_uid = vault.keeper_auth.auth_context.account_uid + return utils.base64_url_encode(account_uid) if account_uid else '' + + +def _parse_permissions_blob(raw: Any) -> Dict[str, Any]: + if isinstance(raw, dict): + return raw + if isinstance(raw, str) and raw: + try: + parsed = json.loads(raw) + return parsed if isinstance(parsed, dict) else {} + except (TypeError, ValueError): + return {} + return {} + + +def _permission_value(perms: Dict[str, Any], key: str) -> bool: + if not perms: + return False + if key in perms: + return bool(perms[key]) + camel = _PERMISSION_CAMEL_KEYS.get(key) + if camel and camel in perms: + return bool(perms[camel]) + return False + + +def _access_type_is_owner(access_type: Any) -> bool: + if access_type == folder_pb2.AT_OWNER: + return True + return isinstance(access_type, str) and access_type == 'AT_OWNER' + + +def is_current_user_nsf_accessor( + accessor: Dict[str, Any], + vault: VaultOnline, + account_uid_b64: str) -> bool: + """Return True when *accessor* belongs to the logged-in user.""" + username = vault.keeper_auth.auth_context.username + accessor_username = accessor.get('username') or accessor.get('accessor_name') + if accessor_username and username: + return accessor_username.casefold() == username.casefold() + accessor_uid = accessor.get('access_type_uid') or accessor.get('accessor_uid') + return bool(accessor_uid and account_uid_b64 and accessor_uid == account_uid_b64) + + +def _folder_owner_info(vault: VaultOnline, folder_uid: str) -> Tuple[Optional[str], Optional[str]]: + view = vault.nsf_data + if view is None: + return None, None + row = view.storage.folders.get_entity(folder_uid) + if row is None: + return None, None + return row.owner_username or None, row.owner_account_uid or None + + +def is_nsf_folder_owner_user(vault: VaultOnline, folder_uid: str) -> bool: + """Return True when the logged-in user owns *folder_uid*.""" + account_uid_b64 = _current_user_account_uid_b64(vault) + username = vault.keeper_auth.auth_context.username + owner_username, owner_account_uid = _folder_owner_info(vault, folder_uid) + if owner_account_uid and account_uid_b64 and owner_account_uid == account_uid_b64: + return True + if owner_username and username and owner_username.casefold() == username.casefold(): + return True + return False + + +def _folder_accessor_from_storage(fa: Any) -> Dict[str, Any]: + return { + 'access_type_uid': fa.access_type_uid, + 'access_type': fa.access_type, + 'permissions': _parse_permissions_blob(fa.permissions_json), + } + + +def collect_nsf_folder_accessors(vault: VaultOnline, folder_uid: str) -> List[Dict[str, Any]]: + """Folder accessor rows from sync cache, falling back to the access API.""" + accessors: List[Dict[str, Any]] = [] + view = vault.nsf_data + if view is not None: + for fa in view.storage.folder_accesses.get_links_by_subject(folder_uid): + accessors.append(_folder_accessor_from_storage(fa)) + if accessors: + return accessors + from .nsf_management import get_nsf_folder_access + try: + info = get_nsf_folder_access(vault, [folder_uid]) + for result in info.get('results', []): + if result.get('success'): + accessors.extend(result.get('accessors', [])) + except Exception: + pass + return accessors + + +def _record_accessor_from_storage(ra: Any) -> Dict[str, Any]: + return { + 'access_type_uid': ra.access_type_uid, + 'owner': ra.owner, + 'inherited': ra.inherited, + 'denied_access': ra.denied_access, + 'can_update_access': ra.can_update_access, + 'can_change_ownership': ra.can_change_ownership, + 'can_delete': ra.can_delete, + 'can_edit': ra.can_edit, + } + + +def find_record_user_accesses( + vault: VaultOnline, + record_uid: str, + recipient_email: str) -> List[Dict[str, Any]]: + """Return non-owner AT_USER accessor rows for *recipient_email* on *record_uid*.""" + email_cf = recipient_email.casefold() + matches: List[Dict[str, Any]] = [] + for accessor in collect_nsf_record_accessors(vault, record_uid): + if accessor.get('owner'): + continue + access_type = accessor.get('access_type') or 'AT_USER' + if access_type not in ('AT_USER', ''): + continue + accessor_name = accessor.get('accessor_name') or accessor.get('username') or '' + if accessor_name.casefold() != email_cf: + continue + matches.append(accessor) + return matches + + +def record_user_has_direct_access(accesses: List[Dict[str, Any]]) -> bool: + return any(not accessor.get('inherited') for accessor in accesses) + + +def record_user_has_inherited_access(accesses: List[Dict[str, Any]]) -> bool: + return any(accessor.get('inherited') for accessor in accesses) + + +def collect_nsf_record_accessors(vault: VaultOnline, record_uid: str) -> List[Dict[str, Any]]: + """Record accessor rows from sync cache, falling back to the access API.""" + accessors: List[Dict[str, Any]] = [] + view = vault.nsf_data + if view is not None: + for ra in view.storage.record_accesses.get_links_by_subject(record_uid): + accessors.append(_record_accessor_from_storage(ra)) + if accessors: + return accessors + from .nsf_management import get_nsf_record_accesses + try: + info = get_nsf_record_accesses(vault, [record_uid]) + accessors.extend(info.get('record_accesses', [])) + except Exception: + pass + return accessors + + +def _record_permission_value(accessor: Dict[str, Any], key: str) -> bool: + if key in accessor: + return bool(accessor[key]) + return _permission_value(accessor.get('permissions') or {}, key) + + +def require_nsf_folder_permission( + vault: VaultOnline, + folder_uid: str, + permission_key: str, + error_message: str) -> None: + """Raise ValueError when the current user lacks *permission_key* on a folder.""" + if is_nsf_folder_owner_user(vault, folder_uid): + return + + accessors = collect_nsf_folder_accessors(vault, folder_uid) + if not accessors: + raise ValueError("No accessors data found for folder {folder_uid}") + + account_uid_b64 = _current_user_account_uid_b64(vault) + owner_username, owner_account_uid = _folder_owner_info(vault, folder_uid) + for accessor in accessors: + if not is_current_user_nsf_accessor(accessor, vault, account_uid_b64): + continue + if (accessor.get('owner') + or _access_type_is_owner(accessor.get('access_type')) + or is_nsf_folder_owner(accessor, owner_username, owner_account_uid)): + return + perms = accessor.get('permissions') or {} + if _permission_value(perms, permission_key): + return + raise ValueError(error_message) + + raise ValueError(error_message) + + +def require_nsf_folder_share_permission(vault: VaultOnline, folder_uid: str) -> None: + """Raise ValueError when the current user cannot share or manage folder access.""" + require_nsf_folder_permission( + vault, + folder_uid, + 'can_update_access', + 'You do not have permission to share this folder.') + + +def require_nsf_record_permission( + vault: VaultOnline, + record_uid: str, + permission_key: str, + error_message: str) -> None: + """Raise ValueError when the current user lacks *permission_key* on a record.""" + accessors = collect_nsf_record_accessors(vault, record_uid) + if not accessors: + raise ValueError("No accessors data found for record {record_uid}") + + account_uid_b64 = _current_user_account_uid_b64(vault) + for accessor in accessors: + if not is_current_user_nsf_accessor(accessor, vault, account_uid_b64): + continue + if accessor.get('owner'): + return + if _record_permission_value(accessor, permission_key): + return + raise ValueError(error_message) + + raise ValueError(error_message) + + +def require_nsf_record_share_permission(vault: VaultOnline, record_uid: str) -> None: + """Raise ValueError when the current user cannot share or manage record access.""" + require_nsf_record_permission( + vault, + record_uid, + 'can_update_access', + 'You do not have permission to share this record.') + + +def require_nsf_record_ownership_permission(vault: VaultOnline, record_uid: str) -> None: + """Raise ValueError when the current user cannot transfer record ownership.""" + require_nsf_record_permission( + vault, + record_uid, + 'can_change_ownership', + 'You do not have permission to transfer ownership of this record.') + + +def folder_inherits_parent_permissions(vault: VaultOnline, folder_uid: str) -> bool: + """Return True when *folder_uid* has a parent and still inherits its access list.""" + from .nsf_management import ROOT_FOLDER_UID + + view = vault.nsf_data + if view is None: + return False + node = view.get_folder(folder_uid) + parent_uid = node.parent_uid if node else None + if not parent_uid: + row = view.storage.folders.get_entity(folder_uid) + parent_uid = row.parent_uid if row else None + if not parent_uid or parent_uid == ROOT_FOLDER_UID: + return False + row = view.storage.folders.get_entity(folder_uid) + if row is None: + return True + return row.inherit_user_permissions != int(folder_pb2.BOOLEAN_FALSE) + + +def ensure_folder_direct_permissions( + vault: VaultOnline, + folder_uid: str, + *, + request_sync: bool = False) -> bool: + """Disable parent permission inheritance so folder access changes apply locally.""" + if not folder_inherits_parent_permissions(vault, folder_uid): + return False + from .nsf_management import update_nsf_folder + update_nsf_folder(vault, folder_uid, inherit_permissions=False, request_sync=request_sync) + return True + + def access_role_label(access: Dict[str, Any]) -> str: if access.get('owner'): return 'owner' diff --git a/keepersdk-package/src/keepersdk/vault/nsf_management.py b/keepersdk-package/src/keepersdk/vault/nsf_management.py index 6d8b53e5..090d11a1 100644 --- a/keepersdk-package/src/keepersdk/vault/nsf_management.py +++ b/keepersdk-package/src/keepersdk/vault/nsf_management.py @@ -786,8 +786,11 @@ def get_nsf_record_accesses( } for flag in ('can_view_title', 'can_edit', 'can_view', 'can_list_access', 'can_update_access', 'can_delete', 'can_change_ownership', - 'can_request_access', 'can_approve_access'): - ao[flag] = getattr(d, flag, False) + 'can_request_access', 'can_approve_access', 'denied_access'): + if flag == 'denied_access': + ao[flag] = getattr(d, 'deniedAccess', False) + else: + ao[flag] = getattr(d, flag, False) result['record_accesses'].append(ao) for fu in rs.forbiddenRecords: result['forbidden_records'].append(utils.base64_url_encode(fu)) diff --git a/keepersdk-package/src/keepersdk/vault/nsf_sharing.py b/keepersdk-package/src/keepersdk/vault/nsf_sharing.py index a45fddac..38ffb98b 100644 --- a/keepersdk-package/src/keepersdk/vault/nsf_sharing.py +++ b/keepersdk-package/src/keepersdk/vault/nsf_sharing.py @@ -16,6 +16,7 @@ _get_record_key, _nsf_view, _request_sync, + get_nsf_folder_access, get_nsf_record_accesses, is_nsf_folder, resolve_nsf_folder_uid, @@ -26,6 +27,32 @@ _SHARE_BATCH_SIZE = 200 +def _ensure_folder_share_permission(vault: VaultOnline, folder_uid: str) -> None: + try: + nsf_common.require_nsf_folder_share_permission(vault, folder_uid) + except ValueError as exc: + raise NsfError(str(exc)) from exc + + +def _ensure_record_share_permission(vault: VaultOnline, record_uid: str) -> None: + try: + nsf_common.require_nsf_record_share_permission(vault, record_uid) + except ValueError as exc: + raise NsfError(str(exc)) from exc + + +def _ensure_record_ownership_permission(vault: VaultOnline, record_uid: str) -> None: + try: + nsf_common.require_nsf_record_ownership_permission(vault, record_uid) + except ValueError as exc: + raise NsfError(str(exc)) from exc + + +def _prepare_folder_for_access_change(vault: VaultOnline, folder_uid: str) -> None: + """Break parent permission inheritance before changing folder accessors.""" + nsf_common.ensure_folder_direct_permissions(vault, folder_uid, request_sync=False) + + @dataclass class NsfShareResult: success: bool @@ -38,6 +65,7 @@ class NsfRecordPermissionPlan: updates: List[Dict[str, Any]] = field(default_factory=list) creates: List[Dict[str, Any]] = field(default_factory=list) revokes: List[Dict[str, Any]] = field(default_factory=list) + denies: List[Dict[str, Any]] = field(default_factory=list) skipped: List[Dict[str, Any]] = field(default_factory=list) @@ -62,6 +90,52 @@ def _folder_access_update( return response +def _resolve_folder_accessor( + vault: VaultOnline, + recipient: str, + *, + as_team: bool) -> Tuple[bytes, str, int]: + """Return (uid_bytes, label, access_type_enum) for a folder accessor.""" + if as_team: + resolved = nsf_common.resolve_team_identifier(vault, recipient) + if not resolved: + raise NsfError(f"Team '{recipient}' not found") + team_uid_b64, uid_bytes = resolved + return uid_bytes, team_uid_b64, folder_pb2.AT_TEAM + + if '@' in recipient: + _, _, uid_bytes, _ = nsf_common.get_user_public_key(vault, recipient) + if not uid_bytes: + raise NsfError(f"User '{recipient}' not found") + return uid_bytes, recipient, folder_pb2.AT_USER + + uid_bytes = nsf_common.resolve_user_uid_bytes(vault, recipient) + if not uid_bytes: + raise NsfError(f"User '{recipient}' not found") + return uid_bytes, recipient, folder_pb2.AT_USER + + +def _check_existing_nsf_folder_access( + vault: VaultOnline, + folder_uid: str, + uid_bytes: bytes, + access_type_label: str) -> Optional[str]: + """Return the existing AccessRoleType name for an accessor, or None.""" + try: + uid_encoded = utils.base64_url_encode(uid_bytes) + info = get_nsf_folder_access(vault, [folder_uid]) + for result in info.get('results', []): + if not result.get('success'): + continue + for accessor in result.get('accessors', []): + if (accessor.get('access_type') == access_type_label + and accessor.get('accessor_uid') == uid_encoded): + return accessor.get('role') + except Exception: + pass + return None + + def collect_nsf_records_in_folder( vault: VaultOnline, folder_identifier: Optional[str], @@ -107,50 +181,117 @@ def grant_nsf_folder_access( folder_uid = resolve_nsf_folder_uid(vault, folder_identifier) or folder_identifier if not is_nsf_folder(vault, folder_uid): raise NsfError(f'NSF folder not found: {folder_identifier}') + _ensure_folder_share_permission(vault, folder_uid) + _prepare_folder_for_access_change(vault, folder_uid) access_role = nsf_common.resolve_nsf_role(role) + target_role_name = folder_pb2.AccessRoleType.Name(access_role) + access_type_label = 'AT_TEAM' if as_team else 'AT_USER' + + uid_bytes, label, access_type_enum = _resolve_folder_accessor( + vault, recipient, as_team=as_team) + + existing_role = _check_existing_nsf_folder_access( + vault, folder_uid, uid_bytes, access_type_label) + if existing_role is not None: + if existing_role == target_role_name and expiration_timestamp is None: + return { + 'folder_uid': folder_uid, + 'accessor': label, + 'access_type': access_type_label, + 'status': 'SUCCESS', + 'message': f"{'Team' if as_team else 'User'} already has {role} access", + 'success': True, + 'action_taken': 'already_had_access', + } + result = update_nsf_folder_access( + vault, folder_uid, recipient, role=role, as_team=as_team, + expiration_timestamp=expiration_timestamp, request_sync=request_sync) + result['action_taken'] = 'updated' + return result + ad = folder_pb2.FolderAccessData() ad.folderUid = utils.base64_url_decode(folder_uid) + ad.accessTypeUid = uid_bytes + ad.accessType = access_type_enum ad.accessRoleType = access_role ad.permissions.CopyFrom(nsf_common.get_folder_permissions_for_role(access_role)) + if expiration_timestamp is not None: + ad.tlaProperties.expiration = expiration_timestamp + + fk = _get_folder_key(vault, folder_uid) + ek = folder_pb2.EncryptedDataKey() if as_team: - resolved = nsf_common.resolve_team_identifier(vault, recipient) - if not resolved: - raise NsfError(f"Team '{recipient}' not found") - _, uid_bytes = resolved - ad.accessTypeUid = uid_bytes - ad.accessType = folder_pb2.AT_TEAM - fk = _get_folder_key(vault, folder_uid) - vault.keeper_auth.load_team_keys([utils.base64_url_encode(uid_bytes)]) - team_keys = vault.keeper_auth.get_team_keys(utils.base64_url_encode(uid_bytes)) - if not team_keys: - raise NsfError(f'Team keys not available for {recipient}') - efk, key_type = nsf_common.encrypt_for_team(fk, team_keys, forbid_rsa=vault.keeper_auth.auth_context.forbid_rsa) - ek = folder_pb2.EncryptedDataKey() - ek.encryptedKey = efk - ek.encryptedKeyType = key_type - ad.folderKey.CopyFrom(ek) - label = recipient + team_uid_b64 = utils.base64_url_encode(uid_bytes) + try: + team_keys = nsf_common.get_team_keys(vault, team_uid_b64) + except ValueError as exc: + raise NsfError(f'Team keys not available for {recipient}') from exc + efk, key_type = nsf_common.encrypt_for_team( + fk, team_keys, + forbid_rsa=vault.keeper_auth.auth_context.forbid_rsa) else: - pub_key, use_ecc, uid_bytes, _ = nsf_common.get_user_public_key(vault, recipient) - ad.accessTypeUid = uid_bytes - ad.accessType = folder_pb2.AT_USER - fk = _get_folder_key(vault, folder_uid) - ek = folder_pb2.EncryptedDataKey() - ek.encryptedKey = nsf_common.encrypt_for_recipient(fk, pub_key, use_ecc) - ek.encryptedKeyType = ( + pub_key, use_ecc, _, _ = nsf_common.get_user_public_key(vault, recipient) + efk = nsf_common.encrypt_for_recipient(fk, pub_key, use_ecc) + key_type = ( folder_pb2.encrypted_by_public_key_ecc if use_ecc else folder_pb2.encrypted_by_public_key) - ad.folderKey.CopyFrom(ek) - label = recipient + ek.encryptedKey = efk + ek.encryptedKeyType = key_type + ad.folderKey.CopyFrom(ek) + response = _folder_access_update(vault, adds=[ad]) + result = nsf_common.parse_folder_access_result( + response, folder_uid, label, 'Access granted successfully') + result['access_type'] = access_type_label + result.setdefault('action_taken', 'granted' if result['success'] else 'grant_failed') + if not result['success']: + raise KeeperApiError(result['status'], result['message']) + _request_sync(vault, request_sync) + return result + + +def update_nsf_folder_access( + vault: VaultOnline, + folder_identifier: str, + recipient: str, + *, + role: Optional[str] = None, + hidden: Optional[bool] = None, + expiration_timestamp: Optional[int] = None, + as_team: bool = False, + request_sync: bool = True) -> Dict[str, Any]: + """Update role, visibility, or expiration for an existing NSF folder accessor.""" + if role is None and hidden is None and expiration_timestamp is None: + raise NsfError('At least one field (role, hidden, or expiration) is required') + + folder_uid = resolve_nsf_folder_uid(vault, folder_identifier) or folder_identifier + if not is_nsf_folder(vault, folder_uid): + raise NsfError(f'NSF folder not found: {folder_identifier}') + _ensure_folder_share_permission(vault, folder_uid) + _prepare_folder_for_access_change(vault, folder_uid) + + uid_bytes, label, access_type_enum = _resolve_folder_accessor( + vault, recipient, as_team=as_team) + + ad = folder_pb2.FolderAccessData() + ad.folderUid = utils.base64_url_decode(folder_uid) + ad.accessTypeUid = uid_bytes + ad.accessType = access_type_enum + if role is not None: + access_role = nsf_common.resolve_nsf_role(role) + ad.accessRoleType = access_role + ad.permissions.CopyFrom(nsf_common.get_folder_permissions_for_role(access_role)) + if hidden is not None: + ad.hidden = hidden if expiration_timestamp is not None: ad.tlaProperties.expiration = expiration_timestamp - response = _folder_access_update(vault, adds=[ad]) + response = _folder_access_update(vault, updates=[ad]) result = nsf_common.parse_folder_access_result( - response, folder_uid, label, 'Access granted successfully') + response, folder_uid, label, 'Access updated successfully') + result['access_type'] = 'AT_TEAM' if as_team else 'AT_USER' if not result['success']: raise KeeperApiError(result['status'], result['message']) _request_sync(vault, request_sync) @@ -168,26 +309,21 @@ def revoke_nsf_folder_access( folder_uid = resolve_nsf_folder_uid(vault, folder_identifier) or folder_identifier if not is_nsf_folder(vault, folder_uid): raise NsfError(f'NSF folder not found: {folder_identifier}') + _ensure_folder_share_permission(vault, folder_uid) + _prepare_folder_for_access_change(vault, folder_uid) + + uid_bytes, label, access_type_enum = _resolve_folder_accessor( + vault, recipient, as_team=as_team) ad = folder_pb2.FolderAccessData() ad.folderUid = utils.base64_url_decode(folder_uid) - if as_team: - resolved = nsf_common.resolve_team_identifier(vault, recipient) - if not resolved: - raise NsfError(f"Team '{recipient}' not found") - _, uid_bytes = resolved - ad.accessTypeUid = uid_bytes - ad.accessType = folder_pb2.AT_TEAM - else: - uid_bytes = nsf_common.resolve_user_uid_bytes(vault, recipient) - if not uid_bytes: - raise NsfError(f"User '{recipient}' not found") - ad.accessTypeUid = uid_bytes - ad.accessType = folder_pb2.AT_USER + ad.accessTypeUid = uid_bytes + ad.accessType = access_type_enum response = _folder_access_update(vault, removes=[ad]) result = nsf_common.parse_folder_access_result( - response, folder_uid, recipient, 'Access revoked successfully') + response, folder_uid, label, 'Access revoked successfully') + result['access_type'] = 'AT_TEAM' if as_team else 'AT_USER' if not result['success']: raise KeeperApiError(result['status'], result['message']) _request_sync(vault, request_sync) @@ -201,7 +337,8 @@ def _build_share_permission( access_role_type: Optional[int], expiration_timestamp: Optional[int], *, - include_role: bool) -> record_sharing_pb2.Permissions: + include_role: bool, + denied_access: bool = False) -> record_sharing_pb2.Permissions: record_key = _get_record_key(vault, record_uid) pub_key, use_ecc, uid_bytes, _ = nsf_common.get_user_public_key(vault, recipient_email) enc_rk = nsf_common.encrypt_for_recipient(record_key, pub_key, use_ecc) @@ -215,13 +352,55 @@ def _build_share_permission( perm.rules.accessType = folder_pb2.AT_USER perm.rules.recordUid = uid_b perm.rules.owner = False - if include_role and access_role_type is not None: + if denied_access: + perm.rules.deniedAccess = True + elif include_role and access_role_type is not None: perm.rules.accessRoleType = access_role_type if expiration_timestamp: perm.rules.tlaProperties.expiration = expiration_timestamp return perm +def _build_revoke_share_permission( + vault: VaultOnline, + record_uid: str, + recipient_email: str) -> record_sharing_pb2.Permissions: + uid_bytes = nsf_common.resolve_user_uid_bytes(vault, recipient_email) + if not uid_bytes: + raise NsfError(f"User '{recipient_email}' not found") + uid_b = utils.base64_url_decode(record_uid) + perm = record_sharing_pb2.Permissions() + perm.recipientUid = uid_bytes + perm.recordUid = uid_b + perm.rules.accessTypeUid = uid_bytes + perm.rules.accessType = folder_pb2.AT_USER + perm.rules.recordUid = uid_b + return perm + + +def _revoke_direct_record_share( + vault: VaultOnline, + record_uid: str, + recipient_email: str) -> NsfShareResult: + perm = _build_revoke_share_permission(vault, record_uid, recipient_email) + rq = record_sharing_pb2.Request() + rq.revokeSharingPermissions.append(perm) + return _share_rest(vault, rq, 'revokedSharingStatus') + + +def _deny_inherited_record_share( + vault: VaultOnline, + record_uid: str, + recipient_email: str) -> NsfShareResult: + """Add a direct deny row so inherited folder access no longer applies.""" + perm = _build_share_permission( + vault, record_uid, recipient_email, None, None, + include_role=False, denied_access=True) + rq = record_sharing_pb2.Request() + rq.createSharingPermissions.append(perm) + return _share_rest(vault, rq, 'createdSharingStatus') + + def _share_rest( vault: VaultOnline, rq: record_sharing_pb2.Request, @@ -247,6 +426,7 @@ def share_nsf_record( request_sync: bool = True) -> NsfShareResult: """Grant record share.""" resolved = resolve_nsf_record_uid(vault, record_uid) or record_uid + _ensure_record_share_permission(vault, resolved) role_type = nsf_common.resolve_nsf_role(role) perm = _build_share_permission( vault, resolved, recipient_email, role_type, expiration_timestamp, include_role=True) @@ -269,6 +449,12 @@ def update_nsf_record_share( expiration_timestamp: Optional[int] = None, request_sync: bool = True) -> NsfShareResult: resolved = resolve_nsf_record_uid(vault, record_uid) or record_uid + _ensure_record_share_permission(vault, resolved) + user_accesses = nsf_common.find_record_user_accesses(vault, resolved, recipient_email) + if not nsf_common.record_user_has_direct_access(user_accesses): + return share_nsf_record( + vault, resolved, recipient_email, role=role, + expiration_timestamp=expiration_timestamp, request_sync=request_sync) role_type = nsf_common.resolve_nsf_role(role) perm = _build_share_permission( vault, resolved, recipient_email, role_type, expiration_timestamp, include_role=True) @@ -289,24 +475,38 @@ def unshare_nsf_record( *, request_sync: bool = True) -> NsfShareResult: resolved = resolve_nsf_record_uid(vault, record_uid) or record_uid - uid_bytes = nsf_common.resolve_user_uid_bytes(vault, recipient_email) - if not uid_bytes: - raise NsfError(f"User '{recipient_email}' not found") - uid_b = utils.base64_url_decode(resolved) - perm = record_sharing_pb2.Permissions() - perm.recipientUid = uid_bytes - perm.recordUid = uid_b - perm.rules.accessTypeUid = uid_bytes - perm.rules.accessType = folder_pb2.AT_USER - perm.rules.recordUid = uid_b - rq = record_sharing_pb2.Request() - rq.revokeSharingPermissions.append(perm) - result = _share_rest(vault, rq, 'revokedSharingStatus') - if not result.success: - msg = result.results[0]['message'] if result.results else 'Revoke failed' - raise KeeperApiError('share_revoke_failed', msg) + _ensure_record_share_permission(vault, resolved) + user_accesses = nsf_common.find_record_user_accesses(vault, resolved, recipient_email) + has_direct = nsf_common.record_user_has_direct_access(user_accesses) + has_inherited = nsf_common.record_user_has_inherited_access(user_accesses) + results: List[Dict[str, Any]] = [] + + if has_direct: + revoke_result = _revoke_direct_record_share(vault, resolved, recipient_email) + results.extend(revoke_result.results) + if not revoke_result.success: + msg = revoke_result.results[0]['message'] if revoke_result.results else 'Revoke failed' + raise KeeperApiError('share_revoke_failed', msg) + + if has_inherited: + deny_result = _deny_inherited_record_share(vault, resolved, recipient_email) + results.extend(deny_result.results) + if not deny_result.success: + msg = deny_result.results[0]['message'] if deny_result.results else 'Deny failed' + raise KeeperApiError('share_revoke_failed', msg) + + if not has_direct and not has_inherited: + revoke_result = _revoke_direct_record_share(vault, resolved, recipient_email) + results.extend(revoke_result.results) + if not revoke_result.success: + msg = revoke_result.results[0]['message'] if revoke_result.results else 'Revoke failed' + raise KeeperApiError('share_revoke_failed', msg) + _request_sync(vault, request_sync) - return result + return NsfShareResult( + success=all(r.get('success', False) for r in results) if results else True, + results=results, + ) def transfer_nsf_record_ownership( @@ -319,6 +519,7 @@ def transfer_nsf_record_ownership( record_uid = resolve_nsf_record_uid(vault, record_identifier) if not record_uid: raise NsfError(f'NSF record not found: {record_identifier}') + _ensure_record_ownership_permission(vault, record_uid) record_key = _get_record_key(vault, record_uid) pub_key, use_ecc, _, _ = nsf_common.get_user_public_key( vault, new_owner_email, require_uid=False) @@ -386,6 +587,8 @@ def share_nsf_record_with_action( - action: 'grant', 'update', 'revoke', or 'owner' """ resolved = resolve_nsf_record_uid(vault, record_uid) or record_uid + if action != 'owner': + _ensure_record_share_permission(vault, resolved) if action == 'owner': return transfer_nsf_record_ownership( vault, resolved, recipient_email, request_sync=request_sync), 'owner' @@ -394,14 +597,8 @@ def share_nsf_record_with_action( vault, resolved, recipient_email, request_sync=request_sync), 'revoke' if not role: raise NsfError('Role is required for grant action') - accesses = get_nsf_record_accesses(vault, [resolved]).get('record_accesses', []) - already_shared = any( - a.get('record_uid') == resolved - and not a.get('owner') - and a.get('access_type', '') in ('AT_USER', '') - and not a.get('inherited') - and (a.get('accessor_name') or '').casefold() == recipient_email.casefold() - for a in accesses) + user_accesses = nsf_common.find_record_user_accesses(vault, resolved, recipient_email) + already_shared = nsf_common.record_user_has_direct_access(user_accesses) if already_shared: return update_nsf_record_share( vault, resolved, recipient_email, role=role, @@ -476,9 +673,8 @@ def plan_nsf_record_permissions( plan.updates.append(entry) elif not role or cur_role == role: if access.get('inherited'): - plan.skipped.append({ + plan.denies.append({ 'record_uid': rec_uid, 'email': email, 'cur_role': cur_role, - 'reason': 'Inherited — revoke at parent folder', }) else: plan.revokes.append({'record_uid': rec_uid, 'email': email, 'cur_role': cur_role}) @@ -498,17 +694,13 @@ def _batch_share( for item in chunk: try: if mode == 'revoke': - uid_bytes = nsf_common.resolve_user_uid_bytes(vault, item['email']) - if not uid_bytes: - raise ValueError(f"User {item['email']} not found") - uid_b = utils.base64_url_decode(item['record_uid']) - perm = record_sharing_pb2.Permissions() - perm.recipientUid = uid_bytes - perm.recordUid = uid_b - perm.rules.accessTypeUid = uid_bytes - perm.rules.accessType = folder_pb2.AT_USER - perm.rules.recordUid = uid_b + perm = _build_revoke_share_permission(vault, item['record_uid'], item['email']) rq.revokeSharingPermissions.append(perm) + elif mode == 'deny': + perm = _build_share_permission( + vault, item['record_uid'], item['email'], + None, None, include_role=False, denied_access=True) + rq.createSharingPermissions.append(perm) else: perm = _build_share_permission( vault, item['record_uid'], item['email'], @@ -527,6 +719,7 @@ def _batch_share( 'create': 'createdSharingStatus', 'update': 'updatedSharingStatus', 'revoke': 'revokedSharingStatus', + 'deny': 'createdSharingStatus', }[mode] try: result = _share_rest(vault, rq, status_attr) @@ -550,6 +743,7 @@ def apply_nsf_record_permissions( 'updates': _batch_share(vault, plan.updates, mode='update'), 'creates': _batch_share(vault, plan.creates, mode='create'), 'revokes': _batch_share(vault, plan.revokes, mode='revoke'), + 'denies': _batch_share(vault, plan.denies, mode='deny'), } _request_sync(vault, request_sync) return results diff --git a/keepersdk-package/src/keepersdk/vault/share_management_utils.py b/keepersdk-package/src/keepersdk/vault/share_management_utils.py index 13c8223a..f6ecfbeb 100644 --- a/keepersdk-package/src/keepersdk/vault/share_management_utils.py +++ b/keepersdk-package/src/keepersdk/vault/share_management_utils.py @@ -6,7 +6,7 @@ from typing import Optional, Dict, List, Any, Generator, Iterable, Set, Tuple, Union from .. import crypto, utils -from ..proto import enterprise_pb2, folder_pb2, record_pb2 +from ..proto import enterprise_pb2, folder_pb2, pam_pb2, record_pb2, router_pb2 from . import vault_data, storage_types, vault_online, vault_record, vault_types, vault_utils, sync_down from ..enterprise import enterprise_data @@ -106,6 +106,94 @@ def parse_nsf_share_expiration( return value * 1000 +PAM_USER_RECORD_TYPE = 'pamUser' + + +def validate_rotate_on_expiration(share_expiration: int, rotate_on_expiration: bool) -> None: + """Require a positive expiration when rotate-on-expiration is requested.""" + if not rotate_on_expiration: + return + if share_expiration <= 0 or share_expiration == NEVER_EXPIRES: + raise ShareValidationError( + '--rotate-on-expiration requires a positive --expire-at or --expire-in (not "never")' + ) + + +def is_pam_user_record(vault: vault_online.VaultOnline, record_uid: str) -> bool: + info = vault.vault_data.get_record(record_uid) + return bool(info and info.record_type == PAM_USER_RECORD_TYPE) + + +def pam_user_has_rotation_configured(vault: vault_online.VaultOnline, record_uid: str) -> bool: + """True when pam/get_rotation_info reports an enabled rotation configuration.""" + try: + rq = pam_pb2.PAMGenericUidRequest() + rq.uid = utils.base64_url_decode(record_uid) + rs = vault.keeper_auth.execute_auth_rest( + rest_endpoint='pam/get_rotation_info', + request=rq, + response_type=router_pb2.RouterRotationInfo, + ) + except Exception: + return False + if not rs or rs.disabled: + return False + return bool(rs.configurationUid) + + +def get_shared_folder_record_uids(vault: vault_online.VaultOnline, shared_folder_uid: str) -> Set[str]: + """Collect record UIDs contained in a shared folder tree.""" + record_uids: Set[str] = set() + folder = vault.vault_data.get_folder(shared_folder_uid) + if not folder: + return record_uids + + def add_records(folder_obj: vault_types.Folder) -> None: + record_uids.update(folder_obj.records) + + vault_utils.traverse_folder_tree(vault.vault_data, folder, add_records) + return record_uids + + +def validate_record_shares_rotate_on_expiration( + vault: vault_online.VaultOnline, + record_uids: Iterable[str], + rotate_on_expiration: bool, +) -> None: + if not rotate_on_expiration: + return + for record_uid in record_uids: + if not is_pam_user_record(vault, record_uid): + info = vault.vault_data.get_record(record_uid) + title = info.title if info else record_uid + raise ShareValidationError( + f'--rotate-on-expiration is supported only for pamUser records ' + f'("{title}" / {record_uid})' + ) + + +def validate_folder_shares_rotate_on_expiration( + vault: vault_online.VaultOnline, + shared_folder_uids: Iterable[str], + rotate_on_expiration: bool, +) -> None: + if not rotate_on_expiration: + return + for sf_uid in shared_folder_uids: + record_uids = get_shared_folder_record_uids(vault, sf_uid) + for record_uid in record_uids: + if is_pam_user_record(vault, record_uid) and pam_user_has_rotation_configured(vault, record_uid): + return + sf_name = sf_uid + sf_info = vault.vault_data.get_shared_folder(sf_uid) + if sf_info: + sf_name = sf_info.name or sf_uid + raise ShareValidationError( + f'--rotate-on-expiration requires at least one pamUser record with rotation ' + f'configured in shared folder "{sf_name}"' + ) + + def get_share_objects(vault: vault_online.VaultOnline) -> Dict[str, Dict[str, Any]]: try: request = record_pb2.GetShareObjectsRequest() diff --git a/keepersdk-package/src/keepersdk/vault/shares_management.py b/keepersdk-package/src/keepersdk/vault/shares_management.py index 5e076005..c692abc4 100644 --- a/keepersdk-package/src/keepersdk/vault/shares_management.py +++ b/keepersdk-package/src/keepersdk/vault/shares_management.py @@ -52,12 +52,14 @@ class ManagePermission(Enum): FOLDER_TYPE_SHARED_FOLDER = 'shared_folder' FOLDER_TYPE_SHARED_FOLDER_FOLDER = 'shared_folder_folder' -def set_expiration_fields(obj, expiration): - """Set expiration and timerNotificationType fields on proto object if expiration is provided.""" +def set_expiration_fields(obj, expiration, rotate_on_expiration: bool = False): + """Set expiration, notification, and optional rotateOnExpiration on a share proto object.""" if isinstance(expiration, int): if expiration > 0: obj.expiration = expiration * TIMESTAMP_MILLISECONDS_FACTOR obj.timerNotificationType = record_pb2.NOTIFY_OWNER + if rotate_on_expiration: + obj.rotateOnExpiration = True elif expiration < 0: obj.expiration = -1 @@ -205,7 +207,8 @@ def _encrypt_record_key_for_user(vault, record_key, email, ro): @staticmethod def _build_shared_record(vault, email, record_uid, record_path, action, - can_edit, can_share, share_expiration, existing_shares): + can_edit, can_share, share_expiration, existing_shares, + rotate_on_expiration: bool = False): """Build a SharedRecord proto object for a user.""" ro = record_pb2.SharedRecord() ro.toUsername = email @@ -227,21 +230,22 @@ def _build_shared_record(vault, email, record_uid, record_path, action, else: ro.editable = bool(can_edit) ro.shareable = bool(can_share) - set_expiration_fields(ro, share_expiration) + set_expiration_fields(ro, share_expiration, rotate_on_expiration) else: if can_share or can_edit: if email in existing_shares: current = existing_shares[email] ro.editable = False if can_edit else current.get('editable') ro.shareable = False if can_share else current.get('shareable') - set_expiration_fields(ro, share_expiration) + set_expiration_fields(ro, share_expiration, rotate_on_expiration) return ro @staticmethod def _process_record_shares(vault, record_uids, all_users, action, can_edit, can_share, share_expiration, record_cache, - not_owned_records, is_share_admin, enterprise): + not_owned_records, is_share_admin, enterprise, + rotate_on_expiration: bool = False): """Process shares for all records and users, building the request.""" rq = record_pb2.RecordShareUpdateRequest() @@ -284,7 +288,8 @@ def _process_record_shares(vault, record_uids, all_users, action, can_edit, for email in all_users: ro = RecordShares._build_shared_record( vault, email, record_uid, record_path, action, - can_edit, can_share, share_expiration, existing_shares + can_edit, can_share, share_expiration, existing_shares, + rotate_on_expiration, ) if action in {ShareAction.GRANT.value, ShareAction.OWNER.value}: @@ -316,7 +321,8 @@ def prep_request(vault: vault_online.VaultOnline, enterprise_access: bool = False, recursive: bool = False, can_edit: bool = False, - can_share: bool = False): + can_share: bool = False, + rotate_on_expiration: bool = False): """Prepare a record share update request.""" # Build caches record_cache = {x.record_uid: x for x in vault.vault_data.records()} @@ -345,6 +351,14 @@ def prep_request(vault: vault_online.VaultOnline, if not record_uids: raise ValueError('There are no records to share selected') + + if rotate_on_expiration: + if action != ShareAction.GRANT.value: + raise ValueError('--rotate-on-expiration is only valid with --action grant') + share_management_utils.validate_rotate_on_expiration(share_expiration, rotate_on_expiration) + share_management_utils.validate_record_shares_rotate_on_expiration( + vault, record_uids, rotate_on_expiration + ) if action == ShareAction.OWNER.value and len(emails) > 1: raise ValueError('You can transfer ownership to a single account only') @@ -382,7 +396,8 @@ def prep_request(vault: vault_online.VaultOnline, # Build the request return RecordShares._process_record_shares( vault, record_uids, all_users, action, can_edit, can_share, - share_expiration, record_cache, not_owned_records, is_share_admin, enterprise + share_expiration, record_cache, not_owned_records, is_share_admin, enterprise, + rotate_on_expiration, ) @staticmethod @@ -521,7 +536,8 @@ def _process_default_account_permissions(rq, action, mr, mu, default_account): rq.defaultManageUsers = FolderShares._convert_manage_permission(mu) @staticmethod - def _process_users(vault, rq, curr_sf, users, action, mr, mu, share_expiration): + def _process_users(vault, rq, curr_sf, users, action, mr, mu, share_expiration, + rotate_on_expiration: bool = False): """Process user shares for the shared folder.""" if not users: return @@ -531,7 +547,7 @@ def _process_users(vault, rq, curr_sf, users, action, mr, mu, share_expiration): for email in users: uo = folder_pb2.SharedFolderUpdateUser() uo.username = email - set_expiration_fields(uo, share_expiration) + set_expiration_fields(uo, share_expiration, rotate_on_expiration) if email in existing_users: if action == ShareAction.GRANT.value: @@ -563,7 +579,8 @@ def _process_users(vault, rq, curr_sf, users, action, mr, mu, share_expiration): logger.warning('User %s not found', email) @staticmethod - def _process_teams(vault, rq, curr_sf, teams, action, mr, mu, share_expiration): + def _process_teams(vault, rq, curr_sf, teams, action, mr, mu, share_expiration, + rotate_on_expiration: bool = False): """Process team shares for the shared folder.""" if not teams: return @@ -573,7 +590,7 @@ def _process_teams(vault, rq, curr_sf, teams, action, mr, mu, share_expiration): for team_uid in teams: to = folder_pb2.SharedFolderUpdateTeam() to.teamUid = utils.base64_url_decode(team_uid) - set_expiration_fields(to, share_expiration) + set_expiration_fields(to, share_expiration, rotate_on_expiration) if team_uid in existing_teams: team = existing_teams[team_uid] @@ -608,7 +625,8 @@ def _process_default_record_permissions(rq, action, ce, cs, default_record): rq.defaultCanShare = FolderShares._convert_manage_permission(cs) @staticmethod - def _process_records(vault, rq, curr_sf, rec_uids, action, ce, cs, share_expiration): + def _process_records(vault, rq, curr_sf, rec_uids, action, ce, cs, share_expiration, + rotate_on_expiration: bool = False): """Process record shares for the shared folder.""" if not rec_uids: return @@ -618,7 +636,7 @@ def _process_records(vault, rq, curr_sf, rec_uids, action, ce, cs, share_expirat for record_uid in rec_uids: ro = folder_pb2.SharedFolderUpdateRecord() ro.recordUid = utils.base64_url_decode(record_uid) - set_expiration_fields(ro, share_expiration) + set_expiration_fields(ro, share_expiration, rotate_on_expiration) if record_uid in existing_records: if action == ShareAction.GRANT.value: @@ -650,7 +668,7 @@ def _process_records(vault, rq, curr_sf, rec_uids, action, ce, cs, share_expirat @staticmethod def prepare_request(vault: vault_online.VaultOnline, kwargs, curr_sf, users, teams, rec_uids, *, default_record=False, default_account=False, - share_expiration=None): + share_expiration=None, rotate_on_expiration: bool = False): """Prepare a shared folder update request.""" rq = folder_pb2.SharedFolderUpdateV3Request() FolderShares._initialize_request(rq, curr_sf) @@ -662,10 +680,10 @@ def prepare_request(vault: vault_online.VaultOnline, kwargs, curr_sf, users, tea cs = kwargs.get('can_share') FolderShares._process_default_account_permissions(rq, action, mr, mu, default_account) - FolderShares._process_users(vault, rq, curr_sf, users, action, mr, mu, share_expiration) - FolderShares._process_teams(vault, rq, curr_sf, teams, action, mr, mu, share_expiration) + FolderShares._process_users(vault, rq, curr_sf, users, action, mr, mu, share_expiration, rotate_on_expiration) + FolderShares._process_teams(vault, rq, curr_sf, teams, action, mr, mu, share_expiration, rotate_on_expiration) FolderShares._process_default_record_permissions(rq, action, ce, cs, default_record) - FolderShares._process_records(vault, rq, curr_sf, rec_uids, action, ce, cs, share_expiration) + FolderShares._process_records(vault, rq, curr_sf, rec_uids, action, ce, cs, share_expiration, rotate_on_expiration) return rq diff --git a/keepersdk-package/unit_tests/test_device_management.py b/keepersdk-package/unit_tests/test_device_management.py index 3e877240..7e7b012f 100644 --- a/keepersdk-package/unit_tests/test_device_management.py +++ b/keepersdk-package/unit_tests/test_device_management.py @@ -5,16 +5,30 @@ from keepersdk.proto import DeviceManagement_pb2 -def _device(name: str, last_modified: int = 0) -> DeviceManagement_pb2.Device: +def _device( + name: str, + last_modified: int = 0, + token: bytes = b'\x01\x02', +) -> DeviceManagement_pb2.Device: d = DeviceManagement_pb2.Device() d.deviceName = name d.lastModifiedTime = last_modified d.clientType = DeviceManagement_pb2.COMMANDER d.loginState = 0 - d.encryptedDeviceToken = b'\x01\x02' + d.encryptedDeviceToken = token return d +def _admin_list_response(enterprise_user_id: int, *devices: DeviceManagement_pb2.Device): + rs = DeviceManagement_pb2.DeviceAdminResponse() + user_list = rs.deviceUserList.add() + user_list.enterpriseUserId = enterprise_user_id + group = user_list.deviceGroups.add() + for device in devices: + group.devices.append(device) + return rs + + class DeviceManagementSdkTests(unittest.TestCase): def test_list_user_devices(self): auth = MagicMock() @@ -54,6 +68,26 @@ def test_logout_user_devices(self): DeviceManagement_pb2.DA_LOGOUT, ) + def test_logout_user_devices_rejects_duplicate_identifiers(self): + auth = MagicMock() + list_rs = DeviceManagement_pb2.DeviceUserResponse() + g = list_rs.deviceGroups.add() + g.devices.append(_device('Laptop', 100)) + auth.execute_auth_rest.return_value = list_rs + + with self.assertRaisesRegex(ValueError, 'Duplicate device specified'): + device_management.logout_user_devices(auth, ['1', '1']) + + def test_logout_user_devices_rejects_id_and_name_for_same_device(self): + auth = MagicMock() + list_rs = DeviceManagement_pb2.DeviceUserResponse() + g = list_rs.deviceGroups.add() + g.devices.append(_device('Laptop', 100)) + auth.execute_auth_rest.return_value = list_rs + + with self.assertRaisesRegex(ValueError, 'Duplicate device specified'): + device_management.logout_user_devices(auth, ['1', 'Laptop']) + def test_remove_user_devices(self): auth = MagicMock() list_rs = DeviceManagement_pb2.DeviceUserResponse() @@ -75,6 +109,198 @@ def test_remove_user_devices(self): DeviceManagement_pb2.DA_REMOVE, ) + def test_list_admin_devices(self): + auth = MagicMock() + auth.execute_auth_rest.return_value = _admin_list_response( + 12345, _device('A', 100), _device('B', 200) + ) + + devices = device_management.list_admin_devices(auth, [12345]) + self.assertEqual(len(devices), 2) + self.assertEqual(devices[0].name, 'B') + self.assertEqual(devices[0].enterprise_user_id, 12345) + self.assertEqual(devices[0].list_index, 1) + call = auth.execute_auth_rest.call_args + self.assertEqual(call.kwargs.get('rest_endpoint'), 'dm/device_admin_list') + + def test_list_admin_devices_requires_user_ids(self): + auth = MagicMock() + with self.assertRaises(ValueError): + device_management.list_admin_devices(auth, []) + + def test_list_admin_devices_rejects_bool_user_id(self): + auth = MagicMock() + with self.assertRaises(ValueError): + device_management.list_admin_devices(auth, [True]) + + def test_logout_admin_user_devices(self): + auth = MagicMock() + list_rs = _admin_list_response(12345, _device('Laptop', 100)) + + action_rs = DeviceManagement_pb2.DeviceAdminActionResponse() + ar = action_rs.deviceAdminActionResults.add() + ar.deviceActionStatus = DeviceManagement_pb2.SUCCESS + ar.encryptedDeviceToken.append(b'\x01\x02') + + auth.execute_auth_rest.side_effect = [list_rs, action_rs] + + names = device_management.logout_admin_user_devices(auth, 12345, ['1']) + self.assertEqual(names, ['Laptop']) + action_call = auth.execute_auth_rest.call_args_list[1] + self.assertEqual(action_call.kwargs.get('rest_endpoint'), 'dm/device_admin_action') + request = action_call.kwargs.get('request') + admin_action = request.deviceAdminAction[0] + self.assertEqual(admin_action.deviceActionType, DeviceManagement_pb2.DA_LOGOUT) + self.assertEqual(admin_action.enterpriseUserId, 12345) + + def test_remove_admin_user_devices(self): + auth = MagicMock() + list_rs = _admin_list_response(99999, _device('Phone', 50)) + + action_rs = DeviceManagement_pb2.DeviceAdminActionResponse() + ar = action_rs.deviceAdminActionResults.add() + ar.deviceActionStatus = DeviceManagement_pb2.SUCCESS + ar.encryptedDeviceToken.append(b'\x01\x02') + + auth.execute_auth_rest.side_effect = [list_rs, action_rs] + + names = device_management.remove_admin_user_devices(auth, 99999, ['Phone']) + self.assertEqual(names, ['Phone']) + request = auth.execute_auth_rest.call_args_list[1].kwargs.get('request') + admin_action = request.deviceAdminAction[0] + self.assertEqual(admin_action.deviceActionType, DeviceManagement_pb2.DA_REMOVE) + self.assertEqual(admin_action.enterpriseUserId, 99999) + + def test_lock_user_devices(self): + auth = MagicMock() + list_rs = DeviceManagement_pb2.DeviceUserResponse() + g = list_rs.deviceGroups.add() + g.devices.append(_device('Workstation', 75)) + + action_rs = DeviceManagement_pb2.DeviceActionResponse() + ar = action_rs.deviceActionResult.add() + ar.deviceActionStatus = DeviceManagement_pb2.SUCCESS + ar.encryptedDeviceToken.append(b'\x01\x02') + + auth.execute_auth_rest.side_effect = [list_rs, action_rs] + + names = device_management.lock_user_devices(auth, ['Workstation']) + self.assertEqual(names, ['Workstation']) + request = auth.execute_auth_rest.call_args_list[1].kwargs.get('request') + self.assertEqual( + request.deviceAction[0].deviceActionType, + DeviceManagement_pb2.DA_LOCK, + ) + + def test_link_user_devices_requires_two_identifiers(self): + auth = MagicMock() + with self.assertRaises(ValueError): + device_management.link_user_devices(auth, ['1']) + + def test_link_user_devices(self): + auth = MagicMock() + list_rs = DeviceManagement_pb2.DeviceUserResponse() + g = list_rs.deviceGroups.add() + g.devices.append(_device('Phone', 100, b'\x01')) + g.devices.append(_device('Laptop', 50, b'\x02')) + + action_rs = DeviceManagement_pb2.DeviceActionResponse() + ar = action_rs.deviceActionResult.add() + ar.deviceActionStatus = DeviceManagement_pb2.SUCCESS + ar.encryptedDeviceToken.extend([b'\x01', b'\x02']) + + auth.execute_auth_rest.side_effect = [list_rs, action_rs] + + names = device_management.link_user_devices(auth, ['1', '2']) + self.assertEqual(names, ['Phone', 'Laptop']) + request = auth.execute_auth_rest.call_args_list[1].kwargs.get('request') + self.assertEqual( + request.deviceAction[0].deviceActionType, + DeviceManagement_pb2.DA_LINK, + ) + + def test_unlink_user_devices(self): + auth = MagicMock() + list_rs = DeviceManagement_pb2.DeviceUserResponse() + g = list_rs.deviceGroups.add() + g.devices.append(_device('Phone', 100, b'\x01')) + g.devices.append(_device('Laptop', 50, b'\x02')) + + action_rs = DeviceManagement_pb2.DeviceActionResponse() + ar = action_rs.deviceActionResult.add() + ar.deviceActionStatus = DeviceManagement_pb2.SUCCESS + ar.encryptedDeviceToken.extend([b'\x01', b'\x02']) + + auth.execute_auth_rest.side_effect = [list_rs, action_rs] + + names = device_management.unlink_user_devices(auth, ['Phone', 'Laptop']) + self.assertEqual(names, ['Phone', 'Laptop']) + request = auth.execute_auth_rest.call_args_list[1].kwargs.get('request') + self.assertEqual( + request.deviceAction[0].deviceActionType, + DeviceManagement_pb2.DA_UNLINK, + ) + + def test_account_unlock_user_devices(self): + auth = MagicMock() + list_rs = DeviceManagement_pb2.DeviceUserResponse() + g = list_rs.deviceGroups.add() + g.devices.append(_device('Tablet', 10)) + + action_rs = DeviceManagement_pb2.DeviceActionResponse() + ar = action_rs.deviceActionResult.add() + ar.deviceActionStatus = DeviceManagement_pb2.SUCCESS + ar.encryptedDeviceToken.append(b'\x01\x02') + + auth.execute_auth_rest.side_effect = [list_rs, action_rs] + + names = device_management.account_unlock_user_devices(auth, ['1']) + self.assertEqual(names, ['Tablet']) + request = auth.execute_auth_rest.call_args_list[1].kwargs.get('request') + self.assertEqual( + request.deviceAction[0].deviceActionType, + DeviceManagement_pb2.DA_DEVICE_ACCOUNT_UNLOCK, + ) + + + def test_resolve_device_requires_exact_name(self): + auth = MagicMock() + list_rs = DeviceManagement_pb2.DeviceUserResponse() + g = list_rs.deviceGroups.add() + g.devices.append(_device('Web Vault Chrome', 100)) + g.devices.append(_device('Commander CLI on macOS', 50)) + auth.execute_auth_rest.return_value = list_rs + + with self.assertRaises(ValueError): + device_management.logout_user_devices(auth, ['Web Vault']) + + def test_resolve_device_exact_name(self): + auth = MagicMock() + list_rs = DeviceManagement_pb2.DeviceUserResponse() + g = list_rs.deviceGroups.add() + g.devices.append(_device('Web Vault Chrome', 100)) + + action_rs = DeviceManagement_pb2.DeviceActionResponse() + ar = action_rs.deviceActionResult.add() + ar.deviceActionStatus = DeviceManagement_pb2.SUCCESS + ar.encryptedDeviceToken.append(b'\x01\x02') + + auth.execute_auth_rest.side_effect = [list_rs, action_rs] + + names = device_management.logout_user_devices(auth, ['Web Vault Chrome']) + self.assertEqual(names, ['Web Vault Chrome']) + + def test_ambiguous_device_name_lists_matches(self): + auth = MagicMock() + list_rs = DeviceManagement_pb2.DeviceUserResponse() + g = list_rs.deviceGroups.add() + g.devices.append(_device('web Vault Chrome', 200, b'\x01')) + g.devices.append(_device('Web Vault Chrome', 100, b'\x02')) + auth.execute_auth_rest.return_value = list_rs + + with self.assertRaisesRegex(ValueError, 'No matching devices found'): + device_management.unlock_user_devices(auth, ['Web Vault Chrome']) + if __name__ == '__main__': unittest.main() diff --git a/keepersdk-package/unit_tests/test_nsf_permissions.py b/keepersdk-package/unit_tests/test_nsf_permissions.py new file mode 100644 index 00000000..b38ba997 --- /dev/null +++ b/keepersdk-package/unit_tests/test_nsf_permissions.py @@ -0,0 +1,154 @@ +import unittest +from unittest.mock import MagicMock + +from keepersdk import utils +from keepersdk.proto import folder_pb2 +from keepersdk.vault import memory_nsf_storage, nsf_common, nsf_storage_types as nsf + + +class TestNsfPermissions(unittest.TestCase): + def _vault(self, *, username='alice@example.com', account_uid=None): + vault = MagicMock() + vault.keeper_auth.auth_context.username = username + vault.keeper_auth.auth_context.account_uid = account_uid or utils.base64_url_decode( + utils.generate_uid()) + return vault + + def test_folder_share_denied_for_viewer(self): + folder_uid = utils.generate_uid() + account_uid = utils.generate_uid() + storage = memory_nsf_storage.InMemoryNSFStorage() + storage.folder_accesses.put_links([ + nsf.NSFFolderAccess( + folder_uid=folder_uid, + access_type_uid=account_uid, + access_type=int(folder_pb2.AT_USER), + permissions_json='{"canUpdateAccess":false,"canViewRecords":true}', + ), + ]) + vault = self._vault(account_uid=utils.base64_url_decode(account_uid)) + vault.nsf_data.storage = storage + + with self.assertRaisesRegex(ValueError, 'permission to share'): + nsf_common.require_nsf_folder_share_permission(vault, folder_uid) + + def test_folder_share_allowed_for_share_manager(self): + folder_uid = utils.generate_uid() + account_uid = utils.generate_uid() + storage = memory_nsf_storage.InMemoryNSFStorage() + storage.folder_accesses.put_links([ + nsf.NSFFolderAccess( + folder_uid=folder_uid, + access_type_uid=account_uid, + access_type=int(folder_pb2.AT_USER), + permissions_json='{"canUpdateAccess":true}', + ), + ]) + vault = self._vault(account_uid=utils.base64_url_decode(account_uid)) + vault.nsf_data.storage = storage + + nsf_common.require_nsf_folder_share_permission(vault, folder_uid) + + def test_folder_share_allowed_for_owner_row(self): + folder_uid = utils.generate_uid() + account_uid = utils.generate_uid() + storage = memory_nsf_storage.InMemoryNSFStorage() + storage.folders.put_entities([nsf.NSFFolder( + folder_uid=folder_uid, + owner_account_uid=account_uid, + owner_username='alice@example.com', + )]) + vault = self._vault(account_uid=utils.base64_url_decode(account_uid)) + vault.nsf_data.storage = storage + + nsf_common.require_nsf_folder_share_permission(vault, folder_uid) + + def test_record_share_denied_without_permission(self): + record_uid = utils.generate_uid() + account_uid = utils.generate_uid() + storage = memory_nsf_storage.InMemoryNSFStorage() + storage.record_accesses.put_links([ + nsf.NSFRecordAccess( + record_uid=record_uid, + access_type_uid=account_uid, + can_update_access=False, + ), + ]) + vault = self._vault(account_uid=utils.base64_url_decode(account_uid)) + vault.nsf_data.storage = storage + + with self.assertRaisesRegex(ValueError, 'permission to share'): + nsf_common.require_nsf_record_share_permission(vault, record_uid) + + def test_record_access_inheritance_helpers(self): + record_uid = utils.generate_uid() + vault = self._vault() + from unittest.mock import patch + api_access = [{ + 'record_uid': record_uid, + 'accessor_name': 'alice@example.com', + 'access_type': 'AT_USER', + 'inherited': True, + 'owner': False, + }] + with patch.object( + nsf_common, 'collect_nsf_record_accessors', return_value=api_access): + accesses = nsf_common.find_record_user_accesses( + vault, record_uid, 'alice@example.com') + self.assertTrue(nsf_common.record_user_has_inherited_access(accesses)) + self.assertFalse(nsf_common.record_user_has_direct_access(accesses)) + + def test_folder_inherit_detection(self): + parent_uid = utils.generate_uid() + folder_uid = utils.generate_uid() + storage = memory_nsf_storage.InMemoryNSFStorage() + storage.folders.put_entities([ + nsf.NSFFolder( + folder_uid=parent_uid, + inherit_user_permissions=int(folder_pb2.BOOLEAN_TRUE), + ), + nsf.NSFFolder( + folder_uid=folder_uid, + parent_uid=parent_uid, + inherit_user_permissions=int(folder_pb2.BOOLEAN_TRUE), + ), + ]) + vault = self._vault() + vault.nsf_data.storage = storage + + self.assertTrue(nsf_common.folder_inherits_parent_permissions(vault, folder_uid)) + + storage.folders.put_entities([ + nsf.NSFFolder( + folder_uid=folder_uid, + parent_uid=parent_uid, + inherit_user_permissions=int(folder_pb2.BOOLEAN_FALSE), + ), + ]) + self.assertFalse(nsf_common.folder_inherits_parent_permissions(vault, folder_uid)) + + +class TestEncryptForTeam(unittest.TestCase): + def test_prefer_asymmetric_then_aes_fallback(self): + from keepersdk.authentication.keeper_auth import UserKeys + from keepersdk import crypto + + folder_key = utils.generate_aes_key() + team_aes = utils.generate_aes_key() + keys = UserKeys(aes=team_aes, rsa=None, ec=None) + encrypted, key_type = nsf_common.encrypt_for_team( + folder_key, keys, forbid_rsa=False) + self.assertEqual(crypto.decrypt_aes_v1(encrypted, team_aes), folder_key) + self.assertEqual(key_type, folder_pb2.encrypted_by_data_key) + + def test_invalid_aes_size_is_not_used_as_fallback(self): + from keepersdk.authentication.keeper_auth import UserKeys + + keys = UserKeys(aes=b'x' * 480, rsa=None, ec=None) + with self.assertRaisesRegex(ValueError, 'No public key found for team'): + nsf_common.encrypt_for_team( + utils.generate_aes_key(), keys, forbid_rsa=False) + + +if __name__ == '__main__': + unittest.main() diff --git a/keepersdk-package/unit_tests/test_share_rotate_on_expiration.py b/keepersdk-package/unit_tests/test_share_rotate_on_expiration.py new file mode 100644 index 00000000..006a0f91 --- /dev/null +++ b/keepersdk-package/unit_tests/test_share_rotate_on_expiration.py @@ -0,0 +1,39 @@ +import unittest +from unittest import mock + +from keepersdk.vault import share_management_utils + + +class TestRotateOnExpirationValidation(unittest.TestCase): + + def test_validate_rotate_on_expiration_requires_positive_expiration(self): + with self.assertRaises(share_management_utils.ShareValidationError): + share_management_utils.validate_rotate_on_expiration(0, True) + with self.assertRaises(share_management_utils.ShareValidationError): + share_management_utils.validate_rotate_on_expiration(-1, True) + + def test_validate_rotate_on_expiration_allows_positive_expiration(self): + share_management_utils.validate_rotate_on_expiration(1_700_000_000, True) + + def test_set_expiration_fields_sets_rotate_flag(self): + from keepersdk.proto import record_pb2 + from keepersdk.vault.shares_management import set_expiration_fields + + ro = record_pb2.SharedRecord() + set_expiration_fields(ro, 1_700_000_000, rotate_on_expiration=True) + self.assertTrue(ro.rotateOnExpiration) + self.assertGreater(ro.expiration, 0) + + def test_validate_record_shares_requires_pam_user(self): + vault = mock.Mock() + info = mock.Mock(record_type='login', title='Not PAM') + vault.vault_data.get_record.return_value = info + + with self.assertRaises(share_management_utils.ShareValidationError): + share_management_utils.validate_record_shares_rotate_on_expiration( + vault, ['abc123'], True + ) + + +if __name__ == '__main__': + unittest.main()