diff --git a/fedcloudclient/secret.py b/fedcloudclient/secret.py index c0e6fff..3289468 100644 --- a/fedcloudclient/secret.py +++ b/fedcloudclient/secret.py @@ -1,10 +1,16 @@ """ Implementation of "fedcloud secret" commands for accessing secret management service """ +import base64 +import json import click import hvac +import yaml +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC from tabulate import tabulate from fedcloudclient.checkin import get_checkin_id @@ -13,6 +19,7 @@ VAULT_ADDR = "https://vault.services.fedcloud.eu:8200" VAULT_ROLE = "demo" VAULT_MOUNT_POINT = "/secrets" +VAULT_SALT = b"e8d3af638e26ede70afc3b3755e7c093" def secret_client(access_token, command, path, data): @@ -34,7 +41,7 @@ def secret_client(access_token, command, path, data): "read_secret": client.secrets.kv.v1.read_secret, "delete_secret": client.secrets.kv.v1.read_secret, } - if command == "set": + if command == "put": response = client.secrets.kv.v1.create_or_update_secret( path=full_path, mount_point=VAULT_MOUNT_POINT, @@ -45,6 +52,80 @@ def secret_client(access_token, command, path, data): return response +def secret_params_to_dict(params): + """ + Convert secret params "key=value" to dict {"key":"value"} + :param params: input string in format "key=value" + :return: dict {"key":"value"} + """ + + result = {} + + if len(params) == 0: + raise click.UsageError( + "Expecting 'key=value' arguments for secrets, None provided." + ) + + for param in params: + try: + key, value = param.split("=", 1) + except ValueError: + raise click.UsageError( + f"Expecting 'key=value' arguments for secrets. '{param}' provided." + ) + if value.startswith("@"): + with open(value[1:]) as f: + value = f.read() + result[key] = value + return result + + +def generate_derived_key(passphrase): + """ + Generate derived encryption/decryption key from passphrase + :param passphrase: + :return: derived key + """ + + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=VAULT_SALT, + iterations=390000, + ) + return base64.urlsafe_b64encode(kdf.derive(passphrase.encode())) + + +def encrypt_data(encrypt_key, secrets): + """ + Encrypt values in secrets using key + :param encrypt_key: encryption key + :param secrets: dict containing secrets + :return: dict with encrypted values + """ + + derived_key = generate_derived_key(encrypt_key) + fernet = Fernet(derived_key) + for key in secrets: + secrets[key] = fernet.encrypt(secrets[key].encode()) + return secrets + + +def decrypt_data(decrypt_key, secrets): + """ + Decrypt values in secrets using key + :param decrypt_key: decryption key + :param secrets: dict containing encrypted secrets + :return: dict with decrypted values + """ + + derived_key = generate_derived_key(decrypt_key) + fernet = Fernet(derived_key) + for key in secrets: + secrets[key] = fernet.decrypt(secrets[key].encode()).decode() + return secrets + + @click.group() def secret(): """ @@ -54,22 +135,46 @@ def secret(): @secret.command() @oidc_params -@click.argument("short_path") +@click.option( + "--output-format", + "-f", + required=False, + type=click.Choice(["text", "YAML", "JSON"], case_sensitive=False), +) +@click.option("--decrypt-key", required=False) +@click.argument("short_path", metavar="[secret path]") +@click.argument("key", metavar="[key]", required=False) def get( access_token, short_path, + key, + output_format, + decrypt_key, ): """ - Get a secret from the path + Get a secret from the path. If a key is given, print only the value of the key """ data = secret_client(access_token, "read_secret", short_path, None) - print(tabulate(data["data"].items(), headers=["key", "value"])) + if decrypt_key: + data["data"] = decrypt_data(decrypt_key, data["data"]) + if not key: + if output_format == "JSON": + print(json.dumps(data["data"], indent=4)) + elif output_format == "YAML": + print(yaml.dump(data["data"], sort_keys=False)) + else: + print(tabulate(data["data"].items(), headers=["key", "value"])) + else: + if key in data["data"]: + print(data["data"][key]) + else: + raise SystemExit(f"Error: {key} not found in {short_path}") @secret.command("list") @oidc_params -@click.argument("short_path", required=False, default="") +@click.argument("short_path", metavar="[secret path]", required=False, default="") def list_( access_token, short_path, @@ -80,3 +185,23 @@ def list_( data = secret_client(access_token, "list_secrets", short_path, None) print("\n".join(map(str, data["data"]["keys"]))) + + +@secret.command() +@oidc_params +@click.argument("short_path", metavar="[secret path]") +@click.argument("secrets", nargs=-1, metavar="[key=value...]") +@click.option("--encrypt-key", required=False) +def put( + access_token, + short_path, + secrets, + encrypt_key, +): + """ + Put secrets to the path. Secrets are provided in form key=value + """ + secret_dict = secret_params_to_dict(secrets) + if encrypt_key: + secret_dict = encrypt_data(encrypt_key, secret_dict) + secret_client(access_token, "put", short_path, secret_dict) diff --git a/requirements.txt b/requirements.txt index fc63f5d..debfe1c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,15 @@ -click==8.1.3 +click~=8.1.3 click_option_group>=0.5.3 -tabulate~=0.8.9 -requests==2.28.0 -defusedxml==0.7.1 -pyjwt==2.4.0 +tabulate==0.8.10 +requests~=2.28.0 +defusedxml~=0.7.1 +pyjwt~=2.4.0 python-openstackclient==5.8.0 -liboidcagent==0.4.0 +liboidcagent~=0.4.0 jsonpath-ng==1.5.3 -PyYAML==6.0 -setuptools==62.6.0 -jsonschema==4.6.0 -psutil==5.9.1 -hvac~=0.11.2 \ No newline at end of file +PyYAML~=6.0 +setuptools~=62.6.0 +jsonschema~=4.6.0 +psutil~=5.9.1 +hvac~=0.11.2 +cryptography==37.0.3 \ No newline at end of file