Skip to content

Commit a863573

Browse files
authored
[Key Vault] Add External Key Manager (EKM) support for Managed HSM (Preview) (#33651)
1 parent a07f9a8 commit a863573

12 files changed

Lines changed: 796 additions & 31 deletions

File tree

src/azure-cli/azure/cli/command_modules/keyvault/_client_factory.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,26 @@ def data_plane_azure_keyvault_security_domain_client(cli_ctx, command_args):
244244
verify_challenge_resource=False, **client_kwargs)
245245

246246

247+
def data_plane_azure_keyvault_ekm_client(cli_ctx, command_args):
248+
from azure.keyvault.administration import KeyVaultEkmClient
249+
250+
# Reuse the existing login + URL resolution behavior.
251+
vault_url, credential, _ = _prepare_data_plane_azure_keyvault_client(
252+
cli_ctx, command_args, ResourceType.DATA_KEYVAULT_ADMINISTRATION_SETTING)
253+
254+
command_args.pop('hsm_name', None)
255+
command_args.pop('vault_base_url', None)
256+
command_args.pop('identifier', None)
257+
258+
client_kwargs = prepare_client_kwargs_track2(cli_ctx)
259+
client_kwargs.pop('http_logging_policy')
260+
return KeyVaultEkmClient(
261+
vault_url=vault_url,
262+
credential=credential,
263+
verify_challenge_resource=False,
264+
**client_kwargs)
265+
266+
247267
def _prepare_data_plane_azure_keyvault_client(cli_ctx, command_args, resource_type):
248268
version = str(get_api_version(cli_ctx, resource_type))
249269
profile = Profile(cli_ctx=cli_ctx)

src/azure-cli/azure/cli/command_modules/keyvault/_help.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,46 @@
975975
az keyvault wait-hsm --hsm-name MyHSM --created
976976
"""
977977

978+
helps['keyvault ekm-connection'] = """
979+
type: group
980+
short-summary: Manage External Key Manager (EKM) connection for a Managed HSM.
981+
"""
982+
983+
helps['keyvault ekm-connection create'] = """
984+
type: command
985+
short-summary: Create the EKM connection.
986+
"""
987+
988+
helps['keyvault ekm-connection update'] = """
989+
type: command
990+
short-summary: Update the EKM connection.
991+
"""
992+
993+
helps['keyvault ekm-connection show'] = """
994+
type: command
995+
short-summary: Show the EKM connection.
996+
"""
997+
998+
helps['keyvault ekm-connection check'] = """
999+
type: command
1000+
short-summary: Check connectivity and authentication with the EKM proxy.
1001+
"""
1002+
1003+
helps['keyvault ekm-connection delete'] = """
1004+
type: command
1005+
short-summary: Delete the EKM connection.
1006+
"""
1007+
1008+
helps['keyvault ekm-connection certificate'] = """
1009+
type: group
1010+
short-summary: Manage EKM proxy certificate information.
1011+
"""
1012+
1013+
helps['keyvault ekm-connection certificate show'] = """
1014+
type: command
1015+
short-summary: Show the EKM proxy client certificate.
1016+
"""
1017+
9781018
helps['keyvault security-domain'] = """
9791019
type: group
9801020
short-summary: Manage security domain operations.

src/azure-cli/azure/cli/command_modules/keyvault/_params.py

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from azure.cli.command_modules.keyvault._validators import (
2222
datetime_type, certificate_type, validate_retention_days_on_creation,
2323
get_vault_base_url_type, get_hsm_base_url_type, validate_key_import_type,
24-
validate_key_import_source, validate_key_type, validate_policy_permissions, validate_principal,
24+
validate_key_import_source, validate_policy_permissions, validate_principal,
2525
validate_resource_group_name, validate_x509_certificate_chain,
2626
secret_text_encoding_values, secret_binary_encoding_values, validate_subnet, validate_ip_address,
2727
validate_vault_or_hsm,
@@ -356,10 +356,13 @@ class CLISecurityDomainOperation(str, Enum):
356356
'Release policies are mutable by default.')
357357

358358
with self.argument_context('keyvault key create') as c:
359-
c.argument('kty', arg_type=get_enum_type(JsonWebKeyType), validator=validate_key_type,
359+
c.argument('kty', arg_type=get_enum_type(JsonWebKeyType),
360360
help='The type of key to create. For valid values, see: https://learn.microsoft.com/rest/api/keyvault/keys/create-key/create-key#jsonwebkeytype')
361361
c.argument('curve', arg_type=get_enum_type(KeyCurveName),
362362
help='Elliptic curve name. For valid values, see: https://learn.microsoft.com/rest/api/keyvault/keys/create-key/create-key#jsonwebkeycurvename')
363+
c.argument('external_key_id', options_list=['--external-key-id'], arg_group='External Key',
364+
is_preview=True,
365+
help='Create an external Managed HSM key backed by an External Key Manager (EKM) key id.')
363366

364367
with self.argument_context('keyvault key import') as c:
365368
c.argument('kty', arg_type=get_enum_type(CLIKeyTypeForBYOKImport), validator=validate_key_import_type,
@@ -616,6 +619,49 @@ class CLISecurityDomainOperation(str, Enum):
616619
help='Target operation that needs waiting.')
617620
# endregion
618621

622+
# region keyvault ekm-connection
623+
for scope in ['create', 'update', 'show', 'check', 'delete']:
624+
with self.argument_context('keyvault ekm-connection {}'.format(scope), arg_group='HSM Id') as c:
625+
c.extra('hsm_name', hsm_url_type, required=False,
626+
help='Name of the HSM. Can be omitted if --id is specified.')
627+
c.extra('identifier', options_list=['--id'], validator=validate_vault_or_hsm,
628+
help='Full URI of the HSM.')
629+
c.ignore('vault_base_url')
630+
631+
with self.argument_context('keyvault ekm-connection create', arg_group='EKM Connection') as c:
632+
c.argument('host', options_list=['--host'], required=True,
633+
help='EKM proxy host (FQDN or FQDN:port). If port is omitted, 443 is assumed.')
634+
c.extra('path_prefix', options_list=['--path-prefix'],
635+
help='Optional path prefix to append to EKM proxy requests. Must start with "/".')
636+
c.extra('server_ca_certificates', options_list=['--server-ca-certificate'], nargs='+', type=file_type,
637+
required=True, completer=FilesCompleter(),
638+
help='Path(s) to server CA certificate(s) in PEM or DER format. '
639+
'Pass a single file containing a PEM chain (multiple certificate blocks), '
640+
'or multiple space-separated file paths (each PEM or DER).')
641+
c.extra('server_subject_common_name', options_list=['--server-subject-common-name', '--server-cn'],
642+
help='Optional expected Common Name (CN) for the EKM proxy server certificate.')
643+
644+
with self.argument_context('keyvault ekm-connection update', arg_group='EKM Connection') as c:
645+
c.argument('host', options_list=['--host'], required=False,
646+
help='EKM proxy host (FQDN or FQDN:port). If port is omitted, 443 is assumed.')
647+
c.extra('path_prefix', options_list=['--path-prefix'],
648+
help='Optional path prefix to append to EKM proxy requests. Must start with "/".')
649+
c.extra('server_ca_certificates', options_list=['--server-ca-certificate'], nargs='+', type=file_type,
650+
completer=FilesCompleter(),
651+
help='Path(s) to server CA certificate(s) in PEM or DER format. '
652+
'Pass a single file containing a PEM chain (multiple certificate blocks), '
653+
'or multiple space-separated file paths (each PEM or DER).')
654+
c.extra('server_subject_common_name', options_list=['--server-subject-common-name', '--server-cn'],
655+
help='Optional expected Common Name (CN) for the EKM proxy server certificate.')
656+
657+
with self.argument_context('keyvault ekm-connection certificate show', arg_group='HSM Id') as c:
658+
c.extra('hsm_name', hsm_url_type, required=False,
659+
help='Name of the HSM. Can be omitted if --id is specified.')
660+
c.extra('identifier', options_list=['--id'], validator=validate_vault_or_hsm,
661+
help='Full URI of the HSM.')
662+
c.ignore('vault_base_url')
663+
# endregion
664+
619665
# region keyvault backup/restore
620666
for item in ['backup', 'restore']:
621667
for scope in ['start']: # TODO add 'status' when SDK is ready

src/azure-cli/azure/cli/command_modules/keyvault/_transformers.py

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,9 @@ def transform_key_encryption_output(result, **command_args): # pylint: disable=
6060
'kid': result.key_id,
6161
'result': base64.b64encode(result.ciphertext).decode('utf-8'),
6262
'algorithm': result.algorithm,
63-
'iv': binascii.hexlify(result.iv) if result.iv else None,
64-
'tag': binascii.hexlify(result.tag) if result.tag else None,
65-
'aad': binascii.hexlify(result.aad) if result.aad else None
63+
'iv': binascii.hexlify(result.iv).decode('ascii') if result.iv else None,
64+
'tag': binascii.hexlify(result.tag).decode('ascii') if result.tag else None,
65+
'aad': binascii.hexlify(result.aad).decode('ascii') if result.aad else None
6666
}
6767
return output
6868

@@ -105,6 +105,13 @@ def transform_key_list_output(result, **command_args): # pylint: disable=unused
105105
k['managed'] = key.managed
106106
k['tags'] = key.tags
107107
k['releasePolicy'] = key.release_policy
108+
109+
# External key (EKM) is a preview property and may not exist on all SDK versions.
110+
external_key = getattr(key, 'external_key', None)
111+
external_key_id = getattr(external_key, 'id', None) if external_key else None
112+
if external_key_id:
113+
k['externalKeyId'] = external_key_id
114+
108115
output.append(k)
109116
return output
110117

@@ -141,6 +148,13 @@ def transform_key_output(result, **command_args):
141148
'tags': result.properties.tags,
142149
'releasePolicy': result.properties.release_policy
143150
}
151+
152+
# External key (EKM) is a preview property and may not exist on all SDK versions.
153+
external_key = getattr(result.properties, 'external_key', None)
154+
external_key_id = getattr(external_key, 'id', None) if external_key else None
155+
if external_key_id:
156+
output['externalKeyId'] = external_key_id
157+
144158
if isinstance(result, DeletedKey):
145159
output['deletedDate'] = result.deleted_date
146160
output['scheduledPurgeDate'] = result.scheduled_purge_date

src/azure-cli/azure/cli/command_modules/keyvault/_validators.py

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -732,10 +732,163 @@ def validate_key_create(cmd, ns):
732732
validate_tags(ns)
733733
set_vault_base_url(ns)
734734
validate_keyvault_resource_id('key')(ns)
735-
validate_key_type(ns)
735+
validate_external_key_id(ns)
736+
737+
if getattr(ns, 'external_key_id', None):
738+
# External keys are backed by an External Key Manager (EKM); the service controls the
739+
# key material, so client-specified key-shape arguments are not supported. Fail fast with
740+
# a clear error instead of silently ignoring them.
741+
incompatible = [opt for opt, val in (
742+
('--kty', getattr(ns, 'kty', None)),
743+
('--size', getattr(ns, 'key_size', None)),
744+
('--curve', getattr(ns, 'curve', None)),
745+
('--ops', getattr(ns, 'key_ops', None)),
746+
('--protection', getattr(ns, 'protection', None)),
747+
('--exportable', getattr(ns, 'exportable', None)),
748+
) if val is not None]
749+
if incompatible:
750+
raise CLIError(
751+
'{} cannot be used with --external-key-id. External keys are backed by an External '
752+
'Key Manager and the service controls the key material.'.format(', '.join(incompatible)))
753+
else:
754+
validate_key_type(ns)
755+
736756
process_key_release_policy(cmd, ns)
737757

738758

759+
def validate_external_key_id(ns):
760+
external_key_id = getattr(ns, 'external_key_id', None)
761+
if not external_key_id:
762+
return
763+
if len(external_key_id) > 64:
764+
raise CLIError('--external-key-id must be at most 64 characters.')
765+
if not re.match(r'^[0-9A-Za-z-]+$', external_key_id):
766+
raise CLIError('--external-key-id may contain only letters, digits, and hyphens.')
767+
768+
769+
def _validate_ekm_path_prefix(path_prefix=None):
770+
if path_prefix is None:
771+
return
772+
if not path_prefix.startswith('/'):
773+
raise CLIError('--path-prefix must start with "/".')
774+
if path_prefix.endswith('/'):
775+
raise CLIError('--path-prefix must not end with "/".')
776+
if len(path_prefix) > 64:
777+
raise CLIError('--path-prefix must be at most 64 characters.')
778+
if not re.match(r'^[A-Za-z0-9/-]+$', path_prefix):
779+
raise CLIError('--path-prefix may contain only letters, digits, "/" and "-".')
780+
781+
782+
def _normalize_ekm_host(host: str):
783+
host = (host or '').strip()
784+
if not host:
785+
raise CLIError('--host cannot be empty.')
786+
if '://' in host:
787+
raise CLIError('--host must not include a URL scheme (use FQDN or FQDN:port).')
788+
if '/' in host:
789+
raise CLIError('--host must not include a path (use FQDN or FQDN:port).')
790+
791+
if ':' not in host:
792+
return f'{host}:443'
793+
794+
# Avoid ambiguous parsing for IPv6 literals.
795+
if host.count(':') != 1:
796+
raise CLIError('--host must be in the form FQDN or FQDN:port.')
797+
798+
hostname, port_str = host.split(':', 1)
799+
if not hostname:
800+
raise CLIError('--host must be in the form FQDN or FQDN:port.')
801+
try:
802+
port = int(port_str)
803+
except ValueError as ex:
804+
raise CLIError('--host port must be an integer.') from ex
805+
if port < 1 or port > 65535:
806+
raise CLIError('--host port must be between 1 and 65535.')
807+
return f'{hostname}:{port}'
808+
809+
810+
def _flatten_list(value):
811+
if value is None:
812+
return None
813+
if isinstance(value, list) and value and isinstance(value[0], list):
814+
flattened = []
815+
for item in value:
816+
flattened.extend(item)
817+
return flattened
818+
return value
819+
820+
821+
def _load_certificates_as_der_bytes(cert_paths):
822+
import os
823+
import ssl
824+
825+
cert_paths = _flatten_list(cert_paths)
826+
if not cert_paths:
827+
return []
828+
829+
der_certs = []
830+
for cert_path in cert_paths:
831+
if not cert_path:
832+
continue
833+
expanded = os.path.expanduser(cert_path)
834+
try:
835+
with open(expanded, 'rb') as f:
836+
raw = f.read()
837+
except OSError as ex:
838+
raise CLIError("Unable to load certificate file '{}': {}.".format(cert_path, ex.strerror)) from ex
839+
840+
# PEM may contain multiple cert blocks.
841+
if b'-----BEGIN CERTIFICATE-----' in raw:
842+
text = raw.decode('utf-8', errors='ignore')
843+
begin = '-----BEGIN CERTIFICATE-----'
844+
end = '-----END CERTIFICATE-----'
845+
start = 0
846+
found_any = False
847+
while True:
848+
b_idx = text.find(begin, start)
849+
if b_idx == -1:
850+
break
851+
e_idx = text.find(end, b_idx)
852+
if e_idx == -1:
853+
raise CLIError(f'Invalid PEM certificate in {cert_path}.')
854+
block = text[b_idx:e_idx + len(end)]
855+
der_certs.append(ssl.PEM_cert_to_DER_cert(block))
856+
found_any = True
857+
start = e_idx + len(end)
858+
if not found_any:
859+
raise CLIError(f'Invalid PEM certificate in {cert_path}.')
860+
else:
861+
# Assume DER.
862+
der_certs.append(raw)
863+
864+
return der_certs
865+
866+
867+
def validate_ekm_connection_base(cmd, ns): # pylint: disable=unused-argument
868+
set_vault_base_url(ns)
869+
if not getattr(ns, 'hsm_name', None) and not getattr(ns, 'identifier', None):
870+
raise CLIError('Please specify --hsm-name or --id.')
871+
872+
873+
def validate_ekm_connection_create(cmd, ns):
874+
validate_ekm_connection_base(cmd, ns)
875+
ns.host = _normalize_ekm_host(ns.host)
876+
_validate_ekm_path_prefix(getattr(ns, 'path_prefix', None))
877+
server_ca_certificates = _load_certificates_as_der_bytes(getattr(ns, 'server_ca_certificates', None))
878+
if not server_ca_certificates:
879+
raise CLIError('Please specify at least one --server-ca-certificate for EKM connection creation.')
880+
ns.server_ca_certificates = server_ca_certificates
881+
882+
883+
def validate_ekm_connection_update(cmd, ns):
884+
validate_ekm_connection_base(cmd, ns)
885+
if getattr(ns, 'host', None):
886+
ns.host = _normalize_ekm_host(ns.host)
887+
_validate_ekm_path_prefix(getattr(ns, 'path_prefix', None))
888+
if getattr(ns, 'server_ca_certificates', None):
889+
ns.server_ca_certificates = _load_certificates_as_der_bytes(ns.server_ca_certificates)
890+
891+
739892
# pylint: disable=line-too-long, too-many-locals
740893
def process_certificate_policy(cmd, ns):
741894
policy = getattr(ns, 'policy', None)

0 commit comments

Comments
 (0)