diff --git a/src/azure-cli/azure/cli/command_modules/keyvault/_client_factory.py b/src/azure-cli/azure/cli/command_modules/keyvault/_client_factory.py index adec3e01d86..6d40452430a 100644 --- a/src/azure-cli/azure/cli/command_modules/keyvault/_client_factory.py +++ b/src/azure-cli/azure/cli/command_modules/keyvault/_client_factory.py @@ -244,6 +244,26 @@ def data_plane_azure_keyvault_security_domain_client(cli_ctx, command_args): verify_challenge_resource=False, **client_kwargs) +def data_plane_azure_keyvault_ekm_client(cli_ctx, command_args): + from azure.keyvault.administration import KeyVaultEkmClient + + # Reuse the existing login + URL resolution behavior. + vault_url, credential, _ = _prepare_data_plane_azure_keyvault_client( + cli_ctx, command_args, ResourceType.DATA_KEYVAULT_ADMINISTRATION_SETTING) + + command_args.pop('hsm_name', None) + command_args.pop('vault_base_url', None) + command_args.pop('identifier', None) + + client_kwargs = prepare_client_kwargs_track2(cli_ctx) + client_kwargs.pop('http_logging_policy') + return KeyVaultEkmClient( + vault_url=vault_url, + credential=credential, + verify_challenge_resource=False, + **client_kwargs) + + def _prepare_data_plane_azure_keyvault_client(cli_ctx, command_args, resource_type): version = str(get_api_version(cli_ctx, resource_type)) profile = Profile(cli_ctx=cli_ctx) diff --git a/src/azure-cli/azure/cli/command_modules/keyvault/_help.py b/src/azure-cli/azure/cli/command_modules/keyvault/_help.py index 525b4c5b159..a6a6df2d03e 100644 --- a/src/azure-cli/azure/cli/command_modules/keyvault/_help.py +++ b/src/azure-cli/azure/cli/command_modules/keyvault/_help.py @@ -975,6 +975,46 @@ az keyvault wait-hsm --hsm-name MyHSM --created """ +helps['keyvault ekm-connection'] = """ +type: group +short-summary: Manage External Key Manager (EKM) connection for a Managed HSM. +""" + +helps['keyvault ekm-connection create'] = """ +type: command +short-summary: Create the EKM connection. +""" + +helps['keyvault ekm-connection update'] = """ +type: command +short-summary: Update the EKM connection. +""" + +helps['keyvault ekm-connection show'] = """ +type: command +short-summary: Show the EKM connection. +""" + +helps['keyvault ekm-connection check'] = """ +type: command +short-summary: Check connectivity and authentication with the EKM proxy. +""" + +helps['keyvault ekm-connection delete'] = """ +type: command +short-summary: Delete the EKM connection. +""" + +helps['keyvault ekm-connection certificate'] = """ +type: group +short-summary: Manage EKM proxy certificate information. +""" + +helps['keyvault ekm-connection certificate show'] = """ +type: command +short-summary: Show the EKM proxy client certificate. +""" + helps['keyvault security-domain'] = """ type: group short-summary: Manage security domain operations. diff --git a/src/azure-cli/azure/cli/command_modules/keyvault/_params.py b/src/azure-cli/azure/cli/command_modules/keyvault/_params.py index 5718281f2b4..e35045ff9f1 100644 --- a/src/azure-cli/azure/cli/command_modules/keyvault/_params.py +++ b/src/azure-cli/azure/cli/command_modules/keyvault/_params.py @@ -360,6 +360,8 @@ class CLISecurityDomainOperation(str, Enum): help='The type of key to create. For valid values, see: https://learn.microsoft.com/rest/api/keyvault/keys/create-key/create-key#jsonwebkeytype') c.argument('curve', arg_type=get_enum_type(KeyCurveName), help='Elliptic curve name. For valid values, see: https://learn.microsoft.com/rest/api/keyvault/keys/create-key/create-key#jsonwebkeycurvename') + c.extra('external_key_id', options_list=['--external-key-id'], arg_group='External Key', + help='Create an external Managed HSM key backed by an External Key Manager (EKM) key id.') with self.argument_context('keyvault key import') as c: c.argument('kty', arg_type=get_enum_type(CLIKeyTypeForBYOKImport), validator=validate_key_import_type, @@ -616,6 +618,45 @@ class CLISecurityDomainOperation(str, Enum): help='Target operation that needs waiting.') # endregion + # region keyvault ekm-connection + for scope in ['create', 'update', 'show', 'check', 'delete']: + with self.argument_context('keyvault ekm-connection {}'.format(scope), arg_group='HSM Id') as c: + c.extra('hsm_name', hsm_url_type, required=False, + help='Name of the HSM. Can be omitted if --id is specified.') + c.extra('identifier', options_list=['--id'], validator=validate_vault_or_hsm, + help='Full URI of the HSM.') + c.ignore('vault_base_url') + + with self.argument_context('keyvault ekm-connection create', arg_group='EKM Connection') as c: + c.argument('host', options_list=['--host'], required=True, + help='EKM proxy host (FQDN or FQDN:port). If port is omitted, 443 is assumed.') + c.extra('path_prefix', options_list=['--path-prefix'], + help='Optional path prefix to append to EKM proxy requests. Must start with "/".') + c.extra('server_ca_certificates', options_list=['--server-ca-certificate'], nargs='+', type=file_type, + completer=FilesCompleter(), + help='Path(s) to server CA certificate(s) in PEM or DER format.') + c.extra('server_subject_common_name', options_list=['--server-subject-common-name'], + help='Optional expected Common Name (CN) for the EKM proxy server certificate.') + + with self.argument_context('keyvault ekm-connection update', arg_group='EKM Connection') as c: + c.argument('host', options_list=['--host'], required=False, + help='EKM proxy host (FQDN or FQDN:port). If port is omitted, 443 is assumed.') + c.extra('path_prefix', options_list=['--path-prefix'], + help='Optional path prefix to append to EKM proxy requests. Must start with "/".') + c.extra('server_ca_certificates', options_list=['--server-ca-certificate'], nargs='+', type=file_type, + completer=FilesCompleter(), + help='Path(s) to server CA certificate(s) in PEM or DER format.') + c.extra('server_subject_common_name', options_list=['--server-subject-common-name'], + help='Optional expected Common Name (CN) for the EKM proxy server certificate.') + + with self.argument_context('keyvault ekm-connection certificate show', arg_group='HSM Id') as c: + c.extra('hsm_name', hsm_url_type, required=False, + help='Name of the HSM. Can be omitted if --id is specified.') + c.extra('identifier', options_list=['--id'], validator=validate_vault_or_hsm, + help='Full URI of the HSM.') + c.ignore('vault_base_url') + # endregion + # region keyvault backup/restore for item in ['backup', 'restore']: for scope in ['start']: # TODO add 'status' when SDK is ready diff --git a/src/azure-cli/azure/cli/command_modules/keyvault/_transformers.py b/src/azure-cli/azure/cli/command_modules/keyvault/_transformers.py index 374d02bf962..ae7870faa76 100644 --- a/src/azure-cli/azure/cli/command_modules/keyvault/_transformers.py +++ b/src/azure-cli/azure/cli/command_modules/keyvault/_transformers.py @@ -60,9 +60,9 @@ def transform_key_encryption_output(result, **command_args): # pylint: disable= 'kid': result.key_id, 'result': base64.b64encode(result.ciphertext).decode('utf-8'), 'algorithm': result.algorithm, - 'iv': binascii.hexlify(result.iv) if result.iv else None, - 'tag': binascii.hexlify(result.tag) if result.tag else None, - 'aad': binascii.hexlify(result.aad) if result.aad else None + 'iv': binascii.hexlify(result.iv).decode('ascii') if result.iv else None, + 'tag': binascii.hexlify(result.tag).decode('ascii') if result.tag else None, + 'aad': binascii.hexlify(result.aad).decode('ascii') if result.aad else None } return output @@ -104,6 +104,13 @@ def transform_key_list_output(result, **command_args): # pylint: disable=unused k['managed'] = key.managed k['tags'] = key.tags k['releasePolicy'] = key.release_policy + + # External key (EKM) is a preview property and may not exist on all SDK versions. + external_key = getattr(key, 'external_key', None) + external_key_id = getattr(external_key, 'id', None) if external_key else None + if external_key_id: + k['externalKeyId'] = external_key_id + output.append(k) return output @@ -139,6 +146,13 @@ def transform_key_output(result, **command_args): 'tags': result.properties.tags, 'releasePolicy': result.properties.release_policy } + + # External key (EKM) is a preview property and may not exist on all SDK versions. + external_key = getattr(result.properties, 'external_key', None) + external_key_id = getattr(external_key, 'id', None) if external_key else None + if external_key_id: + output['externalKeyId'] = external_key_id + if isinstance(result, DeletedKey): output['deletedDate'] = result.deleted_date output['scheduledPurgeDate'] = result.scheduled_purge_date diff --git a/src/azure-cli/azure/cli/command_modules/keyvault/_validators.py b/src/azure-cli/azure/cli/command_modules/keyvault/_validators.py index c77a43bb8b7..aace7f96463 100644 --- a/src/azure-cli/azure/cli/command_modules/keyvault/_validators.py +++ b/src/azure-cli/azure/cli/command_modules/keyvault/_validators.py @@ -732,10 +732,151 @@ def validate_key_create(cmd, ns): validate_tags(ns) set_vault_base_url(ns) validate_keyvault_resource_id('key')(ns) - validate_key_type(ns) + validate_external_key_id(ns) + + # External keys are backed by EKM and the service rejects client-specified key type/size/curve. + # Avoid the defaulting behavior in validate_key_type (RSA) when --external-key-id is present. + if getattr(ns, 'external_key_id', None): + setattr(ns, 'kty', None) + setattr(ns, 'key_size', None) + setattr(ns, 'curve', None) + setattr(ns, 'protection', None) + else: + validate_key_type(ns) + process_key_release_policy(cmd, ns) +def validate_external_key_id(ns): + external_key_id = getattr(ns, 'external_key_id', None) + if not external_key_id: + return + if len(external_key_id) > 128: + raise CLIError('--external-key-id must be at most 128 characters.') + if not re.match(r'^[0-9A-Za-z-]+$', external_key_id): + raise CLIError('--external-key-id may contain only letters, digits, and hyphens.') + + +def _validate_ekm_path_prefix(path_prefix=None): + if path_prefix is None: + return + if not path_prefix.startswith('/'): + raise CLIError('--path-prefix must start with "/".') + if path_prefix.endswith('/'): + raise CLIError('--path-prefix must not end with "/".') + if len(path_prefix) > 64: + raise CLIError('--path-prefix must be at most 64 characters.') + if not re.match(r'^[A-Za-z0-9/-]+$', path_prefix): + raise CLIError('--path-prefix may contain only letters, digits, "/" and "-".') + + +def _normalize_ekm_host(host: str): + host = (host or '').strip() + if not host: + raise CLIError('--host cannot be empty.') + if '://' in host: + raise CLIError('--host must not include a URL scheme (use FQDN or FQDN:port).') + if '/' in host: + raise CLIError('--host must not include a path (use FQDN or FQDN:port).') + + if ':' not in host: + return f'{host}:443' + + # Avoid ambiguous parsing for IPv6 literals. + if host.count(':') != 1: + raise CLIError('--host must be in the form FQDN or FQDN:port.') + + hostname, port_str = host.split(':', 1) + if not hostname: + raise CLIError('--host must be in the form FQDN or FQDN:port.') + try: + port = int(port_str) + except ValueError as ex: + raise CLIError('--host port must be an integer.') from ex + if port < 1 or port > 65535: + raise CLIError('--host port must be between 1 and 65535.') + return f'{hostname}:{port}' + + +def _flatten_list(value): + if value is None: + return None + if isinstance(value, list) and value and isinstance(value[0], list): + flattened = [] + for item in value: + flattened.extend(item) + return flattened + return value + + +def _load_certificates_as_der_bytes(cert_paths): + import os + import ssl + + cert_paths = _flatten_list(cert_paths) + if not cert_paths: + return [] + + der_certs = [] + for cert_path in cert_paths: + if not cert_path: + continue + expanded = os.path.expanduser(cert_path) + with open(expanded, 'rb') as f: + raw = f.read() + + # PEM may contain multiple cert blocks. + if b'-----BEGIN CERTIFICATE-----' in raw: + text = raw.decode('utf-8', errors='ignore') + begin = '-----BEGIN CERTIFICATE-----' + end = '-----END CERTIFICATE-----' + start = 0 + found_any = False + while True: + b_idx = text.find(begin, start) + if b_idx == -1: + break + e_idx = text.find(end, b_idx) + if e_idx == -1: + raise CLIError(f'Invalid PEM certificate in {cert_path}.') + block = text[b_idx:e_idx + len(end)] + der_certs.append(ssl.PEM_cert_to_DER_cert(block)) + found_any = True + start = e_idx + len(end) + if not found_any: + raise CLIError(f'Invalid PEM certificate in {cert_path}.') + else: + # Assume DER. + der_certs.append(raw) + + return der_certs + + +def validate_ekm_connection_base(cmd, ns): # pylint: disable=unused-argument + set_vault_base_url(ns) + if not getattr(ns, 'hsm_name', None) and not getattr(ns, 'identifier', None): + raise CLIError('Please specify --hsm-name or --id.') + + +def validate_ekm_connection_create(cmd, ns): + validate_ekm_connection_base(cmd, ns) + ns.host = _normalize_ekm_host(ns.host) + _validate_ekm_path_prefix(getattr(ns, 'path_prefix', None)) + server_ca_certificates = _load_certificates_as_der_bytes(getattr(ns, 'server_ca_certificates', None)) + if not server_ca_certificates: + raise CLIError('Please specify at least one --server-ca-certificate for EKM connection creation.') + ns.server_ca_certificates = server_ca_certificates + + +def validate_ekm_connection_update(cmd, ns): + validate_ekm_connection_base(cmd, ns) + if getattr(ns, 'host', None): + ns.host = _normalize_ekm_host(ns.host) + _validate_ekm_path_prefix(getattr(ns, 'path_prefix', None)) + if getattr(ns, 'server_ca_certificates', None): + ns.server_ca_certificates = _load_certificates_as_der_bytes(ns.server_ca_certificates) + + # pylint: disable=line-too-long, too-many-locals def process_certificate_policy(cmd, ns): policy = getattr(ns, 'policy', None) diff --git a/src/azure-cli/azure/cli/command_modules/keyvault/commands.py b/src/azure-cli/azure/cli/command_modules/keyvault/commands.py index c70ae0b600c..002ea3352b2 100644 --- a/src/azure-cli/azure/cli/command_modules/keyvault/commands.py +++ b/src/azure-cli/azure/cli/command_modules/keyvault/commands.py @@ -9,7 +9,7 @@ from azure.cli.core.profiles import ResourceType from azure.cli.command_modules.keyvault._client_factory import ( - get_client, get_client_factory, Clients) + get_client, get_client_factory, Clients, data_plane_azure_keyvault_ekm_client) from azure.cli.command_modules.keyvault._transformers import ( filter_out_managed_resources, @@ -26,7 +26,8 @@ from azure.cli.command_modules.keyvault._validators import ( process_secret_set_namespace, validate_key_create, - validate_private_endpoint_connection_id, validate_role_assignment_args) + validate_private_endpoint_connection_id, validate_role_assignment_args, + validate_ekm_connection_base, validate_ekm_connection_create, validate_ekm_connection_update) def transform_assignment_list(result): @@ -65,6 +66,11 @@ def load_command_table(self, _): operations_tmpl='azure.cli.command_modules.keyvault.custom#{}', client_factory=get_client_factory(ResourceType.MGMT_KEYVAULT, Clients.managed_hsms) ) + + data_ekm_custom = CliCommandType( + operations_tmpl='azure.cli.command_modules.keyvault.custom#{}', + client_factory=data_plane_azure_keyvault_ekm_client + ) # endregion # Management Plane Commands @@ -137,6 +143,16 @@ def load_command_table(self, _): g.keyvault_custom('download', 'security_domain_download', supports_no_wait=True) g.keyvault_custom('wait', '_wait_security_domain_operation') + with self.command_group('keyvault ekm-connection', command_type=data_ekm_custom) as g: + g.keyvault_custom('create', 'create_ekm_connection', validator=validate_ekm_connection_create) + g.keyvault_custom('update', 'update_ekm_connection', validator=validate_ekm_connection_update) + g.keyvault_custom('show', 'get_ekm_connection', validator=validate_ekm_connection_base) + g.keyvault_custom('check', 'check_ekm_connection', validator=validate_ekm_connection_base) + g.keyvault_custom('delete', 'delete_ekm_connection', validator=validate_ekm_connection_base) + + with self.command_group('keyvault ekm-connection certificate', command_type=data_ekm_custom) as g: + g.keyvault_custom('show', 'get_ekm_certificate', validator=validate_ekm_connection_base) + with self.command_group('keyvault key', data_key_entity.command_type) as g: g.keyvault_custom('create', 'create_key', transform=transform_key_output, validator=validate_key_create) g.keyvault_command('set-attributes', 'update_key_properties', transform=transform_key_output) diff --git a/src/azure-cli/azure/cli/command_modules/keyvault/custom.py b/src/azure-cli/azure/cli/command_modules/keyvault/custom.py index 9cc5f7e3916..fbbbb0ed25a 100644 --- a/src/azure-cli/azure/cli/command_modules/keyvault/custom.py +++ b/src/azure-cli/azure/cli/command_modules/keyvault/custom.py @@ -32,7 +32,7 @@ from cryptography.hazmat.primitives.asymmetric import rsa, ec from cryptography.hazmat.primitives.serialization import load_pem_private_key, Encoding, PublicFormat from cryptography.exceptions import UnsupportedAlgorithm -from cryptography.x509 import load_pem_x509_certificate +from cryptography.x509 import load_pem_x509_certificate, load_der_x509_certificate from knack.log import get_logger from knack.util import CLIError, todict @@ -1086,19 +1086,198 @@ def delete_policy(cmd, client, resource_group_name, vault_name, # region KeyVault Key def create_key(client, name=None, protection=None, # pylint: disable=unused-argument key_size=None, key_ops=None, disabled=False, expires=None, - not_before=None, tags=None, kty=None, curve=None, exportable=None, release_policy=None): - - return client.create_key(name=name, - key_type=kty, - size=key_size, - key_operations=key_ops, - enabled=not disabled, - not_before=not_before, - expires_on=expires, - tags=tags, - curve=curve, - exportable=exportable, - release_policy=release_policy) + not_before=None, tags=None, kty=None, curve=None, exportable=None, release_policy=None, + external_key_id=None): + + external_key = None + if external_key_id: + try: + from azure.keyvault.keys._generated.models import ExternalKey + except ImportError as ex: + raise CLIError('External keys require a preview version of azure-keyvault-keys with ExternalKey support.') from ex + external_key = ExternalKey(id=external_key_id) + + kwargs = { + 'name': name, + 'key_operations': key_ops, + 'enabled': not disabled, + 'not_before': not_before, + 'expires_on': expires, + 'tags': tags, + 'exportable': exportable, + 'external_key': external_key, + 'release_policy': release_policy + } + + # External keys are backed by EKM and the service rejects client-specified key type/size/curve. + if external_key is None: + kwargs.update({ + 'key_type': kty, + 'size': key_size, + 'curve': curve + }) + + return client.create_key(**kwargs) + + +# region KeyVault EKM Connection +def get_ekm_connection(client): + return client.get_ekm_connection() + + +def get_ekm_certificate(client): + certificate = client.get_ekm_certificate() + + # Latest preview SDK mirrors the connection payload (subject_common_name + ca_certificates list). + subject_common_name = getattr(certificate, 'subject_common_name', None) + ca_certificates = getattr(certificate, 'ca_certificates', None) + if isinstance(ca_certificates, (list, tuple)) and ca_certificates: + encoded_certs = [] + for cert_bytes in ca_certificates: + if isinstance(cert_bytes, (bytes, bytearray, memoryview)): + encoded_certs.append(base64.b64encode(bytes(cert_bytes)).decode('ascii')) + if encoded_certs or subject_common_name: + return { + 'subjectCommonName': subject_common_name, + 'caCertificates': encoded_certs + } + + def _extract_der_bytes(obj): + if isinstance(obj, (bytes, bytearray, memoryview)): + return bytes(obj) + + # Known/likely shapes across SDK iterations. + for attr in ('cer', 'certificate', 'cert', 'der', 'value', 'data'): + if hasattr(obj, attr): + value = getattr(obj, attr) + if isinstance(value, (bytes, bytearray, memoryview)): + return bytes(value) + + if isinstance(obj, dict): + for key in ('cer', 'certificate', 'cert', 'der', 'value', 'data'): + value = obj.get(key) + if isinstance(value, (bytes, bytearray, memoryview)): + return bytes(value) + + return None + + def _find_bytes_anywhere(obj, *, _seen=None, _depth=0, _max_depth=4): + if obj is None: + return None + if isinstance(obj, (bytes, bytearray, memoryview)): + return bytes(obj) + if _depth >= _max_depth: + return None + + if _seen is None: + _seen = set() + obj_id = id(obj) + if obj_id in _seen: + return None + _seen.add(obj_id) + + if isinstance(obj, dict): + for v in obj.values(): + found = _find_bytes_anywhere(v, _seen=_seen, _depth=_depth + 1, _max_depth=_max_depth) + if found is not None: + return found + return None + + if isinstance(obj, (list, tuple, set)): + for v in obj: + found = _find_bytes_anywhere(v, _seen=_seen, _depth=_depth + 1, _max_depth=_max_depth) + if found is not None: + return found + return None + + # SDK model objects often keep fields in __dict__. + if hasattr(obj, '__dict__') and isinstance(obj.__dict__, dict): + for v in obj.__dict__.values(): + found = _find_bytes_anywhere(v, _seen=_seen, _depth=_depth + 1, _max_depth=_max_depth) + if found is not None: + return found + + # Last resort: materialize to dict and scan. + try: + as_dict = todict(obj) + except Exception: # pylint: disable=broad-except + return None + return _find_bytes_anywhere(as_dict, _seen=_seen, _depth=_depth + 1, _max_depth=_max_depth) + + def _json_safe(obj): + if isinstance(obj, (bytes, bytearray, memoryview)): + return base64.b64encode(bytes(obj)).decode('ascii') + if isinstance(obj, dict): + return {k: _json_safe(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple, set)): + return [_json_safe(v) for v in obj] + + # Try to materialize SDK models into primitives. + try: + as_dict = todict(obj) + except Exception: # pylint: disable=broad-except + return obj + return _json_safe(as_dict) + + der_bytes = _extract_der_bytes(certificate) + if der_bytes is None: + der_bytes = _find_bytes_anywhere(certificate) + + if der_bytes is None: + # Ensure we never return raw bytes anywhere in the payload. + return _json_safe(certificate) + + pem = None + # Try to decode as PEM first (some services return PEM bytes). + try: + if der_bytes.lstrip().startswith(b'-----BEGIN CERTIFICATE-----'): + pem = der_bytes.decode('ascii', errors='strict') + der_bytes = load_pem_x509_certificate(der_bytes).public_bytes(Encoding.DER) + else: + pem = load_der_x509_certificate(der_bytes).public_bytes(Encoding.PEM).decode('ascii') + except Exception: # pylint: disable=broad-except + # Bytes found, but not a certificate. Fall back to JSON-safe representation. + return _json_safe(certificate) + + return { + 'format': 'der', + 'cer': base64.b64encode(der_bytes).decode('ascii'), + 'pem': pem + } + + +def check_ekm_connection(client): + return client.check_ekm_connection() + + +def delete_ekm_connection(client): + return client.delete_ekm_connection() + + +def create_ekm_connection(client, host, path_prefix=None, server_ca_certificates=None, server_subject_common_name=None): + from azure.keyvault.administration import KeyVaultEkmConnection + + ekm_connection = KeyVaultEkmConnection( + host=host, + path_prefix=path_prefix, + server_ca_certificates=server_ca_certificates, + server_subject_common_name=server_subject_common_name + ) + return client.create_ekm_connection(ekm_connection) + + +def update_ekm_connection(client, host=None, path_prefix=None, server_ca_certificates=None, server_subject_common_name=None): + existing = client.get_ekm_connection() + if host is not None: + existing.host = host + if path_prefix is not None: + existing.path_prefix = path_prefix + if server_ca_certificates is not None: + existing.server_ca_certificates = server_ca_certificates + if server_subject_common_name is not None: + existing.server_subject_common_name = server_subject_common_name + return client.update_ekm_connection(existing) +# endregion def list_keys(client, maxresults=None, include_managed=False): diff --git a/src/azure-cli/azure/cli/command_modules/keyvault/tests/latest/test_keyvault_commands.py b/src/azure-cli/azure/cli/command_modules/keyvault/tests/latest/test_keyvault_commands.py index a1df39f8ac5..89aa2ed0c35 100644 --- a/src/azure-cli/azure/cli/command_modules/keyvault/tests/latest/test_keyvault_commands.py +++ b/src/azure-cli/azure/cli/command_modules/keyvault/tests/latest/test_keyvault_commands.py @@ -5,6 +5,7 @@ import json import os +import argparse import pytest import tempfile import time @@ -106,6 +107,146 @@ def test_parse_asn1_date(self): self.assertEqual(_asn1_to_iso8601("20170424163720Z"), expected) +class KeyVaultEkmValidatorUnitTest(unittest.TestCase): + def test_validate_external_key_id_valid(self): + from azure.cli.command_modules.keyvault._validators import validate_external_key_id + + ns = argparse.Namespace(external_key_id='test-aes-key') + validate_external_key_id(ns) + + def test_validate_external_key_id_invalid_chars(self): + from azure.cli.command_modules.keyvault._validators import validate_external_key_id + + ns = argparse.Namespace(external_key_id='bad_id') + with self.assertRaises(CLIError): + validate_external_key_id(ns) + + def test_validate_external_key_id_too_long(self): + from azure.cli.command_modules.keyvault._validators import validate_external_key_id + + ns = argparse.Namespace(external_key_id='a' * 129) + with self.assertRaises(CLIError): + validate_external_key_id(ns) + + def test_validate_ekm_path_prefix_rules(self): + from azure.cli.command_modules.keyvault._validators import _validate_ekm_path_prefix + + _validate_ekm_path_prefix('/api/v1') + with self.assertRaises(CLIError): + _validate_ekm_path_prefix('api/v1') + with self.assertRaises(CLIError): + _validate_ekm_path_prefix('/api/v1/') + + def test_normalize_ekm_host_rules(self): + from azure.cli.command_modules.keyvault._validators import _normalize_ekm_host + + self.assertEqual(_normalize_ekm_host('example.com'), 'example.com:443') + self.assertEqual(_normalize_ekm_host('example.com:443'), 'example.com:443') + with self.assertRaises(CLIError): + _normalize_ekm_host('https://example.com') + with self.assertRaises(CLIError): + _normalize_ekm_host('example.com/path') + with self.assertRaises(CLIError): + _normalize_ekm_host('example.com:abc') + + def test_load_certificates_as_der_bytes_from_pem(self): + from azure.cli.command_modules.keyvault._validators import _load_certificates_as_der_bytes + + pem_path = os.path.join(TEST_DIR, 'certs', 'cert_0.cer') + certs = _load_certificates_as_der_bytes([pem_path]) + self.assertTrue(certs) + self.assertIsInstance(certs[0], (bytes, bytearray)) + + +class KeyVaultEkmCertificateSerializationUnitTest(unittest.TestCase): + def test_get_ekm_certificate_serializes_der_bytes(self): + from azure.cli.command_modules.keyvault._validators import _load_certificates_as_der_bytes + from azure.cli.command_modules.keyvault.custom import get_ekm_certificate + + pem_path = os.path.join(TEST_DIR, 'certs', 'cert_0.cer') + der_bytes = _load_certificates_as_der_bytes([pem_path])[0] + + class DummyClient: + def get_ekm_certificate(self): + return der_bytes + + result = get_ekm_certificate(DummyClient()) + self.assertIsInstance(result, dict) + self.assertEqual(result.get('format'), 'der') + self.assertIsInstance(result.get('cer'), str) + # PEM is best-effort; if present, it should be a string with header. + if result.get('pem') is not None: + self.assertIsInstance(result.get('pem'), str) + self.assertIn('BEGIN CERTIFICATE', result.get('pem')) + + def test_get_ekm_certificate_serializes_model_value_bytes(self): + from azure.cli.command_modules.keyvault._validators import _load_certificates_as_der_bytes + from azure.cli.command_modules.keyvault.custom import get_ekm_certificate + + pem_path = os.path.join(TEST_DIR, 'certs', 'cert_0.cer') + der_bytes = _load_certificates_as_der_bytes([pem_path])[0] + + class CertModel: + def __init__(self, value): + self.value = value + + class DummyClient: + def get_ekm_certificate(self): + return CertModel(der_bytes) + + result = get_ekm_certificate(DummyClient()) + self.assertIsInstance(result, dict) + self.assertEqual(result.get('format'), 'der') + self.assertIsInstance(result.get('cer'), str) + + def test_get_ekm_certificate_handles_subject_cn_and_ca_list(self): + from azure.cli.command_modules.keyvault.custom import get_ekm_certificate + + class SdkModel: + def __init__(self, subject_common_name, ca_certificates): + self.subject_common_name = subject_common_name + self.ca_certificates = ca_certificates + + class DummyClient: + def get_ekm_certificate(self): + return SdkModel('*.managedhsm-int.azure-int.net', [b'\x01\x02']) + + result = get_ekm_certificate(DummyClient()) + self.assertEqual(result.get('subjectCommonName'), '*.managedhsm-int.azure-int.net') + self.assertEqual(result.get('caCertificates'), ['AQI=']) + + def test_get_ekm_certificate_fallback_json_safe_dict(self): + from azure.cli.command_modules.keyvault.custom import get_ekm_certificate + + class DummyClient: + def get_ekm_certificate(self): + return {'someField': b'\x01\x02\x03'} + + result = get_ekm_certificate(DummyClient()) + self.assertIsInstance(result, dict) + self.assertIsInstance(result.get('someField'), str) + + def test_get_ekm_certificate_finds_bytes_in_unknown_field(self): + from azure.cli.command_modules.keyvault._validators import _load_certificates_as_der_bytes + from azure.cli.command_modules.keyvault.custom import get_ekm_certificate + + pem_path = os.path.join(TEST_DIR, 'certs', 'cert_0.cer') + der_bytes = _load_certificates_as_der_bytes([pem_path])[0] + + class WeirdModel: + def __init__(self): + self.notCer = der_bytes + + class DummyClient: + def get_ekm_certificate(self): + return WeirdModel() + + result = get_ekm_certificate(DummyClient()) + self.assertIsInstance(result, dict) + self.assertEqual(result.get('format'), 'der') + self.assertIsInstance(result.get('cer'), str) + + class KeyVaultPrivateLinkResourceScenarioTest(ScenarioTest): @ResourceGroupPreparer(name_prefix='cli_test_keyvault_plr') @KeyVaultPreparer(name_prefix='cli-test-kv-plr-', location='eastus2') diff --git a/src/azure-cli/requirements.py3.Darwin.txt b/src/azure-cli/requirements.py3.Darwin.txt index 8bb5ade8b2c..38836f55919 100644 --- a/src/azure-cli/requirements.py3.Darwin.txt +++ b/src/azure-cli/requirements.py3.Darwin.txt @@ -12,9 +12,9 @@ azure-core==1.38.0 azure-cosmos==3.2.0 azure-data-tables==12.4.0 azure-datalake-store==1.0.1 -azure-keyvault-administration==4.4.0 +azure-keyvault-administration==4.6.1 azure-keyvault-certificates==4.7.0 -azure-keyvault-keys==4.11.0 +azure-keyvault-keys==4.11.1 azure-keyvault-secrets==4.7.0 azure-keyvault-securitydomain==1.0.0b1 azure-mgmt-advisor==9.0.0 diff --git a/src/azure-cli/requirements.py3.Linux.txt b/src/azure-cli/requirements.py3.Linux.txt index 7157edadc34..c6c4f25ca10 100644 --- a/src/azure-cli/requirements.py3.Linux.txt +++ b/src/azure-cli/requirements.py3.Linux.txt @@ -12,9 +12,9 @@ azure-core==1.38.0 azure-cosmos==3.2.0 azure-data-tables==12.4.0 azure-datalake-store==1.0.1 -azure-keyvault-administration==4.4.0 +azure-keyvault-administration==4.6.1 azure-keyvault-certificates==4.7.0 -azure-keyvault-keys==4.11.0 +azure-keyvault-keys==4.11.1 azure-keyvault-secrets==4.7.0 azure-keyvault-securitydomain==1.0.0b1 azure-mgmt-advisor==9.0.0 diff --git a/src/azure-cli/requirements.py3.windows.txt b/src/azure-cli/requirements.py3.windows.txt index 770968a6514..6a97d7c8507 100644 --- a/src/azure-cli/requirements.py3.windows.txt +++ b/src/azure-cli/requirements.py3.windows.txt @@ -12,9 +12,9 @@ azure-core==1.38.0 azure-cosmos==3.2.0 azure-data-tables==12.4.0 azure-datalake-store==1.0.1 -azure-keyvault-administration==4.4.0 +azure-keyvault-administration==4.6.1 azure-keyvault-certificates==4.7.0 -azure-keyvault-keys==4.11.0 +azure-keyvault-keys==4.11.1 azure-keyvault-secrets==4.7.0 azure-keyvault-securitydomain==1.0.0b1 azure-mgmt-advisor==9.0.0 diff --git a/src/azure-cli/setup.py b/src/azure-cli/setup.py index 8330fed5d66..31e82eae5ec 100644 --- a/src/azure-cli/setup.py +++ b/src/azure-cli/setup.py @@ -58,9 +58,9 @@ 'azure-cosmos~=3.0,>=3.0.2', 'azure-data-tables==12.4.0', 'azure-datalake-store~=1.0.1', - 'azure-keyvault-administration==4.4.0', + 'azure-keyvault-administration==4.6.1', 'azure-keyvault-certificates==4.7.0', - 'azure-keyvault-keys==4.11.0', + 'azure-keyvault-keys==4.11.1', 'azure-keyvault-secrets==4.7.0', 'azure-keyvault-securitydomain==1.0.0b1', 'azure-mgmt-advisor==9.0.0', diff --git a/temp-ekmproxy-chain.pem b/temp-ekmproxy-chain.pem new file mode 100644 index 00000000000..a9c15aa4a3e --- /dev/null +++ b/temp-ekmproxy-chain.pem @@ -0,0 +1,94 @@ +-----BEGIN CERTIFICATE----- +MIIFLDCCA5SgAwIBAgIUHZvCzl0TLsY9+9nmvp7cdDJwk2MwDQYJKoZIhvcNAQEL +BQAwgYUxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJXQTEQMA4GA1UEBwwHUmVkbW9u +ZDESMBAGA1UECgwJTWljcm9zb2Z0MR8wHQYDVQQLDBZFS00gUHJveHkgSW50ZXJt +ZWRpYXRlMSIwIAYDVQQDDBlFS00gUHJveHkgSW50ZXJtZWRpYXRlIENBMB4XDTI1 +MTIxODE5NDYzN1oXDTI2MTIxODE5NDYzN1owgYQxCzAJBgNVBAYTAlVTMQswCQYD +VQQIDAJXQTEQMA4GA1UEBwwHUmVkbW9uZDESMBAGA1UECgwJTWljcm9zb2Z0MRIw +EAYDVQQLDAlFS00gUHJveHkxLjAsBgNVBAMMJXVzLW5vbnByb2QuZWttcHJveHkt +aW50LmF6dXJlLWludC5uZXQwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB +AQDVptA9ZQXIDsheVTqSHzHoXTlX9M73E62gXwUXGhFjXsuG8XIjwFQjNzGxBjeb +4wTLa7DURp0Myxhff5wGDhJd5tDF0nLGaDrjkNXqKde/1SFNF8c2Fv6FtY4hkYyM +7FfbJyL/KieeHtgRrSchQvj5DlozOUsSeUKsm3a8xOLoJOSxmn0LPc8JAgacW8qt +zgyu9X/eTnwfrPo5iWJ309t9lPztc2G7bz8O1Ykke1rNp8ddn5u9n7Gx2+XvteWM +IrRZ5a1Rke4UtV64Vh0VitSRu+majP6Ox8wMXQ0xCPzqZNsHXVltzg+jZJKVIc/H +Fl0M6zmZY5GmeAMlnsOcRzVFAgMBAAGjggERMIIBDTCBkgYDVR0jBIGKMIGHoW+k +bTBrMQswCQYDVQQGEwJVUzELMAkGA1UECAwCV0ExEDAOBgNVBAcMB1JlZG1vbmQx +EjAQBgNVBAoMCU1pY3Jvc29mdDESMBAGA1UECwwJRUtNIFByb3h5MRUwEwYDVQQD +DAxFS00gUHJveHkgQ0GCFDlW0jpsOT/+LbElHDh+EhFiEJymMAkGA1UdEwQCMAAw +CwYDVR0PBAQDAgTwMF4GA1UdEQRXMFWCJXVzLW5vbnByb2QuZWttcHJveHktaW50 +LmF6dXJlLWludC5uZXSCCWxvY2FsaG9zdIIJZWttLXByb3h5hwR/AAABhxAAAAAA +AAAAAAAAAAAAAAABMA0GCSqGSIb3DQEBCwUAA4IBgQAPupf/p2T0LF1H+c6zVjuC +fgQEisY+89dEn1thxqLHAO/n1hjdN+X9r5tO+v3/1JuoqAsVkGGDkq276TUswg21 +sVjN9GEf8AFKSJj+Zc4iJ82s8MGlsiipZYomEYGgi7GrfVTh+LKZ6nsbYBPL7omB +yNl3QOGqVvElsC9xwoG4GepZNzXgv7qb4RdW1H6woX0C3oCFxA2GwRuTBTeTQQwL +tjK8JirqG5gDOBKgYmz+Rq9KFit1+0Ngrq/ay0DlqhOM+vFmeGODeaqT40WQvqY7 +YyvvfaK1cqR2ik+hbUkJeFEG9SZULRDgccVqfhTpOkjInJY8xGnLoWEMfRu55/4X +9hhXO347N5LaqQc5Kms+aBRrmt5Yhwo3ZE7yFvK+rOCnntWeY/bugt3dvWMpLfrp +3veCHKgJ/OmJviejE50SJaw4iE/bEWgynqN4nj+Md74zJXun4ixxKLqGDln1POfw +j0Kji2qYOh5oag3PBpkGksrI/XoO0svgDak5gBjsCeA= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIFQDCCAyigAwIBAgIUOVbSOmw5P/4tsSUcOH4SEWIQnKYwDQYJKoZIhvcNAQEL +BQAwazELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAldBMRAwDgYDVQQHDAdSZWRtb25k +MRIwEAYDVQQKDAlNaWNyb3NvZnQxEjAQBgNVBAsMCUVLTSBQcm94eTEVMBMGA1UE +AwwMRUtNIFByb3h5IENBMB4XDTI1MTIxODE5NDYzN1oXDTI2MTIxODE5NDYzN1ow +gYUxCzAJBgNVBAYTAlVTMQswCQYDVQQIDAJXQTEQMA4GA1UEBwwHUmVkbW9uZDES +MBAGA1UECgwJTWljcm9zb2Z0MR8wHQYDVQQLDBZFS00gUHJveHkgSW50ZXJtZWRp +YXRlMSIwIAYDVQQDDBlFS00gUHJveHkgSW50ZXJtZWRpYXRlIENBMIIBojANBgkq +hkiG9w0BAQEFAAOCAY8AMIIBigKCAYEAwGquLxzZGf79sIAnK6ptlTKi6BntDtXb +yFkXx1MZKWGfb8jpbsBSMcsi28CDxKWTDsCkQ66bWL/zsVW3zcR7Cjgst+q3XTT0 +CNxan1S3C+EIswEL5n/5u6cAY7ItITtyxyXj50Z2la7PJiAsGjSjxVk/pExQ8LUf +sqRZQrWCyaMg7mW2pg+lGmitOnZoXg8huIB/HdudCcmFh9cxRI1zmkqn5ynev8az +/yKlAzh4jQIGm2Yu7JUm4sZE2iGGB7S+i2K1HX7ADciHM1GpPkEzyWncnotLmHi7 +pfuC6ArTEi/hUfpOFv9PoWbgMMKVyoAuIBcZeA9avzbkgK0TI8qZNgMm38xbC4tf +ZdBloAFWEvSVNszvbHMvUVtKpZf0a6cYabg2uH4vKcJBV/fO5idMhUFWD5jfakq/ +Hbrc0xWiBvpoaQzWDFV+9v46EbR7jO5S2y1962JoZ+j6goftQrWT4Q6wHblu5YHW +5avK//6FHfq6qh4I2E2fcG+l6tYy2nGzAgMBAAGjQTA/MB8GA1UdIwQYMBaAFM4v +Bhg504yAum8Yqzrh6WZkVv/uMA8GA1UdEwQIMAYBAf8CAQAwCwYDVR0PBAQDAgEG +MA0GCSqGSIb3DQEBCwUAA4ICAQCV2ckjCrq9sLCr5/+Z0nDwQUQdPkbP9G5BHQYx +oks6KXAU7aTj0lVrhoE+kv9hjOy8tl5aFK4XXIzR1efzmDBbc0710sOzILNRN0Mr +X7gw6snOIf84JKUXDjtzhA4TSDyTS+NCkjgTl/WjWOskeGVeZUsmwG9QCtMjmxCr +4g5PyTd99K0t0kTjSrmBK5plIAMa6HEXqny1BNRkiz9wo4PNNnil+ax7PQoCvas+ +uKuNJlz1HGc1t6F4h6yRgNWZyamCmTycKe61yMrea8K63QEfn240QEhvde/+IXdq +EeKKV3htRv4J+NMSiGfzrPwR1L1c/wITQ76iPYRpUT/H0/FgzEFFFP+W6k+nzEGT +YiluQdzHDl9qzCTc9Pf8Ct45442jCxQEZPTBDBFfG6FakLkXQpHJ60SrQloQOstj +y9O5zwVOrfp8nzUJrc3OAQnJmnLvFD0TfoHgYzag19tXGZ4PoRCxjspzN4G78cKQ +wkPpWplYPHPj3jLC2J4MNcCFogPIDLPoQokv9znFp5NbhrvAjxlTtlIxvNEPtb+x +eAnJ2a1EsF/hfGZczfOgJl5okP8uINSZJqPju/DFRnvHXDgf12ual0mbxl8T4TPV +f6j6D3OgR3dQSQv5FeQ9o9S0R0rgqSlUuHTdoiSPQ8sEj2YFtXlaSQttp3Pileon +1Qio4w== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIFtzCCA5+gAwIBAgIUEkjPEMUlcMaunefJ3IL0A/8yP8YwDQYJKoZIhvcNAQEL +BQAwazELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAldBMRAwDgYDVQQHDAdSZWRtb25k +MRIwEAYDVQQKDAlNaWNyb3NvZnQxEjAQBgNVBAsMCUVLTSBQcm94eTEVMBMGA1UE +AwwMRUtNIFByb3h5IENBMB4XDTI1MTIxODE5NDYzNloXDTI2MTIxODE5NDYzNlow +azELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAldBMRAwDgYDVQQHDAdSZWRtb25kMRIw +EAYDVQQKDAlNaWNyb3NvZnQxEjAQBgNVBAsMCUVLTSBQcm94eTEVMBMGA1UEAwwM +RUtNIFByb3h5IENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAuAEH +PIprLBkoalxIQh7I87T2VvkQKQNROvP1gDJ0lCynAKqIFXCH94q/Q+kSkXvqayhk +ZOArz4uVe8LEpaW674gZkSccrgXBLXzAZbCtMGhe/Q2eGupDKPGZfA4drdmHIfrY +J0DEIcokwTA8tdfXTBAn1PIGECnj6wFNlFsjRSbB7NXuCDpIu0o/vFIPuiqTzb9j +DtO1YbPhjZjw8cHckXv6pB4cQu1Rzgy/FgzLpUYnRLjJybgPRpn/CiDoGdk5WynX +HqFe+WYOz8asn+dNtBalwZogyPS6bUQ0ChXyN/gGSNJx8jvwHVXQbXz7VIFsVGhJ +ErxlUDU+zjOur6oEKcN/K7eEaqwEL6/T9tUy8B6P36fKDK4eJX+ZH0OERV09A2zF +oZbugb15VTiMcPe9dWf/lOEchHvylhx19Q1aUWhWmxoCp0TVk+MhFcoP8Hjdywwl +Kj6tMR4WOwhkTDwhK5bismJyG5dEB2FThWnB0ydsE9hM+AyaZDPt2hOsnVniusdy +EmN86DaBgehaIonq1yK7H84mXq7zx4pDcqW1xlzmfgZEeya5mnk5ZWoL3iGARP3d ++P5BRfJGP+RgeV8/P9uDKCIA3aOmSUVzWD1N4Ns2vyRBxkDycCU3kE6fLrwU3ZSa +Ku8b+chR8gL3ynY9AK6eYv+/yfqamJPGVut2b9cCAwEAAaNTMFEwHQYDVR0OBBYE +FM4vBhg504yAum8Yqzrh6WZkVv/uMB8GA1UdIwQYMBaAFM4vBhg504yAum8Yqzrh +6WZkVv/uMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggIBADEhvtHk +732l4ZliJKJ7w8IdxOZ3lxXof+GjQIJnkh15QP1bQM0FUlrLiNqQGQX4GfwCP/Pg +b2yFNuDpxA3Pgccnpu4LWsw3etKKrSwFxXMArnGzudXamucRvkaFgA/LsgZkyfK5 +/KLyEix3KMkwrXUVkFEkrq1pRKXS8BgtQk61sd60CbcdRA+RR5xl1yPrXTwhgHIv +dNVa9XtzOfj6ur4otTJye+d+X7QbYEZEQvQFghkdwuHMvjPqKl6RfrB+XqcWQ6Nx +GiHgaKYPVTRkrgl6ZViO6Nw9b+f/+MQ+rMEuV+XfQoQvXXE5y6XIHVE83kJ1VTMZ +bDx23VYcjHj7nUg50HtNxVuyk+1cV/UydvCJd58R914JlUAQI4+kGSTJag5lplsA +8qNqgsZN6hFQK62d1m4D0tnCwPt3RB3UBCsLafyP4Ou3P0CqyOoWyE7PXJykZJlP +C7baxfdV2IE/eCsBYYgnrfe4AOsySyntynlpyCbUhAk/mlVuc56e7NeE9PG/BonP +UDfs0nkqoFHdRFuNRUpeHcFU+4VxXDB0OviYsh2k9Ew04OFymXlLdtJ71Wfl0q3r +08aaNHj6bQ5hqO9xWqy44bZW4f0lHWM2FYEcWdBN8N0EYBo0L0rXYhYiYn/+qtro +cEVzZ9DcsvUXGD+BAS++mKQyJqArO1OmDprx +-----END CERTIFICATE----- diff --git a/wheels/azure_keyvault_administration-4.6.1-py3-none-any.whl b/wheels/azure_keyvault_administration-4.6.1-py3-none-any.whl new file mode 100644 index 00000000000..4410d187c28 Binary files /dev/null and b/wheels/azure_keyvault_administration-4.6.1-py3-none-any.whl differ diff --git a/wheels/azure_keyvault_keys-4.11.1-py3-none-any.whl b/wheels/azure_keyvault_keys-4.11.1-py3-none-any.whl new file mode 100644 index 00000000000..6dad1e1506b Binary files /dev/null and b/wheels/azure_keyvault_keys-4.11.1-py3-none-any.whl differ