From b2c5ed32ecf414b0a1d993747082d8359ad2ddf4 Mon Sep 17 00:00:00 2001 From: Stas Schaller Date: Fri, 8 May 2026 15:47:28 -0400 Subject: [PATCH 1/9] chore(python): bump version to 17.2.2 for IL5 dynamic key release --- sdk/python/core/CHANGELOG.md | 3 +++ sdk/python/core/keeper_secrets_manager_core/_version.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/sdk/python/core/CHANGELOG.md b/sdk/python/core/CHANGELOG.md index 0e7f46657..60eca5f96 100644 --- a/sdk/python/core/CHANGELOG.md +++ b/sdk/python/core/CHANGELOG.md @@ -1,5 +1,8 @@ # Change Log +## 17.2.2 +* KSM-901 - IL5 dynamic server public key injection — supports three provisioning paths: config field (`serverPublicKey`), OTS token extension (4-segment IL5 format), and programmatic parameter (`server_public_key` on `SecretsManager`) + ## 17.2.1 * KSM-900 - Added IL5 (DoD Impact Level 5) region support — token prefix `IL5` resolves to `il5.keepersecurity.us` diff --git a/sdk/python/core/keeper_secrets_manager_core/_version.py b/sdk/python/core/keeper_secrets_manager_core/_version.py index 363969ad0..74b231d28 100644 --- a/sdk/python/core/keeper_secrets_manager_core/_version.py +++ b/sdk/python/core/keeper_secrets_manager_core/_version.py @@ -11,4 +11,4 @@ # Single source of truth for package version # This file is imported by both setup.py and the package itself -__version__ = "17.2.1" +__version__ = "17.2.2" From 8503801a0a541df6d43349defbe3b1ad87cfd9d9 Mon Sep 17 00:00:00 2001 From: Stas Schaller Date: Fri, 8 May 2026 15:59:00 -0400 Subject: [PATCH 2/9] =?UTF-8?q?chore(python):=20update=20CHANGELOG=20?= =?UTF-8?q?=E2=80=94=20IL5=20custom=20server=20public=20key=20(KSM-932)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sdk/python/core/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/python/core/CHANGELOG.md b/sdk/python/core/CHANGELOG.md index 60eca5f96..1be8e4219 100644 --- a/sdk/python/core/CHANGELOG.md +++ b/sdk/python/core/CHANGELOG.md @@ -1,7 +1,7 @@ # Change Log ## 17.2.2 -* KSM-901 - IL5 dynamic server public key injection — supports three provisioning paths: config field (`serverPublicKey`), OTS token extension (4-segment IL5 format), and programmatic parameter (`server_public_key` on `SecretsManager`) +* KSM-901 - IL5 custom server public key support — supports three provisioning paths: config field (`serverPublicKey`), OTS token extension (4-segment IL5 format), and programmatic parameter (`server_public_key` on `SecretsManager`) ## 17.2.1 * KSM-900 - Added IL5 (DoD Impact Level 5) region support — token prefix `IL5` resolves to `il5.keepersecurity.us` From 2e231593328c38c2e3c0319ab64d97156de4ea8a Mon Sep 17 00:00:00 2001 From: Stas Schaller Date: Fri, 8 May 2026 16:29:24 -0400 Subject: [PATCH 3/9] fix(python): update hardcoded fallback version and smoke test to 17.2.2 --- .../core/keeper_secrets_manager_core/keeper_globals.py | 2 +- sdk/python/core/tests/smoke_test.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/sdk/python/core/keeper_secrets_manager_core/keeper_globals.py b/sdk/python/core/keeper_secrets_manager_core/keeper_globals.py index eec9e3063..d23f352a0 100644 --- a/sdk/python/core/keeper_secrets_manager_core/keeper_globals.py +++ b/sdk/python/core/keeper_secrets_manager_core/keeper_globals.py @@ -32,7 +32,7 @@ def get_client_version(hardcode=False): # Default version for hardcode mode or when all detection methods fail version_major = "17" version_minor_default = "2" - version_revision_default = "1" + version_revision_default = "2" version = "{}.{}.{}".format(version_major, version_minor_default, version_revision_default) # Allow the default version to be hard coded diff --git a/sdk/python/core/tests/smoke_test.py b/sdk/python/core/tests/smoke_test.py index e4ecd18b4..6fe331bd3 100644 --- a/sdk/python/core/tests/smoke_test.py +++ b/sdk/python/core/tests/smoke_test.py @@ -194,11 +194,11 @@ def test_client_version(self): # Test 1: Normal case - __version__ is available (primary path) client_version = get_client_version(hardcode=False) - self.assertEqual("17.2.1", client_version, "did not get correct version from __version__") + self.assertEqual("17.2.2", client_version, "did not get correct version from __version__") # Test 2: Hardcode mode still works client_version = get_client_version(hardcode=True) - self.assertEqual("17.2.1", client_version, "did not get the correct client version for hardcoded") + self.assertEqual("17.2.2", client_version, "did not get the correct client version for hardcoded") # Test 3: Fallback to importlib.metadata when __version__ import fails # Mock the import to fail, then check fallback works @@ -232,7 +232,7 @@ def test_client_version(self): # But __version__ should take precedence with correct version client_version = get_client_version(hardcode=False) # Should get 17.1.0 from __version__, NOT 16.6.5 from stale metadata - self.assertEqual("17.2.1", client_version, + self.assertEqual("17.2.2", client_version, "KSM-749: Should use __version__ not stale metadata (16.6.5)") def test_il5_region_mapping(self): From 200e9e3488602fe1f55bdefc81c4342b3ffa8436 Mon Sep 17 00:00:00 2001 From: Stas Schaller Date: Fri, 8 May 2026 16:31:42 -0400 Subject: [PATCH 4/9] chore(python): bump helper to 1.1.2, require core>=17.2.2 --- sdk/python/helper/requirements.txt | 2 +- sdk/python/helper/setup.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/sdk/python/helper/requirements.txt b/sdk/python/helper/requirements.txt index f6e93b89b..534fed357 100644 --- a/sdk/python/helper/requirements.txt +++ b/sdk/python/helper/requirements.txt @@ -1,3 +1,3 @@ -keeper-secrets-manager-core>=17.2.1 +keeper-secrets-manager-core>=17.2.2 pyyaml>=6.0.1 iso8601 \ No newline at end of file diff --git a/sdk/python/helper/setup.py b/sdk/python/helper/setup.py index d887cfd20..dc2af3dbd 100644 --- a/sdk/python/helper/setup.py +++ b/sdk/python/helper/setup.py @@ -8,14 +8,14 @@ long_description = f.read() install_requires = [ - 'keeper-secrets-manager-core>=17.2.1', + 'keeper-secrets-manager-core>=17.2.2', 'pyyaml>=6.0.1', 'iso8601' ] setup( name="keeper-secrets-manager-helper", - version="1.1.1", + version="1.1.2", description="Keeper Secrets Manager SDK helper for managing records.", long_description=long_description, long_description_content_type="text/markdown", From acf5e5f4e624dac9796078781f8c3cfdcfc0c74a Mon Sep 17 00:00:00 2001 From: Stas Schaller Date: Tue, 5 May 2026 12:36:08 -0400 Subject: [PATCH 5/9] feat(python): IL5 dynamic server public key injection Three-layer mechanism for injecting a custom EC public key at runtime, allowing IL5 (key_id=20) and other isolated deployments to connect without adding environment-specific keys to the hardcoded map. Layer 1 (core): generate_transmission_key() accepts custom_public_key_b64; key rotation handler retries instead of raising when a custom key is set. Layer 2 (OTT): 4-segment IL5 token (IL5:clientKey:keyId:b64Key) writes key material to config at init time so Layer 1 picks it up automatically. Layer 3 (API): SecretsManager() accepts server_public_key and server_public_key_id constructor params for code-first initialization. Also fixes the long-standing missing 'raise' in generate_transmission_key and prevents __init__ from silently resetting a non-standard key_id to the default when a custom key is present. Tests: 5 new tests (one per layer + raise fix + end-to-end retry flow), all 64 tests pass. --- .../keeper_secrets_manager_core/configkeys.py | 1 + .../core/keeper_secrets_manager_core/core.py | 49 +++++++--- sdk/python/core/tests/il5_test.py | 97 +++++++++++++++++++ 3 files changed, 135 insertions(+), 12 deletions(-) create mode 100644 sdk/python/core/tests/il5_test.py diff --git a/sdk/python/core/keeper_secrets_manager_core/configkeys.py b/sdk/python/core/keeper_secrets_manager_core/configkeys.py index 39e92466e..5641a9ae2 100644 --- a/sdk/python/core/keeper_secrets_manager_core/configkeys.py +++ b/sdk/python/core/keeper_secrets_manager_core/configkeys.py @@ -21,6 +21,7 @@ class ConfigKeys(Enum): KEY_OWNER_PUBLIC_KEY = 'appOwnerPublicKey' # The application owner public key, to create records KEY_PRIVATE_KEY = 'privateKey' # The client's private key KEY_SERVER_PUBLIC_KEY_ID = 'serverPublicKeyId' # Which public key should be using? + KEY_SERVER_PUBLIC_KEY = 'serverPublicKey' # Custom server EC public key (base64) for IL5 / isolated deployments KEY_BINDING_TOKEN = 'bat' KEY_BINDING_KEY = 'bindingKey' diff --git a/sdk/python/core/keeper_secrets_manager_core/core.py b/sdk/python/core/keeper_secrets_manager_core/core.py index a8a77685f..1d6e0062c 100644 --- a/sdk/python/core/keeper_secrets_manager_core/core.py +++ b/sdk/python/core/keeper_secrets_manager_core/core.py @@ -85,7 +85,9 @@ def __init__(self, config=None, log_level=None, custom_post_function=None, - proxy_url=None + proxy_url=None, + server_public_key=None, + server_public_key_id=None, ): # Make sure the Python is 3.6 or higher. We'll handle Python 4 in the future :) @@ -124,6 +126,12 @@ def __init__(self, self.token = token_parts[1] + # Layer 2: IL5 OTT carries embedded key material as extra segments + # Format: IL5:[clientKey]:[keyId]:[serverPublicKeyBase64] + if token_parts[0].upper() == 'IL5' and len(token_parts) == 4: + self._il5_key_id = token_parts[2] + self._il5_server_public_key = token_parts[3] + # Init the log, create a logger for the core. self._init_logger(log_level=log_level) self.logger = logging.getLogger(logger_name) @@ -146,15 +154,27 @@ def __init__(self, if self.hostname is not None: config.set(ConfigKeys.KEY_HOSTNAME, self.hostname) + # Layer 2: IL5 OTT with embedded key material — write before key ID validation runs + if hasattr(self, '_il5_key_id'): + config.set(ConfigKeys.KEY_SERVER_PUBLIC_KEY_ID, self._il5_key_id) + config.set(ConfigKeys.KEY_SERVER_PUBLIC_KEY, self._il5_server_public_key) + + # Layer 3: programmatic injection — takes precedence over config file, written before validation + if server_public_key: + config.set(ConfigKeys.KEY_SERVER_PUBLIC_KEY, server_public_key) + if server_public_key_id: + config.set(ConfigKeys.KEY_SERVER_PUBLIC_KEY_ID, server_public_key_id) + # Make sure our public key id is set and pointing an existing key. if config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY_ID) is None: self.logger.debug("Setting public key id to the default: {}".format(SecretsManager.default_key_id)) config.set(ConfigKeys.KEY_SERVER_PUBLIC_KEY_ID, SecretsManager.default_key_id) elif config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY_ID) not in keeper_public_keys: - self.logger.debug("Public key id {} does not exists, set to default : {}".format( - config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY_ID), - SecretsManager.default_key_id)) - config.set(ConfigKeys.KEY_SERVER_PUBLIC_KEY_ID, SecretsManager.default_key_id) + if not config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY): + self.logger.debug("Public key id {} does not exists, set to default : {}".format( + config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY_ID), + SecretsManager.default_key_id)) + config.set(ConfigKeys.KEY_SERVER_PUBLIC_KEY_ID, SecretsManager.default_key_id) self.config: KeyValueStorage = config @@ -287,13 +307,15 @@ def load_secret_key(self): return current_secret_key @staticmethod - def generate_transmission_key(key_id): + def generate_transmission_key(key_id, custom_public_key_b64=None): transmission_key = utils.generate_random_bytes(32) - if key_id not in keeper_public_keys: - ValueError("The public key id {} does not exist.".format(key_id)) - - server_public_raw_key_bytes = url_safe_str_to_bytes(keeper_public_keys[key_id]) + if custom_public_key_b64: + server_public_raw_key_bytes = url_safe_str_to_bytes(custom_public_key_b64) + elif key_id not in keeper_public_keys: + raise ValueError("The public key id {} does not exist.".format(key_id)) + else: + server_public_raw_key_bytes = url_safe_str_to_bytes(keeper_public_keys[key_id]) encrypted_key = CryptoUtils.public_encrypt(transmission_key, server_public_raw_key_bytes) @@ -590,7 +612,8 @@ def _post_query(self, path, payload): while True: transmission_key_id = self.config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY_ID) - transmission_key = self.generate_transmission_key(transmission_key_id) + custom_key = self.config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY) + transmission_key = self.generate_transmission_key(transmission_key_id, custom_key) encrypted_payload_and_signature = self.encrypt_and_sign_payload(self.config, transmission_key, payload) if self.custom_post_function and path == 'get_secret': @@ -686,7 +709,9 @@ def handler_http_error(self, rs): if key_id is None: raise ValueError("The public key is blank from the server") - elif str(key_id) not in keeper_public_keys: + + custom_key = self.config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY) + if str(key_id) not in keeper_public_keys and not custom_key: raise ValueError("The public key at {} does not exist in the SDK".format(key_id)) self.config.set(ConfigKeys.KEY_SERVER_PUBLIC_KEY_ID, str(key_id)) diff --git a/sdk/python/core/tests/il5_test.py b/sdk/python/core/tests/il5_test.py new file mode 100644 index 000000000..63265524d --- /dev/null +++ b/sdk/python/core/tests/il5_test.py @@ -0,0 +1,97 @@ +import unittest +from unittest.mock import patch, MagicMock + +from keeper_secrets_manager_core import SecretsManager, mock +from keeper_secrets_manager_core.configkeys import ConfigKeys +from keeper_secrets_manager_core.keeper_globals import keeper_public_keys +from keeper_secrets_manager_core.mock import MockConfig +from keeper_secrets_manager_core.storage import InMemoryKeyValueStorage + + +class IL5DynamicKeyTest(unittest.TestCase): + + def test_layer1_generate_transmission_key_uses_custom_key(self): + """Layer 1: generate_transmission_key uses custom key bytes instead of the hardcoded map.""" + custom_key_b64 = keeper_public_keys['7'] # borrow a real key's bytes as the "custom" key + + with patch('keeper_secrets_manager_core.core.CryptoUtils.public_encrypt') as mock_encrypt: + mock_encrypt.return_value = b'fake_encrypted' + SecretsManager.generate_transmission_key('20', custom_key_b64) + + mock_encrypt.assert_called_once() + # First arg to public_encrypt is the transmission key bytes; second is the server public key bytes + used_key_bytes = mock_encrypt.call_args[0][1] + from keeper_secrets_manager_core.utils import url_safe_str_to_bytes + self.assertEqual(used_key_bytes, url_safe_str_to_bytes(custom_key_b64)) + + def test_layer1_generate_transmission_key_raises_without_custom_key(self): + """Layer 1: unknown key_id with no custom key still raises ValueError.""" + with self.assertRaises(ValueError): + SecretsManager.generate_transmission_key('20') + + def test_layer2_ott_4segment_writes_key_material_to_config(self): + """Layer 2: 4-segment IL5 OTT writes key_id and server public key into config at init time.""" + custom_key_b64 = keeper_public_keys['7'] + config = InMemoryKeyValueStorage() + + # IL5:[clientKey]:[keyId]:[serverPublicKeyBase64] + il5_token = 'IL5:fakeClientKey123456789012345:20:' + custom_key_b64 + + secrets_manager = SecretsManager(token=il5_token, config=config) + + self.assertEqual(config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY), custom_key_b64) + self.assertEqual(config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY_ID), '20') + + def test_layer3_programmatic_params_write_key_material_to_config(self): + """Layer 3: server_public_key / server_public_key_id constructor params write to config.""" + custom_key_b64 = keeper_public_keys['7'] + config = InMemoryKeyValueStorage(MockConfig.make_json()) + + secrets_manager = SecretsManager( + config=config, + server_public_key=custom_key_b64, + server_public_key_id='20', + ) + + self.assertEqual(config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY), custom_key_b64) + self.assertEqual(config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY_ID), '20') + + def test_key_rotation_retries_with_custom_key(self): + """When the server sends a key-rotation error with key_id=20 and a custom key is configured, + the SDK updates the key_id and retries instead of raising ValueError.""" + custom_key_b64 = keeper_public_keys['7'] + config = InMemoryKeyValueStorage(MockConfig.make_json()) + + secrets_manager = SecretsManager( + config=config, + server_public_key=custom_key_b64, + server_public_key_id='10', + ) + + # First response: server requests key rotation to key 20 + error_response = mock.Response( + client=secrets_manager, + content='{"error": "key", "key_id": 20}', + status_code=400, + reason="Bad Request", + ) + + # Second response: success with one record + success_response = mock.Response() + rec = success_response.add_record(title="IL5 Record") + rec.field("login", "il5_user") + + res_queue = mock.ResponseQueue(client=secrets_manager) + res_queue.add_response(error_response) + res_queue.add_response(success_response) + + records = secrets_manager.get_secrets([]) + + self.assertEqual(len(records), 1) + self.assertEqual(records[0].title, "IL5 Record") + # key_id should have been updated to '20' by the rotation handler + self.assertEqual(config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY_ID), '20') + + +if __name__ == '__main__': + unittest.main() From 493537cdee7202a538842d81a7f24207b33da0b5 Mon Sep 17 00:00:00 2001 From: Stas Schaller Date: Mon, 18 May 2026 14:35:13 -0400 Subject: [PATCH 6/9] =?UTF-8?q?docs(python):=20fix=20CHANGELOG=20attributi?= =?UTF-8?q?on=20=E2=80=94=20KSM-932=20not=20KSM-901?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KSM-901 is the JavaScript IL5 ticket; the Python IL5 dynamic-key work is tracked under KSM-932. --- sdk/python/core/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/python/core/CHANGELOG.md b/sdk/python/core/CHANGELOG.md index 1be8e4219..8abeb7107 100644 --- a/sdk/python/core/CHANGELOG.md +++ b/sdk/python/core/CHANGELOG.md @@ -1,7 +1,7 @@ # Change Log ## 17.2.2 -* KSM-901 - IL5 custom server public key support — supports three provisioning paths: config field (`serverPublicKey`), OTS token extension (4-segment IL5 format), and programmatic parameter (`server_public_key` on `SecretsManager`) +* KSM-932 - IL5 custom server public key support — supports three provisioning paths: config field (`serverPublicKey`), OTS token extension (4-segment IL5 format), and programmatic parameter (`server_public_key` on `SecretsManager`) ## 17.2.1 * KSM-900 - Added IL5 (DoD Impact Level 5) region support — token prefix `IL5` resolves to `il5.keepersecurity.us` From 51f53372c3bfcdbaf0b4ff0baf492a16f57326a1 Mon Sep 17 00:00:00 2001 From: Stas Schaller Date: Mon, 18 May 2026 15:38:35 -0400 Subject: [PATCH 7/9] =?UTF-8?q?fix(python):=20KSM-932=20follow-up=20?= =?UTF-8?q?=E2=80=94=20LICENSE=20files,=20Python=203.9=20guard,=20parser?= =?UTF-8?q?=20hardening,=20docstrings,=20tests,=20README?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Populate empty LICENSE files in core, helper, cli, and storage packages with the root MIT text. PyPI was about to publish packages declaring license=MIT with zero license text. - Update core.py runtime Python version guard from <3.6 to <3.9 to match setup.py python_requires and the 3.9 floor announced in v17.2.0. - Harden the 4-segment IL5 OTT parser: raise ValueError on wrong segment count, empty keyId/serverPublicKey segments, or invalid base64 in the serverPublicKey segment. Previously these silently degraded to default-key behavior; a credential-handling code path should fail loud. - Add docstrings to SecretsManager.__init__'s new server_public_key / server_public_key_id params and generate_transmission_key's new custom_public_key_b64 param. Document precedence between provisioning paths. Generic wording for isolated deployments. - Add 8 new tests (72/72 passing): - Layer 1 config-file path supplies custom key - Layer precedence: programmatic > token > config - Token > config when no programmatic params - 4 parser-negative cases (3 segments, 5+ segments, empty segments, invalid base64) - Non-IL5 prefix with extra segments stays backwards-compatible - Add a short "Custom Server Public Key (Isolated Deployments)" section to the core README pointing at the new constructor params and OTT format. Generic language; deployment-specific details deferred to the official docs. --- .../keeper_secrets_manager_cli/LICENSE | 21 +++ sdk/python/core/LICENSE.txt | 21 +++ sdk/python/core/README.md | 27 ++++ .../core/keeper_secrets_manager_core/core.py | 52 ++++++- sdk/python/core/tests/il5_test.py | 133 ++++++++++++++++-- sdk/python/helper/LICENSE | 21 +++ .../keeper_secrets_manager_storages/LICENSE | 21 +++ 7 files changed, 277 insertions(+), 19 deletions(-) diff --git a/integration/keeper_secrets_manager_cli/LICENSE b/integration/keeper_secrets_manager_cli/LICENSE index e69de29bb..b63322a17 100644 --- a/integration/keeper_secrets_manager_cli/LICENSE +++ b/integration/keeper_secrets_manager_cli/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Keeper Security + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sdk/python/core/LICENSE.txt b/sdk/python/core/LICENSE.txt index e69de29bb..b63322a17 100644 --- a/sdk/python/core/LICENSE.txt +++ b/sdk/python/core/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Keeper Security + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sdk/python/core/README.md b/sdk/python/core/README.md index 2b4f3704f..81b3ee4a3 100644 --- a/sdk/python/core/README.md +++ b/sdk/python/core/README.md @@ -4,6 +4,33 @@ For more information see our official documentation page https://docs.keeper.io/ **Python Requirements**: Python 3.9 or higher +## Custom Server Public Key (Isolated Deployments) + +For deployments where the server public key is not shipped with the SDK, +a caller-supplied EC P-256 public key can be supplied via any of three +paths (precedence is programmatic > one-time token > pre-existing config): + +```python +from keeper_secrets_manager_core import SecretsManager +from keeper_secrets_manager_core.storage import FileKeyValueStorage + +# Programmatic — wins over the other two if all are present +sm = SecretsManager( + token='REGION:ONE_TIME_TOKEN', + server_public_key='url-safe-base64-EC-P256-public-key', + server_public_key_id='your-key-id', + config=FileKeyValueStorage(), +) +``` + +The one-time-token form embeds the key material directly: +`REGION:clientKey:keyId:serverPublicKeyBase64` (4 colon-separated +segments). The config-file form sets `serverPublicKey` and +`serverPublicKeyId` in the JSON config before the first call. + +For deployment-specific details (region prefixes, key id assignments) +see the official docs link above. + # Change Log See [CHANGELOG.md](CHANGELOG.md) for the full version history. diff --git a/sdk/python/core/keeper_secrets_manager_core/core.py b/sdk/python/core/keeper_secrets_manager_core/core.py index 1d6e0062c..0108044df 100644 --- a/sdk/python/core/keeper_secrets_manager_core/core.py +++ b/sdk/python/core/keeper_secrets_manager_core/core.py @@ -89,11 +89,31 @@ def __init__(self, server_public_key=None, server_public_key_id=None, ): + """Construct a SecretsManager client. + + Custom-server-key parameters (KSM-932) — both optional, intended for + isolated deployments where the server public key is not shipped with + the SDK. Unused for standard Keeper deployments. + + :param server_public_key: Url-safe base64 of an EC P-256 public key + to override the SDK's built-in server public key table. When + provided, this key is persisted to config under + ``serverPublicKey`` and used for all subsequent transmission-key + encryption. + :param server_public_key_id: Optional public key id to pair with + ``server_public_key``. Persisted to config under + ``serverPublicKeyId``. If omitted, an existing value in config + is preserved. + + Precedence when multiple provisioning paths supply a custom key: + programmatic params (this constructor) > one-time-token segments + > pre-existing config file values. + """ - # Make sure the Python is 3.6 or higher. We'll handle Python 4 in the future :) + # Make sure the Python is 3.9 or higher. We'll handle Python 4 in the future :) python_version = sys.version_info - if python_version.major < 3 or (python_version.major == 3 and python_version.minor < 6): - raise Exception("KSM SDK requires Python 3.6 or greater") + if python_version.major < 3 or (python_version.major == 3 and python_version.minor < 9): + raise Exception("KSM SDK requires Python 3.9 or greater") self.token = None self.hostname = None @@ -128,7 +148,19 @@ def __init__(self, # Layer 2: IL5 OTT carries embedded key material as extra segments # Format: IL5:[clientKey]:[keyId]:[serverPublicKeyBase64] - if token_parts[0].upper() == 'IL5' and len(token_parts) == 4: + if token_parts[0].upper() == 'IL5' and len(token_parts) > 2: + if len(token_parts) != 4: + raise ValueError( + "Malformed IL5 one-time token: expected exactly 4 segments " + "'IL5:clientKey:keyId:serverPublicKeyBase64', got {}".format(len(token_parts))) + if not token_parts[2] or not token_parts[3]: + raise ValueError( + "Malformed IL5 one-time token: keyId and serverPublicKey segments must be non-empty") + try: + url_safe_str_to_bytes(token_parts[3]) + except Exception: + raise ValueError( + "Malformed IL5 one-time token: serverPublicKey segment is not valid url-safe base64") self._il5_key_id = token_parts[2] self._il5_server_public_key = token_parts[3] @@ -308,6 +340,18 @@ def load_secret_key(self): @staticmethod def generate_transmission_key(key_id, custom_public_key_b64=None): + """Generate a per-request transmission key wrapped with the server public key. + + :param key_id: Public key id used to look up the wrapping key in the + built-in ``keeper_public_keys`` registry. When + ``custom_public_key_b64`` is provided, ``key_id`` is treated as + opaque metadata and not looked up. + :param custom_public_key_b64: Optional url-safe base64 of an EC P-256 + public key (KSM-932). When supplied, this key wraps the + transmission key instead of the built-in registry — supports + isolated deployments where the server public key is not shipped + with the SDK. + """ transmission_key = utils.generate_random_bytes(32) if custom_public_key_b64: diff --git a/sdk/python/core/tests/il5_test.py b/sdk/python/core/tests/il5_test.py index 63265524d..efc1ae051 100644 --- a/sdk/python/core/tests/il5_test.py +++ b/sdk/python/core/tests/il5_test.py @@ -10,13 +10,18 @@ class IL5DynamicKeyTest(unittest.TestCase): + # Synthetic key id that is not in the built-in keeper_public_keys registry — + # used throughout these tests as the "custom" key id without referencing + # any specific deployment's id. + CUSTOM_KEY_ID = '99' + def test_layer1_generate_transmission_key_uses_custom_key(self): """Layer 1: generate_transmission_key uses custom key bytes instead of the hardcoded map.""" custom_key_b64 = keeper_public_keys['7'] # borrow a real key's bytes as the "custom" key with patch('keeper_secrets_manager_core.core.CryptoUtils.public_encrypt') as mock_encrypt: mock_encrypt.return_value = b'fake_encrypted' - SecretsManager.generate_transmission_key('20', custom_key_b64) + SecretsManager.generate_transmission_key(self.CUSTOM_KEY_ID, custom_key_b64) mock_encrypt.assert_called_once() # First arg to public_encrypt is the transmission key bytes; second is the server public key bytes @@ -27,7 +32,7 @@ def test_layer1_generate_transmission_key_uses_custom_key(self): def test_layer1_generate_transmission_key_raises_without_custom_key(self): """Layer 1: unknown key_id with no custom key still raises ValueError.""" with self.assertRaises(ValueError): - SecretsManager.generate_transmission_key('20') + SecretsManager.generate_transmission_key(self.CUSTOM_KEY_ID) def test_layer2_ott_4segment_writes_key_material_to_config(self): """Layer 2: 4-segment IL5 OTT writes key_id and server public key into config at init time.""" @@ -35,12 +40,12 @@ def test_layer2_ott_4segment_writes_key_material_to_config(self): config = InMemoryKeyValueStorage() # IL5:[clientKey]:[keyId]:[serverPublicKeyBase64] - il5_token = 'IL5:fakeClientKey123456789012345:20:' + custom_key_b64 + il5_token = 'IL5:fakeClientKey123456789012345:' + self.CUSTOM_KEY_ID + ':' + custom_key_b64 secrets_manager = SecretsManager(token=il5_token, config=config) self.assertEqual(config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY), custom_key_b64) - self.assertEqual(config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY_ID), '20') + self.assertEqual(config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY_ID), self.CUSTOM_KEY_ID) def test_layer3_programmatic_params_write_key_material_to_config(self): """Layer 3: server_public_key / server_public_key_id constructor params write to config.""" @@ -50,15 +55,16 @@ def test_layer3_programmatic_params_write_key_material_to_config(self): secrets_manager = SecretsManager( config=config, server_public_key=custom_key_b64, - server_public_key_id='20', + server_public_key_id=self.CUSTOM_KEY_ID, ) self.assertEqual(config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY), custom_key_b64) - self.assertEqual(config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY_ID), '20') + self.assertEqual(config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY_ID), self.CUSTOM_KEY_ID) def test_key_rotation_retries_with_custom_key(self): - """When the server sends a key-rotation error with key_id=20 and a custom key is configured, - the SDK updates the key_id and retries instead of raising ValueError.""" + """When the server sends a key-rotation error pointing at a key id not in the built-in + registry and a custom key is configured, the SDK updates the key_id and retries instead + of raising ValueError.""" custom_key_b64 = keeper_public_keys['7'] config = InMemoryKeyValueStorage(MockConfig.make_json()) @@ -68,18 +74,18 @@ def test_key_rotation_retries_with_custom_key(self): server_public_key_id='10', ) - # First response: server requests key rotation to key 20 + # First response: server requests key rotation to an id outside the built-in registry error_response = mock.Response( client=secrets_manager, - content='{"error": "key", "key_id": 20}', + content='{"error": "key", "key_id": ' + self.CUSTOM_KEY_ID + '}', status_code=400, reason="Bad Request", ) # Second response: success with one record success_response = mock.Response() - rec = success_response.add_record(title="IL5 Record") - rec.field("login", "il5_user") + rec = success_response.add_record(title="Custom-Key Record") + rec.field("login", "test_user") res_queue = mock.ResponseQueue(client=secrets_manager) res_queue.add_response(error_response) @@ -88,9 +94,106 @@ def test_key_rotation_retries_with_custom_key(self): records = secrets_manager.get_secrets([]) self.assertEqual(len(records), 1) - self.assertEqual(records[0].title, "IL5 Record") - # key_id should have been updated to '20' by the rotation handler - self.assertEqual(config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY_ID), '20') + self.assertEqual(records[0].title, "Custom-Key Record") + # key_id should have been updated by the rotation handler + self.assertEqual(config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY_ID), self.CUSTOM_KEY_ID) + + + def test_layer1_config_file_supplies_custom_key(self): + """Layer 1: a config that already contains serverPublicKey is honored on construction + with no token and no programmatic params — the custom key is preserved in config.""" + custom_key_b64 = keeper_public_keys['7'] + config = InMemoryKeyValueStorage(MockConfig.make_json()) + config.set(ConfigKeys.KEY_SERVER_PUBLIC_KEY, custom_key_b64) + config.set(ConfigKeys.KEY_SERVER_PUBLIC_KEY_ID, self.CUSTOM_KEY_ID) + + SecretsManager(config=config) + + # Construction must not clobber the pre-existing custom key or reset the unknown + # key_id back to the default 10 + self.assertEqual(config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY), custom_key_b64) + self.assertEqual(config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY_ID), self.CUSTOM_KEY_ID) + + def test_layer_precedence_programmatic_beats_token_beats_config(self): + """Precedence: programmatic params > 4-segment token > pre-existing config values.""" + config_key = keeper_public_keys['7'] + token_key = keeper_public_keys['8'] + programmatic_key = keeper_public_keys['9'] + + # Seed config with a Layer 1 value + config = InMemoryKeyValueStorage() + config.set(ConfigKeys.KEY_SERVER_PUBLIC_KEY, config_key) + config.set(ConfigKeys.KEY_SERVER_PUBLIC_KEY_ID, 'config-id') + + # Layer 2 (token) and Layer 3 (programmatic) both supplied; programmatic must win + il5_token = 'IL5:fakeClientKey123456789012345:token-id:' + token_key + + SecretsManager( + token=il5_token, + config=config, + server_public_key=programmatic_key, + server_public_key_id='programmatic-id', + ) + + self.assertEqual(config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY), programmatic_key) + self.assertEqual(config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY_ID), 'programmatic-id') + + def test_token_beats_config_when_no_programmatic_params(self): + """Token (Layer 2) overrides pre-existing config (Layer 1) when programmatic params absent.""" + config_key = keeper_public_keys['7'] + token_key = keeper_public_keys['8'] + + config = InMemoryKeyValueStorage() + config.set(ConfigKeys.KEY_SERVER_PUBLIC_KEY, config_key) + config.set(ConfigKeys.KEY_SERVER_PUBLIC_KEY_ID, 'config-id') + + il5_token = 'IL5:fakeClientKey123456789012345:token-id:' + token_key + + SecretsManager(token=il5_token, config=config) + + self.assertEqual(config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY), token_key) + self.assertEqual(config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY_ID), 'token-id') + + # --- Parser hardening (KSM-932 follow-up) ----------------------------------- + + def test_malformed_il5_token_3_segments_raises(self): + """IL5 tokens with 3 segments must fail loud, not silently drop the third segment.""" + with self.assertRaises(ValueError): + SecretsManager(token='IL5:fakeClientKey:' + self.CUSTOM_KEY_ID, config=InMemoryKeyValueStorage()) + + def test_malformed_il5_token_5_segments_raises(self): + """IL5 tokens with more than 4 segments must fail loud, not silently drop the extras.""" + custom_key_b64 = keeper_public_keys['7'] + token = 'IL5:fakeClientKey:' + self.CUSTOM_KEY_ID + ':' + custom_key_b64 + ':extra' + with self.assertRaises(ValueError): + SecretsManager(token=token, config=InMemoryKeyValueStorage()) + + def test_malformed_il5_token_empty_segments_raises(self): + """IL5 tokens with empty keyId or serverPublicKey segments must fail loud.""" + custom_key_b64 = keeper_public_keys['7'] + # Empty keyId + with self.assertRaises(ValueError): + SecretsManager(token='IL5:fakeClientKey::' + custom_key_b64, config=InMemoryKeyValueStorage()) + # Empty serverPublicKey + with self.assertRaises(ValueError): + SecretsManager(token='IL5:fakeClientKey:' + self.CUSTOM_KEY_ID + ':', config=InMemoryKeyValueStorage()) + + def test_malformed_il5_token_invalid_base64_raises(self): + """IL5 tokens with a non-base64 serverPublicKey segment must fail at construction, not deeper in the stack.""" + with self.assertRaises(ValueError): + SecretsManager( + token='IL5:fakeClientKey:' + self.CUSTOM_KEY_ID + ':not!valid@base64', + config=InMemoryKeyValueStorage(), + ) + + def test_non_il5_token_with_extra_segments_is_unchanged(self): + """A non-IL5 prefix with extra segments must not trigger the IL5 parser path — + commercial/standard tokens stay backwards-compatible.""" + config = InMemoryKeyValueStorage() + # US prefix with extra segments — historically these were silently dropped; preserving that. + SecretsManager(token='US:fakeClientKey123456789012345:extra:more', config=config) + # No custom key should have been written + self.assertIsNone(config.get(ConfigKeys.KEY_SERVER_PUBLIC_KEY)) if __name__ == '__main__': diff --git a/sdk/python/helper/LICENSE b/sdk/python/helper/LICENSE index e69de29bb..b63322a17 100644 --- a/sdk/python/helper/LICENSE +++ b/sdk/python/helper/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Keeper Security + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/sdk/python/storage/keeper_secrets_manager_storages/LICENSE b/sdk/python/storage/keeper_secrets_manager_storages/LICENSE index e69de29bb..b63322a17 100644 --- a/sdk/python/storage/keeper_secrets_manager_storages/LICENSE +++ b/sdk/python/storage/keeper_secrets_manager_storages/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Keeper Security + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. From eb90a3e4046dfd2c3dcc3822d61a0189acadaaab Mon Sep 17 00:00:00 2001 From: Stas Schaller Date: Tue, 19 May 2026 13:02:24 -0400 Subject: [PATCH 8/9] docs: embed changelog in README for PyPI visibility --- sdk/python/core/README.md | 118 +++++++++++++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 3 deletions(-) diff --git a/sdk/python/core/README.md b/sdk/python/core/README.md index 81b3ee4a3..e90e1397a 100644 --- a/sdk/python/core/README.md +++ b/sdk/python/core/README.md @@ -1,6 +1,6 @@ # Keeper Secrets Manager Python SDK -For more information see our official documentation page https://docs.keeper.io/secrets-manager/secrets-manager/developer-sdk-library/python-sdk +For more information, see our official documentation page https://docs.keeper.io/secrets-manager/secrets-manager/developer-sdk-library/python-sdk **Python Requirements**: Python 3.9 or higher @@ -31,6 +31,118 @@ segments). The config-file form sets `serverPublicKey` and For deployment-specific details (region prefixes, key id assignments) see the official docs link above. -# Change Log +## Change Log -See [CHANGELOG.md](CHANGELOG.md) for the full version history. +### 17.2.2 +* KSM-932 - IL5 custom server public key support — supports three provisioning paths: config field (`serverPublicKey`), OTS token extension (4-segment IL5 format), and programmatic parameter (`server_public_key` on `SecretsManager`) + +### 17.2.1 +* KSM-900 - Added IL5 (DoD Impact Level 5) region support — token prefix `IL5` resolves to `il5.keepersecurity.us` + +### 17.2.0 +* **Breaking**: Minimum Python version raised from 3.6 to 3.9 + - Python 3.6-3.8 users: pip will automatically install v17.1.x (no action needed) + - Security/bug fixes backported to v17.1.x until August 2026 via `legacy/sdk/python/core/v17.1.x` branch +* **Security**: KSM-777 - Raised dependency floors to resolve multiple CVEs + - `cryptography>=46.0.5` (was >=39.0.1, resolves CVE-2026-26007 elliptic curve vulnerability) + - `urllib3>=2.6.3` unconditionally (was split between urllib3 1.x/2.x, resolves CVE-2026-21441, CVE-2025-66471, CVE-2025-66418, CVE-2025-50181, CVE-2025-50182) + - `requests>=2.32.4` (resolves CVE-2024-47081 .netrc credentials leak) +* Removed `importlib_metadata` dependency (stdlib `importlib.metadata` available since Python 3.8) +* Added Python 3.13 support and CI testing + +### 17.1.0 +* **Security**: KSM-760 - Fixed CVE-2026-23949 (jaraco.context path traversal) in SBOM generation workflow + - Upgraded jaraco.context to >= 6.1.0 in SBOM build environment + - Build-time dependency only, does not affect runtime or published packages +* **Security**: Added version-specific urllib3 dependency to address CVE-2025-66418 and CVE-2025-66471 (HIGH severity) + - Python 3.10+: uses urllib3>=2.6.0 (latest security fixes) + - Python 3.6-3.9: uses urllib3>=1.26.0,<1.27 (compatible with boto3/AWS storage) +* **Security**: KSM-695 - Fixed file permissions for client-config.json (created with 0600 permissions) +* KSM-763 - Fixed file upload/download operations failing when using proxy with verify_ssl_certs=False + - Added verify_ssl_certs and proxy_url parameters to file upload/download functions + - Previously, these settings were ignored, causing SSL verification errors when using proxies +* KSM-749 - Fixed client version detection to prevent stale .dist-info metadata causing "invalid client version id" errors + - Introduced single source of truth for version via _version.py + - Client version now prioritizes package __version__ attribute over importlib_metadata + - Fixes issue where package upgrades left stale metadata causing backend authentication failures +* KSM-740 - Added transmission public key #18 for Gov Cloud Dev support +* KSM-732 - Fixed notation lookup when record shortcuts exist (duplicate UID handling) +* KSM-650 - Improved error messages for malformed configuration files +* KSM-628 - Added GraphSync links support + +### 17.0.0 +* KSM-566 - Added parsing for KSM tokens with prefix +* KSM-631 - Added links2Remove parameter for files removal +* KSM-635 - HTTPError should include response object + +### 16.6.6 +* KSM-552 - Stop generating UIDs that start with "-" + +### 16.6.5 +* KSM-529 - Handle broken encryption in records and files + +### 16.6.4 +* KSM-488 - Remove unused package dependencies + +### 16.6.3 +* KSM-479 - Remove dependency on `distutils` due to Python 3.12 removing it + +### 16.6.2 +* KSM-463 - Python SDK - Fix a bug when fields is null +* KSM-458 - Python SDK - Remove core's dependency on the helper module. Fixes [issue 488](https://github.com/Keeper-Security/secrets-manager/issues/488) + +### 16.6.1 +* KSM-444 - Python - Added folderUid and innerFolderUid to Record + +### 16.6.0 +* KSM-413 - Added support for Folders +* KSM-434 - Improved Passkey field type support + +### 16.5.4 +* KSM-405 - Added new script field type and oneTimeCode to PAM record types +* KSM-410 - New field type: Passkey +* KSM-394 - Ability to load configuration from AWS Secrets Manager using AWS AIM role in EC2 instance or AWS IAM user +* KSM-416 - Fix OS detection bug +* KSM-400 - Unpinned few dependencies + +### 16.5.3 +* KSM-393 - Fix file permissions on localized Windows OS + +### 16.5.2 +* KSM-375 - Make HTTPError to be more informative +* KSM-376 - Support for PAM record types +* KSM-381 - Transactions +* Fixed [Issue 441](https://github.com/Keeper-Security/secrets-manager/issues/441) - Bug caused by space in username + +### 16.5.1 +* KSM-371 - Fix Windows Config file permissions issue +* KSM-370 - Upgrade to latest cryptography>=39.0.1 library + +### 16.5.0 +* KSM-313 - Improved Keeper Notations. New parser, new escape characters, Notation URI, search records by title and other meta data values in the record +* KSM-319 - `KEY_CLIENT_KEY` in configurations is missing in certain situations +* KSM-356 - Ability to create of the new custom field + +### 16.4.2 +* Fix to support dynamic client version + +### 16.4.1 +* Upgrading and pinning `cryptography` dependency to 38.0.3 + +### 16.4.0 +* Record deletion +* KSM-305 - Support for Canada and Japan data centers +* KSM-308 - Improve password generation entropy +* KSM-240 - Config file permission checking (Create new client-config.json with locked down permission/ACL mode. Print STDERR warning if client-config.json ACL mode is too + open. To disable ACL mode checking and setting, set environmental variable `KSM_CONFIG_SKIP_MODE` to `TRUE`. To prevent + warnings of the client-config.json being too open, set environmental variable `KSM_CONFIG_SKIP_MODE_WARNING` to `TRUE`. + For Unix, `client-config.json` is set to `0600` mode. For Windows, `client-config.json` has only the user that created + the `client-config.json` and the **Administrator** group.) + +### 16.3.5 +* Removed non-ASCII characters from source code. Added Python comment flag to allow non-ASCII to source code, just in case. +* Allow `enforceGeneration`, `privacyScreen`, and `complexity` in record fields when creating a record. +* Record creation validation. Making sure that only legitimate record field types, notes section, and title of the record can be saved + +### 16.3.4 +* Provide better exception messages when the config JSON file is not utf-8 encoded. From c93ceef420ac90f159396a70c1eada94983bd360 Mon Sep 17 00:00:00 2001 From: Stas Schaller Date: Wed, 27 May 2026 12:29:02 -0400 Subject: [PATCH 9/9] fix(python): KSM-808 guard config-decoding utilities against None Raise KeeperError with actionable messages instead of cryptic TypeError when None reaches the base64/url-safe decoders from an incomplete config or empty server response field. Utility-level guards (utils.py, crypto.py): base64_to_bytes, url_safe_str_to_bytes, base64_to_string, CryptoUtils.url_safe_str_to_bytes. base64_to_string also gains a binascii.Error handler for parity. Per-call-site guards in core.py at the three unguarded config.get sites (lines 814, 863, 874) name the specific missing ConfigKey ('appKey', 'clientKey') and direct the user to reinitialize with a fresh OTT. Adds 7 regression tests in tests/config_error_test.py. Full core suite (79 tests) green. CHANGELOG and README updated. --- sdk/python/core/CHANGELOG.md | 1 + sdk/python/core/README.md | 1 + .../core/keeper_secrets_manager_core/core.py | 24 ++++- .../keeper_secrets_manager_core/crypto.py | 7 ++ .../core/keeper_secrets_manager_core/utils.py | 32 +++++- sdk/python/core/tests/config_error_test.py | 98 +++++++++++++++++++ 6 files changed, 158 insertions(+), 5 deletions(-) diff --git a/sdk/python/core/CHANGELOG.md b/sdk/python/core/CHANGELOG.md index 8abeb7107..5593583cc 100644 --- a/sdk/python/core/CHANGELOG.md +++ b/sdk/python/core/CHANGELOG.md @@ -2,6 +2,7 @@ ## 17.2.2 * KSM-932 - IL5 custom server public key support — supports three provisioning paths: config field (`serverPublicKey`), OTS token extension (4-segment IL5 format), and programmatic parameter (`server_public_key` on `SecretsManager`) +* KSM-808 - Config-decoding utilities (`base64_to_bytes`, `url_safe_str_to_bytes`, `base64_to_string`, `CryptoUtils.url_safe_str_to_bytes`) now raise `KeeperError` with actionable messages instead of cryptic `TypeError` when passed `None` from an incomplete config or empty server response field. Per-call-site guards in `core.py` name the specific missing `ConfigKey` (`appKey`, `clientKey`) and direct users to reinitialize with a fresh One-Time Token. ## 17.2.1 * KSM-900 - Added IL5 (DoD Impact Level 5) region support — token prefix `IL5` resolves to `il5.keepersecurity.us` diff --git a/sdk/python/core/README.md b/sdk/python/core/README.md index e90e1397a..3a83a58b7 100644 --- a/sdk/python/core/README.md +++ b/sdk/python/core/README.md @@ -35,6 +35,7 @@ see the official docs link above. ### 17.2.2 * KSM-932 - IL5 custom server public key support — supports three provisioning paths: config field (`serverPublicKey`), OTS token extension (4-segment IL5 format), and programmatic parameter (`server_public_key` on `SecretsManager`) +* KSM-808 - Config-decoding utilities (`base64_to_bytes`, `url_safe_str_to_bytes`, `base64_to_string`, `CryptoUtils.url_safe_str_to_bytes`) now raise `KeeperError` with actionable messages instead of cryptic `TypeError` when passed `None` from an incomplete config or empty server response field. Per-call-site guards in `core.py` name the specific missing `ConfigKey` (`appKey`, `clientKey`) and direct users to reinitialize with a fresh One-Time Token. ### 17.2.1 * KSM-900 - Added IL5 (DoD Impact Level 5) region support — token prefix `IL5` resolves to `il5.keepersecurity.us` diff --git a/sdk/python/core/keeper_secrets_manager_core/core.py b/sdk/python/core/keeper_secrets_manager_core/core.py index 0108044df..856334bbc 100644 --- a/sdk/python/core/keeper_secrets_manager_core/core.py +++ b/sdk/python/core/keeper_secrets_manager_core/core.py @@ -811,7 +811,13 @@ def fetch_and_decrypt_folders(self): decrypted_response_str = utils.bytes_to_string(decrypted_response_bytes) decrypted_response_dict = utils.json_to_dict(decrypted_response_str) or {} - app_key = base64_to_bytes(self.config.get(ConfigKeys.KEY_APP_KEY)) + app_key_b64 = self.config.get(ConfigKeys.KEY_APP_KEY) + if app_key_b64 is None: + raise KeeperError( + "Required config key 'appKey' is missing. Reinitialize the SDK " + "with a fresh One-Time Token to repair the configuration." + ) + app_key = base64_to_bytes(app_key_b64) response_folders = decrypted_response_dict.get("folders", []) or [] if not response_folders: return [] @@ -860,7 +866,13 @@ def fetch_and_decrypt_secrets(self, query_options: QueryOptions): just_bound = True encrypted_master_key = url_safe_str_to_bytes(decrypted_response_dict.get('encryptedAppKey')) - client_key = url_safe_str_to_bytes(self.config.get(ConfigKeys.KEY_CLIENT_KEY)) + client_key_b64 = self.config.get(ConfigKeys.KEY_CLIENT_KEY) + if client_key_b64 is None: + raise KeeperError( + "Required config key 'clientKey' is missing. Reinitialize the SDK " + "with a fresh One-Time Token to repair the configuration." + ) + client_key = url_safe_str_to_bytes(client_key_b64) secret_key = CryptoUtils.decrypt_aes(encrypted_master_key, client_key) self.config.set(ConfigKeys.KEY_APP_KEY, bytes_to_base64(secret_key)) @@ -871,7 +883,13 @@ def fetch_and_decrypt_secrets(self, query_options: QueryOptions): self.config.set(ConfigKeys.KEY_OWNER_PUBLIC_KEY, bytes_to_base64(appOwnerPublicKeyBytes)) else: - secret_key = base64_to_bytes(self.config.get(ConfigKeys.KEY_APP_KEY)) + app_key_b64 = self.config.get(ConfigKeys.KEY_APP_KEY) + if app_key_b64 is None: + raise KeeperError( + "Required config key 'appKey' is missing. Reinitialize the SDK " + "with a fresh One-Time Token to repair the configuration." + ) + secret_key = base64_to_bytes(app_key_b64) records_resp = decrypted_response_dict.get('records') folders_resp = decrypted_response_dict.get('folders') diff --git a/sdk/python/core/keeper_secrets_manager_core/crypto.py b/sdk/python/core/keeper_secrets_manager_core/crypto.py index 4e4967a0a..0511a86c7 100644 --- a/sdk/python/core/keeper_secrets_manager_core/crypto.py +++ b/sdk/python/core/keeper_secrets_manager_core/crypto.py @@ -70,6 +70,13 @@ def bytes_to_int(b): @staticmethod def url_safe_str_to_bytes(s): + if s is None: + raise exceptions.KeeperError( + "CryptoUtils.url_safe_str_to_bytes received None. A required " + "configuration value is missing, or a server response field was empty. " + "Verify your configuration is complete, or reinitialize with a fresh " + "One-Time Token if the local config was lost." + ) b = base64.urlsafe_b64decode(s + '==') return b diff --git a/sdk/python/core/keeper_secrets_manager_core/utils.py b/sdk/python/core/keeper_secrets_manager_core/utils.py index 5bacf6893..8100bf88b 100644 --- a/sdk/python/core/keeper_secrets_manager_core/utils.py +++ b/sdk/python/core/keeper_secrets_manager_core/utils.py @@ -88,8 +88,15 @@ def base64_to_bytes(s): Decoded bytes Raises: - KeeperError: If the base64 string is malformed or has incorrect padding + KeeperError: If the base64 string is None, malformed, or has incorrect padding """ + if s is None: + raise exceptions.KeeperError( + "base64_to_bytes received None. A required configuration value is " + "missing, or a server response field was empty. Verify your " + "configuration is complete, or reinitialize with a fresh " + "One-Time Token if the local config was lost." + ) try: return base64.urlsafe_b64decode(s) except binascii.Error as e: @@ -101,7 +108,21 @@ def base64_to_bytes(s): def base64_to_string(b64s): - return base64.b64decode(b64s).decode('UTF-8') + if b64s is None: + raise exceptions.KeeperError( + "base64_to_string received None. A required configuration value is " + "missing, or a server response field was empty. Verify your " + "configuration is complete, or reinitialize with a fresh " + "One-Time Token if the local config was lost." + ) + try: + return base64.b64decode(b64s).decode('UTF-8') + except binascii.Error as e: + raise exceptions.KeeperError( + f"Failed to decode base64 data: {str(e)}. " + "This may indicate a malformed or corrupted value in your configuration. " + "Please verify your configuration file is valid and has not been truncated." + ) def is_base64(s): @@ -116,6 +137,13 @@ def string_to_bytes(s): def url_safe_str_to_bytes(s): + if s is None: + raise exceptions.KeeperError( + "url_safe_str_to_bytes received None. A required configuration value is " + "missing, or a server response field was empty. Verify your " + "configuration is complete, or reinitialize with a fresh " + "One-Time Token if the local config was lost." + ) b = base64.urlsafe_b64decode(s + '==') return b diff --git a/sdk/python/core/tests/config_error_test.py b/sdk/python/core/tests/config_error_test.py index c9e9afd98..6af3318b6 100644 --- a/sdk/python/core/tests/config_error_test.py +++ b/sdk/python/core/tests/config_error_test.py @@ -1,4 +1,6 @@ +import json import unittest +from unittest.mock import patch from keeper_secrets_manager_core.exceptions import KeeperError from keeper_secrets_manager_core.storage import InMemoryKeyValueStorage @@ -6,6 +8,7 @@ from keeper_secrets_manager_core.configkeys import ConfigKeys from keeper_secrets_manager_core.crypto import CryptoUtils from keeper_secrets_manager_core import utils +from keeper_secrets_manager_core.dto.payload import QueryOptions from keeper_secrets_manager_core.mock import MockConfig @@ -120,6 +123,101 @@ def test_empty_private_key(self): "Error loading private key" in error_message ) + # ------------------------------------------------------------------ + # KSM-808: None-guard regression tests for config-decoding utilities + # ------------------------------------------------------------------ + + def test_base64_to_bytes_none_raises_keeper_error(self): + """KSM-808: base64_to_bytes(None) must raise KeeperError, not TypeError.""" + with self.assertRaises(KeeperError) as context: + utils.base64_to_bytes(None) + message = str(context.exception) + self.assertIn("None", message) + self.assertIn("configuration", message.lower()) + + def test_url_safe_str_to_bytes_none_raises_keeper_error(self): + """KSM-808: url_safe_str_to_bytes(None) must raise KeeperError, not TypeError.""" + with self.assertRaises(KeeperError) as context: + utils.url_safe_str_to_bytes(None) + message = str(context.exception) + self.assertIn("None", message) + self.assertIn("configuration", message.lower()) + + def test_base64_to_string_none_raises_keeper_error(self): + """KSM-808: base64_to_string(None) must raise KeeperError, not TypeError.""" + with self.assertRaises(KeeperError) as context: + utils.base64_to_string(None) + message = str(context.exception) + self.assertIn("None", message) + self.assertIn("configuration", message.lower()) + + def test_cryptoutils_url_safe_str_to_bytes_none_raises_keeper_error(self): + """KSM-808: CryptoUtils.url_safe_str_to_bytes(None) must raise KeeperError.""" + with self.assertRaises(KeeperError) as context: + CryptoUtils.url_safe_str_to_bytes(None) + message = str(context.exception) + self.assertIn("None", message) + self.assertIn("configuration", message.lower()) + + def _make_secrets_manager_with_config(self, config_overrides=None, skip_keys=None): + """Build a SecretsManager whose InMemoryKeyValueStorage is missing the requested keys.""" + skip_keys = skip_keys or [] + config_dict = MockConfig.make_config() + for key in skip_keys: + config_dict.pop(key, None) + if config_overrides: + config_dict.update(config_overrides) + storage = InMemoryKeyValueStorage(config_dict) + return SecretsManager(config=storage) + + def test_fetch_records_missing_client_key_in_config(self): + """KSM-808: rebind path (server returns encryptedAppKey) names 'clientKey' when missing.""" + sm = self._make_secrets_manager_with_config(skip_keys=["clientKey"]) + rebind_response = json.dumps({ + "encryptedAppKey": "Zm9vYmFy", + "records": [], + "folders": [], + }).encode("utf-8") + + with patch.object(sm, "_post_query", return_value=rebind_response): + with self.assertRaises(KeeperError) as context: + sm.fetch_and_decrypt_secrets(QueryOptions(records_filter=[], folders_filter=[])) + + message = str(context.exception) + self.assertIn("clientKey", message) + self.assertIn("One-Time Token", message) + + def test_fetch_records_missing_app_key_in_config(self): + """KSM-808: already-bound path (no encryptedAppKey from server) names 'appKey' when missing.""" + sm = self._make_secrets_manager_with_config(skip_keys=["appKey"]) + already_bound_response = json.dumps({ + "records": [], + "folders": [], + }).encode("utf-8") + + with patch.object(sm, "_post_query", return_value=already_bound_response): + with self.assertRaises(KeeperError) as context: + sm.fetch_and_decrypt_secrets(QueryOptions(records_filter=[], folders_filter=[])) + + message = str(context.exception) + self.assertIn("appKey", message) + self.assertIn("One-Time Token", message) + + def test_fetch_and_decrypt_folders_missing_app_key(self): + """KSM-808: fetch_and_decrypt_folders names 'appKey' when missing from config.""" + sm = self._make_secrets_manager_with_config(skip_keys=["appKey"]) + folders_response = json.dumps({ + "folders": [{"folderUid": "abc", "folderKey": "Zm9v"}], + }).encode("utf-8") + + with patch.object(sm, "_post_query", return_value=folders_response): + with self.assertRaises(KeeperError) as context: + sm.fetch_and_decrypt_folders() + + message = str(context.exception) + self.assertIn("appKey", message) + self.assertIn("One-Time Token", message) + if __name__ == '__main__': unittest.main()