From 97f6e96bf18069fdf7b0d03f459843c1ed545d04 Mon Sep 17 00:00:00 2001 From: Nimish Date: Sat, 4 May 2024 15:30:22 +0530 Subject: [PATCH 01/36] feat: added exceptions.py --- src/phase/exceptions.py | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/phase/exceptions.py diff --git a/src/phase/exceptions.py b/src/phase/exceptions.py new file mode 100644 index 0000000..d824f3a --- /dev/null +++ b/src/phase/exceptions.py @@ -0,0 +1,4 @@ +class EnvironmentNotFoundException(Exception): + def __init__(self, env_name): + super().__init__(f"⚠️\u200A Warning: The environment '{env_name}' either does not exist or you do not have access to it.") + \ No newline at end of file From 655ed9ea35b71288d5ca7438b60b6f618fd00bae Mon Sep 17 00:00:00 2001 From: Nimish Date: Sat, 4 May 2024 15:30:36 +0530 Subject: [PATCH 02/36] feat: added network.py --- src/phase/utils/network.py | 309 +++++++++++++++++++++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 src/phase/utils/network.py diff --git a/src/phase/utils/network.py b/src/phase/utils/network.py new file mode 100644 index 0000000..bd2032e --- /dev/null +++ b/src/phase/utils/network.py @@ -0,0 +1,309 @@ +import os +import requests +from .misc import get_user_agent +from typing import List +from typing import Dict +import json + +# Check if SSL verification should be skipped +VERIFY_SSL = os.environ.get('PHASE_VERIFY_SSL', 'True').lower() != 'false' + +# Check if debug mode is enabled +PHASE_DEBUG = os.environ.get('PHASE_DEBUG', 'False').lower() == 'true' + +# Suppress InsecureRequestWarning if SSL verification is skipped +if not VERIFY_SSL: + requests.packages.urllib3.disable_warnings(requests.packages.urllib3.exceptions.InsecureRequestWarning) + + +def handle_request_errors(response: requests.Response) -> None: + """ + Check the HTTP status code of a response and print the error if the status code is not 200. + + Args: + response (requests.Response): The HTTP response to check. + """ + if response.status_code == 403: + print("🚫 Not authorized. Token expired or revoked.") + return + + if response.status_code != 200: + try: + error_details = json.loads(response.text).get('error', 'Unknown error') + except json.JSONDecodeError: + error_details = 'Unknown error' + if PHASE_DEBUG: + error_details += f" (Raw response: {response.text})" + + error_message = f"🗿 Request failed with status code {response.status_code}: {error_details}" + raise Exception(error_message) + + +def handle_connection_error(e: Exception) -> None: + """ + Handle ConnectionError exceptions. + + Args: + e (Exception): The exception to handle. + """ + error_message = "🗿 Network error: Please check your internet connection." + if PHASE_DEBUG: + error_message += f" Detail: {str(e)}" + raise Exception(error_message) + + +def handle_ssl_error(e: Exception) -> None: + """ + Handle SSLError exceptions. + + Args: + e (Exception): The exception to handle. + """ + error_message = "🗿 SSL error: The Phase Console is using an invalid/expired or a self-signed certificate." + if PHASE_DEBUG: + error_message += f" Detail: {str(e)}" + raise Exception(error_message) + + +def construct_http_headers(token_type: str, app_token: str) -> Dict[str, str]: + """ + Construct common headers used for HTTP requests. + + Args: + token_type (str): The type of token being used. + app_token (str): The token for the application. + + Returns: + Dict[str, str]: The common headers including User-Agent. + """ + return { + "Authorization": f"Bearer {token_type.capitalize()} {app_token}", + "User-Agent": get_user_agent() + } + + +def fetch_phase_user(token_type: str, app_token: str, host: str) -> requests.Response: + """ + Fetch users from the Phase API. + + Args: + app_token (str): The token for the application. + + Returns: + requests.Response: The HTTP response from the Phase KMS. + """ + + headers = construct_http_headers(token_type, app_token) + + URL = f"{host}/service/secrets/tokens/" + + try: + response = requests.get(URL, headers=headers, verify=VERIFY_SSL) + handle_request_errors(response) + return response + except requests.exceptions.ConnectionError as e: + handle_connection_error(e) + except requests.exceptions.SSLError as e: + handle_ssl_error(e) + +def fetch_app_key(token_type: str, app_token, host) -> str: + """ + Fetches the application key share from Phase KMS. + + Args: + app_token (str): The token for the application to retrieve the key for. + token_type (str): The type of token being used, either "user" or "service". Defaults to "user". + + Returns: + str: The wrapped key share. + Raises: + Exception: If the app token is invalid (HTTP status code 404). + """ + + headers = construct_http_headers(token_type, app_token) + + URL = f"{host}/service/secrets/tokens/" + + response = requests.get(URL, headers=headers) + + if response.status_code != 200: + raise ValueError(f"Request failed with status code {response.status_code}: {response.text}") + + if not response.text: + raise ValueError("The response body is empty!") + + try: + json_data = response.json() + except requests.exceptions.JSONDecodeError: + raise ValueError(f"Failed to decode JSON from response: {response.text}") + + wrapped_key_share = json_data.get("wrapped_key_share") + if not wrapped_key_share: + raise ValueError("Wrapped key share not found in the response!") + + return wrapped_key_share + + +def fetch_wrapped_key_share(token_type: str, app_token: str, host: str) -> str: + """ + Fetches the wrapped application key share from Phase KMS. + + Args: + token_type (str): The type of token being used, either "user" or "service". + app_token (str): The token for the application to retrieve the key for. + host (str): The host for the API call. + + Returns: + str: The wrapped key share. + + Raises: + ValueError: If any errors occur during the fetch operation. + """ + + headers = construct_http_headers(token_type, app_token) + + URL = f"{host}/service/secrets/tokens/" + + response = requests.get(URL, headers=headers) + + if response.status_code != 200: + raise ValueError(f"Request failed with status code {response.status_code}: {response.text}") + + if not response.text: + raise ValueError("The response body is empty!") + + try: + json_data = response.json() + except requests.exceptions.JSONDecodeError: + raise ValueError(f"Failed to decode JSON from response: {response.text}") + + wrapped_key_share = json_data.get("wrapped_key_share") + if not wrapped_key_share: + raise ValueError("Wrapped key share not found in the response!") + + return wrapped_key_share + + +def fetch_phase_secrets(token_type: str, app_token: str, id: str, host: str, key_digest: str = '', path: str = '') -> requests.Response: + """ + Fetch a single secret from Phase KMS based on key digest, with an optional path parameter. + + Args: + token_type (str): The type of the token. + app_token (str): The token for the application. + id (str): The environment ID. + host (str): The host URL. + key_digest (str): The digest of the key to fetch. + path (str, optional): A specific path to fetch secrets from. + + Returns: + dict: The single secret fetched from the Phase KMS, or an error message. + """ + + headers = {**construct_http_headers(token_type, app_token), "Environment": id, "KeyDigest": key_digest} + if path: + headers["Path"] = path + + URL = f"{host}/service/secrets/" + + try: + response = requests.get(URL, headers=headers, verify=VERIFY_SSL) + handle_request_errors(response) + return response + except requests.exceptions.ConnectionError as e: + handle_connection_error(e) + except requests.exceptions.SSLError as e: + handle_ssl_error(e) + + +def create_phase_secrets(token_type: str, app_token: str, environment_id: str, secrets: List[dict], host: str) -> requests.Response: + """ + Create secrets in Phase KMS through HTTP POST request. + + Args: + app_token (str): The token for the application. + environment_id (str): The environment ID. + secrets (List[dict]): The list of secrets to be created. + + Returns: + requests.Response: The HTTP response from the Phase KMS. + """ + + headers = {**construct_http_headers(token_type, app_token), "Environment": environment_id} + + data = { + "secrets": secrets + } + + URL = f"{host}/service/secrets/" + + try: + response = requests.post(URL, headers=headers, json=data, verify=VERIFY_SSL) + handle_request_errors(response) + return response + except requests.exceptions.ConnectionError as e: + handle_connection_error(e) + except requests.exceptions.SSLError as e: + handle_ssl_error(e) + + +def update_phase_secrets(token_type: str, app_token: str, environment_id: str, secrets: List[dict], host: str) -> requests.Response: + """ + Update secrets in Phase KMS through HTTP PUT request. + + Args: + app_token (str): The token for the application. + environment_id (str): The environment ID. + secrets (List[dict]): The list of secrets to be updated. + + Returns: + requests.Response: The HTTP response from the Phase KMS. + """ + + headers = {**construct_http_headers(token_type, app_token), "Environment": environment_id} + + data = { + "secrets": secrets + } + + URL = f"{host}/service/secrets/" + + try: + response = requests.put(URL, headers=headers, json=data, verify=VERIFY_SSL) + handle_request_errors(response) + return response + except requests.exceptions.ConnectionError as e: + handle_connection_error(e) + except requests.exceptions.SSLError as e: + handle_ssl_error(e) + + +def delete_phase_secrets(token_type: str, app_token: str, environment_id: str, secret_ids: List[str], host: str) -> requests.Response: + """ + Delete secrets from Phase KMS. + + Args: + app_token (str): The token for the application. + environment_id (str): The environment ID. + secret_ids (List[str]): The list of secret IDs to be deleted. + + Returns: + requests.Response: The HTTP response from the Phase KMS. + """ + + headers = {**construct_http_headers(token_type, app_token), "Environment": environment_id} + + data = { + "secrets": secret_ids + } + + URL = f"{host}/service/secrets/" + + try: + response = requests.delete(URL, headers=headers, json=data, verify=VERIFY_SSL) + handle_request_errors(response) + return response + except requests.exceptions.ConnectionError as e: + handle_connection_error(e) + except requests.exceptions.SSLError as e: + handle_ssl_error(e) From 14687db0890cfbda2e86da0d2e04b434e77b872a Mon Sep 17 00:00:00 2001 From: Nimish Date: Sat, 4 May 2024 15:30:54 +0530 Subject: [PATCH 03/36] wip: crypto.py --- src/phase/utils/crypto.py | 400 ++++++++++++++++++++++++-------------- 1 file changed, 249 insertions(+), 151 deletions(-) diff --git a/src/phase/utils/crypto.py b/src/phase/utils/crypto.py index a70ccd9..a008d95 100644 --- a/src/phase/utils/crypto.py +++ b/src/phase/utils/crypto.py @@ -1,161 +1,259 @@ -import requests -import functools import base64 from typing import Tuple -from nacl.bindings import crypto_kx_keypair, crypto_aead_xchacha20poly1305_ietf_encrypt, crypto_aead_xchacha20poly1305_ietf_decrypt, randombytes, crypto_secretbox_NONCEBYTES -from ..version import __version__ - - -def xor_bytes(a, b) -> bytes: - """ - Computes the XOR of two byte arrays byte by byte. - - Args: - a (bytes): The first byte array - b (bytes): The second byte array. - - Returns: - bytes: A byte array representing the XOR of the two input byte arrays. - """ - return bytes(x ^ y for x, y in zip(a, b)) - - -def reconstruct_secret(shares) -> str: - """ - Reconstructs a secret given an array of shares. - - Args: - shares (list): A list of hex-encoded secret shares. - - Returns: - str: The reconstructed secret as a hex-encoded string. - """ - return functools.reduce(xor_bytes, [bytes.fromhex(share) for share in shares]).hex() - - -def random_key_pair() -> Tuple[bytes, bytes]: - """ - Generates a random key exchange keypair. - - Returns: - Tuple[bytes, bytes]: A tuple of two bytes objects representing the public and - private keys of the keypair. - """ - keypair = crypto_kx_keypair() - return keypair - - -def encrypt_raw(plaintext, key) -> bytes: - """ - Encrypts plaintext with the given key and returns the ciphertext with appended nonce - - Args: - plaintext (bytes): Plaintext to be encrypted - key (bytes): The encryption key to be used - - Returns: - bytes: ciphertext + nonce - """ - try: - nonce = randombytes(crypto_secretbox_NONCEBYTES) +import string +from nacl.secret import SecretBox +from typing import List +from nacl.encoding import RawEncoder +import functools +import nacl.bindings +from nacl.encoding import HexEncoder +from nacl.public import PrivateKey +from nacl.bindings import ( + crypto_kx_keypair, + crypto_aead_xchacha20poly1305_ietf_encrypt, + crypto_aead_xchacha20poly1305_ietf_decrypt, + randombytes, + crypto_secretbox_NONCEBYTES, + crypto_kx_server_session_keys, + crypto_kx_client_session_keys, + crypto_kx_seed_keypair, +) +from nacl.hash import blake2b +from nacl.utils import random +from base64 import b64encode, b64decode +from .const import __ph_version__ + + +class CryptoUtils: + VERSION = 1 + + @staticmethod + def random_key_pair() -> Tuple[bytes, bytes]: + """ + Generates a random key exchange keypair. + + Returns: + Tuple[bytes, bytes]: A tuple of two bytes objects representing the public and + private keys of the keypair. + """ + keypair = crypto_kx_keypair() + return keypair + + @staticmethod + def client_session_keys(ephemeral_key_pair, recipient_pub_key): + client_public_key, client_private_key = ephemeral_key_pair + return nacl.bindings.crypto_kx_client_session_keys( + client_public_key, client_private_key, recipient_pub_key + ) + + @staticmethod + def server_session_keys(app_key_pair, data_pub_key): + server_public_key, server_private_key = app_key_pair + return nacl.bindings.crypto_kx_server_session_keys( + server_public_key, server_private_key, data_pub_key + ) + + @staticmethod + def encrypt_asymmetric(plaintext, public_key_hex): + public_key, private_key = CryptoUtils.random_key_pair() + + symmetric_keys = CryptoUtils.client_session_keys( + (public_key, private_key), bytes.fromhex(public_key_hex) + ) + + ciphertext = CryptoUtils.encrypt_string(plaintext, symmetric_keys[1]) + + return f"ph:v{CryptoUtils.VERSION}:{public_key.hex()}:{ciphertext}" + + @staticmethod + def decrypt_asymmetric(ciphertext_string, private_key_hex, public_key_hex): + ciphertext_segments = ciphertext_string.split(":") + + if len(ciphertext_segments) != 4: + raise ValueError("Invalid ciphertext") + + public_key = bytes.fromhex(public_key_hex) + private_key = bytes.fromhex(private_key_hex) + + session_keys = CryptoUtils.server_session_keys( + (public_key, private_key), bytes.fromhex(ciphertext_segments[2]) + ) + + plaintext = CryptoUtils.decrypt_string(ciphertext_segments[3], session_keys[0]) + + return plaintext + + @staticmethod + def digest(input): + hash = blake2b(input.encode(), encoder=nacl.encoding.RawEncoder) + return base64.b64encode(hash).decode() + + @staticmethod + def encrypt_raw(plaintext, key): + nonce = random(nacl.bindings.crypto_secretbox_NONCEBYTES) ciphertext = crypto_aead_xchacha20poly1305_ietf_encrypt( - plaintext, None, nonce, key) - return ciphertext + nonce - except Exception: - raise ValueError('Encryption error') - - -def encrypt_b64(plaintext, key_bytes) -> str: + plaintext.encode(), None, nonce, key + ) + return bytearray(ciphertext + nonce) + + @staticmethod + def decrypt_raw(ct, key) -> bytes: + try: + nonce = ct[-24:] + ciphertext = ct[:-24] + plaintext_bytes = crypto_aead_xchacha20poly1305_ietf_decrypt( + ciphertext, None, nonce, key + ) + return plaintext_bytes + except Exception as e: + print(f"Exception during decryption: {e}") + raise ValueError("Decryption error") from e + + @staticmethod + def encrypt_b64(plaintext, key_bytes) -> str: + """ + Encrypts a string using a key. Returns ciphertext as a base64 string + + Args: + plaintext (str): The plaintext to encrypt. + key (bytes): The key to use for encryption. + + Returns: + str: The ciphertext obtained by encrypting the string with the key, encoded with base64. + """ + + plaintext_bytes = bytes(plaintext, "utf-8") + ciphertext = CryptoUtils.encrypt_raw(plaintext_bytes, key_bytes) + return base64.b64encode(ciphertext).decode("utf-8") + + @staticmethod + def decrypt_b64(ct, key) -> bytes: + """ + Decrypts a base64 ciphertext using a key. + + Args: + ct (str): The ciphertext to decrypt, as a base64 string. + key (str): The key to use for decryption, as a hexadecimal string. + + Returns: + str: The plaintext obtained by decrypting the ciphertext with the key. + """ + + ct_bytes = base64.b64decode(ct) + key_bytes = bytes.fromhex(key) + + plaintext_bytes = CryptoUtils.decrypt_raw(ct_bytes, key_bytes) + + return plaintext_bytes.decode("utf-8") + + @staticmethod + def encrypt_string(plaintext, key): + return base64.b64encode(CryptoUtils.encrypt_raw(plaintext, key)).decode() + + @staticmethod + def decrypt_string(cipherText, key): + return CryptoUtils.decrypt_raw(base64.b64decode(cipherText), key).decode() + + @staticmethod + def env_keypair(env_seed: str): + """ + Derives an env keyring from the given seed + + :param env_seed: Env seed as a hex string + :return: A dictionary containing the public and private keys in hex format + """ + + # Convert the hex seed to bytes + seed_bytes = bytes.fromhex(env_seed) + + # Generate the key pair + public_key, private_key = nacl.bindings.crypto_kx_seed_keypair(seed_bytes) + + # Convert the keys to hex format + public_key_hex = public_key.hex() + private_key_hex = private_key.hex() + + # Return the keys in a dictionary + return {"publicKey": public_key_hex, "privateKey": private_key_hex} + + @staticmethod + def blake2b_digest(input_str: str, salt: str) -> str: + """ + Generate a BLAKE2b hash of the input string with a salt. + + Args: + input_str (str): The input string to be hashed. + salt (str): The salt (key) used for hashing. + + Returns: + str: The hexadecimal representation of the hash. + """ + hash_size = 32 # 32 bytes (256 bits) + hashed = blake2b( + input_str.encode("utf-8"), + key=salt.encode("utf-8"), + encoder=nacl.encoding.RawEncoder, + digest_size=hash_size, + ) + hex_encoded = hashed.hex() + return hex_encoded + + @staticmethod + def xor_bytes(a, b) -> bytes: + """ + Computes the XOR of two byte arrays byte by byte. + + Args: + a (bytes): The first byte array + b (bytes): The second byte array. + + Returns: + bytes: A byte array representing the XOR of the two input byte arrays. + """ + return bytes(x ^ y for x, y in zip(a, b)) + + @staticmethod + def reconstruct_secret(shares) -> str: + """ + Reconstructs a secret given an array of shares. + + Args: + shares (list): A list of hex-encoded secret shares. + + Returns: + str: The reconstructed secret as a hex-encoded string. + """ + return functools.reduce( + CryptoUtils.xor_bytes, [bytes.fromhex(share) for share in shares] + ).hex() + + +def generate_random_secret(type='hex', length=32): """ - Encrypts a string using a key. Returns ciphertext as a base64 string + Generates a random secret based on the specified type and length. Args: - plaintext (str): The plaintext to encrypt. - key (bytes): The key to use for encryption. + type (str): Type of secret to generate ('alphanumeric', 'hex', 'base64', 'base64url', 'key128', 'key256'). + length (int): Length of the secret (1 to 256). For 'key128' and 'key256', length is ignored. Returns: - str: The ciphertext obtained by encrypting the string with the key, encoded with base64. + str: The generated secret. """ - - plaintext_bytes = bytes(plaintext, 'utf-8') - ciphertext = encrypt_raw(plaintext_bytes, key_bytes) - return base64.b64encode(ciphertext).decode('utf-8') - - -def decrypt_raw(ct, key) -> bytes: - """ - Decrypts a ciphertext using a key. - - Args: - ct (bytes): The ciphertext to decrypt. - key (bytes): The key to use for decryption, as a hexadecimal string. - - Returns: - bytes: The plaintext obtained by decrypting the ciphertext with the key. - """ - - try: - nonce = ct[-24:] - ciphertext = ct[:-24] - - plaintext_bytes = crypto_aead_xchacha20poly1305_ietf_decrypt( - ciphertext, None, nonce, key) - - return plaintext_bytes - except Exception: - raise ValueError('Decryption error') - - -def decrypt_b64(ct, key) -> bytes: - """ - Decrypts a base64 ciphertext using a key. - - Args: - ct (str): The ciphertext to decrypt, as a base64 string. - key (str): The key to use for decryption, as a hexadecimal string. - - Returns: - str: The plaintext obtained by decrypting the ciphertext with the key. - """ - - ct_bytes = base64.b64decode(ct) - key_bytes = bytes.fromhex(key) - - plaintext_bytes = decrypt_raw(ct_bytes, key_bytes) - - return plaintext_bytes.decode('utf-8') - - -def fetch_app_key(appToken, wrapKey, appId, dataSize, host) -> str: - """ - Fetches the application key share from Phase KMS. - - Args: - appToken (str): The token for the application to retrieve the key for. - wrapKey (str): The key used to encrypt the wrapped key share. - appId (str): The identifier for the application to retrieve the key for. - dataSize (int): The size of the data to be decrypted. - - Returns: - str: The unwrapped share obtained by decrypting the wrapped key share. - Raises: - Exception: If the app token is invalid (HTTP status code 404). - """ - - headers = { - "Authorization": f"Bearer {appToken}", - "EventType": "decrypt", - "PhaseNode": f"python:{__version__}", - "PhSize": f"{dataSize}" - } - - response = requests.get(f"{host}/{appId}", headers=headers) - - if response.status_code == 404: - raise Exception("Invalid app token") + if not 1 <= length <= 256: + raise ValueError("Length must be between 1 and 256.") + + if type == 'alphanumeric': + chars = string.ascii_letters + string.digits + return ''.join(chars[byte % len(chars)] for byte in nacl.utils.random(length)) + elif type == 'hex': + return nacl.utils.random(length).hex() + elif type == 'base64': + return base64.b64encode(nacl.utils.random(length)).decode() + elif type == 'base64url': + return base64.urlsafe_b64encode(nacl.utils.random(length)).decode() + elif type == 'key128': + return base64.b64encode(nacl.utils.random(SecretBox.KEY_SIZE // 2)).decode() + elif type == 'key256': + return base64.b64encode(nacl.utils.random(SecretBox.KEY_SIZE)).decode() else: - json_data = response.json() - wrapped_key_share = json_data["wrappedKeyShare"] - unwrapped_key = decrypt_raw(bytes.fromhex( - wrapped_key_share), bytes.fromhex(wrapKey)) - return unwrapped_key.decode("utf-8") + raise ValueError("Invalid secret type specified.") From 7a3ab8fe326a9a2f57b05306e2dc0a12ae307c07 Mon Sep 17 00:00:00 2001 From: Nimish Date: Sat, 4 May 2024 15:31:12 +0530 Subject: [PATCH 04/36] chore: remove version.py - version moved to const.py --- src/phase/version.py | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 src/phase/version.py diff --git a/src/phase/version.py b/src/phase/version.py deleted file mode 100644 index e843230..0000000 --- a/src/phase/version.py +++ /dev/null @@ -1,2 +0,0 @@ -__version__ = "1.1.0" -__ph_version__ = "v1" From e479649f4e56be91f4db389ec9af42dff4560148 Mon Sep 17 00:00:00 2001 From: Nimish Date: Sat, 4 May 2024 15:31:18 +0530 Subject: [PATCH 05/36] feat: added const.py --- src/phase/utils/const.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 src/phase/utils/const.py diff --git a/src/phase/utils/const.py b/src/phase/utils/const.py new file mode 100644 index 0000000..ec3c99f --- /dev/null +++ b/src/phase/utils/const.py @@ -0,0 +1,18 @@ +import os +import re + +__version__ = "2.0.0" +__ph_version__ = "v1" + + +SECRET_REF_REGEX = re.compile(r'\$\{([^}]+)\}') + + +PHASE_CLOUD_API_HOST = "https://console.phase.dev" + +pss_user_pattern = re.compile(r"^pss_user:v(\d+):([a-fA-F0-9]{64}):([a-fA-F0-9]{64}):([a-fA-F0-9]{64}):([a-fA-F0-9]{64})$") +pss_service_pattern = re.compile(r"^pss_service:v(\d+):([a-fA-F0-9]{64}):([a-fA-F0-9]{64}):([a-fA-F0-9]{64}):([a-fA-F0-9]{64})$") + +cross_env_pattern = re.compile(r"\$\{(.+?)\.(.+?)\}") +local_ref_pattern = re.compile(r"\$\{([^.]+?)\}") + From f5f1eb723c74573eceb41f6d3c03801c221776ca Mon Sep 17 00:00:00 2001 From: Nimish Date: Sat, 4 May 2024 15:31:43 +0530 Subject: [PATCH 06/36] feat: added secret management support to phase --- src/phase/phase.py | 346 +++++++++++++++++++++++++++++++++++++++------ 1 file changed, 300 insertions(+), 46 deletions(-) diff --git a/src/phase/phase.py b/src/phase/phase.py index 0e9fa95..20cabcb 100644 --- a/src/phase/phase.py +++ b/src/phase/phase.py @@ -1,67 +1,322 @@ -import re -from nacl.bindings import crypto_kx_server_session_keys, crypto_kx_client_session_keys +import requests +from typing import Tuple +from typing import List, Dict from dataclasses import dataclass -from .utils.crypto import decrypt_b64, encrypt_b64, fetch_app_key, random_key_pair, reconstruct_secret -from .version import __version__, __ph_version__ - -DEFAULT_KMS_HOST = "https://kms.phase.dev" +from .utils.network import ( + fetch_phase_user, + fetch_app_key, + fetch_wrapped_key_share, + fetch_phase_secrets, + create_phase_secrets, + update_phase_secrets, + delete_phase_secrets +) +from nacl.bindings import ( + crypto_kx_server_session_keys, +) +from .utils.crypto import CryptoUtils +from .utils.const import __ph_version__, pss_user_pattern, pss_service_pattern +from .utils.misc import phase_get_context, normalize_tag, tag_matches @dataclass class AppSecret: prefix: str - pss_version: str + pes_version: str app_token: str + pss_user_public_key: str keyshare0: str keyshare1_unwrap_key: str class Phase: - _app_id = '' _app_pub_key = '' + _api_host = '' _app_secret = None - _kms_host = '' - def __init__(self, app_id, app_secret, custom_kms_host=None): - app_id_pattern = re.compile(r"^phApp:v(\d+):([a-fA-F0-9]{64})$") - app_secret_pattern = re.compile( - r"^pss:v(\d+):([a-fA-F0-9]{64}):([a-fA-F0-9]{64,128}):([a-fA-F0-9]{64})$") - if not app_id_pattern.match(app_id): - raise ValueError("Invalid Phase APP_ID") + def __init__(self, init=True, pss=None, host=None): + """ + Initializes the Phase class with optional parameters. + + Parameters: + - init (bool): Whether to initialize using default methods or use provided parameters. + - pss (str): The Phase user token. Used if init is False. + - host (str): The host URL. Used if init is False. + """ + + app_secret = pss + self._api_host = host + + # Determine the type of the token (service token or user token) + self.is_service_token = pss_service_pattern.match(app_secret) is not None + self.is_user_token = pss_user_pattern.match(app_secret) is not None + + # If it's neither a service token nor a user token, raise an error + if not self.is_service_token and not self.is_user_token: + token_type = "service token" if "pss_service" in app_secret else "user token" + raise ValueError(f"Invalid Phase {token_type}") + + # Storing the token type as a string for easier access + self._token_type = "service" if self.is_service_token else "user" + + pss_segments = app_secret.split(':') + self._app_secret = AppSecret(*pss_segments) - if not app_secret_pattern.match(app_secret): - raise ValueError("Invalid Phase APP_SECRET") - self._app_id = app_id - self._app_pub_key = app_id.split(':')[2] + def _find_matching_environment_key(self, user_data, env_id): + for app in user_data.get("apps", []): + for environment_key in app.get("environment_keys", []): + if environment_key["environment"]["id"] == env_id: + return environment_key + return None - app_secret_segments = app_secret.split(':') - self._app_secret = AppSecret(*app_secret_segments) - self._kms_host = f"{custom_kms_host}/kms" if custom_kms_host else DEFAULT_KMS_HOST - def encrypt(self, plaintext, tag="") -> str | None: + def create(self, key_value_pairs: List[Tuple[str, str]], env_name: str, app_name: str, path: str = '/') -> requests.Response: """ - Encrypts a plaintext string. + Create secrets in Phase KMS with support for specifying a path. Args: - plaintext (str): The plaintext to encrypt. - tag (str, optional): A tag to include in the encrypted message. The tag will not be encrypted. + key_value_pairs (List[Tuple[str, str]]): List of tuples where each tuple contains a key and a value. + env_name (str): The name (or partial name) of the desired environment. + app_name (str): The name of the application context. + path (str, optional): The path under which to store the secrets. Defaults to the root path '/'. Returns: - str: The encrypted message, formatted as a string that includes the public key used for the one-time keypair, - the ciphertext, and the tag. Returns `None` if an error occurs. + requests.Response: The HTTP response from the Phase KMS. """ - try: - one_time_keypair = random_key_pair() - symmetric_keys = crypto_kx_client_session_keys( - one_time_keypair[0], one_time_keypair[1], bytes.fromhex(self._app_pub_key)) - ciphertext = encrypt_b64(plaintext, symmetric_keys[1]) - pub_key = one_time_keypair[0].hex() + user_response = fetch_phase_user(self._token_type, self._app_secret.app_token, self._api_host) + if user_response.status_code != 200: + raise ValueError(f"Request failed with status code {user_response.status_code}: {user_response.text}") - return f"ph:{__ph_version__}:{pub_key}:{ciphertext}:{tag}" - except ValueError as err: - raise ValueError(f"Something went wrong: {err}") + user_data = user_response.json() + app_name, app_id, env_name, env_id, public_key = phase_get_context(user_data, app_name=app_name, env_name=env_name) + + environment_key = self._find_matching_environment_key(user_data, env_id) + if environment_key is None: + raise ValueError(f"No environment found with id: {env_id}") + + wrapped_salt = environment_key.get("wrapped_salt") + decrypted_salt = self.decrypt(wrapped_salt) + + secrets = [] + for key, value in key_value_pairs: + encrypted_key = CryptoUtils.encrypt_asymmetric(key, public_key) + encrypted_value = CryptoUtils.encrypt_asymmetric(value, public_key) + key_digest = CryptoUtils.blake2b_digest(key, decrypted_salt) + + secret = { + "key": encrypted_key, + "keyDigest": key_digest, + "value": encrypted_value, + "path": path, + "tags": [], # TODO: Implement tags and comments creation + "comment": "" + } + secrets.append(secret) + + return create_phase_secrets(self._token_type, self._app_secret.app_token, env_id, secrets, self._api_host) + + + def get(self, env_name: str, keys: List[str] = None, app_name: str = None, tag: str = None, path: str = '') -> List[Dict]: + """ + Get secrets from Phase KMS based on key and environment, with support for personal overrides, + optional tag matching, decrypting comments, and now including path support and key digest optimization. + + Args: + env_name (str): The name (or partial name) of the desired environment. + keys (List[str], optional): The keys for which to retrieve the secret values. + app_name (str, optional): The name of the desired application. + tag (str, optional): The tag to match against the secrets. + path (str, optional): The path under which to fetch secrets, default is root. + + Returns: + List[Dict]: A list of dictionaries for all secrets in the environment that match the criteria, including their paths. + """ + + user_response = fetch_phase_user(self._token_type, self._app_secret.app_token, self._api_host) + if user_response.status_code != 200: + raise ValueError(f"Request failed with status code {user_response.status_code}: {user_response.text}") + + user_data = user_response.json() + app_name, app_id, env_name, env_id, public_key = phase_get_context(user_data, app_name=app_name, env_name=env_name) + + environment_key = self._find_matching_environment_key(user_data, env_id) + if environment_key is None: + raise ValueError("No environment found with id: {}".format(env_id)) + + wrapped_seed = environment_key.get("wrapped_seed") + decrypted_seed = self.decrypt(wrapped_seed) + key_pair = CryptoUtils.env_keypair(decrypted_seed) + env_private_key = key_pair['privateKey'] + + params = {"path": path} + if keys and len(keys) == 1: + wrapped_salt = environment_key.get("wrapped_salt") + decrypted_salt = self.decrypt(wrapped_salt) + key_digest = CryptoUtils.blake2b_digest(keys[0], decrypted_salt) + params["key_digest"] = key_digest + + secrets_response = fetch_phase_secrets(self._token_type, self._app_secret.app_token, env_id, self._api_host, **params) + + secrets_data = secrets_response.json() + + results = [] + for secret in secrets_data: + # Check if a tag filter is applied and if the secret has the correct tags. + if tag and not tag_matches(secret.get("tags", []), tag): + continue + + secret_id = secret["id"] + override = secret.get("override") + # Check if the override exists and is active. + use_override = override and override.get("is_active") + + key_to_decrypt = secret["key"] + # Select the correct value based on override status. + value_to_decrypt = override["value"] if use_override else secret["value"] + comment_to_decrypt = secret["comment"] + + decrypted_key = CryptoUtils.decrypt_asymmetric(key_to_decrypt, env_private_key, public_key) + decrypted_value = CryptoUtils.decrypt_asymmetric(value_to_decrypt, env_private_key, public_key) + decrypted_comment = CryptoUtils.decrypt_asymmetric(comment_to_decrypt, env_private_key, public_key) if comment_to_decrypt else None + + result = { + "key": decrypted_key, + "value": decrypted_value, + "overridden": use_override, + "tags": secret.get("tags", []), + "comment": decrypted_comment, + "path": secret.get("path", "/"), + "application": app_name, + "environment": env_name + } + + # Only add the secret to results if the requested keys are not specified or the decrypted key is one of the requested keys. + if not keys or decrypted_key in keys: + results.append(result) + + return results + + + def update(self, env_name: str, key: str, value: str, app_name: str = None, source_path: str = '', destination_path: str = None) -> str: + """ + Update a secret in Phase KMS based on key and environment, with support for source and destination paths. + + Args: + env_name (str): The name (or partial name) of the desired environment. + key (str): The key for which to update the secret value. + value (str): The new value for the secret. + app_name (str, optional): The name of the desired application. + source_path (str, optional): The current path of the secret. Defaults to root path '/'. + destination_path (str, optional): The new path for the secret, if changing its location. If not provided, the path is not updated. + + Returns: + str: A message indicating the outcome of the update operation. + """ + + user_response = fetch_phase_user(self._token_type, self._app_secret.app_token, self._api_host) + if user_response.status_code != 200: + raise ValueError(f"Request failed with status code {user_response.status_code}: {user_response.text}") + + user_data = user_response.json() + app_name, app_id, env_name, env_id, public_key = phase_get_context(user_data, app_name=app_name, env_name=env_name) + + environment_key = self._find_matching_environment_key(user_data, env_id) + if environment_key is None: + raise ValueError(f"No environment found with id: {env_id}") + + # Fetch secrets from the specified source path + secrets_response = fetch_phase_secrets(self._token_type, self._app_secret.app_token, env_id, self._api_host, path=source_path) + secrets_data = secrets_response.json() + + wrapped_seed = environment_key.get("wrapped_seed") + decrypted_seed = self.decrypt(wrapped_seed) + key_pair = CryptoUtils.env_keypair(decrypted_seed) + env_private_key = key_pair['privateKey'] + + matching_secret = next((secret for secret in secrets_data if CryptoUtils.decrypt_asymmetric(secret["key"], env_private_key, public_key) == key), None) + if not matching_secret: + return f"Key '{key}' doesn't exist in path '{source_path}'." + + encrypted_key = CryptoUtils.encrypt_asymmetric(key, public_key) + encrypted_value = CryptoUtils.encrypt_asymmetric(value, public_key) + + wrapped_salt = environment_key.get("wrapped_salt") + decrypted_salt = self.decrypt(wrapped_salt) + key_digest = CryptoUtils.blake2b_digest(key, decrypted_salt) + + secret_update_payload = { + "id": matching_secret["id"], + "key": encrypted_key, + "keyDigest": key_digest, + "value": encrypted_value, + "tags": matching_secret.get("tags", []), # TODO: Implement tags and comments updates + "comment": matching_secret.get("comment", ""), + "path": destination_path if destination_path is not None else matching_secret["path"] + } + + response = update_phase_secrets(self._token_type, self._app_secret.app_token, env_id, [secret_update_payload], self._api_host) + + if response.status_code == 200: + return "Success" + else: + return f"Error: Failed to update secret. HTTP Status Code: {response.status_code}" + + + def delete(self, env_name: str, keys_to_delete: List[str], app_name: str = None, path: str = None) -> List[str]: + """ + Delete secrets in Phase KMS based on keys and environment, with optional path support. + + Args: + env_name (str): The name (or partial name) of the desired environment. + keys_to_delete (List[str]): The keys for which to delete the secrets. + app_name (str, optional): The name of the desired application. + path (str, optional): The path within which to delete the secrets. If specified, only deletes secrets within this path. + + Returns: + List[str]: A list of keys that were not found and could not be deleted. + """ + + user_response = fetch_phase_user(self._token_type, self._app_secret.app_token, self._api_host) + if user_response.status_code != 200: + raise ValueError(f"Request failed with status code {user_response.status_code}: {user_response.text}") + + user_data = user_response.json() + app_name, app_id, env_name, env_id, public_key = phase_get_context(user_data, app_name=app_name, env_name=env_name) + + environment_key = self._find_matching_environment_key(user_data, env_id) + if environment_key is None: + raise ValueError(f"No environment found with id: {env_id}") + + wrapped_seed = environment_key.get("wrapped_seed") + decrypted_seed = self.decrypt(wrapped_seed) + key_pair = CryptoUtils.env_keypair(decrypted_seed) + env_private_key = key_pair['privateKey'] + + secret_ids_to_delete = [] + keys_not_found = [] + secrets_response = fetch_phase_secrets(self._token_type, self._app_secret.app_token, env_id, self._api_host, path=path) + secrets_data = secrets_response.json() + + for key in keys_to_delete: + found = False + for secret in secrets_data: + if path is not None and secret.get("path", "/") != path: + continue # Skip secrets not in the specified path + decrypted_key = CryptoUtils.decrypt_asymmetric(secret["key"], env_private_key, public_key) + if decrypted_key == key: + secret_ids_to_delete.append(secret["id"]) + found = True + break + if not found: + keys_not_found.append(key) + + if secret_ids_to_delete: + delete_phase_secrets(self._token_type, self._app_secret.app_token, env_id, secret_ids_to_delete, self._api_host) + + return keys_not_found + def decrypt(self, phase_ciphertext) -> str | None: """ @@ -76,24 +331,23 @@ def decrypt(self, phase_ciphertext) -> str | None: Raises: ValueError: If the ciphertext is not in the expected format (e.g. wrong prefix, wrong number of fields). """ - try: - [prefix, version, client_pub_key_hex, ct, - tag] = phase_ciphertext.split(':') - if prefix != 'ph' or len(phase_ciphertext.split(':')) != 5: + [prefix, version, client_pub_key_hex, ct] = phase_ciphertext.split(':') + if prefix != 'ph' or len(phase_ciphertext.split(':')) != 4: raise ValueError('Ciphertext is invalid') client_pub_key = bytes.fromhex(client_pub_key_hex) - keyshare1 = fetch_app_key( - self._app_secret.app_token, self._app_secret.keyshare1_unwrap_key, self._app_id, len(ct)/2, self._kms_host) + wrapped_key_share = fetch_wrapped_key_share( + self._token_type, self._app_secret.app_token, self._api_host) + keyshare1 = CryptoUtils.decrypt_raw(bytes.fromhex(wrapped_key_share), bytes.fromhex(self._app_secret.keyshare1_unwrap_key)).decode("utf-8") - app_priv_key = reconstruct_secret( + app_priv_key = CryptoUtils.reconstruct_secret( [self._app_secret.keyshare0, keyshare1]) session_keys = crypto_kx_server_session_keys(bytes.fromhex( - self._app_pub_key), bytes.fromhex(app_priv_key), client_pub_key) + self._app_secret.pss_user_public_key), bytes.fromhex(app_priv_key), client_pub_key) - plaintext = decrypt_b64(ct, session_keys[0].hex()) + plaintext = CryptoUtils.decrypt_b64(ct, session_keys[0].hex()) return plaintext From 99f71e755fe9aeb962f1b0a1de8e224702587f6a Mon Sep 17 00:00:00 2001 From: Nimish Date: Sat, 4 May 2024 15:32:03 +0530 Subject: [PATCH 07/36] wip: misc utils and helper functions --- src/phase/utils/misc.py | 169 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 src/phase/utils/misc.py diff --git a/src/phase/utils/misc.py b/src/phase/utils/misc.py new file mode 100644 index 0000000..667cf48 --- /dev/null +++ b/src/phase/utils/misc.py @@ -0,0 +1,169 @@ +import os +import platform +import subprocess +import webbrowser +import getpass +import json +from ..exceptions import EnvironmentNotFoundException +from urllib.parse import urlparse +from typing import Union, List +from .const import __version__, PHASE_CLOUD_API_HOST, cross_env_pattern, local_ref_pattern + + +def get_default_user_token() -> str: + """ + Fetch the default user's personal access token from the config file in PHASE_SECRETS_DIR. + + Returns: + - str: The default user's personal access token. + + Raises: + - ValueError: If the config file is not found, the default user's ID is missing, or the token is not set. + """ + config_file_path = os.path.join(PHASE_SECRETS_DIR, 'config.json') + + if not os.path.exists(config_file_path): + raise ValueError("Config file not found. Please login with phase auth or supply a PHASE_SERVICE_TOKEN as an environment variable.") + + with open(config_file_path, 'r') as f: + config_data = json.load(f) + + default_user_id = config_data.get("default-user") + if not default_user_id: + raise ValueError("Default user ID is missing in the config file.") + + for user in config_data.get("phase-users", []): + if user['id'] == default_user_id: + token = user.get("token") + if not token: + raise ValueError(f"Token for the default user (ID: {default_user_id}) is not found in the config file.") + return token + + raise ValueError("Default user not found in the config file.") + + +def phase_get_context(user_data, app_name=None, env_name=None): + """ + Get the context (ID, name, and publicKey) for a specified application and environment or the default application and environment. + + Parameters: + - user_data (dict): The user data from the API response. + - app_name (str, optional): The name (or partial name) of the desired application. + - env_name (str, optional): The name (or partial name) of the desired environment. + + Returns: + - tuple: A tuple containing the application's name, application's ID, environment's name, environment's ID, and publicKey. + + Raises: + - ValueError: If no matching application or environment is found. + """ + + # 2. If env_name isn't explicitly provided, use the default + default_env_name = "Development" + app_id = None + env_name = env_name or default_env_name + + # 3. Match the application using app_id or find the best match for partial app_name + try: + if app_name: + matching_apps = [app for app in user_data["apps"] if app_name.lower() in app["name"].lower()] + if not matching_apps: + raise ValueError(f"🔍 No application found with the name '{app_name}'.") + # Sort matching applications by the length of their names, shorter names are likely to be more specific matches + matching_apps.sort(key=lambda app: len(app["name"])) + application = matching_apps[0] + elif app_id: + application = next((app for app in user_data["apps"] if app["id"] == app_id), None) + if not application: + raise ValueError(f"🔍 No application found with the name '{app_name_from_config}' and ID: '{app_id}'.") + else: + raise ValueError("🤔 No application context provided. Please run 'phase init' or pass the '--app' flag followed by your application name.") + + # 4. Attempt to match environment with the exact name or a name that contains the env_name string + environment = next((env for env in application["environment_keys"] if env_name.lower() in env["environment"]["name"].lower()), None) + + if not environment: + raise EnvironmentNotFoundException(env_name) + + # Return application name, application ID, environment name, environment ID, and public key + return (application["name"], application["id"], environment["environment"]["name"], environment["environment"]["id"], environment["identity_key"]) + except StopIteration: + raise ValueError("🔍 Application or environment not found.") + + +def normalize_tag(tag): + """ + Normalize a tag by replacing underscores with spaces. + + Args: + tag (str): The tag to normalize. + + Returns: + str: The normalized tag. + """ + return tag.replace('_', ' ').lower() + + +def tag_matches(secret_tags, user_tag): + """ + Check if the user-provided tag partially matches any of the secret tags. + + Args: + secret_tags (list): The list of tags associated with a secret. + user_tag (str): The user-provided tag to match. + + Returns: + bool: True if there's a partial match, False otherwise. + """ + normalized_user_tag = normalize_tag(user_tag) + for tag in secret_tags: + normalized_secret_tag = normalize_tag(tag) + if normalized_user_tag in normalized_secret_tag: + return True + return False + + +def get_user_agent(): + """ + Constructs a user agent string containing information about the CLI's version, + the operating system, its version, its architecture, and the local username with machine name. + + Returns: + str: The constructed user agent string. + """ + + details = [] + + # Get CLI version + try: + cli_version = f"phase-python-sdk/{__version__}" + details.append(cli_version) + except: + pass + + # Get OS and version + try: + os_type = platform.system() # e.g., Windows, Linux, Darwin (for macOS) + os_version = platform.release() + details.append(f"{os_type} {os_version}") + except: + pass + + # Get architecture + try: + architecture = platform.machine() + details.append(architecture) + except: + pass + + # Get username and hostname + try: + username = getpass.getuser() + hostname = platform.node() + user_host_string = f"{username}@{hostname}" + details.append(user_host_string) + except: + pass + + user_agent_str = ' '.join(details) + return user_agent_str \ No newline at end of file From 055aae7e8c549f3e45324000ffc2135d6bafa9e5 Mon Sep 17 00:00:00 2001 From: Nimish Date: Sat, 4 May 2024 15:34:28 +0530 Subject: [PATCH 08/36] temporary: remove tests --- tests/test_decrypt.py | 40 ---------------------------------------- tests/test_encrypt.py | 41 ----------------------------------------- tests/test_init.py | 22 ---------------------- 3 files changed, 103 deletions(-) delete mode 100644 tests/test_decrypt.py delete mode 100644 tests/test_encrypt.py delete mode 100644 tests/test_init.py diff --git a/tests/test_decrypt.py b/tests/test_decrypt.py deleted file mode 100644 index added1e..0000000 --- a/tests/test_decrypt.py +++ /dev/null @@ -1,40 +0,0 @@ -import pytest -from src.phase.phase import Phase - -APP_ID = "phApp:v1:e0e50cb9a1953c610126b4092093b1beca51d08d91fc3d9f8d90482a32853215" -APP_SECRET = "pss:v1:d261abecb6708c18bebdb8b2748ee574e2b0bdeaf19b081a5f10006cc83d48d0:d146c8c6d326a7842ff9b2da0da455b3f7f568a70808e2eb0cfc5143d4fe170f:59e413612e06d75d251e3416361d0743345a9c9eda1cbcf2b1ef16e3077c011c" -APP_SECRET_INVALID = "pss:v1:d251abecb6708c18bebdb8b2748ee574e2b0bdeaf19b081a5f10006cc83d48d0:d146c8c6d326a7842ff9b2da0da455b3f7f568a70808e2eb0cfc5143d4fe170d:59e413612e06d75d251e3416361d0743345a9c9eda1cbcf2b1ef16e3077c012d" - - -@pytest.fixture(scope="module") -def phase_instance(): - return Phase(APP_ID, APP_SECRET) - - -def mock_fetch_app_key(appToken, wrapKey, appId, dataSize, custom_kms_host=None): - return "e35ae9560207c90fa3dd68a8715e13a1ef988bffa284db73f04328df17f37cfe" - - -def test_phase_decrypt_returns_correct_plaintext(phase_instance, monkeypatch): - data = "Signal" - - monkeypatch.setattr("src.phase.phase.fetch_app_key", mock_fetch_app_key) - - ciphertext = phase_instance.encrypt(data) - - plaintext = phase_instance.decrypt(ciphertext) - - assert plaintext is not None - assert plaintext == data - - -def test_phase_decrypt_fails_with_incorrect_app_secret(monkeypatch): - phase = Phase(APP_ID, APP_SECRET_INVALID) - - monkeypatch.setattr("src.phase.phase.fetch_app_key", mock_fetch_app_key) - - data = "Signal" - ciphertext = phase.encrypt(data) - - with pytest.raises(ValueError, match="Something went wrong"): - phase.decrypt(ciphertext) diff --git a/tests/test_encrypt.py b/tests/test_encrypt.py deleted file mode 100644 index e45cefe..0000000 --- a/tests/test_encrypt.py +++ /dev/null @@ -1,41 +0,0 @@ -import pytest -import re -from src.phase import Phase - -APP_ID = "phApp:v1:cd2d579490fd794f1640590220de86a3676fa7979d419056bc631741b320b701" -APP_SECRET = "pss:v1:a7a0822aa4a4e4d37919009264200ba6ab978d92c8b4f7db5ae9ce0dfaf604fe:801605dfb89822ff52957abe39949bcfc44b9058ad81de58dd54fb0b110037b4b2bbde5a1143d31bbb3895f72e4ee52f5bd:625d395987f52c37022063eaf9b6260cad9ca03c99609213f899cae7f1bb04e7" - - -@pytest.fixture(scope="module") -def phase_instance(): - return Phase(APP_ID, APP_SECRET) - - -def test_phase_encrypt_returns_valid_ph(phase_instance): - plaintext = "Signal" - tag = "Phase Tag" - PH_VERSION = "v1" - - ciphertext = phase_instance.encrypt(plaintext, tag) - - assert ciphertext is not None - segments = ciphertext.split(":") - assert len(segments) == 5 - assert segments[0] == "ph" - assert segments[1] == PH_VERSION - assert segments[4] == tag - assert re.match("^[0-9a-f]+$", segments[2]) is not None - assert re.match( - "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=$)", segments[3]) is not None - - -def test_phase_encrypt_produces_same_length_ciphertexts(phase_instance): - data = "hello world" - num_of_trials = 10 - ciphertext_lengths = set() - - for _ in range(num_of_trials): - ciphertext = phase_instance.encrypt(data) - ciphertext_lengths.add(len(ciphertext)) - - assert len(ciphertext_lengths) == 1 diff --git a/tests/test_init.py b/tests/test_init.py deleted file mode 100644 index 37bb02b..0000000 --- a/tests/test_init.py +++ /dev/null @@ -1,22 +0,0 @@ -from src.phase import Phase -import pytest - -APP_ID_INVALID = "phApp:v1:cd2d579490fd794f1640590220de86a3676fa7979d419056bc631741b320b701" -APP_SECRET_INVALID = "pss:v1:a7a0822aa4a4e4d37919009264200ba6ab978d92c8b4f7db5ae9ce0dfaf604fe:801605dfb89822ff52957abe39949bcfc44b9058ad81de58dd54fb0b110037b4b2bbde5a1143d31bbb3895f72e4ee52f5bd:625d395987f52c37022063eaf9b6260cad9ca03c99609213f899cae7f1bb04e7" - - -@pytest.fixture(scope="module") -def phase_instance(): - return Phase(APP_ID_INVALID, APP_SECRET_INVALID) - - -def test_init_fails_with_invalid_app_id(phase_instance): - invalid_app_id = "phApp:version:cd2d579490fd794f1640590220de86a3676fa7979d419056bc631741b320b701" - with pytest.raises(ValueError, match="Invalid Phase APP_ID"): - Phase(invalid_app_id, APP_SECRET_INVALID) - - -def test_test_init_fails_with_invalid_app_secret(phase_instance): - invalid_app_secret = "pss:v1:00000000000000000000000000000000:00000000000000000000000000000000:00000000000000000000000000000000" - with pytest.raises(ValueError, match="Invalid Phase APP_SECRET"): - Phase(APP_ID_INVALID, invalid_app_secret) From b9a3a9ee96ccdcac327a985880a746eb6846f760 Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 6 May 2024 19:03:37 +0530 Subject: [PATCH 09/36] chore: move exceptions --- src/phase/{ => utils}/exceptions.py | 0 src/phase/utils/misc.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename src/phase/{ => utils}/exceptions.py (100%) diff --git a/src/phase/exceptions.py b/src/phase/utils/exceptions.py similarity index 100% rename from src/phase/exceptions.py rename to src/phase/utils/exceptions.py diff --git a/src/phase/utils/misc.py b/src/phase/utils/misc.py index 667cf48..cd5c4c6 100644 --- a/src/phase/utils/misc.py +++ b/src/phase/utils/misc.py @@ -4,7 +4,7 @@ import webbrowser import getpass import json -from ..exceptions import EnvironmentNotFoundException +from .exceptions import EnvironmentNotFoundException from urllib.parse import urlparse from typing import Union, List from .const import __version__, PHASE_CLOUD_API_HOST, cross_env_pattern, local_ref_pattern From 46fed151cd7c10f717357bac5eb73d50970cd204 Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 6 May 2024 20:00:06 +0530 Subject: [PATCH 10/36] feat: phase.create --- src/phase/phase.py | 43 ++++++++++++++++++++++++++++--------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/src/phase/phase.py b/src/phase/phase.py index 20cabcb..7e4413f 100644 --- a/src/phase/phase.py +++ b/src/phase/phase.py @@ -34,6 +34,14 @@ class Phase: _api_host = '' _app_secret = None + @dataclass + class PhaseSecret: + key: str + value: str + comment: str + path: str + tags: List[str] + def __init__(self, init=True, pss=None, host=None): """ @@ -72,15 +80,14 @@ def _find_matching_environment_key(self, user_data, env_id): return None - def create(self, key_value_pairs: List[Tuple[str, str]], env_name: str, app_name: str, path: str = '/') -> requests.Response: + def create(self, secrets: List[PhaseSecret], env_name: str, app_name: str) -> requests.Response: """ - Create secrets in Phase KMS with support for specifying a path. + Create secrets in Phase KMS with support for specifying environment and application context. Args: - key_value_pairs (List[Tuple[str, str]]): List of tuples where each tuple contains a key and a value. + secrets (List[PhaseSecret]): List of PhaseSecret objects containing key, value, comment, path, and tags. env_name (str): The name (or partial name) of the desired environment. app_name (str): The name of the application context. - path (str, optional): The path under which to store the secrets. Defaults to the root path '/'. Returns: requests.Response: The HTTP response from the Phase KMS. @@ -99,23 +106,29 @@ def create(self, key_value_pairs: List[Tuple[str, str]], env_name: str, app_name wrapped_salt = environment_key.get("wrapped_salt") decrypted_salt = self.decrypt(wrapped_salt) - secrets = [] - for key, value in key_value_pairs: - encrypted_key = CryptoUtils.encrypt_asymmetric(key, public_key) - encrypted_value = CryptoUtils.encrypt_asymmetric(value, public_key) - key_digest = CryptoUtils.blake2b_digest(key, decrypted_salt) + encrypted_secrets = [] + for secret in secrets: + # Validate and modify the key + formatted_key = secret.key.upper().replace(" ", "") + if " " in formatted_key: + raise ValueError("Secret key cannot contain spaces.") + + encrypted_key = CryptoUtils.encrypt_asymmetric(formatted_key, public_key) + encrypted_value = CryptoUtils.encrypt_asymmetric(secret.value, public_key) + encrypted_comment = CryptoUtils.encrypt_asymmetric(secret.comment, public_key) + key_digest = CryptoUtils.blake2b_digest(formatted_key, decrypted_salt) - secret = { + encrypted_secret = { "key": encrypted_key, "keyDigest": key_digest, "value": encrypted_value, - "path": path, - "tags": [], # TODO: Implement tags and comments creation - "comment": "" + "path": secret.path, + "tags": secret.tags, + "comment": encrypted_comment } - secrets.append(secret) + encrypted_secrets.append(encrypted_secret) - return create_phase_secrets(self._token_type, self._app_secret.app_token, env_id, secrets, self._api_host) + return create_phase_secrets(self._token_type, self._app_secret.app_token, env_id, encrypted_secrets, self._api_host) def get(self, env_name: str, keys: List[str] = None, app_name: str = None, tag: str = None, path: str = '') -> List[Dict]: From efaf95c5990552aeb3e98ea1a7d7d9e0cda20fba Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 6 May 2024 22:38:26 +0530 Subject: [PATCH 11/36] feat: phase.get secrets --- src/phase/phase.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/phase/phase.py b/src/phase/phase.py index 7e4413f..f834359 100644 --- a/src/phase/phase.py +++ b/src/phase/phase.py @@ -1,6 +1,6 @@ import requests from typing import Tuple -from typing import List, Dict +from typing import List, Dict, Optional from dataclasses import dataclass from .utils.network import ( fetch_phase_user, @@ -41,6 +41,7 @@ class PhaseSecret: comment: str path: str tags: List[str] + overridden: bool def __init__(self, init=True, pss=None, host=None): @@ -131,7 +132,7 @@ def create(self, secrets: List[PhaseSecret], env_name: str, app_name: str) -> re return create_phase_secrets(self._token_type, self._app_secret.app_token, env_id, encrypted_secrets, self._api_host) - def get(self, env_name: str, keys: List[str] = None, app_name: str = None, tag: str = None, path: str = '') -> List[Dict]: + def get(self, env_name: str, keys: List[str] = None, app_name: str = None, tag: str = None, path: str = '') -> List[PhaseSecret]: """ Get secrets from Phase KMS based on key and environment, with support for personal overrides, optional tag matching, decrypting comments, and now including path support and key digest optimization. @@ -144,7 +145,7 @@ def get(self, env_name: str, keys: List[str] = None, app_name: str = None, tag: path (str, optional): The path under which to fetch secrets, default is root. Returns: - List[Dict]: A list of dictionaries for all secrets in the environment that match the criteria, including their paths. + List[PhaseSecret]: A list of PhaseSecret objects for all secrets in the environment that match the criteria. """ user_response = fetch_phase_user(self._token_type, self._app_secret.app_token, self._api_host) @@ -194,20 +195,20 @@ def get(self, env_name: str, keys: List[str] = None, app_name: str = None, tag: decrypted_value = CryptoUtils.decrypt_asymmetric(value_to_decrypt, env_private_key, public_key) decrypted_comment = CryptoUtils.decrypt_asymmetric(comment_to_decrypt, env_private_key, public_key) if comment_to_decrypt else None - result = { - "key": decrypted_key, - "value": decrypted_value, - "overridden": use_override, - "tags": secret.get("tags", []), - "comment": decrypted_comment, - "path": secret.get("path", "/"), - "application": app_name, - "environment": env_name - } + override_info = secret.get("override", {}) + is_active_override = override_info.get("is_active", False) if override_info else False + + secret_obj = self.PhaseSecret( + key=decrypted_key, + value=decrypted_value, + comment=decrypted_comment, + path=secret.get("path", "/"), + tags=secret.get("tags", []), + overridden=is_active_override + ) - # Only add the secret to results if the requested keys are not specified or the decrypted key is one of the requested keys. if not keys or decrypted_key in keys: - results.append(result) + results.append(secret_obj) return results From 011ff849483c6bc8c41c82afbb7d0a254c7837f6 Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 7 May 2024 12:44:41 +0530 Subject: [PATCH 12/36] feat: phase.update secrets --- src/phase/phase.py | 28 +++++++++++++--------------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/src/phase/phase.py b/src/phase/phase.py index f834359..160c371 100644 --- a/src/phase/phase.py +++ b/src/phase/phase.py @@ -213,16 +213,14 @@ def get(self, env_name: str, keys: List[str] = None, app_name: str = None, tag: return results - def update(self, env_name: str, key: str, value: str, app_name: str = None, source_path: str = '', destination_path: str = None) -> str: + def update(self, secret: PhaseSecret, env_name: str, app_name: str = None, destination_path: str = None) -> str: """ - Update a secret in Phase KMS based on key and environment, with support for source and destination paths. + Update a secret in Phase KMS based on key and environment, with support for changing its path. Args: + secret (PhaseSecret): The secret object containing updated values. env_name (str): The name (or partial name) of the desired environment. - key (str): The key for which to update the secret value. - value (str): The new value for the secret. app_name (str, optional): The name of the desired application. - source_path (str, optional): The current path of the secret. Defaults to root path '/'. destination_path (str, optional): The new path for the secret, if changing its location. If not provided, the path is not updated. Returns: @@ -240,8 +238,7 @@ def update(self, env_name: str, key: str, value: str, app_name: str = None, sour if environment_key is None: raise ValueError(f"No environment found with id: {env_id}") - # Fetch secrets from the specified source path - secrets_response = fetch_phase_secrets(self._token_type, self._app_secret.app_token, env_id, self._api_host, path=source_path) + secrets_response = fetch_phase_secrets(self._token_type, self._app_secret.app_token, env_id, self._api_host, path=secret.path) secrets_data = secrets_response.json() wrapped_seed = environment_key.get("wrapped_seed") @@ -249,24 +246,25 @@ def update(self, env_name: str, key: str, value: str, app_name: str = None, sour key_pair = CryptoUtils.env_keypair(decrypted_seed) env_private_key = key_pair['privateKey'] - matching_secret = next((secret for secret in secrets_data if CryptoUtils.decrypt_asymmetric(secret["key"], env_private_key, public_key) == key), None) + matching_secret = next((s for s in secrets_data if CryptoUtils.decrypt_asymmetric(s["key"], env_private_key, public_key) == secret.key), None) if not matching_secret: - return f"Key '{key}' doesn't exist in path '{source_path}'." + return f"Key '{secret.key}' doesn't exist in path '{secret.path}'." + + encrypted_key = CryptoUtils.encrypt_asymmetric(secret.key, public_key) + encrypted_value = CryptoUtils.encrypt_asymmetric(secret.value, public_key) + encrypted_comment = CryptoUtils.encrypt_asymmetric(secret.comment, public_key) if secret.comment else "" - encrypted_key = CryptoUtils.encrypt_asymmetric(key, public_key) - encrypted_value = CryptoUtils.encrypt_asymmetric(value, public_key) - wrapped_salt = environment_key.get("wrapped_salt") decrypted_salt = self.decrypt(wrapped_salt) - key_digest = CryptoUtils.blake2b_digest(key, decrypted_salt) + key_digest = CryptoUtils.blake2b_digest(secret.key, decrypted_salt) secret_update_payload = { "id": matching_secret["id"], "key": encrypted_key, "keyDigest": key_digest, "value": encrypted_value, - "tags": matching_secret.get("tags", []), # TODO: Implement tags and comments updates - "comment": matching_secret.get("comment", ""), + "tags": secret.tags, + "comment": encrypted_comment, "path": destination_path if destination_path is not None else matching_secret["path"] } From da4bc4658a5df1bb207748559a7dcd2b33e4731d Mon Sep 17 00:00:00 2001 From: Nimish Date: Wed, 8 May 2024 11:49:16 +0530 Subject: [PATCH 13/36] feat: make decrypt private --- src/phase/phase.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/phase/phase.py b/src/phase/phase.py index 160c371..e10289b 100644 --- a/src/phase/phase.py +++ b/src/phase/phase.py @@ -105,7 +105,7 @@ def create(self, secrets: List[PhaseSecret], env_name: str, app_name: str) -> re raise ValueError(f"No environment found with id: {env_id}") wrapped_salt = environment_key.get("wrapped_salt") - decrypted_salt = self.decrypt(wrapped_salt) + decrypted_salt = self._decrypt(wrapped_salt) encrypted_secrets = [] for secret in secrets: @@ -160,14 +160,14 @@ def get(self, env_name: str, keys: List[str] = None, app_name: str = None, tag: raise ValueError("No environment found with id: {}".format(env_id)) wrapped_seed = environment_key.get("wrapped_seed") - decrypted_seed = self.decrypt(wrapped_seed) + decrypted_seed = self._decrypt(wrapped_seed) key_pair = CryptoUtils.env_keypair(decrypted_seed) env_private_key = key_pair['privateKey'] params = {"path": path} if keys and len(keys) == 1: wrapped_salt = environment_key.get("wrapped_salt") - decrypted_salt = self.decrypt(wrapped_salt) + decrypted_salt = self._decrypt(wrapped_salt) key_digest = CryptoUtils.blake2b_digest(keys[0], decrypted_salt) params["key_digest"] = key_digest @@ -242,7 +242,7 @@ def update(self, secret: PhaseSecret, env_name: str, app_name: str = None, desti secrets_data = secrets_response.json() wrapped_seed = environment_key.get("wrapped_seed") - decrypted_seed = self.decrypt(wrapped_seed) + decrypted_seed = self._decrypt(wrapped_seed) key_pair = CryptoUtils.env_keypair(decrypted_seed) env_private_key = key_pair['privateKey'] @@ -255,7 +255,7 @@ def update(self, secret: PhaseSecret, env_name: str, app_name: str = None, desti encrypted_comment = CryptoUtils.encrypt_asymmetric(secret.comment, public_key) if secret.comment else "" wrapped_salt = environment_key.get("wrapped_salt") - decrypted_salt = self.decrypt(wrapped_salt) + decrypted_salt = self._decrypt(wrapped_salt) key_digest = CryptoUtils.blake2b_digest(secret.key, decrypted_salt) secret_update_payload = { @@ -302,7 +302,7 @@ def delete(self, env_name: str, keys_to_delete: List[str], app_name: str = None, raise ValueError(f"No environment found with id: {env_id}") wrapped_seed = environment_key.get("wrapped_seed") - decrypted_seed = self.decrypt(wrapped_seed) + decrypted_seed = self._decrypt(wrapped_seed) key_pair = CryptoUtils.env_keypair(decrypted_seed) env_private_key = key_pair['privateKey'] @@ -330,7 +330,7 @@ def delete(self, env_name: str, keys_to_delete: List[str], app_name: str = None, return keys_not_found - def decrypt(self, phase_ciphertext) -> str | None: + def _decrypt(self, phase_ciphertext) -> str | None: """ Decrypts a Phase ciphertext string. From 83df419a4406e4be267b29bfa3ee018e797d8451 Mon Sep 17 00:00:00 2001 From: Nimish Date: Wed, 8 May 2024 12:23:44 +0530 Subject: [PATCH 14/36] chore: updated comments --- src/phase/phase.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/phase/phase.py b/src/phase/phase.py index e10289b..ebc111b 100644 --- a/src/phase/phase.py +++ b/src/phase/phase.py @@ -83,7 +83,7 @@ def _find_matching_environment_key(self, user_data, env_id): def create(self, secrets: List[PhaseSecret], env_name: str, app_name: str) -> requests.Response: """ - Create secrets in Phase KMS with support for specifying environment and application context. + Create one or more secrets in Phase in a specified application and environment. Args: secrets (List[PhaseSecret]): List of PhaseSecret objects containing key, value, comment, path, and tags. @@ -134,8 +134,8 @@ def create(self, secrets: List[PhaseSecret], env_name: str, app_name: str) -> re def get(self, env_name: str, keys: List[str] = None, app_name: str = None, tag: str = None, path: str = '') -> List[PhaseSecret]: """ - Get secrets from Phase KMS based on key and environment, with support for personal overrides, - optional tag matching, decrypting comments, and now including path support and key digest optimization. + Fetch one or more secrets stored in Phase in a specified application and environment. + Note: Personal secret overrides are only supported when authenticating with a Personal Access Token (PAT). Args: env_name (str): The name (or partial name) of the desired environment. @@ -215,7 +215,7 @@ def get(self, env_name: str, keys: List[str] = None, app_name: str = None, tag: def update(self, secret: PhaseSecret, env_name: str, app_name: str = None, destination_path: str = None) -> str: """ - Update a secret in Phase KMS based on key and environment, with support for changing its path. + Update a secret in Phase in a given application and environment. Args: secret (PhaseSecret): The secret object containing updated values. @@ -278,7 +278,7 @@ def update(self, secret: PhaseSecret, env_name: str, app_name: str = None, desti def delete(self, env_name: str, keys_to_delete: List[str], app_name: str = None, path: str = None) -> List[str]: """ - Delete secrets in Phase KMS based on keys and environment, with optional path support. + Delete one or more secrets stored in Phase in a given application and environment. Args: env_name (str): The name (or partial name) of the desired environment. From b104939494aefa1be287d478b6bae1316fedfb67 Mon Sep 17 00:00:00 2001 From: Nimish Date: Wed, 8 May 2024 12:26:42 +0530 Subject: [PATCH 15/36] chore: update comments --- src/phase/utils/network.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/phase/utils/network.py b/src/phase/utils/network.py index bd2032e..0e4872b 100644 --- a/src/phase/utils/network.py +++ b/src/phase/utils/network.py @@ -90,7 +90,7 @@ def fetch_phase_user(token_type: str, app_token: str, host: str) -> requests.Res app_token (str): The token for the application. Returns: - requests.Response: The HTTP response from the Phase KMS. + requests.Response: The HTTP response from the Phase API. """ headers = construct_http_headers(token_type, app_token) @@ -108,7 +108,7 @@ def fetch_phase_user(token_type: str, app_token: str, host: str) -> requests.Res def fetch_app_key(token_type: str, app_token, host) -> str: """ - Fetches the application key share from Phase KMS. + Fetches the application key share from Phase API. Args: app_token (str): The token for the application to retrieve the key for. @@ -146,7 +146,7 @@ def fetch_app_key(token_type: str, app_token, host) -> str: def fetch_wrapped_key_share(token_type: str, app_token: str, host: str) -> str: """ - Fetches the wrapped application key share from Phase KMS. + Fetches the wrapped application key share from Phase API. Args: token_type (str): The type of token being used, either "user" or "service". @@ -186,7 +186,7 @@ def fetch_wrapped_key_share(token_type: str, app_token: str, host: str) -> str: def fetch_phase_secrets(token_type: str, app_token: str, id: str, host: str, key_digest: str = '', path: str = '') -> requests.Response: """ - Fetch a single secret from Phase KMS based on key digest, with an optional path parameter. + Fetch a single secret from Phase API based on key digest, with an optional path parameter. Args: token_type (str): The type of the token. @@ -197,7 +197,7 @@ def fetch_phase_secrets(token_type: str, app_token: str, id: str, host: str, key path (str, optional): A specific path to fetch secrets from. Returns: - dict: The single secret fetched from the Phase KMS, or an error message. + dict: The single secret fetched from the Phase API, or an error message. """ headers = {**construct_http_headers(token_type, app_token), "Environment": id, "KeyDigest": key_digest} @@ -218,7 +218,7 @@ def fetch_phase_secrets(token_type: str, app_token: str, id: str, host: str, key def create_phase_secrets(token_type: str, app_token: str, environment_id: str, secrets: List[dict], host: str) -> requests.Response: """ - Create secrets in Phase KMS through HTTP POST request. + Create secrets in Phase API through HTTP POST request. Args: app_token (str): The token for the application. @@ -226,7 +226,7 @@ def create_phase_secrets(token_type: str, app_token: str, environment_id: str, s secrets (List[dict]): The list of secrets to be created. Returns: - requests.Response: The HTTP response from the Phase KMS. + requests.Response: The HTTP response from the Phase API. """ headers = {**construct_http_headers(token_type, app_token), "Environment": environment_id} @@ -249,7 +249,7 @@ def create_phase_secrets(token_type: str, app_token: str, environment_id: str, s def update_phase_secrets(token_type: str, app_token: str, environment_id: str, secrets: List[dict], host: str) -> requests.Response: """ - Update secrets in Phase KMS through HTTP PUT request. + Update secrets in Phase API through HTTP PUT request. Args: app_token (str): The token for the application. @@ -257,7 +257,7 @@ def update_phase_secrets(token_type: str, app_token: str, environment_id: str, s secrets (List[dict]): The list of secrets to be updated. Returns: - requests.Response: The HTTP response from the Phase KMS. + requests.Response: The HTTP response from the Phase API. """ headers = {**construct_http_headers(token_type, app_token), "Environment": environment_id} @@ -280,7 +280,7 @@ def update_phase_secrets(token_type: str, app_token: str, environment_id: str, s def delete_phase_secrets(token_type: str, app_token: str, environment_id: str, secret_ids: List[str], host: str) -> requests.Response: """ - Delete secrets from Phase KMS. + Delete secrets from Phase API. Args: app_token (str): The token for the application. @@ -288,7 +288,7 @@ def delete_phase_secrets(token_type: str, app_token: str, environment_id: str, s secret_ids (List[str]): The list of secret IDs to be deleted. Returns: - requests.Response: The HTTP response from the Phase KMS. + requests.Response: The HTTP response from the Phase API. """ headers = {**construct_http_headers(token_type, app_token), "Environment": environment_id} From 06357b8417c3df9002a402f42268486d48f126fa Mon Sep 17 00:00:00 2001 From: Nimish Date: Wed, 8 May 2024 12:26:55 +0530 Subject: [PATCH 16/36] chore: fix comment --- src/phase/phase.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/phase/phase.py b/src/phase/phase.py index ebc111b..8735f8f 100644 --- a/src/phase/phase.py +++ b/src/phase/phase.py @@ -91,7 +91,7 @@ def create(self, secrets: List[PhaseSecret], env_name: str, app_name: str) -> re app_name (str): The name of the application context. Returns: - requests.Response: The HTTP response from the Phase KMS. + requests.Response: The HTTP response from the Phase API. """ user_response = fetch_phase_user(self._token_type, self._app_secret.app_token, self._api_host) if user_response.status_code != 200: From 14746a2f3419911de569c5f58d13a98c81615bbf Mon Sep 17 00:00:00 2001 From: Nimish Date: Wed, 8 May 2024 12:32:41 +0530 Subject: [PATCH 17/36] feat: improved phase delete --- src/phase/phase.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/phase/phase.py b/src/phase/phase.py index 8735f8f..7c94147 100644 --- a/src/phase/phase.py +++ b/src/phase/phase.py @@ -276,7 +276,7 @@ def update(self, secret: PhaseSecret, env_name: str, app_name: str = None, desti return f"Error: Failed to update secret. HTTP Status Code: {response.status_code}" - def delete(self, env_name: str, keys_to_delete: List[str], app_name: str = None, path: str = None) -> List[str]: + def delete(self, env_name: str, keys_to_delete: List[str], app_name: str = None, path: str = None) -> dict: """ Delete one or more secrets stored in Phase in a given application and environment. @@ -287,7 +287,9 @@ def delete(self, env_name: str, keys_to_delete: List[str], app_name: str = None, path (str, optional): The path within which to delete the secrets. If specified, only deletes secrets within this path. Returns: - List[str]: A list of keys that were not found and could not be deleted. + dict: A dictionary with two keys: + 'deleted': List of keys that were successfully deleted. + 'not_found': List of keys that were not found. """ user_response = fetch_phase_user(self._token_type, self._app_secret.app_token, self._api_host) @@ -325,10 +327,13 @@ def delete(self, env_name: str, keys_to_delete: List[str], app_name: str = None, keys_not_found.append(key) if secret_ids_to_delete: - delete_phase_secrets(self._token_type, self._app_secret.app_token, env_id, secret_ids_to_delete, self._api_host) - - return keys_not_found - + delete_response = delete_phase_secrets(self._token_type, self._app_secret.app_token, env_id, secret_ids_to_delete, self._api_host) + if delete_response.status_code != 200: + raise ValueError(f"Failed to delete secrets: {delete_response.text}") + + # Provide detailed results + return {'deleted': [key for key in keys_to_delete if key not in keys_not_found], 'not_found': keys_not_found} + def _decrypt(self, phase_ciphertext) -> str | None: """ From 3d4d11d3a8b235e202c55615a311128467c6161a Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 22 Jul 2024 14:11:02 +0530 Subject: [PATCH 18/36] chore: updated pyproject --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 24f761c..c9d691e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta" [project] name = "phase_dev" -version = "1.1.0" -description = "Python SDK for Phase" +version = "2.0.0" +description = "Python SDK for Phase secrets manager" readme = "README.md" requires-python = ">=3.10" classifiers = [ From 99dec972b2035699b8b6da423907d1c3c780ff67 Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 22 Jul 2024 14:14:26 +0530 Subject: [PATCH 19/36] chore: updated README --- README.md | 103 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 94 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index cb8e690..6c55e14 100644 --- a/README.md +++ b/README.md @@ -1,35 +1,120 @@ # Python SDK for Phase -SDK to integrate Phase in server-side applications running Python +SDK to integrate Phase in server-side applications running Python. This SDK allows you to manage secrets securely using the Phase platform. ## Install -`pip install phase-dev` +``` +pip install phase-dev +``` ## Import ```python -from phase import Phase; +from phase import Secrets ``` ## Initialize -Initialize the SDK with your `APP_ID` and `APP_SECRET`: +Initialize the SDK with your host and token: ```python -phase = Phase(APP_ID, APP_SECRET) +phase = Secrets( + host='https://your-phase-host.com', + pss=PHASE_SERVICE_TOKEN +) ``` ## Usage -### Encrypt +### Create Secrets + +Create one or more secrets in a specified application and environment: ```python -ciphertext = phase.encrypt("hello world"); +new_secrets = [ + phase.PhaseSecret( + key="API_KEY", + value="your-api-key", + comment="API key for our service", + path="/", + tags=["api", "credentials"], + overridden=False + ), + # Add more secrets as needed +] + +response = phase.create(secrets=new_secrets, env_name="Development", app_name="Your App Name") +print(f"Create Response Status Code: {response.status_code}") ``` -### Decrypt +### Get Secrets + +Fetch one or more secrets from a specified application and environment: ```python -plaintext = phase.decrypt(ciphertext); +secrets = phase.get( + env_name="Development", + keys=["API_KEY"], # Optional: specify keys to retrieve + app_name="Your App Name", + tag="api", # Optional: filter by tag + path="/" # Optional: specify path +) + +for secret in secrets: + print(f"Key: {secret.key}, Value: {secret.value}") ``` + +### Update Secrets + +Update an existing secret in a specified application and environment: + +```python +updated_secret = phase.PhaseSecret( + key="API_KEY", + value="new-api-key-value", + comment="Updated API key", + path="/", + tags=["api", "credentials", "updated"], + overridden=False +) + +result = phase.update( + secret=updated_secret, + env_name="Development", + app_name="Your App Name" +) +print(f"Update result: {result}") +``` + +### Delete Secrets + +Delete one or more secrets from a specified application and environment: + +```python +keys_to_delete = ["API_KEY", "DB_PASSWORD"] +result = phase.delete( + env_name="Development", + keys_to_delete=keys_to_delete, + app_name="Your App Name", + path="/" # Optional: specify path +) + +print(f"Deleted secrets: {result['deleted']}") +print(f"Secrets not found: {result['not_found']}") +``` + +## Error Handling + +The SDK methods may raise exceptions for various error conditions. It's recommended to wrap SDK calls in try-except blocks to handle potential errors: + +```python +try: + secrets = phase.get(env_name="Development", app_name="Your App Name") +except ValueError as e: + print(f"An error occurred: {e}") +``` + +## Note on Security + +Never hard-code sensitive information like tokens or secrets directly in your code. Always use environment variables or secure configuration management to provide these values to your application. \ No newline at end of file From 2fb17cc5b838e4dd7e83ddfff449e25685630381 Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 22 Jul 2024 14:14:51 +0530 Subject: [PATCH 20/36] fix: phase delete path --- src/phase/phase.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/phase/phase.py b/src/phase/phase.py index 7c94147..d71cb5c 100644 --- a/src/phase/phase.py +++ b/src/phase/phase.py @@ -29,7 +29,7 @@ class AppSecret: keyshare1_unwrap_key: str -class Phase: +class Secrets: _app_pub_key = '' _api_host = '' _app_secret = None @@ -312,11 +312,16 @@ def delete(self, env_name: str, keys_to_delete: List[str], app_name: str = None, keys_not_found = [] secrets_response = fetch_phase_secrets(self._token_type, self._app_secret.app_token, env_id, self._api_host, path=path) secrets_data = secrets_response.json() + + # Normalize the provided path + normalized_path = path.rstrip('/') + '/' if path else '/' for key in keys_to_delete: found = False for secret in secrets_data: - if path is not None and secret.get("path", "/") != path: + # Normalize the secret's path + secret_path = secret.get("path", "/").rstrip('/') + '/' + if normalized_path != secret_path: continue # Skip secrets not in the specified path decrypted_key = CryptoUtils.decrypt_asymmetric(secret["key"], env_private_key, public_key) if decrypted_key == key: @@ -334,7 +339,6 @@ def delete(self, env_name: str, keys_to_delete: List[str], app_name: str = None, # Provide detailed results return {'deleted': [key for key in keys_to_delete if key not in keys_not_found], 'not_found': keys_not_found} - def _decrypt(self, phase_ciphertext) -> str | None: """ Decrypts a Phase ciphertext string. From f4a8494614f08568f016dc68e97264b51fce5a5d Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 22 Jul 2024 14:15:12 +0530 Subject: [PATCH 21/36] chore: create phase python module --- src/phase/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/phase/__init__.py b/src/phase/__init__.py index ff34f12..9c09f6f 100644 --- a/src/phase/__init__.py +++ b/src/phase/__init__.py @@ -1,3 +1,3 @@ -from .phase import Phase +from .phase import Secrets -__all__ = ['Phase'] +__all__ = ['Secrets'] From 72cb566ce7fb449fdf335476f4a0d80f62bafb4a Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 22 Jul 2024 14:27:14 +0530 Subject: [PATCH 22/36] feat: added tests --- tests/crypto.py | 149 ++++++++++++++++++++++++++++++++++++ tests/secret_referencing.py | 118 ++++++++++++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 tests/crypto.py create mode 100644 tests/secret_referencing.py diff --git a/tests/crypto.py b/tests/crypto.py new file mode 100644 index 0000000..e435ec7 --- /dev/null +++ b/tests/crypto.py @@ -0,0 +1,149 @@ +import pytest +import base64 +import re +import os +from src.phase.utils.crypto import CryptoUtils +from src.phase.utils.crypto import generate_random_secret +from nacl.secret import SecretBox + +class TestCryptoUtils: + def test_random_key_pair(self): + # Testing if the generated key pair (public and private keys) are of the correct length (32 bytes each) + public_key, private_key = CryptoUtils.random_key_pair() + assert len(public_key) == 32 + assert len(private_key) == 32 + + def test_client_session_keys(self): + # Testing client session keys generation by ensuring the keys are of the correct length + client_keypair = CryptoUtils.random_key_pair() + server_keypair = CryptoUtils.random_key_pair() + client_keys = CryptoUtils.client_session_keys(client_keypair, server_keypair[0]) + assert len(client_keys[0]) == 32 and len(client_keys[1]) == 32 + + def test_server_session_keys(self): + # Testing server session keys generation similar to client session keys + client_keypair = CryptoUtils.random_key_pair() + server_keypair = CryptoUtils.random_key_pair() + server_keys = CryptoUtils.server_session_keys(server_keypair, client_keypair[0]) + assert len(server_keys[0]) == 32 and len(server_keys[1]) == 32 + +def test_encrypt_and_decrypt_asymmetric(): + # Testing asymmetric encryption and decryption to ensure the decrypted text matches the original plaintext + test_plaintext = "Saigon, I'm still only in Saigon. Every time I think I'm gonna wake up back in the jungle.." + public_key, private_key = CryptoUtils.random_key_pair() + encrypted_data = CryptoUtils.encrypt_asymmetric(test_plaintext, public_key.hex()) + decrypted_data = CryptoUtils.decrypt_asymmetric(encrypted_data, private_key.hex(), public_key.hex()) + pattern = rf"ph:v{CryptoUtils.VERSION}:[0-9a-fA-F]{{64}}:.+" + assert re.match(pattern, encrypted_data) is not None + assert decrypted_data == test_plaintext + +class TestGenerateRandomSecret: + @pytest.mark.parametrize("secret_type, expected_length", [ + # Testing random secret generation for various types and lengths + ('hex', 64), ('alphanumeric', 32), ('base64', 44), ('base64url', 44), + ('key128', 24), ('key256', 44) + ]) + def test_generate_random_secret_valid_types(self, secret_type, expected_length): + secret = generate_random_secret(secret_type, 32) + assert len(secret) == expected_length + if secret_type == 'hex': + assert re.fullmatch(r'[0-9a-fA-F]+', secret) is not None + elif secret_type == 'alphanumeric': + assert re.fullmatch(r'[0-9a-zA-Z]+', secret) is not None + elif secret_type in ['base64', 'base64url']: + base64_bytes = base64.urlsafe_b64decode(secret + '==') + assert len(base64_bytes) == 32 + + def test_generate_random_secret_invalid_type(self): + # Ensuring that an invalid secret type raises a ValueError + with pytest.raises(ValueError): + generate_random_secret('invalid', 32) + + def test_generate_random_secret_invalid_length(self): + # Ensuring that an invalid length (e.g., zero) raises a ValueError + with pytest.raises(ValueError): + generate_random_secret('hex', 0) + + @pytest.mark.parametrize("key_type", ['key256']) + def test_encrypt_decrypt_with_generated_keys(self, key_type): + # Testing encryption and decryption with XChaCha20-Poly1305 using a generated key + key = generate_random_secret(key_type) + key_bytes = base64.b64decode(key) + test_plaintext = "Test message for encryption and decryption." + encrypted_text = CryptoUtils.encrypt_string(test_plaintext, key_bytes) + decrypted_plaintext = CryptoUtils.decrypt_string(encrypted_text, key_bytes) + assert decrypted_plaintext == test_plaintext + + # AES 256-bit custom encrypt and decrypt methods + @staticmethod + def encrypt_aes256(plaintext, key): + # Ensuring key length for AES 256 is 32 bytes and performing encryption + assert len(key) == 32 + secret_box = SecretBox(key) + return secret_box.encrypt(plaintext.encode()) + + @staticmethod + def decrypt_aes256(ciphertext, key): + # Ensuring key length for AES 256 is 32 bytes and performing decryption + assert len(key) == 32 + secret_box = SecretBox(key) + return secret_box.decrypt(ciphertext).decode() + + def test_aes256_encryption_decryption(self): + # Testing AES 256 encryption and decryption to ensure the decrypted text matches the original plaintext + key = generate_random_secret('key256') + key_bytes = base64.b64decode(key) + test_plaintext = "Test message for AES 256." + ciphertext = TestGenerateRandomSecret.encrypt_aes256(test_plaintext, key_bytes) + decrypted_plaintext = TestGenerateRandomSecret.decrypt_aes256(ciphertext, key_bytes) + assert decrypted_plaintext == test_plaintext + +class TestBlake2bDigest: + def test_blake2b_digest_length(self): + # Testing the length of the BLAKE2b hash to ensure it's 64 characters (32 bytes hex encoded) + input_str = "test string" + salt = "salt" + result = CryptoUtils.blake2b_digest(input_str, salt) + assert len(result) == 64 + + def test_blake2b_digest_consistency(self): + # Testing hash consistency for the same input and salt + input_str = "consistent input" + salt = "consistent salt" + hash1 = CryptoUtils.blake2b_digest(input_str, salt) + hash2 = CryptoUtils.blake2b_digest(input_str, salt) + assert hash1 == hash2 + + def test_blake2b_digest_unique_with_different_inputs(self): + # Ensuring different inputs with the same salt produce different hashes + salt = "salt" + hash1 = CryptoUtils.blake2b_digest("input1", salt) + hash2 = CryptoUtils.blake2b_digest("input2", salt) + assert hash1 != hash2 + + def test_blake2b_digest_unique_with_different_salts(self): + # Ensuring the same input with different salts produces different hashes + input_str = "input" + hash1 = CryptoUtils.blake2b_digest(input_str, "salt1") + hash2 = CryptoUtils.blake2b_digest(input_str, "salt2") + assert hash1 != hash2 + + @pytest.mark.parametrize("input_str, salt, expected_hash", [ + # Testing known hash values for specific inputs and salts + ("hello", "world", "38010cfe3a8e684cb17e6d049525e71d4e9dc3be173fc05bf5c5ca1c7e7c25e7"), + ("another test", "another salt", "5afad949edcfb22bd24baeed4e75b0aeca41731b8dff78f989a5a4c0564f211f") + ]) + def test_blake2b_digest_known_values(self, input_str, salt, expected_hash): + # Testing that the calculated hash matches the expected known hash + result = CryptoUtils.blake2b_digest(input_str, salt) + assert result == expected_hash + +class TestSecretSplitting: + def test_xor_secret_splitting_and_reconstruction(self): + # Testing XOR-based secret splitting and reconstruction + original_secret_hex = "6eed8a70ac9e75ab1894b06d4a5e21d1072649529753f3244316c6d9e4c9c951" + original_secret_bytes = bytes.fromhex(original_secret_hex) + random_share = os.urandom(len(original_secret_bytes)) + second_share = CryptoUtils.xor_bytes(original_secret_bytes, random_share) + reconstructed_secret_hex = CryptoUtils.reconstruct_secret([random_share.hex(), second_share.hex()]) + assert reconstructed_secret_hex == original_secret_hex diff --git a/tests/secret_referencing.py b/tests/secret_referencing.py new file mode 100644 index 0000000..efd6a95 --- /dev/null +++ b/tests/secret_referencing.py @@ -0,0 +1,118 @@ +import pytest +from unittest.mock import Mock, patch +from src.phase.utils.secret_referencing import resolve_secret_reference, resolve_all_secrets +from src.phase.utils.exceptions import EnvironmentNotFoundException +from src.phase.utils.const import SECRET_REF_REGEX + +# Mock data for secrets +secrets_dict = { + "current": { + "/": { + "KEY": "value1" + }, + "/backend/payments": { + "STRIPE_KEY": "stripe_value" + } + }, + "staging": { + "/": { + "DEBUG": "staging_debug_value" + } + }, + "prod": { + "/frontend": { + "SECRET_KEY": "prod_secret_value" + } + } +} + +# Mock Phase class +class MockPhase: + def get(self, env_name, app_name, keys, path): + if env_name == "prod" and path == "/frontend": + return [{"key": "SECRET_KEY", "value": "prod_secret_value"}] + raise EnvironmentNotFoundException(env_name=env_name) + +@pytest.fixture +def phase(): + return MockPhase() + +@pytest.fixture +def current_env_name(): + return "current" + +@pytest.fixture +def current_application_name(): + return "test_app" + +def test_resolve_local_reference_root(phase, current_application_name, current_env_name): + ref = "KEY" + resolved_value = resolve_secret_reference(ref, secrets_dict, phase, current_application_name, current_env_name) + assert resolved_value == "value1" + +def test_resolve_local_reference_path(phase, current_application_name, current_env_name): + ref = "/backend/payments/STRIPE_KEY" + resolved_value = resolve_secret_reference(ref, secrets_dict, phase, current_application_name, current_env_name) + assert resolved_value == "stripe_value" + +def test_resolve_cross_environment_root(phase, current_application_name, current_env_name): + ref = "staging.DEBUG" + resolved_value = resolve_secret_reference(ref, secrets_dict, phase, current_application_name, current_env_name) + assert resolved_value == "staging_debug_value" + +def test_resolve_cross_environment_path(phase, current_application_name, current_env_name): + ref = "prod./frontend/SECRET_KEY" + resolved_value = resolve_secret_reference(ref, secrets_dict, phase, current_application_name, current_env_name) + assert resolved_value == "prod_secret_value" + +def test_resolve_all_secrets(phase, current_application_name, current_env_name): + value = "Use this key: ${KEY}, and this staging key: ${staging.DEBUG}, and this path key: ${/backend/payments/STRIPE_KEY}" + all_secrets = [ + {"environment": "current", "path": "/", "key": "KEY", "value": "value1"}, + {"environment": "staging", "path": "/", "key": "DEBUG", "value": "staging_debug_value"}, + {"environment": "current", "path": "/backend/payments", "key": "STRIPE_KEY", "value": "stripe_value"} + ] + resolved_value = resolve_all_secrets(value, all_secrets, phase, current_application_name, current_env_name) + expected_value = "Use this key: value1, and this staging key: staging_debug_value, and this path key: stripe_value" + assert resolved_value == expected_value + +# Edge Case: Missing key in the current environment +def test_resolve_missing_local_key(phase, current_application_name, current_env_name): + ref = "MISSING_KEY" + resolved_value = resolve_secret_reference(ref, secrets_dict, phase, current_application_name, current_env_name) + assert resolved_value == "${MISSING_KEY}" + +# Edge Case: Missing key in a cross environment reference +def test_resolve_missing_cross_env_key(phase, current_application_name, current_env_name): + ref = "prod.MISSING_KEY" + resolved_value = resolve_secret_reference(ref, secrets_dict, phase, current_application_name, current_env_name) + assert resolved_value == "${prod.MISSING_KEY}" + +# Edge Case: Missing path in a cross environment reference +def test_resolve_missing_cross_env_path(phase, current_application_name, current_env_name): + ref = "prod./missing_path/SECRET_KEY" + resolved_value = resolve_secret_reference(ref, secrets_dict, phase, current_application_name, current_env_name) + assert resolved_value == "${prod./missing_path/SECRET_KEY}" + +# Complex Case: Mixed references with missing values +def test_resolve_mixed_references_with_missing(phase, current_application_name, current_env_name): + value = "Local: ${KEY}, Missing Local: ${MISSING_KEY}, Cross: ${staging.DEBUG}, Missing Cross: ${prod.MISSING_KEY}" + all_secrets = [ + {"environment": "current", "path": "/", "key": "KEY", "value": "value1"}, + {"environment": "staging", "path": "/", "key": "DEBUG", "value": "staging_debug_value"} + ] + resolved_value = resolve_all_secrets(value, all_secrets, phase, current_application_name, current_env_name) + expected_value = "Local: value1, Missing Local: ${MISSING_KEY}, Cross: staging_debug_value, Missing Cross: ${prod.MISSING_KEY}" + assert resolved_value == expected_value + +# Edge Case: Local reference with missing path +def test_resolve_local_reference_missing_path(phase, current_application_name, current_env_name): + ref = "/missing_path/KEY" + resolved_value = resolve_secret_reference(ref, secrets_dict, phase, current_application_name, current_env_name) + assert resolved_value == "${/missing_path/KEY}" + +# Edge Case: Invalid reference format +def test_resolve_invalid_reference_format(phase, current_application_name, current_env_name): + ref = "invalid_format" + resolved_value = resolve_secret_reference(ref, secrets_dict, phase, current_application_name, current_env_name) + assert resolved_value == "${invalid_format}" \ No newline at end of file From 89b2a6d1b17418875f3423f84fa5dd0201faa3fb Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 22 Jul 2024 14:27:29 +0530 Subject: [PATCH 23/36] feat: added secret_referencing logic --- src/phase/utils/secret_referencing.py | 147 ++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 src/phase/utils/secret_referencing.py diff --git a/src/phase/utils/secret_referencing.py b/src/phase/utils/secret_referencing.py new file mode 100644 index 0000000..7196eab --- /dev/null +++ b/src/phase/utils/secret_referencing.py @@ -0,0 +1,147 @@ +import re +from typing import Dict, List +from .exceptions import EnvironmentNotFoundException +from .const import SECRET_REF_REGEX + +""" + Secret Referencing Syntax: + + This documentation explains the syntax used for referencing secrets within the configuration. + Secrets can be referenced both locally (within the same environment) and across different environments, + with or without specifying a path. + + Syntax Patterns: + + 1. Local Reference (Root Path): + Syntax: `${KEY}` + - Environment: Same as the current environment. + - Path: Root path (`/`). + - Secret Key: `KEY` + - Description: References a secret named `KEY` in the root path of the current environment. + + 2. Cross-Environment Reference (Root Path): + Syntax: `${staging.DEBUG}` + - Environment: Different environment (e.g., `dev`). + - Path: Root path (`/`) of the specified environment. + - Secret Key: `DEBUG` + - Description: References a secret named `DEBUG` in the root path of the `staging` environment. + + 3. Cross-Environment Reference (Specific Path): + Syntax: `${prod./frontend/SECRET_KEY}` + - Environment: Different environment (e.g., `prod`). + - Path: Specifies a path within the environment (`/frontend/`). + - Secret Key: `SECRET_KEY` + - Description: References a secret named `SECRET_KEY` located at `/frontend/` in the `prod` environment. + + 4. Local Reference (Specified Path): + Syntax: `${/backend/payments/STRIPE_KEY}` + - Environment: Same as the current environment. + - Path: Specifies a path within the environment (`/backend/payments/`). + - Secret Key: `STRIPE_KEY` + - Description: References a secret named `STRIPE_KEY` located at `/backend/payments/` in the current environment. + + Note: + The syntax allows for flexible secret management, enabling both straightforward local references and more complex cross-environment references. +""" + + +def resolve_secret_reference(ref: str, secrets_dict: Dict[str, Dict[str, Dict[str, str]]], phase: 'Phase', current_application_name: str, current_env_name: str) -> str: + """ + Resolves a single secret reference to its actual value by fetching it from the specified environment. + + The function supports both local and cross-environment secret references, allowing for flexible secret management. + Local references are identified by the absence of a dot '.' in the reference string, implying the current environment. + Cross-environment references include an environment name, separated by a dot from the rest of the path. + + Args: + ref (str): The secret reference string, which could be a local or cross-environment reference. + current_env_name (str): The current environment name, used for resolving local references. + phase ('Phase'): An instance of the Phase class to fetch secrets. + + Returns: + str: The resolved secret value. + + Raises: + ValueError: If the current environment name is not provided, or the secret is not found. + """ + + env_name = current_env_name + path = "/" # Default root path + key_name = ref + + # Parse the reference to identify environment, path, and secret key. + if "." in ref: # Cross-environment references, split by the first dot to get environment and the rest. + parts = ref.split(".", 1) + env_name, rest = parts[0], parts[1] + last_slash_index = rest.rfind("/") + if last_slash_index != -1: + path = rest[:last_slash_index] + key_name = rest[last_slash_index + 1:] + else: + key_name = rest + elif "/" in ref: # Local reference with specified path + last_slash_index = ref.rfind("/") + path = ref[:last_slash_index] + key_name = ref[last_slash_index + 1:] + + # Adjust for leading slash in path if not present + if not path.startswith("/"): + path = "/" + path + + try: + # Lookup with environment, path, and key + if env_name in secrets_dict and path in secrets_dict[env_name] and key_name in secrets_dict[env_name][path]: + return secrets_dict[env_name][path][key_name] + else: + # Handle fallback for cross-environment or missing secrets + if env_name != current_env_name: + fetched_secrets = phase.get(env_name=env_name, app_name=current_application_name, keys=[key_name], path=path) + for secret in fetched_secrets: + if secret["key"] == key_name: + return secret["value"] + except EnvironmentNotFoundException: + pass + + # Return the reference as is if not resolved + return f"${{{ref}}}" + + +def resolve_all_secrets(value: str, all_secrets: List[Dict[str, str]], phase: 'Phase', current_application_name: str, current_env_name: str) -> str: + """ + Resolves all secret references within a given string to their actual values. + + This function is particularly useful for processing configuration strings or entire files that + may contain multiple secret references. It iterates through each reference found in the input string, + resolves it using `resolve_secret_reference`, and replaces the reference with the resolved value. + + Args: + value (str): The input string containing one or more secret references. + current_env_name (str): The current environment name for resolving local references. + phase ('Phase'): An instance of the Phase class to fetch secrets. + + Returns: + str: The input string with all secret references resolved to their actual values. + + Raises: + ValueError: If the current environment name is not provided. + """ + + secrets_dict = {} + for secret in all_secrets: + env_name = secret['environment'] + path = secret['path'] + key = secret['key'] + if env_name not in secrets_dict: + secrets_dict[env_name] = {} + if path not in secrets_dict[env_name]: + secrets_dict[env_name][path] = {} + secrets_dict[env_name][path][key] = secret['value'] + + refs = SECRET_REF_REGEX.findall(value) + resolved_value = value + # Resolve each found reference and replace it with resolved_secret_value. + for ref in refs: + resolved_secret_value = resolve_secret_reference(ref, secrets_dict, phase, current_application_name, current_env_name) + resolved_value = resolved_value.replace(f"${{{ref}}}", resolved_secret_value) + + return resolved_value From 3f453cb3c408bd2f0ca201146a61694eaf3d0d7b Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 22 Jul 2024 14:28:01 +0530 Subject: [PATCH 24/36] chore: updated exceptions --- src/phase/utils/exceptions.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/phase/utils/exceptions.py b/src/phase/utils/exceptions.py index d824f3a..128d292 100644 --- a/src/phase/utils/exceptions.py +++ b/src/phase/utils/exceptions.py @@ -1,4 +1,7 @@ class EnvironmentNotFoundException(Exception): def __init__(self, env_name): super().__init__(f"⚠️\u200A Warning: The environment '{env_name}' either does not exist or you do not have access to it.") - \ No newline at end of file + +class OverrideNotFoundException(Exception): + def __init__(self, key): + super().__init__(f"No override exists for this secret. To set one, run: phase secrets update {key} --override") From 01303d6ffb5aa9700af6716cec9a63895e6476f8 Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 22 Jul 2024 14:30:17 +0530 Subject: [PATCH 25/36] test: fix workflow --- .github/workflows/pytest.yml | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index c18f85e..2338a8f 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -17,10 +17,19 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: 3.x + python-version: '3.12' - name: Install dependencies - run: pip install -r requirements.txt + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest + + - name: Print directory structure + run: | + pwd + ls -R - name: Run pytest - run: python -m pytest + run: | + python -m pytest -v tests/ \ No newline at end of file From dfbfd60186eaf141adb352174f73da0dd9e95724 Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 22 Jul 2024 14:32:43 +0530 Subject: [PATCH 26/36] fix: test names --- tests/{crypto.py => test_crypto.py} | 0 tests/{secret_referencing.py => test_secret_referencing.py} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename tests/{crypto.py => test_crypto.py} (100%) rename tests/{secret_referencing.py => test_secret_referencing.py} (100%) diff --git a/tests/crypto.py b/tests/test_crypto.py similarity index 100% rename from tests/crypto.py rename to tests/test_crypto.py diff --git a/tests/secret_referencing.py b/tests/test_secret_referencing.py similarity index 100% rename from tests/secret_referencing.py rename to tests/test_secret_referencing.py From 53b66cefd57e9cd42142869465a8768904488261 Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 22 Jul 2024 14:34:00 +0530 Subject: [PATCH 27/36] chore: cleanup workflow --- .github/workflows/pytest.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 2338a8f..e61f609 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -25,11 +25,6 @@ jobs: pip install -r requirements.txt pip install pytest - - name: Print directory structure - run: | - pwd - ls -R - - name: Run pytest run: | python -m pytest -v tests/ \ No newline at end of file From 47a08db9b710c6695180b40c17fa18ce2e4bf6b6 Mon Sep 17 00:00:00 2001 From: Nimish Date: Mon, 22 Jul 2024 20:34:46 +0530 Subject: [PATCH 28/36] feat: added secret referencing support --- src/phase/phase.py | 9 +++++++++ src/phase/utils/secret_referencing.py | 25 +++++++++++++------------ 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/src/phase/phase.py b/src/phase/phase.py index d71cb5c..a96da57 100644 --- a/src/phase/phase.py +++ b/src/phase/phase.py @@ -17,6 +17,7 @@ from .utils.crypto import CryptoUtils from .utils.const import __ph_version__, pss_user_pattern, pss_service_pattern from .utils.misc import phase_get_context, normalize_tag, tag_matches +from .utils.secret_referencing import resolve_all_secrets @dataclass @@ -176,6 +177,7 @@ def get(self, env_name: str, keys: List[str] = None, app_name: str = None, tag: secrets_data = secrets_response.json() results = [] + all_secrets = [] # List to store all secrets for resolving references for secret in secrets_data: # Check if a tag filter is applied and if the secret has the correct tags. if tag and not tag_matches(secret.get("tags", []), tag): @@ -209,6 +211,13 @@ def get(self, env_name: str, keys: List[str] = None, app_name: str = None, tag: if not keys or decrypted_key in keys: results.append(secret_obj) + + all_secrets.append(secret_obj) + + # Resolve secret references + for secret in results: + resolved_value = resolve_all_secrets(secret.value, all_secrets, self, app_name, env_name) + secret.value = resolved_value return results diff --git a/src/phase/utils/secret_referencing.py b/src/phase/utils/secret_referencing.py index 7196eab..9fa3af9 100644 --- a/src/phase/utils/secret_referencing.py +++ b/src/phase/utils/secret_referencing.py @@ -66,11 +66,11 @@ def resolve_secret_reference(ref: str, secrets_dict: Dict[str, Dict[str, Dict[st """ env_name = current_env_name - path = "/" # Default root path + path = "/" # Default root path key_name = ref # Parse the reference to identify environment, path, and secret key. - if "." in ref: # Cross-environment references, split by the first dot to get environment and the rest. + if "." in ref: # Cross-environment references parts = ref.split(".", 1) env_name, rest = parts[0], parts[1] last_slash_index = rest.rfind("/") @@ -90,15 +90,17 @@ def resolve_secret_reference(ref: str, secrets_dict: Dict[str, Dict[str, Dict[st try: # Lookup with environment, path, and key - if env_name in secrets_dict and path in secrets_dict[env_name] and key_name in secrets_dict[env_name][path]: - return secrets_dict[env_name][path][key_name] + if env_name in secrets_dict and path in secrets_dict[env_name]: + for secret in secrets_dict[env_name][path]: + if secret.key == key_name: + return secret.value else: # Handle fallback for cross-environment or missing secrets if env_name != current_env_name: fetched_secrets = phase.get(env_name=env_name, app_name=current_application_name, keys=[key_name], path=path) for secret in fetched_secrets: - if secret["key"] == key_name: - return secret["value"] + if secret.key == key_name: + return secret.value except EnvironmentNotFoundException: pass @@ -128,14 +130,13 @@ def resolve_all_secrets(value: str, all_secrets: List[Dict[str, str]], phase: 'P secrets_dict = {} for secret in all_secrets: - env_name = secret['environment'] - path = secret['path'] - key = secret['key'] + env_name = current_env_name # Assume current environment if not specified + path = secret.path if env_name not in secrets_dict: secrets_dict[env_name] = {} if path not in secrets_dict[env_name]: - secrets_dict[env_name][path] = {} - secrets_dict[env_name][path][key] = secret['value'] + secrets_dict[env_name][path] = [] + secrets_dict[env_name][path].append(secret) refs = SECRET_REF_REGEX.findall(value) resolved_value = value @@ -144,4 +145,4 @@ def resolve_all_secrets(value: str, all_secrets: List[Dict[str, str]], phase: 'P resolved_secret_value = resolve_secret_reference(ref, secrets_dict, phase, current_application_name, current_env_name) resolved_value = resolved_value.replace(f"${{{ref}}}", resolved_secret_value) - return resolved_value + return resolved_value \ No newline at end of file From 3879f937f11786aad1cde809e631d3ff0f3f66ff Mon Sep 17 00:00:00 2001 From: Nimish Date: Tue, 30 Jul 2024 16:43:00 +0530 Subject: [PATCH 29/36] wip --- src/phase/utils/secret_referencing.py | 35 +++++++++------------------ 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/src/phase/utils/secret_referencing.py b/src/phase/utils/secret_referencing.py index 9fa3af9..51bc64b 100644 --- a/src/phase/utils/secret_referencing.py +++ b/src/phase/utils/secret_referencing.py @@ -45,7 +45,7 @@ """ -def resolve_secret_reference(ref: str, secrets_dict: Dict[str, Dict[str, Dict[str, str]]], phase: 'Phase', current_application_name: str, current_env_name: str) -> str: +def resolve_secret_reference(ref: str, secrets_dict: Dict[str, Dict[str, List['PhaseSecret']]], phase: 'Phase', current_application_name: str, current_env_name: str) -> str: """ Resolves a single secret reference to its actual value by fetching it from the specified environment. @@ -64,12 +64,10 @@ def resolve_secret_reference(ref: str, secrets_dict: Dict[str, Dict[str, Dict[st Raises: ValueError: If the current environment name is not provided, or the secret is not found. """ - env_name = current_env_name - path = "/" # Default root path + path = "/" key_name = ref - # Parse the reference to identify environment, path, and secret key. if "." in ref: # Cross-environment references parts = ref.split(".", 1) env_name, rest = parts[0], parts[1] @@ -84,29 +82,22 @@ def resolve_secret_reference(ref: str, secrets_dict: Dict[str, Dict[str, Dict[st path = ref[:last_slash_index] key_name = ref[last_slash_index + 1:] - # Adjust for leading slash in path if not present if not path.startswith("/"): path = "/" + path try: - # Lookup with environment, path, and key - if env_name in secrets_dict and path in secrets_dict[env_name]: - for secret in secrets_dict[env_name][path]: + if env_name in secrets_dict and path in secrets_dict[env_name] and key_name in secrets_dict[env_name][path]: + return secrets_dict[env_name][path][key_name] + elif env_name != current_env_name: + fetched_secrets = phase.get(env_name=env_name, app_name=current_application_name, keys=[key_name], path=path) + for secret in fetched_secrets: if secret.key == key_name: return secret.value - else: - # Handle fallback for cross-environment or missing secrets - if env_name != current_env_name: - fetched_secrets = phase.get(env_name=env_name, app_name=current_application_name, keys=[key_name], path=path) - for secret in fetched_secrets: - if secret.key == key_name: - return secret.value except EnvironmentNotFoundException: pass - # Return the reference as is if not resolved return f"${{{ref}}}" - + def resolve_all_secrets(value: str, all_secrets: List[Dict[str, str]], phase: 'Phase', current_application_name: str, current_env_name: str) -> str: """ @@ -130,17 +121,15 @@ def resolve_all_secrets(value: str, all_secrets: List[Dict[str, str]], phase: 'P secrets_dict = {} for secret in all_secrets: - env_name = current_env_name # Assume current environment if not specified - path = secret.path + env_name = secret.environment if hasattr(secret, 'environment') else current_env_name if env_name not in secrets_dict: secrets_dict[env_name] = {} - if path not in secrets_dict[env_name]: - secrets_dict[env_name][path] = [] - secrets_dict[env_name][path].append(secret) + if secret.path not in secrets_dict[env_name]: + secrets_dict[env_name][secret.path] = {} + secrets_dict[env_name][secret.path][secret.key] = secret.value refs = SECRET_REF_REGEX.findall(value) resolved_value = value - # Resolve each found reference and replace it with resolved_secret_value. for ref in refs: resolved_secret_value = resolve_secret_reference(ref, secrets_dict, phase, current_application_name, current_env_name) resolved_value = resolved_value.replace(f"${{{ref}}}", resolved_secret_value) From 369a9874633254b2e6b4ff3256e9e44a37355491 Mon Sep 17 00:00:00 2001 From: Nimish Date: Wed, 31 Jul 2024 14:56:51 +0530 Subject: [PATCH 30/36] chore: updated module --- src/phase/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/phase/__init__.py b/src/phase/__init__.py index 9c09f6f..31a5401 100644 --- a/src/phase/__init__.py +++ b/src/phase/__init__.py @@ -1,3 +1,3 @@ -from .phase import Secrets +from .phase import Phase -__all__ = ['Secrets'] +__all__ = ['Phase'] \ No newline at end of file From 4fe2ae6e61b28f01b4737d105f2e3f8cc8759e49 Mon Sep 17 00:00:00 2001 From: Nimish Date: Wed, 31 Jul 2024 14:57:13 +0530 Subject: [PATCH 31/36] refactor: phase class --- src/phase/phase.py | 435 +++++++-------------------------------------- 1 file changed, 62 insertions(+), 373 deletions(-) diff --git a/src/phase/phase.py b/src/phase/phase.py index a96da57..44a118e 100644 --- a/src/phase/phase.py +++ b/src/phase/phase.py @@ -1,385 +1,74 @@ -import requests -from typing import Tuple -from typing import List, Dict, Optional from dataclasses import dataclass -from .utils.network import ( - fetch_phase_user, - fetch_app_key, - fetch_wrapped_key_share, - fetch_phase_secrets, - create_phase_secrets, - update_phase_secrets, - delete_phase_secrets -) -from nacl.bindings import ( - crypto_kx_server_session_keys, -) -from .utils.crypto import CryptoUtils -from .utils.const import __ph_version__, pss_user_pattern, pss_service_pattern -from .utils.misc import phase_get_context, normalize_tag, tag_matches +from typing import List, Dict, Optional +from .utils.phase_io import Phase as PhaseIO from .utils.secret_referencing import resolve_all_secrets - @dataclass -class AppSecret: - prefix: str - pes_version: str - app_token: str - pss_user_public_key: str - keyshare0: str - keyshare1_unwrap_key: str - - -class Secrets: - _app_pub_key = '' - _api_host = '' - _app_secret = None - - @dataclass - class PhaseSecret: - key: str - value: str - comment: str - path: str - tags: List[str] - overridden: bool - - +class PhaseSecret: + key: str + value: str + comment: str + path: str + tags: List[str] + overridden: bool + +class Phase: def __init__(self, init=True, pss=None, host=None): - """ - Initializes the Phase class with optional parameters. - - Parameters: - - init (bool): Whether to initialize using default methods or use provided parameters. - - pss (str): The Phase user token. Used if init is False. - - host (str): The host URL. Used if init is False. - """ - - app_secret = pss - self._api_host = host - - # Determine the type of the token (service token or user token) - self.is_service_token = pss_service_pattern.match(app_secret) is not None - self.is_user_token = pss_user_pattern.match(app_secret) is not None - - # If it's neither a service token nor a user token, raise an error - if not self.is_service_token and not self.is_user_token: - token_type = "service token" if "pss_service" in app_secret else "user token" - raise ValueError(f"Invalid Phase {token_type}") - - # Storing the token type as a string for easier access - self._token_type = "service" if self.is_service_token else "user" - - pss_segments = app_secret.split(':') - self._app_secret = AppSecret(*pss_segments) - - - def _find_matching_environment_key(self, user_data, env_id): - for app in user_data.get("apps", []): - for environment_key in app.get("environment_keys", []): - if environment_key["environment"]["id"] == env_id: - return environment_key - return None - - - def create(self, secrets: List[PhaseSecret], env_name: str, app_name: str) -> requests.Response: - """ - Create one or more secrets in Phase in a specified application and environment. - - Args: - secrets (List[PhaseSecret]): List of PhaseSecret objects containing key, value, comment, path, and tags. - env_name (str): The name (or partial name) of the desired environment. - app_name (str): The name of the application context. - - Returns: - requests.Response: The HTTP response from the Phase API. - """ - user_response = fetch_phase_user(self._token_type, self._app_secret.app_token, self._api_host) - if user_response.status_code != 200: - raise ValueError(f"Request failed with status code {user_response.status_code}: {user_response.text}") - - user_data = user_response.json() - app_name, app_id, env_name, env_id, public_key = phase_get_context(user_data, app_name=app_name, env_name=env_name) - - environment_key = self._find_matching_environment_key(user_data, env_id) - if environment_key is None: - raise ValueError(f"No environment found with id: {env_id}") - - wrapped_salt = environment_key.get("wrapped_salt") - decrypted_salt = self._decrypt(wrapped_salt) - - encrypted_secrets = [] - for secret in secrets: - # Validate and modify the key - formatted_key = secret.key.upper().replace(" ", "") - if " " in formatted_key: - raise ValueError("Secret key cannot contain spaces.") - - encrypted_key = CryptoUtils.encrypt_asymmetric(formatted_key, public_key) - encrypted_value = CryptoUtils.encrypt_asymmetric(secret.value, public_key) - encrypted_comment = CryptoUtils.encrypt_asymmetric(secret.comment, public_key) - key_digest = CryptoUtils.blake2b_digest(formatted_key, decrypted_salt) - - encrypted_secret = { - "key": encrypted_key, - "keyDigest": key_digest, - "value": encrypted_value, - "path": secret.path, - "tags": secret.tags, - "comment": encrypted_comment - } - encrypted_secrets.append(encrypted_secret) - - return create_phase_secrets(self._token_type, self._app_secret.app_token, env_id, encrypted_secrets, self._api_host) + self._phase_io = PhaseIO(init=init, pss=pss, host=host) + def create(self, env_name: str, app_name: str, secrets: List[PhaseSecret], path: str = '/') -> str: + key_value_pairs = [(secret.key, secret.value) for secret in secrets] + response = self._phase_io.create(key_value_pairs, env_name, app_name, path) + return "Success" if response.status_code == 200 else f"Error: {response.status_code}" def get(self, env_name: str, keys: List[str] = None, app_name: str = None, tag: str = None, path: str = '') -> List[PhaseSecret]: - """ - Fetch one or more secrets stored in Phase in a specified application and environment. - Note: Personal secret overrides are only supported when authenticating with a Personal Access Token (PAT). - - Args: - env_name (str): The name (or partial name) of the desired environment. - keys (List[str], optional): The keys for which to retrieve the secret values. - app_name (str, optional): The name of the desired application. - tag (str, optional): The tag to match against the secrets. - path (str, optional): The path under which to fetch secrets, default is root. - - Returns: - List[PhaseSecret]: A list of PhaseSecret objects for all secrets in the environment that match the criteria. - """ + secrets = self._phase_io.get(env_name, keys, app_name, tag, path) + phase_secrets = [ + PhaseSecret( + key=secret['key'], + value=secret['value'], + comment=secret.get('comment', ''), + path=secret.get('path', '/'), + tags=secret.get('tags', []), + overridden=secret.get('overridden', False) + ) + for secret in secrets + ] + return self._resolve_references(phase_secrets, env_name, app_name) + + def update(self, env_name: str, secret: PhaseSecret, app_name: str = None) -> str: + response = self._phase_io.update( + env_name, + secret.key, + secret.value, + app_name, + source_path=secret.path + ) + return response + + def delete(self, env_name: str, keys: List[str], app_name: str = None, path: str = None) -> List[str]: + return self._phase_io.delete(env_name, keys, app_name, path) + + def _resolve_references(self, secrets: List[PhaseSecret], env_name: str, app_name: str) -> List[PhaseSecret]: + all_secrets = [ + { + 'environment': env_name, + 'application': app_name, + 'key': secret.key, + 'value': secret.value, + 'path': secret.path + } + for secret in secrets + ] - user_response = fetch_phase_user(self._token_type, self._app_secret.app_token, self._api_host) - if user_response.status_code != 200: - raise ValueError(f"Request failed with status code {user_response.status_code}: {user_response.text}") - - user_data = user_response.json() - app_name, app_id, env_name, env_id, public_key = phase_get_context(user_data, app_name=app_name, env_name=env_name) - - environment_key = self._find_matching_environment_key(user_data, env_id) - if environment_key is None: - raise ValueError("No environment found with id: {}".format(env_id)) - - wrapped_seed = environment_key.get("wrapped_seed") - decrypted_seed = self._decrypt(wrapped_seed) - key_pair = CryptoUtils.env_keypair(decrypted_seed) - env_private_key = key_pair['privateKey'] - - params = {"path": path} - if keys and len(keys) == 1: - wrapped_salt = environment_key.get("wrapped_salt") - decrypted_salt = self._decrypt(wrapped_salt) - key_digest = CryptoUtils.blake2b_digest(keys[0], decrypted_salt) - params["key_digest"] = key_digest - - secrets_response = fetch_phase_secrets(self._token_type, self._app_secret.app_token, env_id, self._api_host, **params) - - secrets_data = secrets_response.json() - - results = [] - all_secrets = [] # List to store all secrets for resolving references - for secret in secrets_data: - # Check if a tag filter is applied and if the secret has the correct tags. - if tag and not tag_matches(secret.get("tags", []), tag): - continue - - secret_id = secret["id"] - override = secret.get("override") - # Check if the override exists and is active. - use_override = override and override.get("is_active") - - key_to_decrypt = secret["key"] - # Select the correct value based on override status. - value_to_decrypt = override["value"] if use_override else secret["value"] - comment_to_decrypt = secret["comment"] - - decrypted_key = CryptoUtils.decrypt_asymmetric(key_to_decrypt, env_private_key, public_key) - decrypted_value = CryptoUtils.decrypt_asymmetric(value_to_decrypt, env_private_key, public_key) - decrypted_comment = CryptoUtils.decrypt_asymmetric(comment_to_decrypt, env_private_key, public_key) if comment_to_decrypt else None - - override_info = secret.get("override", {}) - is_active_override = override_info.get("is_active", False) if override_info else False - - secret_obj = self.PhaseSecret( - key=decrypted_key, - value=decrypted_value, - comment=decrypted_comment, - path=secret.get("path", "/"), - tags=secret.get("tags", []), - overridden=is_active_override + for secret in secrets: + resolved_value = resolve_all_secrets( + secret.value, + all_secrets, + self._phase_io, + app_name, + env_name ) - - if not keys or decrypted_key in keys: - results.append(secret_obj) - - all_secrets.append(secret_obj) - - # Resolve secret references - for secret in results: - resolved_value = resolve_all_secrets(secret.value, all_secrets, self, app_name, env_name) secret.value = resolved_value - - return results - - - def update(self, secret: PhaseSecret, env_name: str, app_name: str = None, destination_path: str = None) -> str: - """ - Update a secret in Phase in a given application and environment. - Args: - secret (PhaseSecret): The secret object containing updated values. - env_name (str): The name (or partial name) of the desired environment. - app_name (str, optional): The name of the desired application. - destination_path (str, optional): The new path for the secret, if changing its location. If not provided, the path is not updated. - - Returns: - str: A message indicating the outcome of the update operation. - """ - - user_response = fetch_phase_user(self._token_type, self._app_secret.app_token, self._api_host) - if user_response.status_code != 200: - raise ValueError(f"Request failed with status code {user_response.status_code}: {user_response.text}") - - user_data = user_response.json() - app_name, app_id, env_name, env_id, public_key = phase_get_context(user_data, app_name=app_name, env_name=env_name) - - environment_key = self._find_matching_environment_key(user_data, env_id) - if environment_key is None: - raise ValueError(f"No environment found with id: {env_id}") - - secrets_response = fetch_phase_secrets(self._token_type, self._app_secret.app_token, env_id, self._api_host, path=secret.path) - secrets_data = secrets_response.json() - - wrapped_seed = environment_key.get("wrapped_seed") - decrypted_seed = self._decrypt(wrapped_seed) - key_pair = CryptoUtils.env_keypair(decrypted_seed) - env_private_key = key_pair['privateKey'] - - matching_secret = next((s for s in secrets_data if CryptoUtils.decrypt_asymmetric(s["key"], env_private_key, public_key) == secret.key), None) - if not matching_secret: - return f"Key '{secret.key}' doesn't exist in path '{secret.path}'." - - encrypted_key = CryptoUtils.encrypt_asymmetric(secret.key, public_key) - encrypted_value = CryptoUtils.encrypt_asymmetric(secret.value, public_key) - encrypted_comment = CryptoUtils.encrypt_asymmetric(secret.comment, public_key) if secret.comment else "" - - wrapped_salt = environment_key.get("wrapped_salt") - decrypted_salt = self._decrypt(wrapped_salt) - key_digest = CryptoUtils.blake2b_digest(secret.key, decrypted_salt) - - secret_update_payload = { - "id": matching_secret["id"], - "key": encrypted_key, - "keyDigest": key_digest, - "value": encrypted_value, - "tags": secret.tags, - "comment": encrypted_comment, - "path": destination_path if destination_path is not None else matching_secret["path"] - } - - response = update_phase_secrets(self._token_type, self._app_secret.app_token, env_id, [secret_update_payload], self._api_host) - - if response.status_code == 200: - return "Success" - else: - return f"Error: Failed to update secret. HTTP Status Code: {response.status_code}" - - - def delete(self, env_name: str, keys_to_delete: List[str], app_name: str = None, path: str = None) -> dict: - """ - Delete one or more secrets stored in Phase in a given application and environment. - - Args: - env_name (str): The name (or partial name) of the desired environment. - keys_to_delete (List[str]): The keys for which to delete the secrets. - app_name (str, optional): The name of the desired application. - path (str, optional): The path within which to delete the secrets. If specified, only deletes secrets within this path. - - Returns: - dict: A dictionary with two keys: - 'deleted': List of keys that were successfully deleted. - 'not_found': List of keys that were not found. - """ - - user_response = fetch_phase_user(self._token_type, self._app_secret.app_token, self._api_host) - if user_response.status_code != 200: - raise ValueError(f"Request failed with status code {user_response.status_code}: {user_response.text}") - - user_data = user_response.json() - app_name, app_id, env_name, env_id, public_key = phase_get_context(user_data, app_name=app_name, env_name=env_name) - - environment_key = self._find_matching_environment_key(user_data, env_id) - if environment_key is None: - raise ValueError(f"No environment found with id: {env_id}") - - wrapped_seed = environment_key.get("wrapped_seed") - decrypted_seed = self._decrypt(wrapped_seed) - key_pair = CryptoUtils.env_keypair(decrypted_seed) - env_private_key = key_pair['privateKey'] - - secret_ids_to_delete = [] - keys_not_found = [] - secrets_response = fetch_phase_secrets(self._token_type, self._app_secret.app_token, env_id, self._api_host, path=path) - secrets_data = secrets_response.json() - - # Normalize the provided path - normalized_path = path.rstrip('/') + '/' if path else '/' - - for key in keys_to_delete: - found = False - for secret in secrets_data: - # Normalize the secret's path - secret_path = secret.get("path", "/").rstrip('/') + '/' - if normalized_path != secret_path: - continue # Skip secrets not in the specified path - decrypted_key = CryptoUtils.decrypt_asymmetric(secret["key"], env_private_key, public_key) - if decrypted_key == key: - secret_ids_to_delete.append(secret["id"]) - found = True - break - if not found: - keys_not_found.append(key) - - if secret_ids_to_delete: - delete_response = delete_phase_secrets(self._token_type, self._app_secret.app_token, env_id, secret_ids_to_delete, self._api_host) - if delete_response.status_code != 200: - raise ValueError(f"Failed to delete secrets: {delete_response.text}") - - # Provide detailed results - return {'deleted': [key for key in keys_to_delete if key not in keys_not_found], 'not_found': keys_not_found} - - def _decrypt(self, phase_ciphertext) -> str | None: - """ - Decrypts a Phase ciphertext string. - - Args: - phase_ciphertext (str): The encrypted message to decrypt. - - Returns: - str: The decrypted plaintext as a string. Returns `None` if an error occurs. - - Raises: - ValueError: If the ciphertext is not in the expected format (e.g. wrong prefix, wrong number of fields). - """ - try: - [prefix, version, client_pub_key_hex, ct] = phase_ciphertext.split(':') - if prefix != 'ph' or len(phase_ciphertext.split(':')) != 4: - raise ValueError('Ciphertext is invalid') - client_pub_key = bytes.fromhex(client_pub_key_hex) - - wrapped_key_share = fetch_wrapped_key_share( - self._token_type, self._app_secret.app_token, self._api_host) - keyshare1 = CryptoUtils.decrypt_raw(bytes.fromhex(wrapped_key_share), bytes.fromhex(self._app_secret.keyshare1_unwrap_key)).decode("utf-8") - - app_priv_key = CryptoUtils.reconstruct_secret( - [self._app_secret.keyshare0, keyshare1]) - - session_keys = crypto_kx_server_session_keys(bytes.fromhex( - self._app_secret.pss_user_public_key), bytes.fromhex(app_priv_key), client_pub_key) - - plaintext = CryptoUtils.decrypt_b64(ct, session_keys[0].hex()) - - return plaintext - - except ValueError as err: - raise ValueError(f"Something went wrong: {err}") + return secrets \ No newline at end of file From d16f42d764b3de7f9d696e0bdf27995f3345dcf6 Mon Sep 17 00:00:00 2001 From: Nimish Date: Wed, 31 Jul 2024 14:57:35 +0530 Subject: [PATCH 32/36] feat: updated phase_io and secret ref logic --- src/phase/utils/phase_io.py | 429 ++++++++++++++++++++++++++ src/phase/utils/secret_referencing.py | 102 +++--- 2 files changed, 493 insertions(+), 38 deletions(-) create mode 100644 src/phase/utils/phase_io.py diff --git a/src/phase/utils/phase_io.py b/src/phase/utils/phase_io.py new file mode 100644 index 0000000..677b3ef --- /dev/null +++ b/src/phase/utils/phase_io.py @@ -0,0 +1,429 @@ +import requests +from typing import Tuple +from typing import List, Dict +from dataclasses import dataclass +from .network import ( + fetch_phase_user, + fetch_app_key, + fetch_wrapped_key_share, + fetch_phase_secrets, + create_phase_secrets, + update_phase_secrets, + delete_phase_secrets +) +from nacl.bindings import ( + crypto_kx_server_session_keys, +) +from .crypto import CryptoUtils +from .const import __ph_version__, pss_user_pattern, pss_service_pattern +from .misc import phase_get_context, normalize_tag, tag_matches +from .secret_referencing import resolve_all_secrets + + +@dataclass +class AppSecret: + prefix: str + pes_version: str + app_token: str + pss_user_public_key: str + keyshare0: str + keyshare1_unwrap_key: str + + +class Phase: + _app_pub_key = '' + _api_host = '' + _app_secret = None + + + def __init__(self, init=True, pss=None, host=None): + """ + Initializes the Phase class with optional parameters. + + Parameters: + - init (bool): Whether to initialize using default methods or use provided parameters. + - pss (str): The Phase user token. Used if init is False. + - host (str): The host URL. Used if init is False. + """ + + app_secret = pss + self._api_host = host + + # Determine the type of the token (service token or user token) + self.is_service_token = pss_service_pattern.match(app_secret) is not None + self.is_user_token = pss_user_pattern.match(app_secret) is not None + + # If it's neither a service token nor a user token, raise an error + if not self.is_service_token and not self.is_user_token: + token_type = "service token" if "pss_service" in app_secret else "user token" + raise ValueError(f"Invalid Phase {token_type}") + + # Storing the token type as a string for easier access + self._token_type = "service" if self.is_service_token else "user" + + pss_segments = app_secret.split(':') + self._app_secret = AppSecret(*pss_segments) + + + def _find_matching_environment_key(self, user_data, env_id): + for app in user_data.get("apps", []): + for environment_key in app.get("environment_keys", []): + if environment_key["environment"]["id"] == env_id: + return environment_key + return None + + + def auth(self): + try: + key = fetch_app_key( + self._token_type, self._app_secret.app_token, self._api_host) + + return "Success" + + except ValueError as err: + raise ValueError(f"Invalid Phase credentials") + + + def init(self): + response = fetch_phase_user(self._token_type, self._app_secret.app_token, self._api_host) + + # Ensure the response is OK + if response.status_code != 200: + raise ValueError(f"Request failed with status code {response.status_code}: {response.text}") + + # Parse and return the JSON content + return response.json() + + + def create(self, key_value_pairs: List[Tuple[str, str]], env_name: str, app_name: str, path: str = '/', override_value: str = None) -> requests.Response: + """ + Create secrets in Phase KMS with support for specifying a path and overrides. + + Args: + key_value_pairs (List[Tuple[str, str]]): List of tuples where each tuple contains a key and a value. + env_name (str): The name (or partial name) of the desired environment. + app_name (str): The name of the application context. + path (str, optional): The path under which to store the secrets. Defaults to the root path '/'. + override_value (str, optional): The overridden value for the secret. Defaults to None. + + Returns: + requests.Response: The HTTP response from the Phase KMS. + """ + user_response = fetch_phase_user(self._token_type, self._app_secret.app_token, self._api_host) + if user_response.status_code != 200: + raise ValueError(f"Request failed with status code {user_response.status_code}: {user_response.text}") + + user_data = user_response.json() + app_name, app_id, env_name, env_id, public_key = phase_get_context(user_data, app_name=app_name, env_name=env_name) + + environment_key = self._find_matching_environment_key(user_data, env_id) + if environment_key is None: + raise ValueError(f"No environment found with id: {env_id}") + + wrapped_salt = environment_key.get("wrapped_salt") + decrypted_salt = self.decrypt(wrapped_salt) + + secrets = [] + for key, value in key_value_pairs: + encrypted_key = CryptoUtils.encrypt_asymmetric(key, public_key) + encrypted_value = CryptoUtils.encrypt_asymmetric(value, public_key) + key_digest = CryptoUtils.blake2b_digest(key, decrypted_salt) + + secret = { + "key": encrypted_key, + "keyDigest": key_digest, + "value": encrypted_value, + "path": path, + "tags": [], # TODO: Implement tags and comments creation + "comment": "" + } + + if override_value: + encrypted_override_value = CryptoUtils.encrypt_asymmetric(override_value, public_key) + secret["override"] = { + "value": encrypted_override_value, + "isActive": True + } + + secrets.append(secret) + + return create_phase_secrets(self._token_type, self._app_secret.app_token, env_id, secrets, self._api_host) + + + def get(self, env_name: str, keys: List[str] = None, app_name: str = None, tag: str = None, path: str = '') -> List[Dict]: + """ + Get secrets from Phase KMS based on key and environment, with support for personal overrides, + optional tag matching, decrypting comments, and now including path support and key digest optimization. + + Args: + env_name (str): The name (or partial name) of the desired environment. + keys (List[str], optional): The keys for which to retrieve the secret values. + app_name (str, optional): The name of the desired application. + tag (str, optional): The tag to match against the secrets. + path (str, optional): The path under which to fetch secrets, default is root. + + Returns: + List[Dict]: A list of dictionaries for all secrets in the environment that match the criteria, including their paths. + """ + + user_response = fetch_phase_user(self._token_type, self._app_secret.app_token, self._api_host) + if user_response.status_code != 200: + raise ValueError(f"Request failed with status code {user_response.status_code}: {user_response.text}") + + user_data = user_response.json() + app_name, app_id, env_name, env_id, public_key = phase_get_context(user_data, app_name=app_name, env_name=env_name) + + environment_key = self._find_matching_environment_key(user_data, env_id) + if environment_key is None: + raise ValueError("No environment found with id: {}".format(env_id)) + + wrapped_seed = environment_key.get("wrapped_seed") + decrypted_seed = self.decrypt(wrapped_seed) + key_pair = CryptoUtils.env_keypair(decrypted_seed) + env_private_key = key_pair['privateKey'] + + params = {"path": path} + if keys and len(keys) == 1: + wrapped_salt = environment_key.get("wrapped_salt") + decrypted_salt = self.decrypt(wrapped_salt) + key_digest = CryptoUtils.blake2b_digest(keys[0], decrypted_salt) + params["key_digest"] = key_digest + + secrets_response = fetch_phase_secrets(self._token_type, self._app_secret.app_token, env_id, self._api_host, **params) + + secrets_data = secrets_response.json() + + results = [] + for secret in secrets_data: + # Check if a tag filter is applied and if the secret has the correct tags. + if tag and not tag_matches(secret.get("tags", []), tag): + continue + + secret_id = secret["id"] + override = secret.get("override") + # Check if the override exists and is active. + use_override = override and override.get("is_active") + + key_to_decrypt = secret["key"] + # Select the correct value based on override status. + value_to_decrypt = override["value"] if use_override else secret["value"] + comment_to_decrypt = secret["comment"] + + decrypted_key = CryptoUtils.decrypt_asymmetric(key_to_decrypt, env_private_key, public_key) + decrypted_value = CryptoUtils.decrypt_asymmetric(value_to_decrypt, env_private_key, public_key) + decrypted_comment = CryptoUtils.decrypt_asymmetric(comment_to_decrypt, env_private_key, public_key) if comment_to_decrypt else None + + result = { + "key": decrypted_key, + "value": decrypted_value, + "overridden": use_override, + "tags": secret.get("tags", []), + "comment": decrypted_comment, + "path": secret.get("path", "/"), + "application": app_name, + "environment": env_name + } + + # Only add the secret to results if the requested keys are not specified or the decrypted key is one of the requested keys. + if not keys or decrypted_key in keys: + results.append(result) + + return results + + + def update(self, env_name: str, key: str, value: str = None, app_name: str = None, source_path: str = '', destination_path: str = None, override: bool = False, toggle_override: bool = False) -> str: + """ + Update a secret in Phase KMS based on key and environment, with support for source and destination paths. + + Args: + env_name (str): The name (or partial name) of the desired environment. + key (str): The key for which to update the secret value. + value (str, optional): The new value for the secret. Defaults to None. + app_name (str, optional): The name of the desired application. + source_path (str, optional): The current path of the secret. Defaults to root path '/'. + destination_path (str, optional): The new path for the secret, if changing its location. If not provided, the path is not updated. + override (bool, optional): Whether to update an overridden secret value. Defaults to False. + toggle_override (bool, optional): Whether to toggle the override state between active and inactive. Defaults to False. + + Returns: + str: A message indicating the outcome of the update operation. + """ + + user_response = fetch_phase_user(self._token_type, self._app_secret.app_token, self._api_host) + if user_response.status_code != 200: + raise ValueError(f"Request failed with status code {user_response.status_code}: {user_response.text}") + + user_data = user_response.json() + app_name, app_id, env_name, env_id, public_key = phase_get_context(user_data, app_name=app_name, env_name=env_name) + + environment_key = self._find_matching_environment_key(user_data, env_id) + if environment_key is None: + raise ValueError(f"No environment found with id: {env_id}") + + # Fetch secrets from the specified source path + secrets_response = fetch_phase_secrets(self._token_type, self._app_secret.app_token, env_id, self._api_host, path=source_path) + secrets_data = secrets_response.json() + + wrapped_seed = environment_key.get("wrapped_seed") + decrypted_seed = self.decrypt(wrapped_seed) + key_pair = CryptoUtils.env_keypair(decrypted_seed) + env_private_key = key_pair['privateKey'] + + matching_secret = next((secret for secret in secrets_data if CryptoUtils.decrypt_asymmetric(secret["key"], env_private_key, public_key) == key), None) + if not matching_secret: + return f"Key '{key}' doesn't exist in path '{source_path}'." + + encrypted_key = CryptoUtils.encrypt_asymmetric(key, public_key) + encrypted_value = CryptoUtils.encrypt_asymmetric(value or "", public_key) + + wrapped_salt = environment_key.get("wrapped_salt") + decrypted_salt = self.decrypt(wrapped_salt) + key_digest = CryptoUtils.blake2b_digest(key, decrypted_salt) + + # Determine secret value to be updated - only update shared value if not overriding or toggling + if not override and not toggle_override: + payload_value = encrypted_value + else: + payload_value = matching_secret["value"] + + secret_update_payload = { + "id": matching_secret["id"], + "key": encrypted_key, + "keyDigest": key_digest, + "value": payload_value, + "tags": matching_secret.get("tags", []), # TODO: Implement tags and comments updates + "comment": matching_secret.get("comment", ""), + "path": destination_path if destination_path is not None else matching_secret["path"] + } + + if toggle_override: + # Check if the secret has an existing override. If not, raise a custom exception. + # This prevents toggling an override on a secret that doesn't have one. + if "override" not in matching_secret or matching_secret["override"] is None: + raise OverrideNotFoundException(key) + + # Retrieve the current override state. If the override is not active, it defaults to False. + current_override_state = matching_secret["override"].get("is_active", False) + + # Prepare the payload to update the override status. The value of the override remains unchanged, + # but the isActive status is toggled. + secret_update_payload["override"] = { + "value": matching_secret["override"]["value"], + "isActive": not current_override_state # Toggle the current state. + } + elif override: + # If the override flag is set, check if there is an existing override. + if matching_secret["override"] is None: + # If no override exists, create a new override entry. + # The value for the override is the encrypted value provided by the user, + # and the override is activated by default. + secret_update_payload["override"] = { + "value": encrypted_value, + "isActive": True + } + else: + # If an override already exists, update its value. + # If a new value is provided, use it; otherwise, retain the existing override value. + # The isActive status of the override is also retained from the existing override. + secret_update_payload["override"] = { + "value": encrypted_value if value is not None else matching_secret["override"]["value"], + "isActive": matching_secret["override"].get("is_active", True) + } + + response = update_phase_secrets(self._token_type, self._app_secret.app_token, env_id, [secret_update_payload], self._api_host) + + if response.status_code == 200: + return "Success" + else: + return f"Error: Failed to update secret. HTTP Status Code: {response.status_code}" + + + def delete(self, env_name: str, keys_to_delete: List[str], app_name: str = None, path: str = None) -> List[str]: + """ + Delete secrets in Phase KMS based on keys and environment, with optional path support. + + Args: + env_name (str): The name (or partial name) of the desired environment. + keys_to_delete (List[str]): The keys for which to delete the secrets. + app_name (str, optional): The name of the desired application. + path (str, optional): The path within which to delete the secrets. If specified, only deletes secrets within this path. + + Returns: + List[str]: A list of keys that were not found and could not be deleted. + """ + + user_response = fetch_phase_user(self._token_type, self._app_secret.app_token, self._api_host) + if user_response.status_code != 200: + raise ValueError(f"Request failed with status code {user_response.status_code}: {user_response.text}") + + user_data = user_response.json() + app_name, app_id, env_name, env_id, public_key = phase_get_context(user_data, app_name=app_name, env_name=env_name) + + environment_key = self._find_matching_environment_key(user_data, env_id) + if environment_key is None: + raise ValueError(f"No environment found with id: {env_id}") + + wrapped_seed = environment_key.get("wrapped_seed") + decrypted_seed = self.decrypt(wrapped_seed) + key_pair = CryptoUtils.env_keypair(decrypted_seed) + env_private_key = key_pair['privateKey'] + + secret_ids_to_delete = [] + keys_not_found = [] + secrets_response = fetch_phase_secrets(self._token_type, self._app_secret.app_token, env_id, self._api_host, path=path) + secrets_data = secrets_response.json() + + for key in keys_to_delete: + found = False + for secret in secrets_data: + if path is not None and secret.get("path", "/") != path: + continue # Skip secrets not in the specified path + decrypted_key = CryptoUtils.decrypt_asymmetric(secret["key"], env_private_key, public_key) + if decrypted_key == key: + secret_ids_to_delete.append(secret["id"]) + found = True + break + if not found: + keys_not_found.append(key) + + if secret_ids_to_delete: + delete_phase_secrets(self._token_type, self._app_secret.app_token, env_id, secret_ids_to_delete, self._api_host) + + return keys_not_found + + + def decrypt(self, phase_ciphertext) -> str | None: + """ + Decrypts a Phase ciphertext string. + + Args: + phase_ciphertext (str): The encrypted message to decrypt. + + Returns: + str: The decrypted plaintext as a string. Returns `None` if an error occurs. + + Raises: + ValueError: If the ciphertext is not in the expected format (e.g. wrong prefix, wrong number of fields). + """ + try: + [prefix, version, client_pub_key_hex, ct] = phase_ciphertext.split(':') + if prefix != 'ph' or len(phase_ciphertext.split(':')) != 4: + raise ValueError('Ciphertext is invalid') + client_pub_key = bytes.fromhex(client_pub_key_hex) + + wrapped_key_share = fetch_wrapped_key_share( + self._token_type, self._app_secret.app_token, self._api_host) + keyshare1 = CryptoUtils.decrypt_raw(bytes.fromhex(wrapped_key_share), bytes.fromhex(self._app_secret.keyshare1_unwrap_key)).decode("utf-8") + + app_priv_key = CryptoUtils.reconstruct_secret( + [self._app_secret.keyshare0, keyshare1]) + + session_keys = crypto_kx_server_session_keys(bytes.fromhex( + self._app_secret.pss_user_public_key), bytes.fromhex(app_priv_key), client_pub_key) + + plaintext = CryptoUtils.decrypt_b64(ct, session_keys[0].hex()) + + return plaintext + + except ValueError as err: + raise ValueError(f"Something went wrong: {err}") diff --git a/src/phase/utils/secret_referencing.py b/src/phase/utils/secret_referencing.py index 51bc64b..cb7fb12 100644 --- a/src/phase/utils/secret_referencing.py +++ b/src/phase/utils/secret_referencing.py @@ -21,7 +21,7 @@ 2. Cross-Environment Reference (Root Path): Syntax: `${staging.DEBUG}` - - Environment: Different environment (e.g., `dev`). + - Environment: Different environment (e.g., `staging`). - Path: Root path (`/`) of the specified environment. - Secret Key: `DEBUG` - Description: References a secret named `DEBUG` in the root path of the `staging` environment. @@ -45,7 +45,32 @@ """ -def resolve_secret_reference(ref: str, secrets_dict: Dict[str, Dict[str, List['PhaseSecret']]], phase: 'Phase', current_application_name: str, current_env_name: str) -> str: +def split_path_and_key(ref: str) -> tuple: + """ + Splits a reference string into path and key components. + + Args: + ref (str): The reference string to split. + + Returns: + tuple: A tuple containing the path and key. + """ + last_slash_index = ref.rfind("/") + if last_slash_index != -1: + path = ref[:last_slash_index] + key_name = ref[last_slash_index + 1:] + else: + path = "/" + key_name = ref + + # Ensure path starts with a slash + if not path.startswith("/"): + path = "/" + path + + return path, key_name + + +def resolve_secret_reference(ref: str, secrets_dict: Dict[str, Dict[str, Dict[str, str]]], phase: 'Phase', current_application_name: str, current_env_name: str) -> str: """ Resolves a single secret reference to its actual value by fetching it from the specified environment. @@ -55,49 +80,48 @@ def resolve_secret_reference(ref: str, secrets_dict: Dict[str, Dict[str, List['P Args: ref (str): The secret reference string, which could be a local or cross-environment reference. - current_env_name (str): The current environment name, used for resolving local references. + secrets_dict (Dict[str, Dict[str, Dict[str, str]]]): A dictionary containing known secrets. phase ('Phase'): An instance of the Phase class to fetch secrets. + current_application_name (str): The name of the current application. + current_env_name (str): The current environment name, used for resolving local references. Returns: - str: The resolved secret value. - - Raises: - ValueError: If the current environment name is not provided, or the secret is not found. + str: The resolved secret value or the original reference if not resolved. """ env_name = current_env_name - path = "/" + path = "/" # Default root path key_name = ref + # Parse the reference to identify environment, path, and secret key. if "." in ref: # Cross-environment references parts = ref.split(".", 1) env_name, rest = parts[0], parts[1] - last_slash_index = rest.rfind("/") - if last_slash_index != -1: - path = rest[:last_slash_index] - key_name = rest[last_slash_index + 1:] - else: - key_name = rest - elif "/" in ref: # Local reference with specified path - last_slash_index = ref.rfind("/") - path = ref[:last_slash_index] - key_name = ref[last_slash_index + 1:] - - if not path.startswith("/"): - path = "/" + path + path, key_name = split_path_and_key(rest) + else: # Local reference + path, key_name = split_path_and_key(ref) try: - if env_name in secrets_dict and path in secrets_dict[env_name] and key_name in secrets_dict[env_name][path]: - return secrets_dict[env_name][path][key_name] - elif env_name != current_env_name: - fetched_secrets = phase.get(env_name=env_name, app_name=current_application_name, keys=[key_name], path=path) - for secret in fetched_secrets: - if secret.key == key_name: - return secret.value + # Lookup with environment, path, and key + if env_name in secrets_dict: + # Try to find the secret in the exact path + if path in secrets_dict[env_name] and key_name in secrets_dict[env_name][path]: + return secrets_dict[env_name][path][key_name] + + # For local references, try to find the secret in the root path only if the original path was root + if env_name == current_env_name and path == "/" and '/' in secrets_dict[env_name] and key_name in secrets_dict[env_name]['/']: + return secrets_dict[env_name]['/'][key_name] + + # If the secret is not found in secrets_dict, try to fetch it from Phase + fetched_secrets = phase.get(env_name=env_name, app_name=current_application_name, keys=[key_name], path=path) + for secret in fetched_secrets: + if secret["key"] == key_name: + return secret["value"] except EnvironmentNotFoundException: pass + # Return the reference as is if not resolved return f"${{{ref}}}" - + def resolve_all_secrets(value: str, all_secrets: List[Dict[str, str]], phase: 'Phase', current_application_name: str, current_env_name: str) -> str: """ @@ -109,29 +133,31 @@ def resolve_all_secrets(value: str, all_secrets: List[Dict[str, str]], phase: 'P Args: value (str): The input string containing one or more secret references. - current_env_name (str): The current environment name for resolving local references. + all_secrets (List[Dict[str, str]]): A list of all known secrets. phase ('Phase'): An instance of the Phase class to fetch secrets. + current_application_name (str): The name of the current application. + current_env_name (str): The current environment name for resolving local references. Returns: str: The input string with all secret references resolved to their actual values. - - Raises: - ValueError: If the current environment name is not provided. """ secrets_dict = {} for secret in all_secrets: - env_name = secret.environment if hasattr(secret, 'environment') else current_env_name + env_name = secret['environment'] + path = secret['path'] + key = secret['key'] if env_name not in secrets_dict: secrets_dict[env_name] = {} - if secret.path not in secrets_dict[env_name]: - secrets_dict[env_name][secret.path] = {} - secrets_dict[env_name][secret.path][secret.key] = secret.value + if path not in secrets_dict[env_name]: + secrets_dict[env_name][path] = {} + secrets_dict[env_name][path][key] = secret['value'] refs = SECRET_REF_REGEX.findall(value) resolved_value = value + # Resolve each found reference and replace it with resolved_secret_value. for ref in refs: resolved_secret_value = resolve_secret_reference(ref, secrets_dict, phase, current_application_name, current_env_name) resolved_value = resolved_value.replace(f"${{{ref}}}", resolved_secret_value) - return resolved_value \ No newline at end of file + return resolved_value From e6dc78b8ff88279c7e19f7ee3454d6a590c3c281 Mon Sep 17 00:00:00 2001 From: Nimish Date: Thu, 1 Aug 2024 11:52:34 +0530 Subject: [PATCH 33/36] feat: extended Phase to utilize all phase_io utils --- src/phase/__init__.py | 20 ++++++- src/phase/phase.py | 126 ++++++++++++++++++++++++++++++++++-------- 2 files changed, 120 insertions(+), 26 deletions(-) diff --git a/src/phase/__init__.py b/src/phase/__init__.py index 31a5401..e6ef62f 100644 --- a/src/phase/__init__.py +++ b/src/phase/__init__.py @@ -1,3 +1,19 @@ -from .phase import Phase +from .phase import ( + Phase, + GetSecretOptions, + GetAllSecretsOptions, + CreateSecretsOptions, + SecretUpdateOptions, + DeleteSecretOptions, + PhaseSecret +) -__all__ = ['Phase'] \ No newline at end of file +__all__ = [ + 'Phase', + 'GetSecretOptions', + 'GetAllSecretsOptions', + 'CreateSecretsOptions', + 'SecretUpdateOptions', + 'DeleteSecretOptions', + 'PhaseSecret' +] \ No newline at end of file diff --git a/src/phase/phase.py b/src/phase/phase.py index 44a118e..69bbf8a 100644 --- a/src/phase/phase.py +++ b/src/phase/phase.py @@ -1,29 +1,89 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import List, Dict, Optional from .utils.phase_io import Phase as PhaseIO from .utils.secret_referencing import resolve_all_secrets +@dataclass +class GetSecretOptions: + env_name: str + app_name: str + key_to_find: Optional[str] = None + tag: Optional[str] = None + secret_path: str = "/" + +@dataclass +class GetAllSecretsOptions: + env_name: str + app_name: str + tag: Optional[str] = None + secret_path: str = "/" + +@dataclass +class CreateSecretsOptions: + env_name: str + app_name: str + key_value_pairs: List[Dict[str, str]] + secret_path: str = "/" + +@dataclass +class SecretUpdateOptions: + env_name: str + app_name: str + key: str + value: Optional[str] = None + secret_path: str = "/" + destination_path: Optional[str] = None + override: bool = False + toggle_override: bool = False + +@dataclass +class DeleteSecretOptions: + env_name: str + app_name: str + key_to_delete: str + secret_path: str = "/" + @dataclass class PhaseSecret: key: str value: str - comment: str - path: str - tags: List[str] - overridden: bool + comment: str = "" + path: str = "/" + tags: List[str] = field(default_factory=list) + overridden: bool = False class Phase: def __init__(self, init=True, pss=None, host=None): self._phase_io = PhaseIO(init=init, pss=pss, host=host) - def create(self, env_name: str, app_name: str, secrets: List[PhaseSecret], path: str = '/') -> str: - key_value_pairs = [(secret.key, secret.value) for secret in secrets] - response = self._phase_io.create(key_value_pairs, env_name, app_name, path) - return "Success" if response.status_code == 200 else f"Error: {response.status_code}" + def get_secret(self, options: GetSecretOptions) -> Optional[PhaseSecret]: + secrets = self._phase_io.get( + env_name=options.env_name, + keys=[options.key_to_find] if options.key_to_find else None, + app_name=options.app_name, + tag=options.tag, + path=options.secret_path + ) + if secrets: + secret = secrets[0] + return PhaseSecret( + key=secret['key'], + value=secret['value'], + comment=secret.get('comment', ''), + path=secret.get('path', '/'), + tags=secret.get('tags', []), + overridden=secret.get('overridden', False) + ) + return None - def get(self, env_name: str, keys: List[str] = None, app_name: str = None, tag: str = None, path: str = '') -> List[PhaseSecret]: - secrets = self._phase_io.get(env_name, keys, app_name, tag, path) - phase_secrets = [ + def get_all_secrets(self, options: GetAllSecretsOptions) -> List[PhaseSecret]: + secrets = self._phase_io.get( + env_name=options.env_name, + app_name=options.app_name, + tag=options.tag, + path=options.secret_path + ) + return [ PhaseSecret( key=secret['key'], value=secret['value'], @@ -34,22 +94,40 @@ def get(self, env_name: str, keys: List[str] = None, app_name: str = None, tag: ) for secret in secrets ] - return self._resolve_references(phase_secrets, env_name, app_name) - def update(self, env_name: str, secret: PhaseSecret, app_name: str = None) -> str: - response = self._phase_io.update( - env_name, - secret.key, - secret.value, - app_name, - source_path=secret.path + def create_secrets(self, options: CreateSecretsOptions) -> str: + # Convert the list of dictionaries to a list of tuples + key_value_tuples = [(list(item.keys())[0], list(item.values())[0]) for item in options.key_value_pairs] + + response = self._phase_io.create( + key_value_pairs=key_value_tuples, + env_name=options.env_name, + app_name=options.app_name, + path=options.secret_path + ) + return "Success" if response.status_code == 200 else f"Error: {response.status_code}" + + def update_secret(self, options: SecretUpdateOptions) -> str: + return self._phase_io.update( + env_name=options.env_name, + key=options.key, + value=options.value, + app_name=options.app_name, + source_path=options.secret_path, + destination_path=options.destination_path, + override=options.override, + toggle_override=options.toggle_override ) - return response - def delete(self, env_name: str, keys: List[str], app_name: str = None, path: str = None) -> List[str]: - return self._phase_io.delete(env_name, keys, app_name, path) + def delete_secret(self, options: DeleteSecretOptions) -> List[str]: + return self._phase_io.delete( + env_name=options.env_name, + keys_to_delete=[options.key_to_delete], + app_name=options.app_name, + path=options.secret_path + ) - def _resolve_references(self, secrets: List[PhaseSecret], env_name: str, app_name: str) -> List[PhaseSecret]: + def resolve_references(self, secrets: List[PhaseSecret], env_name: str, app_name: str) -> List[PhaseSecret]: all_secrets = [ { 'environment': env_name, From 07346488a282f1f3f772f4f4bf9e46199e3ba026 Mon Sep 17 00:00:00 2001 From: Nimish Date: Thu, 1 Aug 2024 11:55:02 +0530 Subject: [PATCH 34/36] feat: updated readme --- README.md | 105 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 64 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 6c55e14..9040753 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ pip install phase-dev ## Import ```python -from phase import Secrets +from phase import Phase, CreateSecretsOptions, GetAllSecretsOptions, GetSecretOptions, SecretUpdateOptions, DeleteSecretOptions ``` ## Initialize @@ -19,9 +19,11 @@ from phase import Secrets Initialize the SDK with your host and token: ```python -phase = Secrets( - host='https://your-phase-host.com', +phase = Phase( + init=False, + host='https://your-phase-host.com', pss=PHASE_SERVICE_TOKEN + ) ``` @@ -32,20 +34,17 @@ phase = Secrets( Create one or more secrets in a specified application and environment: ```python -new_secrets = [ - phase.PhaseSecret( - key="API_KEY", - value="your-api-key", - comment="API key for our service", - path="/", - tags=["api", "credentials"], - overridden=False - ), - # Add more secrets as needed -] - -response = phase.create(secrets=new_secrets, env_name="Development", app_name="Your App Name") -print(f"Create Response Status Code: {response.status_code}") +create_options = CreateSecretsOptions( + env_name="Development", + app_name="Your App Name", + key_value_pairs=[ + {"API_KEY": "your-api-key"}, + {"DB_PASSWORD": "your-db-password"} + ], + secret_path="/api" +) +result = phase.create_secrets(create_options) +print(f"Create secrets result: {result}") ``` ### Get Secrets @@ -53,55 +52,78 @@ print(f"Create Response Status Code: {response.status_code}") Fetch one or more secrets from a specified application and environment: ```python -secrets = phase.get( - env_name="Development", - keys=["API_KEY"], # Optional: specify keys to retrieve +get_options = GetAllSecretsOptions( + env_name="Development", app_name="Your App Name", tag="api", # Optional: filter by tag - path="/" # Optional: specify path + secret_path="/api" # Optional: specify path ) - +secrets = phase.get_all_secrets(get_options) for secret in secrets: print(f"Key: {secret.key}, Value: {secret.value}") ``` +To get a specific secret: + +```python +get_options = GetSecretOptions( + env_name="Development", + app_name="Your App Name", + key_to_find="API_KEY", + secret_path="/api" +) +secret = phase.get_secret(get_options) +if secret: + print(f"Key: {secret.key}, Value: {secret.value}") +``` + ### Update Secrets Update an existing secret in a specified application and environment: ```python -updated_secret = phase.PhaseSecret( +update_options = SecretUpdateOptions( + env_name="Development", + app_name="Your App Name", key="API_KEY", value="new-api-key-value", - comment="Updated API key", - path="/", - tags=["api", "credentials", "updated"], - overridden=False -) - -result = phase.update( - secret=updated_secret, - env_name="Development", - app_name="Your App Name" + secret_path="/api", + destination_path="/new-api", # Optional: move secret to a new path + override=False, # Optional: create a personal override + toggle_override=False # Optional: toggle personal override ) +result = phase.update_secret(update_options) print(f"Update result: {result}") ``` ### Delete Secrets -Delete one or more secrets from a specified application and environment: +Delete a secret from a specified application and environment: ```python -keys_to_delete = ["API_KEY", "DB_PASSWORD"] -result = phase.delete( +delete_options = DeleteSecretOptions( env_name="Development", - keys_to_delete=keys_to_delete, app_name="Your App Name", - path="/" # Optional: specify path + key_to_delete="API_KEY", + secret_path="/api" ) +result = phase.delete_secret(delete_options) +print(f"Delete result: {result}") +``` -print(f"Deleted secrets: {result['deleted']}") -print(f"Secrets not found: {result['not_found']}") +### Resolve Secret References + +Resolve references in secret values: + +```python +get_options = GetAllSecretsOptions( + env_name="Development", + app_name="Your App Name" +) +secrets = phase.get_all_secrets(get_options) +resolved_secrets = phase.resolve_references(secrets, "Development", "Your App Name") +for secret in resolved_secrets: + print(f"Key: {secret.key}, Resolved Value: {secret.value}") ``` ## Error Handling @@ -110,7 +132,8 @@ The SDK methods may raise exceptions for various error conditions. It's recommen ```python try: - secrets = phase.get(env_name="Development", app_name="Your App Name") + get_options = GetAllSecretsOptions(env_name="Development", app_name="Your App Name") + secrets = phase.get_all_secrets(get_options) except ValueError as e: print(f"An error occurred: {e}") ``` From 2e2d4e04b34656162954214f45405ec92cad3080 Mon Sep 17 00:00:00 2001 From: rohan-chaturvedi Date: Wed, 7 Aug 2024 14:39:49 +0530 Subject: [PATCH 35/36] feat: rename SecretUpdateOptions to UpdateSecretOptions --- README.md | 6 +++--- src/phase/__init__.py | 4 ++-- src/phase/phase.py | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 9040753..76df54e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ pip install phase-dev ## Import ```python -from phase import Phase, CreateSecretsOptions, GetAllSecretsOptions, GetSecretOptions, SecretUpdateOptions, DeleteSecretOptions +from phase import Phase, CreateSecretsOptions, GetAllSecretsOptions, GetSecretOptions, UpdateSecretOptions, DeleteSecretOptions ``` ## Initialize @@ -82,7 +82,7 @@ if secret: Update an existing secret in a specified application and environment: ```python -update_options = SecretUpdateOptions( +update_options = UpdateSecretOptions( env_name="Development", app_name="Your App Name", key="API_KEY", @@ -140,4 +140,4 @@ except ValueError as e: ## Note on Security -Never hard-code sensitive information like tokens or secrets directly in your code. Always use environment variables or secure configuration management to provide these values to your application. \ No newline at end of file +Never hard-code sensitive information like tokens or secrets directly in your code. Always use environment variables or secure configuration management to provide these values to your application. diff --git a/src/phase/__init__.py b/src/phase/__init__.py index e6ef62f..fd01ad2 100644 --- a/src/phase/__init__.py +++ b/src/phase/__init__.py @@ -3,7 +3,7 @@ GetSecretOptions, GetAllSecretsOptions, CreateSecretsOptions, - SecretUpdateOptions, + UpdateSecretOptions, DeleteSecretOptions, PhaseSecret ) @@ -13,7 +13,7 @@ 'GetSecretOptions', 'GetAllSecretsOptions', 'CreateSecretsOptions', - 'SecretUpdateOptions', + 'UpdateSecretOptions', 'DeleteSecretOptions', 'PhaseSecret' ] \ No newline at end of file diff --git a/src/phase/phase.py b/src/phase/phase.py index 69bbf8a..602fa68 100644 --- a/src/phase/phase.py +++ b/src/phase/phase.py @@ -26,7 +26,7 @@ class CreateSecretsOptions: secret_path: str = "/" @dataclass -class SecretUpdateOptions: +class UpdateSecretOptions: env_name: str app_name: str key: str @@ -107,7 +107,7 @@ def create_secrets(self, options: CreateSecretsOptions) -> str: ) return "Success" if response.status_code == 200 else f"Error: {response.status_code}" - def update_secret(self, options: SecretUpdateOptions) -> str: + def update_secret(self, options: UpdateSecretOptions) -> str: return self._phase_io.update( env_name=options.env_name, key=options.key, From cec10e8431a955c5553f9460c5ad5b0ad1b2a855 Mon Sep 17 00:00:00 2001 From: rohan-chaturvedi Date: Wed, 7 Aug 2024 15:10:18 +0530 Subject: [PATCH 36/36] feat: add contributing doc --- CONTRIBUTING.md | 116 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 CONTRIBUTING.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..ab78836 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,116 @@ +## Local setup + +Clone the reposistory to your machine and install the required dependencies. + +### Create a virtual environment + +```fish +python3 -m venv.venv +``` + +### Install dependencies + +```fish +pip install -r requirements.txt +``` + +### Demo script + +This demo python script will create, read, update and delete secrets via the SDK. Just update the host, app, env and token constants at the top. + +```python +from src.phase import Phase, CreateSecretsOptions, GetAllSecretsOptions, UpdateSecretOptions, DeleteSecretOptions + +CONSOLE_HOST = 'https://console.phase.dev' +APP_NAME = '' +ENV_NAME = "" +TOKEN = '' + +# Initialize the Phase object with host and service token +phase = Phase(init=False, + pss=TOKEN, + host=CONSOLE_HOST) + +# Create secrets with references +create_options = CreateSecretsOptions( + env_name=ENV_NAME, + app_name=APP_NAME, + key_value_pairs=[ + {"BASE_URL": "https://api.example.com"}, + {"API_ENDPOINT": "${BASE_URL}/v1/data"}, + {"NESTED_REF": "Nested ${API_ENDPOINT}"} + ] +) +create_result = phase.create_secrets(create_options) +print(f"Create secrets result: {create_result}") + +# Read and resolve references +get_options = GetAllSecretsOptions( + env_name=ENV_NAME, + app_name=APP_NAME +) +secrets = phase.get_all_secrets(get_options) + +resolved_secrets = phase.resolve_references(secrets, ENV_NAME, APP_NAME) + +print("\nResolved Secrets:") +print("----------------") +for secret in resolved_secrets: + print(f"{secret.key}: {secret.value}") + +# Update secrets +update_options = UpdateSecretOptions( + env_name=ENV_NAME, + app_name=APP_NAME, + key="BASE_URL", + value="https://api.acme.com", + secret_path="/", + destination_path="/", # Optional: move secret to a new path + override=False, # Optional: create a personal override + toggle_override=False # Optional: toggle personal override +) +update_result = phase.update_secret(update_options) + +print(f"\nUpdate secrets result: {update_result}") +print("----------------") + + +## Refetch secrets +secrets = phase.get_all_secrets(get_options) + +resolved_secrets = phase.resolve_references(secrets, ENV_NAME, APP_NAME) + +print("\nResolved Secrets:") +print("----------------") +for secret in resolved_secrets: + print(f"{secret.key}: {secret.value}") + + +# Delete secrets +delete_options = DeleteSecretOptions( + env_name=ENV_NAME, + app_name=APP_NAME, + key_to_delete="BASE_URL", + secret_path="/" +) +result = phase.delete_secret(delete_options) +print(f"Delete result: {result}") + +## Refetch secrets +secrets = phase.get_all_secrets(get_options) + +resolved_secrets = phase.resolve_references(secrets, ENV_NAME, APP_NAME) + +print("\nResolved Secrets:") +print("----------------") +for secret in resolved_secrets: + print(f"{secret.key}: {secret.value}") +``` + +## Running Tests + +Run the test suite with: + +```fish +python -m pytest -v tests/ +```