Repository: localstack/localstack

Issue:
enhancement request: Support preseeding the new KMS provider
### Is there an existing issue for this?

- [X] I have searched the existing issues

### Enhancement description

It would be great if the new KMS provider could be pre-seeded with actual key material.

The former `local-kms` provider support pre-seeding, but also lacked several endpoints. While new provider recently [added support for custom key IDs](https://github.com/localstack/localstack/commit/82bc739e2286c2a29bfcdf9c4e6ad3856adb9694), there are scenarios where this is not sufficient.

In particular, when developing against web3 smart contracts, having static key material for testing can be necessary to establish trust between local services and the blockchain.

Unfortunately, using `local-kms` isn't an option due to the lack of HMAC support.

### 🧑‍💻 Implementation

Ideally the key material could be loaded from a local file mounted into the localstack container, similar to how `local-kms` worked. Another option could be an API that can be called via an initialization hook.

### Anything else?

_No response_


Discussion:
Welcome to LocalStack! Thanks for reporting your first issue and our team will be working towards fixing the issue for you or reach out for more background information. We recommend joining our [Slack Community](https://localstack.cloud/contact/) for real-time help and drop a message to LocalStack Pro Support if you are a Pro user! If you are willing to contribute towards fixing this issue, please have a look at our [contributing guidelines](https://github.com/localstack/.github/blob/main/CONTRIBUTING.md) and our [contributing guide](https://docs.localstack.cloud/contributing/).
Hello,

Is there any plan to support it ? 
Since `local-kms` isn't available anymore this prevents us from upgrading to localstack 3+.
We'd love to have this feature too.

Our use case is we use Localstack for our local environments. We store some secrets in a local database, and when someone reboots their computer, Localstack gets reset, which then means the secrets they've stored are invalidated.

We used to be able to seed KMS, which meant the keys were consistent and the values of those keys too. 

Is there a way around this?
Same here, this feature is crucial to my team being able to upgrade.

Resolving patch:
diff --git a/localstack/services/kms/models.py b/localstack/services/kms/models.py
--- a/localstack/services/kms/models.py
+++ b/localstack/services/kms/models.py
@@ -103,6 +103,11 @@
 # list of key names that should be skipped when serializing the encryption context
 IGNORED_CONTEXT_KEYS = ["aws-crypto-public-key"]
 
+# special tag name to allow specifying a custom ID for created keys
+TAG_KEY_CUSTOM_ID = "_custom_id_"
+# special tag name to allow specifying a custom key material for created keys
+TAG_KEY_CUSTOM_KEY_MATERIAL = "_custom_key_material_"
+
 
 def _serialize_ciphertext_blob(ciphertext: Ciphertext) -> bytes:
     header = struct.pack(
@@ -161,14 +166,14 @@ class KmsCryptoKey:
     key_material: bytes
     key_spec: str
 
-    def __init__(self, key_spec: str):
+    def __init__(self, key_spec: str, key_material: Optional[bytes] = None):
         self.private_key = None
         self.public_key = None
         # Technically, key_material, being a symmetric encryption key, is only relevant for
         #   key_spec == SYMMETRIC_DEFAULT.
         # But LocalStack uses symmetric encryption with this key_material even for other specs. Asymmetric keys are
         # generated, but are not actually used for encryption. Signing is different.
-        self.key_material = os.urandom(SYMMETRIC_DEFAULT_MATERIAL_LENGTH)
+        self.key_material = key_material or os.urandom(SYMMETRIC_DEFAULT_MATERIAL_LENGTH)
         self.key_spec = key_spec
 
         if key_spec == "SYMMETRIC_DEFAULT":
@@ -189,7 +194,9 @@ def __init__(self, key_spec: str):
                     f"ECC_SECG_P256K1, RSA_4096, SYMMETRIC_DEFAULT, HMAC_256, HMAC_224, HMAC_512]"
                 )
             minimum_length, maximum_length = HMAC_RANGE_KEY_LENGTHS.get(key_spec)
-            self.key_material = os.urandom(random.randint(minimum_length, maximum_length))
+            self.key_material = key_material or os.urandom(
+                random.randint(minimum_length, maximum_length)
+            )
             return
         else:
             # We do not support SM2 - asymmetric keys both suitable for ENCRYPT_DECRYPT and SIGN_VERIFY,
@@ -239,8 +246,7 @@ def __init__(
         region: str = None,
     ):
         create_key_request = create_key_request or CreateKeyRequest()
-        self._populate_metadata(create_key_request, account_id, region)
-        self.crypto_key = KmsCryptoKey(self.metadata.get("KeySpec"))
+
         # Please keep in mind that tags of a key could be present in the request, they are not a part of metadata. At
         # least in the sense of DescribeKey not returning them with the rest of the metadata. Instead, tags are more
         # like aliases:
@@ -255,6 +261,15 @@ def __init__(
         # disable it."
         self.is_key_rotation_enabled = False
 
+        self._populate_metadata(create_key_request, account_id, region)
+        custom_key_material = None
+        if TAG_KEY_CUSTOM_KEY_MATERIAL in self.tags:
+            # check if the _custom_key_material_ tag is specified, to use a custom key material for this key
+            custom_key_material = base64.b64decode(self.tags[TAG_KEY_CUSTOM_KEY_MATERIAL])
+            # remove the _custom_key_material_ tag from the tags to not readily expose the custom key material
+            del self.tags[TAG_KEY_CUSTOM_KEY_MATERIAL]
+        self.crypto_key = KmsCryptoKey(self.metadata.get("KeySpec"), custom_key_material)
+
     def calculate_and_set_arn(self, account_id, region):
         self.metadata["Arn"] = kms_key_arn(self.metadata.get("KeyId"), account_id, region)
 
@@ -431,11 +446,15 @@ def _populate_metadata(
             if create_key_request.get("Origin") != OriginType.EXTERNAL
             else KeyState.PendingImport
         )
-        # https://docs.aws.amazon.com/kms/latest/developerguide/multi-region-keys-overview.html
-        # "Notice that multi-Region keys have a distinctive key ID that begins with mrk-. You can use the mrk- prefix to
-        # identify MRKs programmatically."
-        # The ID for MultiRegion keys also do not have dashes.
-        if self.metadata.get("MultiRegion"):
+
+        if TAG_KEY_CUSTOM_ID in self.tags:
+            # check if the _custom_id_ tag is specified, to set a user-defined KeyId for this key
+            self.metadata["KeyId"] = self.tags[TAG_KEY_CUSTOM_ID].strip()
+        elif self.metadata.get("MultiRegion"):
+            # https://docs.aws.amazon.com/kms/latest/developerguide/multi-region-keys-overview.html
+            # "Notice that multi-Region keys have a distinctive key ID that begins with mrk-. You can use the mrk- prefix to
+            # identify MRKs programmatically."
+            # The ID for MultiRegion keys also do not have dashes.
             self.metadata["KeyId"] = "mrk-" + str(uuid.uuid4().hex)
         else:
             self.metadata["KeyId"] = str(uuid.uuid4())
diff --git a/localstack/services/kms/provider.py b/localstack/services/kms/provider.py
--- a/localstack/services/kms/provider.py
+++ b/localstack/services/kms/provider.py
@@ -142,9 +142,6 @@
     "GenerateDataKeyPairWithoutPlaintext",
 ]
 
-# special tag name to allow specifying a custom ID for created keys
-TAG_KEY_CUSTOM_ID = "_custom_id_"
-
 
 class ValidationError(CommonServiceException):
     """General validation error type (defined in the AWS docs, but not part of the botocore spec)"""
@@ -202,14 +199,6 @@ def _create_kms_key(
     ) -> KmsKey:
         store = kms_stores[account_id][region_name]
         key = KmsKey(request, account_id, region_name)
-
-        # check if the _custom_id_ tag is specified, to set a user-defined KeyId for this key
-        tags_dict = {tag["TagKey"]: tag["TagValue"] for tag in request.get("Tags", [])}
-        custom_id = tags_dict.get(TAG_KEY_CUSTOM_ID)
-        if custom_id and custom_id.strip():
-            key.metadata["KeyId"] = custom_id.strip()
-            key.calculate_and_set_arn(account_id=account_id, region=region_name)
-
         key_id = key.metadata["KeyId"]
         store.keys[key_id] = key
         return key


Test patch:
diff --git a/tests/aws/services/kms/test_kms.py b/tests/aws/services/kms/test_kms.py
--- a/tests/aws/services/kms/test_kms.py
+++ b/tests/aws/services/kms/test_kms.py
@@ -1,6 +1,7 @@
 import base64
 import hashlib
 import json
+import os
 import uuid
 from datetime import datetime
 from random import getrandbits
@@ -8,12 +9,14 @@
 import pytest
 from botocore.config import Config
 from botocore.exceptions import ClientError
-from cryptography.hazmat.primitives import hashes, serialization
+from cryptography.hazmat.primitives import hashes, hmac, serialization
 from cryptography.hazmat.primitives.asymmetric import ec, padding
 from cryptography.hazmat.primitives.serialization import load_der_public_key
 
+from localstack.services.kms.models import IV_LEN, Ciphertext, _serialize_ciphertext_blob
 from localstack.services.kms.utils import get_hash_algorithm
 from localstack.testing.pytest import markers
+from localstack.utils.crypto import encrypt
 from localstack.utils.strings import short_uid, to_str
 
 
@@ -108,6 +111,65 @@ def test_create_key_custom_id(self, kms_create_key, aws_client):
         assert result["KeyId"] == key_id
         assert result["Arn"].endswith(f":key/{key_id}")
 
+    @markers.aws.only_localstack
+    def test_create_key_custom_key_material_hmac(self, kms_create_key, aws_client):
+        custom_key_material = b"custom test key material"
+        custom_key_tag_value = base64.b64encode(custom_key_material).decode("utf-8")
+        message = "some important message"
+        key_spec = "HMAC_256"
+        mac_algo = "HMAC_SHA_256"
+
+        # Generate expected MAC
+        h = hmac.HMAC(custom_key_material, hashes.SHA256())
+        h.update(message.encode("utf-8"))
+        expected_mac = h.finalize()
+
+        key_id = kms_create_key(
+            KeySpec=key_spec,
+            KeyUsage="GENERATE_VERIFY_MAC",
+            Tags=[{"TagKey": "_custom_key_material_", "TagValue": custom_key_tag_value}],
+        )["KeyId"]
+
+        mac = aws_client.kms.generate_mac(
+            KeyId=key_id,
+            Message=message,
+            MacAlgorithm=mac_algo,
+        )["Mac"]
+        assert mac == expected_mac
+
+        verify_mac_response = aws_client.kms.verify_mac(
+            KeyId=key_id,
+            Message="some important message",
+            MacAlgorithm=mac_algo,
+            Mac=expected_mac,
+        )
+        assert verify_mac_response["MacValid"]
+
+    @markers.aws.only_localstack
+    def test_create_key_custom_key_material_symmetric_decrypt(self, kms_create_key, aws_client):
+        custom_key_material = b"custom test key material"
+        custom_key_tag_value = base64.b64encode(custom_key_material).decode("utf-8")
+        algo = "SYMMETRIC_DEFAULT"
+        message = b"test message 123 !%$@ 1234567890"
+
+        key_id = kms_create_key(
+            Tags=[{"TagKey": "_custom_key_material_", "TagValue": custom_key_tag_value}]
+        )["KeyId"]
+
+        # Generate expected cipher text
+        iv = os.urandom(IV_LEN)
+        ciphertext, tag = encrypt(custom_key_material, message, iv, b"")
+        expected_ciphertext_blob = _serialize_ciphertext_blob(
+            ciphertext=Ciphertext(key_id=key_id, iv=iv, ciphertext=ciphertext, tag=tag)
+        )
+
+        plaintext = aws_client.kms.decrypt(
+            KeyId=key_id,
+            CiphertextBlob=expected_ciphertext_blob,
+            EncryptionAlgorithm=algo,
+        )["Plaintext"]
+        assert plaintext == message
+
     @markers.aws.validated
     def test_get_key_in_different_region(
         self, kms_client_for_region, kms_create_key, snapshot, region_name, secondary_region_name