Skip to content

Commit

Permalink
Merge pull request #191 from zowe/multi-credentials
Browse files Browse the repository at this point in the history
Implements secure value loading method for multiple Windows credentials
  • Loading branch information
zFernand0 authored Aug 29, 2023
2 parents 558edfb + 58178c0 commit 4bc1a86
Show file tree
Hide file tree
Showing 7 changed files with 382 additions and 80 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ All notable changes to the Zowe Client Python SDK will be documented in this fil

## Recent Changes

- Feature: Added a CredentialManager class to securely retrieve values from credentials and manage multiple credential entries on Windows [#134](https://github.com/zowe/zowe-client-python-sdk/issues/134)
- Feature: Added method to load profile properties from environment variables
1 change: 1 addition & 0 deletions src/core/zowe/core_for_zowe_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@
from .session import Session
from .zosmf_profile import ZosmfProfile
from .config_file import ConfigFile
from .credential_manager import CredentialManager
66 changes: 6 additions & 60 deletions src/core/zowe/core_for_zowe_sdk/config_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,36 +10,28 @@
Copyright Contributors to the Zowe Project.
"""

import base64
import os.path
import re
import json
import requests
import sys
import warnings
from dataclasses import dataclass, field
from typing import Optional, NamedTuple

import commentjson

from .constants import constants
from .credential_manager import CredentialManager
from .custom_warnings import (
ProfileNotFoundWarning,
ProfileParsingWarning,
SecurePropsNotFoundWarning,
)
from .exceptions import ProfileNotFound, SecureProfileLoadFailed
from .exceptions import ProfileNotFound
from .profile_constants import (
GLOBAL_CONFIG_NAME,
TEAM_CONFIG,
USER_CONFIG,
)

HAS_KEYRING = True
try:
import keyring
except ImportError:
HAS_KEYRING = False

HOME = os.path.expanduser("~")
GLOBAl_CONFIG_LOCATION = os.path.join(HOME, ".zowe")
Expand Down Expand Up @@ -309,7 +301,8 @@ def load_profile_properties(self, profile_name: str) -> dict:
Load exact profile properties (without prepopulated fields from base profile)
from the profile dict and populate fields from the secure credentials storage
"""

# if self.profiles is None:
# self.init_from_file()
props = {}
lst = profile_name.split(".")
secure_fields: list = []
Expand All @@ -330,8 +323,8 @@ def load_profile_properties(self, profile_name: str) -> dict:

# load secure props only if there are secure fields
if secure_fields:
self.load_secure_props()

CredentialManager.load_secure_props()
self.secure_props = CredentialManager.secure_props.get(self.filepath, {})
# load properties with key as profile.{profile_name}.properties.{*}
for (key, value) in self.secure_props.items():
if re.match(
Expand All @@ -346,50 +339,3 @@ def load_profile_properties(self, profile_name: str) -> dict:
# self._missing_secure_props.extend(secure_fields)

return props

def load_secure_props(self) -> None:
"""
load secure_props stored for the given config file
Returns
-------
None
if keyring is not initialized, set empty value
"""
if not HAS_KEYRING:
self.secure_props = {}
return

try:
service_name = constants["ZoweServiceName"]

if sys.platform == "win32":
service_name += "/" + constants["ZoweAccountName"]

secret_value = keyring.get_password(
service_name, constants["ZoweAccountName"]
)

except Exception as exc:
raise SecureProfileLoadFailed(
constants["ZoweServiceName"], error_msg=str(exc)
) from exc

secure_config: str
if sys.platform == "win32":
secure_config = secret_value.encode("utf-16")
else:
secure_config = secret_value

secure_config_json = commentjson.loads(base64.b64decode(secure_config).decode())

# look for credentials stored for currently loaded config
try:
self.secure_props = secure_config_json.get(self.filepath, {})
except KeyError as exc:
error_msg = str(exc)
warnings.warn(
f"No credentials found for loaded config file '{self.filepath}'"
f" with error '{error_msg}'",
SecurePropsNotFoundWarning,
)
1 change: 1 addition & 0 deletions src/core/zowe/core_for_zowe_sdk/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@
"ZoweCredentialKey": "Zowe-Plugin",
"ZoweServiceName": "Zowe",
"ZoweAccountName": "secure_config_props",
"WIN32_CRED_MAX_STRING_LENGTH" : 2560
}
185 changes: 185 additions & 0 deletions src/core/zowe/core_for_zowe_sdk/credential_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
"""Zowe Python Client SDK.
This program and the accompanying materials are made available under the terms of the
Eclipse Public License v2.0 which accompanies this distribution, and is available at
https://www.eclipse.org/legal/epl-v20.html
SPDX-License-Identifier: EPL-2.0
Copyright Contributors to the Zowe Project.
"""
import sys
import warnings
import base64
import logging
from typing import Optional
import commentjson
from zowe.core_for_zowe_sdk import constants
from zowe.core_for_zowe_sdk.exceptions import (
SecureProfileLoadFailed
)

HAS_KEYRING = True
try:
import keyring

except ImportError:
HAS_KEYRING = False

class CredentialManager:
secure_props = {}



@staticmethod
def load_secure_props() -> None:
"""
load secure_props stored for the given config file
Returns
-------
None
if keyring is not initialized, set empty value
"""
if not HAS_KEYRING:
CredentialManager.secure_props = {}
return

try:
service_name = constants["ZoweServiceName"]
secret_value = CredentialManager._retrieve_credential(service_name)
# Handle the case when secret_value is None
if secret_value is None:
return

except Exception as exc:
raise SecureProfileLoadFailed(
constants["ZoweServiceName"], error_msg=str(exc)
) from exc

secure_config: str
secure_config = secret_value.encode()
secure_config_json = commentjson.loads(base64.b64decode(secure_config).decode())
# update the secure props
CredentialManager.secure_props = secure_config_json


@staticmethod
def _retrieve_credential(service_name: str) -> Optional[str]:
"""
Retrieve the credential from the keyring or storage.
If the credential exceeds the maximum length, retrieve it in parts.
Parameters
----------
service_name: str
The service name for the credential retrieval
Returns
-------
str
The retrieved encoded credential
"""
# Configure the logger to ignore warning messages
logging.getLogger().setLevel(logging.ERROR)
is_win32 = sys.platform == "win32"
if is_win32:
service_name += "/" + constants["ZoweAccountName"]
encoded_credential = keyring.get_password(service_name, constants["ZoweAccountName"])
if encoded_credential is None and is_win32:
# Retrieve the secure value with an index
index = 1
temp_value = keyring.get_password(f"{service_name}-{index}", f"{constants['ZoweAccountName']}-{index}")
while temp_value is not None:
if encoded_credential is None:
encoded_credential = temp_value
else:
encoded_credential += temp_value
index += 1
temp_value = keyring.get_password(f"{service_name}-{index}", f"{constants['ZoweAccountName']}-{index}")

if is_win32:
try:
encoded_credential = encoded_credential.encode('utf-16le').decode()
except (UnicodeDecodeError, AttributeError):
# The credential is not encoded in UTF-16
pass

if encoded_credential is not None and encoded_credential.endswith("\0"):
encoded_credential = encoded_credential[:-1]

return encoded_credential


@staticmethod
def delete_credential(service_name: str, account_name: str) -> None:
"""
Delete the credential from the keyring or storage.
If the keyring.delete_password function is not available, iterate through and delete credentials.
Parameters
----------
service_name: str
The service name for the credential deletion
account_name: str
The account name for the credential deletion
Returns
-------
None
"""

try:
keyring.delete_password(service_name, account_name)
except keyring.errors.PasswordDeleteError:
pass

# Handling multiple credentials stored when the operating system is Windows
if sys.platform == "win32":
index = 1
while True:
field_name = f"{account_name}-{index}"
service_name = f"{service_name}-{index}"
try:
keyring.delete_password(service_name, field_name)
except keyring.errors.PasswordDeleteError:
break
index += 1


@staticmethod
def save_secure_props()-> None:
"""
Set secure_props for the given config file
Returns
-------
None
"""
if not HAS_KEYRING:
return

service_name = constants["ZoweServiceName"]
credential = CredentialManager.secure_props
# Check if credential is a non-empty string
if credential:
is_win32 = sys.platform == "win32"

encoded_credential = base64.b64encode(commentjson.dumps(credential).encode()).decode()
if is_win32:
service_name += "/" + constants["ZoweAccountName"]
# Delete the existing credential
CredentialManager.delete_credential(service_name , constants["ZoweAccountName"])
# Check if the encoded credential exceeds the maximum length for win32
if is_win32 and len(encoded_credential) > constants["WIN32_CRED_MAX_STRING_LENGTH"]:
# Split the encoded credential string into chunks of maximum length
chunk_size = constants["WIN32_CRED_MAX_STRING_LENGTH"]
encoded_credential+='\0'
chunks = [encoded_credential[i: i + chunk_size] for i in range(0, len(encoded_credential), chunk_size)]
# Set the individual chunks as separate keyring entries
for index, chunk in enumerate(chunks, start=1):
password=(chunk + '\0' *(len(chunk)%2)).encode().decode('utf-16le')
field_name = f"{constants['ZoweAccountName']}-{index}"
keyring.set_password(f"{service_name}-{index}", field_name, password)

else:
# Credential length is within the maximum limit or not on win32, set it as a single keyring entry
keyring.set_password(
service_name, constants["ZoweAccountName"],
encoded_credential)
6 changes: 5 additions & 1 deletion src/core/zowe/core_for_zowe_sdk/zosmf_profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,10 @@ def __get_secure_value(self, name):
service_name += "/" + account_name

secret_value = keyring.get_password(service_name, account_name)

# Handle the case when secret_value is None
if secret_value is None:
secret_value = ""

if sys.platform == "win32":
secret_value = secret_value.encode("utf-16")
Expand Down Expand Up @@ -156,4 +160,4 @@ def get_password(self, service, username):
else:
return self.__get_password(service, username, collection)

keyring.set_keyring(KeyringBackend())
keyring.set_keyring(KeyringBackend())
Loading

0 comments on commit 4bc1a86

Please sign in to comment.