diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 00000000..4b2c59c4 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,69 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ master ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ master ] + schedule: + - cron: '32 11 * * 1' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + contents: read + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] + # Learn more: + # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + + # â„šī¸ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # âœī¸ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 \ No newline at end of file diff --git a/.github/workflows/publish-to-pypi.yml b/.github/workflows/publish-cli-to-pypi.yml similarity index 54% rename from .github/workflows/publish-to-pypi.yml rename to .github/workflows/publish-cli-to-pypi.yml index 6cd49350..6265f43a 100644 --- a/.github/workflows/publish-to-pypi.yml +++ b/.github/workflows/publish-cli-to-pypi.yml @@ -1,51 +1,52 @@ -name: Publish Commander to PyPi +name: Publish CLI to PyPi on: workflow_dispatch: inputs: version: - description: Version to release (Tag from Keeper-Security/keeper-sdk-pyton) + description: Version to release (Tag from Keeper-Security/keeper-sdk-python) required: true jobs: build-n-publish: - name: Build and publish Keeper SDK for Python đŸ“Ļ to PyPI + name: Build and publish Keeper CLI for Python to TestPyPI runs-on: ubuntu-latest timeout-minutes: 25 # To keep builds from running too long - + permissions: + contents: read + steps: - name: Checkout source code uses: actions/checkout@v2 - - name: Set up Python 3.10 + - name: Set up Python 3.11 uses: actions/setup-python@v4 with: - python-version: '3.10' + python-version: '3.11' architecture: 'x64' - name: Build the package run: | python -m pip install -U setuptools pip build wheel twine - python -m build --wheel + python -m build --wheel keepercli-package - name: Archive the package uses: actions/upload-artifact@v3 with: - name: KeeperSdkWheel + name: KeeperCLIWheel retention-days: 1 - path: dist/* + path: keepercli-package/dist/* if-no-files-found: error - - name: Publish Commander to test PyPi + - name: Publish keepercli to test PyPi env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.TEST_PYPI_TOKEN }} run: | - twine upload -r testpypi dist/* - + twine upload -r testpypi keepercli-package/dist/* publish-pypi: - name: Publish Keeper SDK to PyPi + name: Publish Keeper CLI to PyPi runs-on: ubuntu-latest needs: [build-n-publish] environment: prod @@ -53,27 +54,18 @@ jobs: steps: - uses: actions/download-artifact@v3 with: - name: CommanderWheel - path: dist + name: KeeperCLIWheel + path: keepercli-package/dist - - name: Set up Python 3.10 + - name: Set up Python 3.11 uses: actions/setup-python@v4 with: - python-version: '3.10' - architecture: 'x64' - - - name: Retrieve secrets from Keeper - id: ksecrets - uses: Keeper-Security/ksm-action@master - with: - keeper-secret-config: ${{ secrets.KSM_COMMANDER_SECRET_CONFIG }} - secrets: | - gD5LOOhI5QbnSFk8mIg3gg/field/password > PYPI_PASSWORD + python-version: '3.11' - - name: Publish to PyPi + - name: Publish keepercli to PyPi env: TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ steps.ksecrets.outputs.PYPI_PASSWORD }} + TWINE_PASSWORD: ${{ secrets.PYPI_PUBLISH_TOKEN }} run: | python -m pip install -U setuptools pip wheel twine - twine upload dist/* + twine upload -r pypi keepercli-package/dist/* \ No newline at end of file diff --git a/.github/workflows/publish-sdk.yml b/.github/workflows/publish-sdk.yml index 88844220..4d9aea96 100644 --- a/.github/workflows/publish-sdk.yml +++ b/.github/workflows/publish-sdk.yml @@ -1,72 +1,90 @@ -name: Publish Keeper SDK to PyPi +name: Publish Keeper SDK to PyPI on: [workflow_dispatch] jobs: - build-wheel: - name: Build and publish Keeper SDK for Python đŸ“Ļ to PyPI + build-and-test: + name: Build and test Keeper SDK package runs-on: ubuntu-latest - timeout-minutes: 25 # To keep builds from running too long + timeout-minutes: 25 + permissions: + contents: read steps: - name: Checkout source code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - - name: Set up Python 3.11 - uses: actions/setup-python@v4 + - name: Set up Python 3.13 + uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.13' + + - name: Install dependencies + run: | + pip install keepersdk-package/ + + - name: Run unit tests + run: python -m unittest discover -s keepersdk-package/unit_tests/ - name: Build the package run: | - python3 -m pip install -U setuptools build wheel twine + python3 -m pip install -U build wheel twine python3 -m build --wheel keepersdk-package - name: Archive the package - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: KeeperSdkWheel retention-days: 1 path: keepersdk-package/dist/* if-no-files-found: error - - name: Publish Commander to test PyPi + publish-test-pypi: + name: Publish to Test PyPI + runs-on: ubuntu-latest + needs: [build-and-test] + environment: test + + steps: + - uses: actions/download-artifact@v4 + with: + name: KeeperSdkWheel + path: keepersdk-package/dist + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Publish to Test PyPI env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.TEST_PYPI_TOKEN }} run: | - twine upload -r testpypi dist/* - + python -m pip install -U twine + twine upload --repository testpypi keepersdk-package/dist/* publish-pypi: - name: Publish Keeper SDK to PyPi + name: Publish to Production PyPI runs-on: ubuntu-latest - needs: [build-wheel] + needs: [publish-test-pypi] environment: prod steps: - - uses: actions/download-artifact@v3 + - uses: actions/download-artifact@v4 with: - name: CommanderWheel - path: dist - - - name: Set up Python 3.10 - uses: actions/setup-python@v4 - with: - python-version: '3.11' + name: KeeperSdkWheel + path: keepersdk-package/dist - - name: Retrieve secrets from Keeper - id: ksecrets - uses: Keeper-Security/ksm-action@master + - name: Set up Python 3.13 + uses: actions/setup-python@v5 with: - keeper-secret-config: ${{ secrets.KSM_COMMANDER_SECRET_CONFIG }} - secrets: | - gD5LOOhI5QbnSFk8mIg3gg/field/password > PYPI_PASSWORD + python-version: '3.13' - - name: Publish to PyPi + - name: Publish to PyPI env: TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ steps.ksecrets.outputs.PYPI_PASSWORD }} + TWINE_PASSWORD: ${{ secrets.PYPI_PUBLISH_TOKEN }} run: | - python -m pip install -U setuptools pip wheel twine - twine upload dist/* + python -m pip install -U twine + twine upload keepersdk-package/dist/* \ No newline at end of file diff --git a/.github/workflows/test-with-pytest.yml b/.github/workflows/run-unit-tests.yml similarity index 60% rename from .github/workflows/test-with-pytest.yml rename to .github/workflows/run-unit-tests.yml index 447aeea3..1c6460c2 100644 --- a/.github/workflows/test-with-pytest.yml +++ b/.github/workflows/run-unit-tests.yml @@ -1,35 +1,36 @@ -name: Test with pytest +name: Test with unittest on: pull_request: branches: - - masterlet' + - master workflow_dispatch: env: PYTHONUNBUFFERED: 1 jobs: - test-with-pytest: + test-with-unittest: strategy: matrix: python-version: ['3.8', '3.14'] runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout branch uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install package with test dependencies + - name: Install package run: | - cd keepersdk-package - pip install .[test] + pip install -e keepersdk-package/ - name: Run unit tests - run: pytest keepersdk-package/unit_tests/ \ No newline at end of file + run: python -m unittest discover -s keepersdk-package/unit_tests/ \ No newline at end of file diff --git a/keepercli-package/requirements.txt b/keepercli-package/requirements.txt index 96bd2998..8ff4ffbc 100644 --- a/keepercli-package/requirements.txt +++ b/keepercli-package/requirements.txt @@ -8,4 +8,4 @@ cbor2; sys_platform == "darwin" and python_version>='3.10' pyobjc-framework-LocalAuthentication; sys_platform == "darwin" and python_version>='3.10' winrt-runtime; sys_platform == "win32" winrt-Windows.Foundation; sys_platform == "win32" -winrt-Windows.Security.Credentials.UI; sys_platform == "win32" +winrt-Windows.Security.Credentials.UI; sys_platform == "win32" \ No newline at end of file diff --git a/keepercli-package/src/keepercli/__init__.py b/keepercli-package/src/keepercli/__init__.py index b7c1ec0f..6bd77a3d 100644 --- a/keepercli-package/src/keepercli/__init__.py +++ b/keepercli-package/src/keepercli/__init__.py @@ -9,5 +9,5 @@ # Contact: commander@keepersecurity.com # -__version__ = '17.0.0' +__version__ = '1.0.0-beta01' diff --git a/keepersdk-package/mypy.ini b/keepersdk-package/mypy.ini index 2c5e80a2..4839e50a 100644 --- a/keepersdk-package/mypy.ini +++ b/keepersdk-package/mypy.ini @@ -1,7 +1,14 @@ [mypy] warn_no_return = False files = src/ -python_version = 3.8 +python_version = 3.9 [mypy-keepersdk.proto.*] ignore_errors = True + +[mypy-fido2.*] +follow_imports = skip +ignore_errors = True + +[mypy-keepersdk.authentication.yubikey] +ignore_errors = True diff --git a/keepersdk-package/requirements.txt b/keepersdk-package/requirements.txt index 1a8481d0..83f3555c 100644 --- a/keepersdk-package/requirements.txt +++ b/keepersdk-package/requirements.txt @@ -1,7 +1,6 @@ attrs>=23.1.0 -certifi -requests>=2.31.0 -cryptography>=41.0.7 +requests>=2.32.2 +cryptography>=45.0.1 protobuf>=5.28.3 -websockets>=12.0 +websockets>=13.1 fido2>=2.0.0; python_version>='3.10' diff --git a/keepersdk-package/setup.cfg b/keepersdk-package/setup.cfg index 4e06773c..f57db56c 100644 --- a/keepersdk-package/setup.cfg +++ b/keepersdk-package/setup.cfg @@ -26,10 +26,10 @@ package_dir = include_package_data = True install_requires = attrs>=23.1.0 - requests>=2.31.0 - cryptography>=40.0.0 - protobuf>=4.25.0 - websockets>=12.0 + requests>=2.32.2 + cryptography>=45.0.1 + protobuf>=5.28.3 + websockets>=13.1 fido2>=2.0.0; python_version>='3.10' diff --git a/keepersdk-package/src/keepersdk/__init__.py b/keepersdk-package/src/keepersdk/__init__.py index b3bbb4f3..b22d4696 100644 --- a/keepersdk-package/src/keepersdk/__init__.py +++ b/keepersdk-package/src/keepersdk/__init__.py @@ -10,6 +10,6 @@ # from . import background -__version__ = '0.9.10' +__version__ = '0.9.11' background.init() diff --git a/keepersdk-package/src/keepersdk/authentication/keeper_auth.py b/keepersdk-package/src/keepersdk/authentication/keeper_auth.py index 38091ef3..4382511d 100644 --- a/keepersdk-package/src/keepersdk/authentication/keeper_auth.py +++ b/keepersdk-package/src/keepersdk/authentication/keeper_auth.py @@ -1,7 +1,6 @@ from __future__ import annotations import abc -import asyncio import enum import json import logging @@ -14,7 +13,7 @@ from google.protobuf.json_format import MessageToJson from . import endpoint, notifications -from .. import errors, utils, crypto, background +from .. import errors, utils, crypto from ..proto import APIRequest_pb2 @@ -51,8 +50,8 @@ class UserKeys: class AuthContext: def __init__(self) -> None: self.username = '' - self.account_uid = b'' - self.session_token = b'' + self.account_uid: bytes = b'' + self.session_token: bytes = b'' self.session_token_restriction: SessionTokenRestriction = SessionTokenRestriction.Unrestricted self.data_key: bytes = b'' self.client_key: bytes = b'' @@ -70,7 +69,6 @@ def __init__(self) -> None: self.device_token: bytes = b'' self.device_private_key: Optional[EllipticCurvePrivateKey] = None self.forbid_rsa: bool = False - self.session_token: bytes = b'' self.message_session_uid: bytes = b'' @@ -100,13 +98,13 @@ def check_keepalive(self) -> bool: class KeeperAuth: - def __init__(self, keeper_endpoint: endpoint.KeeperEndpoint, auth_context: AuthContext) -> None: + def __init__(self, keeper_endpoint: endpoint.KeeperEndpoint, auth_context: AuthContext, + push_notifications: Optional[notifications.FanOut[Dict[str, Any]]] = None) -> None: self.keeper_endpoint = keeper_endpoint self.auth_context = auth_context - self._push_notifications = notifications.KeeperPushNotifications() + self._push_notifications: Optional[notifications.FanOut[Dict[str, Any]]] = push_notifications self._ttk: Optional[TimeToKeepalive] = None self._key_cache: Optional[Dict[str, UserKeys]] = None - self._use_pushes = False def __enter__(self): return self @@ -115,12 +113,12 @@ def __exit__(self, exc_type, exc_val, exc_tb): self.close() @property - def push_notifications(self) -> notifications.FanOut[Dict[str, Any]]: + def push_notifications(self) -> Optional[notifications.FanOut[Dict[str, Any]]]: return self._push_notifications def close(self) -> None: - if self.push_notifications and not self.push_notifications.is_completed: - self.stop_pushes() + if self._push_notifications and not self._push_notifications.is_completed: + self._push_notifications.shutdown() def _update_ttk(self): if self._ttk: @@ -315,28 +313,3 @@ def get_user_keys(self, username: str) -> Optional[UserKeys]: def get_team_keys(self, team_uid: str) -> Optional[UserKeys]: if self._key_cache: return self._key_cache.get(team_uid) - - async def _push_server_guard(self): - transmission_key = utils.generate_aes_key() - self._use_pushes = True - try: - while self._use_pushes: - url = self.keeper_endpoint.get_push_url( - transmission_key, self.auth_context.device_token, self.auth_context.message_session_uid) - await self._push_notifications.main_loop(url, transmission_key, self.auth_context.session_token) - self.execute_auth_rest('keep_alive', None) - except Exception as e: - utils.get_logger().debug(e) - finally: - self._use_pushes = False - - @property - def use_pushes(self) -> bool: - return self._use_pushes - - def start_pushes(self): - asyncio.run_coroutine_threadsafe(self._push_server_guard(), loop=background.get_loop()) - - def stop_pushes(self): - self._use_pushes = False - self._push_notifications.shutdown() diff --git a/keepersdk-package/src/keepersdk/authentication/login_auth.py b/keepersdk-package/src/keepersdk/authentication/login_auth.py index f33905f0..eef25a5a 100644 --- a/keepersdk-package/src/keepersdk/authentication/login_auth.py +++ b/keepersdk-package/src/keepersdk/authentication/login_auth.py @@ -8,7 +8,7 @@ from cryptography.hazmat.primitives.asymmetric import ec from google.protobuf.json_format import MessageToDict -from . import endpoint, configuration, notifications, keeper_auth, auth_utils +from . import endpoint, configuration, keeper_auth, auth_utils, notifications, push_notifications from .. import crypto, utils, errors from ..proto import APIRequest_pb2, breachwatch_pb2, ssocloud_pb2 @@ -624,7 +624,7 @@ def _ensure_push_notifications(login: LoginAuth) -> None: if login.push_notifications: return - keeper_pushes = notifications.KeeperPushNotifications() + keeper_pushes = push_notifications.KeeperPushNotifications() transmission_key = utils.generate_aes_key() url = login.keeper_endpoint.get_push_url(transmission_key, login.context.device_token, login.context.message_session_uid) keeper_pushes.connect_to_push_channel(url, transmission_key) @@ -754,11 +754,15 @@ def _on_logged_in(login: LoginAuth, response: APIRequest_pb2.LoginResponse, auth_context.message_session_uid = login.context.message_session_uid keeper_endpoint = login.keeper_endpoint - logged_auth = keeper_auth.KeeperAuth(keeper_endpoint, auth_context) + # Create push_notifications if not provided (for testing or custom implementations) + push_notif = login.push_notifications if login.push_notifications is not None else push_notifications.KeeperPushNotifications() + logged_auth = keeper_auth.KeeperAuth(keeper_endpoint, auth_context, push_notifications=push_notif) _post_login(logged_auth) + # Start push notifications if unrestricted and using KeeperPushNotifications if auth_context.session_token_restriction == keeper_auth.SessionTokenRestriction.Unrestricted: - logged_auth.start_pushes() + if isinstance(push_notif, push_notifications.KeeperPushNotifications): + push_notif.start_push_server(logged_auth) login.login_step = _ConnectedLoginStep(logged_auth) logged_auth.on_idle() diff --git a/keepersdk-package/src/keepersdk/authentication/notifications.py b/keepersdk-package/src/keepersdk/authentication/notifications.py index 5f21322f..34cc6b2e 100644 --- a/keepersdk-package/src/keepersdk/authentication/notifications.py +++ b/keepersdk-package/src/keepersdk/authentication/notifications.py @@ -1,21 +1,13 @@ -import asyncio -import json -import ssl -from typing import Optional, TypeVar, Generic, Callable, List, Dict, Any +"""Generic observer/pub-sub pattern implementation.""" -import websockets -import websockets.frames -import websockets.protocol -import websockets.exceptions - -from .. import crypto, utils, background -from ..proto import push_pb2 -from . import endpoint +from typing import Optional, TypeVar, Generic, Callable, List M = TypeVar('M') class FanOut(Generic[M]): + """Generic fan-out/publish-subscribe pattern for distributing messages to multiple callbacks.""" + def __init__(self) -> None: self._callbacks: List[Callable[[M], Optional[bool]]] = [] self._is_completed = False @@ -25,6 +17,10 @@ def is_completed(self): return self._is_completed def push(self, message: M) -> None: + """Push a message to all registered callbacks. + + Callbacks that return True or raise exceptions are automatically removed. + """ if self._is_completed: return to_remove = [] @@ -38,11 +34,13 @@ def push(self, message: M) -> None: self._remove_indexes(to_remove) def register_callback(self, callback: Callable[[M], Optional[bool]]) -> None: + """Register a callback to receive pushed messages.""" if self._is_completed: return self._callbacks.append(callback) def remove_callback(self, callback: Callable[[M], Optional[bool]]) -> None: + """Remove a specific callback.""" if self._is_completed: return to_remove = [] @@ -52,6 +50,7 @@ def remove_callback(self, callback: Callable[[M], Optional[bool]]) -> None: self._remove_indexes(to_remove) def remove_all(self): + """Remove all registered callbacks.""" self._callbacks.clear() def _remove_indexes(self, to_remove: List[int]): @@ -61,58 +60,6 @@ def _remove_indexes(self, to_remove: List[int]): del self._callbacks[idx] def shutdown(self): + """Shutdown the FanOut, marking it as completed and removing all callbacks.""" self._is_completed = True self._callbacks.clear() - - -class KeeperPushNotifications(FanOut[Dict[str, Any]]): - def __init__(self) -> None: - super().__init__() - self._ws_app: Optional[websockets.ClientConnection] = None - - async def main_loop(self, push_url: str, transmission_key: bytes, data: Optional[bytes] = None): - logger = utils.get_logger() - try: - await self.close_ws() - except Exception as e: - logger.debug('Push notification close error: %s', e) - - ssl_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) - if not endpoint.get_certificate_check(): - ssl_context.verify_mode = ssl.CERT_NONE - - try: - async with websockets.connect(push_url, ping_interval=30, open_timeout=4, ssl=ssl_context) as ws_app: - self._ws_app = ws_app - if data: - await ws_app.send(utils.base64_url_encode(data)) - async for message in ws_app: - if isinstance(message, bytes): - try: - decrypted_data = crypto.decrypt_aes_v2(message, transmission_key) - rs = push_pb2.WssClientResponse() - rs.ParseFromString(decrypted_data) - self.push(json.loads(rs.message)) - except Exception as e: - logger.debug('Push notification: decrypt error: ', e) - except Exception as e: - logger.debug('Push notification: exception: %s', e) - - logger.debug('Push notification: exit.') - if self._ws_app == ws_app: - self._ws_app = None - - async def close_ws(self): - ws_app = self._ws_app - if ws_app and ws_app.state == websockets.protocol.State.OPEN: - try: - await ws_app.close(websockets.frames.CloseCode.GOING_AWAY) - except Exception: - pass - - def connect_to_push_channel(self, push_url: str, transmission_key: bytes, data: Optional[bytes]=None) -> None: - asyncio.run_coroutine_threadsafe(self.main_loop(push_url, transmission_key, data), background.get_loop()) - - def shutdown(self): - super().shutdown() - asyncio.run_coroutine_threadsafe(self.close_ws(), loop=background.get_loop()).result() diff --git a/keepersdk-package/src/keepersdk/authentication/push_notifications.py b/keepersdk-package/src/keepersdk/authentication/push_notifications.py new file mode 100644 index 00000000..6c2d1f1c --- /dev/null +++ b/keepersdk-package/src/keepersdk/authentication/push_notifications.py @@ -0,0 +1,94 @@ +import asyncio +import json +import ssl +from typing import Optional, Dict, Any + +import websockets +import websockets.frames +import websockets.protocol +import websockets.exceptions + +from . import endpoint, notifications, keeper_auth +from .. import crypto, utils, background +from ..proto import push_pb2 + + +class KeeperPushNotifications(notifications.FanOut[Dict[str, Any]]): + """Keeper Security push notification handler with WebSocket connection management.""" + + def __init__(self) -> None: + super().__init__() + self._ws_app: Optional[websockets.ClientConnection] = None + self._use_pushes = False + + async def main_loop(self, push_url: str, transmission_key: bytes, data: Optional[bytes] = None): + """Main WebSocket connection loop for receiving push notifications.""" + logger = utils.get_logger() + try: + await self.close_ws() + except Exception as e: + logger.debug('Push notification close error: %s', e) + + ssl_context = ssl.create_default_context(ssl.Purpose.SERVER_AUTH) + if not endpoint.get_certificate_check(): + ssl_context.verify_mode = ssl.CERT_NONE + + ws_app = None + try: + async with websockets.connect(push_url, ping_interval=30, open_timeout=4, ssl=ssl_context) as ws_app: + self._ws_app = ws_app + if data: + await ws_app.send(utils.base64_url_encode(data)) + async for message in ws_app: + if isinstance(message, bytes): + try: + decrypted_data = crypto.decrypt_aes_v2(message, transmission_key) + rs = push_pb2.WssClientResponse() + rs.ParseFromString(decrypted_data) + self.push(json.loads(rs.message)) + except Exception as e: + logger.debug('Push notification: decrypt error: ', e) + except Exception as e: + logger.debug('Push notification: exception: %s', e) + + logger.debug('Push notification: exit.') + if self._ws_app == ws_app: + self._ws_app = None + + async def close_ws(self): + """Close the WebSocket connection if open.""" + self._use_pushes = False + ws_app = self._ws_app + if ws_app and ws_app.state == websockets.protocol.State.OPEN: + try: + await ws_app.close(websockets.frames.CloseCode.GOING_AWAY) + except Exception: + pass + + def connect_to_push_channel(self, push_url: str, transmission_key: bytes, data: Optional[bytes] = None) -> None: + """Connect to a push notification channel.""" + asyncio.run_coroutine_threadsafe(self.main_loop(push_url, transmission_key, data), background.get_loop()) + + def shutdown(self): + """Shutdown push notifications and close connections.""" + super().shutdown() + asyncio.run_coroutine_threadsafe(self.close_ws(), loop=background.get_loop()).result() + + async def _push_server_guard(self, auth: keeper_auth.KeeperAuth): + """Guard loop that maintains push notification connection with keep-alive.""" + transmission_key = utils.generate_aes_key() + self._use_pushes = True + try: + while self._use_pushes: + url = auth.keeper_endpoint.get_push_url( + transmission_key, auth.auth_context.device_token, auth.auth_context.message_session_uid) + await self.main_loop(url, transmission_key, auth.auth_context.session_token) + auth.execute_auth_rest('keep_alive', None) + except Exception as e: + utils.get_logger().debug(e) + finally: + self._use_pushes = False + + def start_push_server(self, auth: keeper_auth.KeeperAuth): + """Start push notification server with authenticated session.""" + asyncio.run_coroutine_threadsafe(self._push_server_guard(auth), loop=background.get_loop()) diff --git a/keepersdk-package/src/keepersdk/enterprise/account_transfer.py b/keepersdk-package/src/keepersdk/enterprise/account_transfer.py index 39ffd807..89236dab 100644 --- a/keepersdk-package/src/keepersdk/enterprise/account_transfer.py +++ b/keepersdk-package/src/keepersdk/enterprise/account_transfer.py @@ -187,9 +187,8 @@ def _decrypt_transfer_key2(self, encrypted_key: bytes, key_type: int) -> bytes: raise AccountTransferError(f'Unsupported transfer key type: {key_type}') def _decrypt_legacy_transfer_key(self, pre_transfer: PreTransferResponse) -> bytes: - enterprise_data = self.loader.enterprise_data - tree_key = enterprise_data.enterprise_info.tree_key - + loader = self.loader + role_key = None if pre_transfer.role_key: rsa_private_key = self.auth.auth_context.rsa_private_key @@ -200,12 +199,9 @@ def _decrypt_legacy_transfer_key(self, pre_transfer: PreTransferResponse) -> byt rsa_private_key) elif pre_transfer.role_key_id: role_key_id = pre_transfer.role_key_id - role_keys2 = enterprise_data.role_keys.get_all_entities() - key_entry = next((x for x in role_keys2 if x.role_id == role_key_id), None) - if key_entry: - role_key = utils.base64_url_decode(key_entry.encrypted_key) - role_key = crypto.decrypt_aes_v2(role_key, tree_key) - + loader.load_role_keys([role_key_id]) + role_key = loader.get_role_keys(role_key_id) + if not role_key: raise AccountTransferError('Cannot decrypt role key') diff --git a/keepersdk-package/src/keepersdk/enterprise/enterprise_user_management.py b/keepersdk-package/src/keepersdk/enterprise/enterprise_user_management.py index 0d507e9e..f30b26ee 100644 --- a/keepersdk-package/src/keepersdk/enterprise/enterprise_user_management.py +++ b/keepersdk-package/src/keepersdk/enterprise/enterprise_user_management.py @@ -351,6 +351,7 @@ def execute_provision_request( provision_request, response_type=enterprise_pb2.EnterpriseUsersProvisionResponse ) + assert rs is not None self._validate_provision_response(rs, email) return rs diff --git a/keepersdk-package/src/keepersdk/vault/ksm.py b/keepersdk-package/src/keepersdk/vault/ksm.py index be9a7633..41018f7f 100644 --- a/keepersdk-package/src/keepersdk/vault/ksm.py +++ b/keepersdk-package/src/keepersdk/vault/ksm.py @@ -1,6 +1,6 @@ -import datetime +from datetime import datetime from dataclasses import dataclass -from typing import Optional +from typing import Optional, List @dataclass(frozen=True) @@ -31,5 +31,5 @@ class SecretsManagerApp: folders: int count: int last_access: datetime - client_devices: Optional[list[ClientDevice]] = None - shared_secrets: Optional[list[SharedSecretsInfo]] = None + client_devices: Optional[List[ClientDevice]] = None + shared_secrets: Optional[List[SharedSecretsInfo]] = None diff --git a/keepersdk-package/src/keepersdk/vault/ksm_management.py b/keepersdk-package/src/keepersdk/vault/ksm_management.py index f424b002..9cf40d63 100644 --- a/keepersdk-package/src/keepersdk/vault/ksm_management.py +++ b/keepersdk-package/src/keepersdk/vault/ksm_management.py @@ -1,6 +1,6 @@ import datetime import json -from typing import Optional +from typing import Optional, List, Union from . import vault_online, ksm, record_management, vault_types from ..proto.APIRequest_pb2 import GetApplicationsSummaryResponse, ApplicationShareType, GetAppInfoRequest, GetAppInfoResponse @@ -14,7 +14,7 @@ CLIENT_SHORT_ID_LENGTH = 8 -def list_secrets_manager_apps(vault: vault_online.VaultOnline) -> list[ksm.SecretsManagerApp]: +def list_secrets_manager_apps(vault: vault_online.VaultOnline) -> List[ksm.SecretsManagerApp]: response = vault.keeper_auth.execute_auth_rest( URL_GET_SUMMARY_API, request=None, @@ -139,7 +139,7 @@ def remove_secrets_manager_app(vault: vault_online.VaultOnline, uid_or_name: str return app.uid -def get_app_info(vault: vault_online.VaultOnline, app_uid: str | list[str]) -> list: +def get_app_info(vault: vault_online.VaultOnline, app_uid: Union[str, List[str]]) -> List: rq = GetAppInfoRequest() if isinstance(app_uid, str): diff --git a/keepersdk-package/src/keepersdk/vault/trash_management.py b/keepersdk-package/src/keepersdk/vault/trash_management.py index c41eff9b..61f8df5b 100644 --- a/keepersdk-package/src/keepersdk/vault/trash_management.py +++ b/keepersdk-package/src/keepersdk/vault/trash_management.py @@ -448,7 +448,7 @@ def get_trash_record(vault: vault_online.VaultOnline, record_uid: str) -> Tuple[ return record, is_shared -def restore_trash_records(vault: vault_online.VaultOnline, records: List[str], confirm: Optional[Callable[[str], bool]] = None) -> None: +def restore_trash_records(vault: vault_online.VaultOnline, records: List[str], confirm: Optional[Callable[[str], str]] = None) -> None: """Restore deleted records from trash. Args: @@ -658,7 +658,7 @@ def _is_restore_plan_empty(restore_plan: Dict[str, Any]) -> bool: return record_count == 0 and folder_count == 0 -def _confirm_restoration(restore_plan: Dict[str, Any], confirm_func: Callable[[str], bool]) -> bool: +def _confirm_restoration(restore_plan: Dict[str, Any], confirm_func: Callable[[str], str]) -> bool: """Confirm restoration with the user.""" record_count = len(restore_plan['records_to_restore']) for folder_records in restore_plan['folder_records_to_restore'].values(): diff --git a/keepersdk-package/unit_tests/test_ksm_management.py b/keepersdk-package/unit_tests/test_ksm_management.py index 69789595..7bc0d9c8 100644 --- a/keepersdk-package/unit_tests/test_ksm_management.py +++ b/keepersdk-package/unit_tests/test_ksm_management.py @@ -226,7 +226,7 @@ def test_create_app_duplicate_raises(self): self.vault.vault_data.records.return_value = [mock_record] with self.assertRaises(ValueError) as cm: ksm_management.create_secrets_manager_app(self.vault, 'TestApp') - self.assertEqual(str(cm.exception), 'Application with the same name TestApp already exists.') + self.assertEqual(str(cm.exception), 'Application with the same name TestApp already exists. Set force to true to add Application with same name') def test_create_app_duplicate_force_add(self): mock_record = MagicMock(title='TestApp') diff --git a/keepersdk-package/unit_tests/test_login.py b/keepersdk-package/unit_tests/test_login.py index cf9e95d1..f8050821 100644 --- a/keepersdk-package/unit_tests/test_login.py +++ b/keepersdk-package/unit_tests/test_login.py @@ -148,17 +148,10 @@ def execute_rest(rest_endpoint, request=None, response_type=None, session_token= session_token=session_token, payload_version=payload_version) - def connect_to_push_server(session_uid, device_token, data=None): - return notifications.FanOut() - mock = MagicMock() mock.side_effect = execute_rest keeper_endpoint.execute_rest = mock - mock = MagicMock() - mock.side_effect = connect_to_push_server - keeper_endpoint.connect_to_push_server = mock - mock = MagicMock() mock.side_effect = Exception keeper_endpoint.v2_execute = mock @@ -167,7 +160,9 @@ def connect_to_push_server(session_uid, device_token, data=None): mock.side_effect = Exception keeper_endpoint._communicate_keeper = mock - return login_auth.LoginAuth(keeper_endpoint) + auth = login_auth.LoginAuth(keeper_endpoint) + auth.push_notifications = notifications.FanOut() + return auth @staticmethod def reset_stops(): diff --git a/keepersdk-package/unit_tests/test_utils.py b/keepersdk-package/unit_tests/test_utils.py index 57b5e50f..a7ed5987 100644 --- a/keepersdk-package/unit_tests/test_utils.py +++ b/keepersdk-package/unit_tests/test_utils.py @@ -28,8 +28,8 @@ def test_dataclass(self): _ = ne.store_data(data, tree_key) ee: enterprise_types.IEnterpriseEntity[enterprise_types.Node, int] = ne for n in ee.get_all_entities(): - print(n) + pass # Test that iteration works def test_ore(self): a: Dict[Tuple[str, int], Any] = {('eeee', 33): 'rrrrr'} - print(a) + self.assertIsNotNone(a)