8000 KMS: on-demand key rotation by agseco · Pull Request #12342 · localstack/localstack · GitHub
[go: up one dir, main page]

Skip to content

KMS: on-demand key rotation #12342

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 27 commits into from
Mar 13, 2025
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d132b3b
Draft for on-demand key rotation feature
Mar 5, 2025
3c0b110
Minimum required integration test
Mar 5, 2025
b3e4043
Test before and after status of the key, with different cases for whe…
Mar 6, 2025
ed9938f
Prevent on-demand key rotation for keys with imported key material
Mar 6, 2025
e8f05e0
Raise error on disabled key.
Mar 6, 2025
9594a82
Ensure key material changes on key rotation.
Mar 6, 2025
c1afb6b
Make linter happy 😅
Mar 6, 2025
c44d62b
Merge branch 'master' into rotate-key-on-demand
Mar 7, 2025
3d27bd9
Merge branch 'localstack:master' into rotate-key-on-demand
agseco Mar 7, 2025
ed64624
Parity tests
Mar 8, 2025
80f6a2f
Minor naming adjustment
Mar 8, 2025
c4bf067
Merge branch 'master' into rotate-key-on-demand
Mar 11, 2025
b5a8bc2
Solve response conflict by ignoring response's property
Mar 11, 2025
b815b4f
Explicitly set `RotationPeriodInDays` and `OnDemandRotationStartDate`…
Mar 11, 2025
4ac3a5a
Remove unused fixture.
Mar 11, 2025
111c0f4
Added a TODO with missing functionality.
Mar 11, 2025
86a5e5a
Remove unnecessary manual assertions and leverage snapshots.
Mar 11, 2025
e9d77a4
Simulate that on demand rotation is executed asynchronously and retur…
Mar 11, 2025
c7d3f36
Use helper consistently.
Mar 11, 2025
dfae403
Removed unused attribute.
Mar 11, 2025
1b16b8c
Revert accidental change.
Mar 11, 2025
dae661a
Remove orphan validations.
Mar 11, 2025
0fab5ca
Relocate async behaviour simulation into model.
Mar 11, 2025
4efcb81
Remove unused attribute.
Mar 11, 2025
cdf1517
Merge branch 'localstack:master' into rotate-key-on-demand
agseco Mar 12, 2025
b76b522
Stop returning `OnDemandRotationStartDate` in LocalStack's response.
Mar 12, 2025
1857312
Rely on existing logic that raises `DisabledException`, so it's not n…
Mar 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions localstack-core/localstack/services/kms/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ class KmsKey:
is_key_rotation_enabled: bool
rotation_period_in_days: int
next_rotation_date: datetime.datetime
on_demand_rotation_start_date: datetime.datetime

def __init__(
self,
Expand Down Expand Up @@ -284,6 +285,7 @@ def __init__(
self.crypto_key = KmsCryptoKey(self.metadata.get("KeySpec"), custom_key_material)
self.rotation_period_in_days = 365
self.next_rotation_date = None
self.on_demand_rotation_start_date = None
Copy link
Contributor Author
@agseco agseco Mar 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For simplicity current implementation is limited to tracking a single on demand rotation.

As part of implementing ListKeyRotations, the model can be evolved to track many.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After some discussion we've decided not to replicate AWS behavior for on_demand_rotation_start_date, which means LocalStack will not return this field. Since we don't consider it essential to emulate and it's only used in the get_key_rotation_status function, please remove support for it from your code, along with all related tests.
To fix tests you can use poll_condition to wait until OnDemandRotationStartDate is no longer in the response, just like you did in the test_rotate_key_on_demand_should_return_rotation_start_date_only_while_its_in_progress.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This, along with @bentsku comment, are the last two things we need to address. After that, we're good to merge the PR 🚢

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both issues fixed 🚀


def calculate_and_set_arn(self, account_id, region):
self.metadata["Arn"] = kms_key_arn(self.metadata.get("KeyId"), account_id, region)
Expand Down Expand Up @@ -597,6 +599,9 @@ def _update_key_rotation_date(self) -> None:
days=self.rotation_period_in_days
)

def _update_on_demand_rotation_start_date(self) -> None:
self.on_demand_rotation_start_date = datetime.datetime.now()

# An example of how the whole policy should look like:
# https://docs.aws.amazon.com/kms/latest/developerguide/key-policy-overview.html
# The default statement is here:
Expand Down
30 changes: 29 additions & 1 deletion localstack-core/localstack/services/kms/provider.py
8000
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@
ReEncryptResponse,
ReplicateKeyRequest,
ReplicateKeyResponse,
RotateKeyOnDemandRequest,
RotateKeyOnDemandResponse,
ScheduleKeyDeletionRequest,
ScheduleKeyDeletionResponse,
SignRequest,
Expand Down Expand Up @@ -1258,7 +1260,10 @@ def get_key_rotation_status(
KeyId=key_id,
KeyRotationEnabled=key.is_key_rotation_enabled,
NextRotationDate=key.next_rotation_date,
RotationPeriodInDays=key.rotation_period_in_days,
OnDemandRotationStartDate=key.on_demand_rotation_start_date,
RotationPeriodInDays=key.rotation_period_in_days
if key.is_key_rotation_enabled
else None,
)

@handler("DisableKeyRotation", expand=False)
Expand Down Expand Up @@ -1334,6 +1339,29 @@ def list_resource_tags(
kwargs = {"NextMarker": next_token, "Truncated": True} if next_token else {}
return ListResourceTagsResponse(Tags=page, **kwargs)

@handler("RotateKeyOnDemand", expand=False)
def rotate_key_on_demand(
self, context: RequestContext, request: RotateKeyOnDemandRequest
) -> RotateKeyOnDemandResponse:
account_id, region_name, key_id = self._parse_key_id(request["KeyId"], context)
key = self._get_kms_key(account_id, region_name, key_id, any_key_state_allowed=True)

if key.metadata["KeyState"] == KeyState.Disabled:
raise DisabledException(f"{key.metadata['Arn']} is disabled.")
if key.metadata["KeySpec"] != KeySpec.SYMMETRIC_DEFAULT:
raise UnsupportedOperationException()
if key.metadata["Origin"] == OriginType.EXTERNAL:
raise UnsupportedOperationException(
f"{key.metadata['Arn']} origin is EXTERNAL which is not valid for this operation."
)

key.crypto_key = KmsCryptoKey(KeySpec.SYMMETRIC_DEFAULT)
key._update_on_demand_rotation_start_date()

return RotateKeyOnDemandResponse(
KeyId=key_id,
)

@handler("TagResource", expand=False)
def tag_resource(self, context: RequestContext, request: TagResourceRequest) -> None:
key = self._get_kms_key(
Expand Down
108 changes: 108 additions & 0 deletions tests/aws/services/kms/test_kms.py
9E81
Original file line number Diff line number Diff line change
Expand Up @@ -1098,6 +1098,114 @@ def test_key_rotation_status(self, kms_key, aws_client):
aws_client.kms.disable_key_rotation(KeyId=key_id)
assert aws_client.kms.get_key_rotation_status(KeyId=key_id)["KeyRotationEnabled"] is False

@markers.aws.validated
def test_rotate_key_on_demand_modifies_key_material(self, kms_create_key, aws_client, snapshot):
key_id = kms_create_key(KeyUsage="ENCRYPT_DECRYPT", KeySpec="SYMMETRIC_DEFAULT")["KeyId"]
message = b"test message 123 !%$@ 1234567890"

ciphertext_before = aws_client.kms.encrypt(
KeyId=key_id,
Plaintext=base64.b64encode(message),
EncryptionAlgorithm="SYMMETRIC_DEFAULT",
)["CiphertextBlob"]

rotate_on_demand_response = aws_client.kms.rotate_key_on_demand(KeyId=key_id)
snapshot.match("rotate-on-demand-response", rotate_on_demand_response)

ciphertext_after = aws_client.kms.encrypt(
KeyId=key_id,
Plaintext=base64.b64encode(message),
EncryptionAlgorithm="SYMMETRIC_DEFAULT",
)["CiphertextBlob"]

assert ciphertext_before != ciphertext_after

@markers.aws.validated
def test_rotate_key_on_demand_with_symmetric_key_and_automatic_rotation_disabled_preserves_automatic_rotation_schedule_attributes(
self, kms_key, aws_client, snapshot
):
key_id = kms_key["KeyId"]

rotation_status_response_before = aws_client.kms.get_key_rotation_status(KeyId=key_id)

rotate_on_demand_response = aws_client.kms.rotate_key_on_demand(KeyId=key_id)
snapshot.match("rotate-on-demand-response", rotate_on_demand_response)

rotation_status_response_after = aws_client.kms.get_key_rotation_status(KeyId=key_id)
assert (
rotation_status_response_after["KeyRotationEnabled"]
== rotation_status_response_before["KeyRotationEnabled"]
)
snapshot.match("rotation-status-response-after-rotation", rotation_status_response_after)

@markers.aws.validated
def test_rotate_key_on_demand_with_symmetric_key_and_automatic_rotation_enabled_preserves_automatic_rotation_schedule_attributes(
self, kms_key, aws_client, snapshot
):
key_id = kms_key["KeyId"]

aws_client.kms.enable_key_rotation(KeyId=key_id)
rotation_status_response_before = aws_client.kms.get_key_rotation_status(KeyId=key_id)

rotate_on_demand_response = aws_client.kms.rotate_key_on_demand(KeyId=key_id)
snapshot.match("rotate-on-demand-response", rotate_on_demand_response)

rotation_status_response_after = aws_client.kms.get_key_rotation_status(KeyId=key_id)
assert (
rotation_status_response_after["KeyRotationEnabled"]
== rotation_status_response_before["KeyRotationEnabled"]
)
assert (
rotation_status_response_after["NextRotationDate"]
== rotation_status_response_before["NextRotationDate"]
)
assert (
rotation_status_response_after["RotationPeriodInDays"]
== rotation_status_response_before["RotationPeriodInDays"]
)
snapshot.match("rotation-status-response-after-rotation", rotation_status_response_after)

@markers.aws.validated
def test_rotate_key_on_demand_raises_error_given_key_is_disabled(
self, kms_create_key, aws_client, snapshot
):
key_id = kms_create_key(KeyUsage="ENCRYPT_DECRYPT", KeySpec="RSA_4096")["KeyId"]
aws_client.kms.disable_key(KeyId=key_id)

with pytest.raises(ClientError) as e:
aws_client.kms.rotate_key_on_demand(KeyId=key_id)
snapshot.match("error-response", e.value.response)

@markers.aws.validated
def test_rotate_key_on_demand_raises_error_given_key_that_does_not_exist(
self, kms_create_key, aws_client, snapshot
):
key_id = "1234abcd-12ab-34cd-56ef-1234567890ab"

with pytest.raises(ClientError) as e:
aws_client.kms.rotate_key_on_demand(KeyId=key_id)
snapshot.match("error-response", e.value.response)

@markers.aws.validated
def test_rotate_key_on_demand_raises_error_given_non_symmetric_key(
self, kms_create_key, aws_client, snapshot
):
key_id = kms_create_key(KeyUsage="ENCRYPT_DECRYPT", KeySpec="RSA_4096")["KeyId"]

with pytest.raises(ClientError) as e:
aws_client.kms.rotate_key_on_demand(KeyId=key_id)
snapshot.match("error-response", e.value.response)

@markers.aws.validated
def test_rotate_key_on_demand_raises_error_given_key_with_imported_key_material(
self, kms_create_key, aws_client, snapshot
):
key_id = kms_create_key(Origin="EXTERNAL")["KeyId"]

with pytest.raises(ClientError) as e:
aws_client.kms.rotate_key_on_demand(KeyId=key_id)
snapshot.match("error-response", e.value.response)

@markers.aws.validated
@pytest.mark.parametrize("rotation_period_in_days", [90, 180])
def test_key_enable_rotation_status(
Expand Down
120 changes: 120 additions & 0 deletions tests/aws/services/kms/test_kms.snapshot.json
Original file line number Diff line number Diff line change
Expand Up @@ -2035,5 +2035,125 @@
}
}
}
},
"tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_modifies_key_material": {
"recorded-date": "08-03-2025, 09:24:16",
"recorded-content": {
"rotate-on-demand-response": {
"KeyId": "<key-id:1>",
"ResponseMetadata": {
"HTTPHeaders": {},
"HTTPStatusCode": 200
}
}
}
},
"tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_with_symmetric_key_and_automatic_rotation_disabled_preserves_automatic_rotation_schedule_attributes": {
"recorded-date": "08-03-2025, 09:25:31",
"recorded-content": {
"rotate-on-demand-response": {
"KeyId": "<key-id:1>",
"ResponseMetadata": {
"HTTPHeaders": {},
"HTTPStatusCode": 200
}
},
"rotation-status-response-after-rotation": {
"KeyId": "<key-id:1>",
"KeyRotationEnabled": false,
"OnDemandRotationStartDate": "datetime",
"ResponseMetadata": {
"HTTPHeaders": {},
"HTTPStatusCode": 200
}
}
}
},
"tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_with_symmetric_key_and_automatic_rotation_enabled_preserves_automatic_rotation_schedule_attributes": {
"recorded-date": "08-03-2025, 09:25:51",
"recorded-content": {
"rotate-on-demand-response": {
"KeyId": "<key-id:1>",
"ResponseMetadata": {
"HTTPHeaders": {},
"HTTPStatusCode": 200
}
},
"rotation-status-response-after-rotation": {
"KeyId": "<key-id:1>",
"KeyRotationEnabled": true,
"NextRotationDate": "datetime",
"OnDemandRotationStartDate": "datetime",
"RotationPeriodInDays": 365,
"ResponseMetadata": {
"HTTPHeaders": {},
"HTTPStatusCode": 200
}
}
}
},
"tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_raises_error_given_key_is_disabled": {
"recorded-date": "08-03-2025, 09:26:50",
"recorded-content": {
"error-response": {
"Error": {
"Code": "DisabledException",
"Message": "<key-arn> is disabled."
},
"message": "<key-arn> is disabled.",
"ResponseMetadata": {
"HTTPHeaders": {},
"HTTPStatusCode": 400
}
}
}
},
"tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_raises_error_given_key_that_does_not_exist": {
"recorded-date": "08-03-2025, 09:27:10",
"recorded-content": {
"error-response": {
"Error": {
"Code": "NotFoundException",
"Message": "Key '<key-arn>' does not exist"
},
"message": "Key '<key-arn>' does not exist",
"ResponseMetadata": {
"HTTPHeaders": {},
"HTTPStatusCode": 400
}
}
}
},
"tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_raises_error_given_non_symmetric_key": {
"recorded-date": "08-03-2025, 09:27:44",
"recorded-content": {
"error-response": {
"Error": {
"Code": "UnsupportedOperationException",
"Message": ""
},
"message": "",
"ResponseMetadata": {
"HTTPHeaders": {},
"HTTPStatusCode": 400
}
}
}
},
"tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_raises_error_given_key_with_imported_key_material": {
"recorded-date": "08-03-2025, 09:28:13",
"recorded-content": {
"error-response": {
"Error": {
"Code": "UnsupportedOperationException",
"Message": "<key-arn> origin is EXTERNAL which is not valid for this operation."
},
"message": "<key-arn> origin is EXTERNAL which is not valid for this operation.",
"ResponseMetadata": {
"HTTPHeaders": {},
"HTTPStatusCode": 400
}
}
}
}
}
21 changes: 21 additions & 0 deletions tests/aws/services/kms/test_kms.validation.json
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,27 @@
"tests/aws/services/kms/test_kms.py::TestKMS::test_revoke_grant": {
"last_validated_date": "2024-04-11T15:52:44+00:00"
},
"tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_modifies_key_material": {
"last_validated_date": "2025-03-08T09:24:15+00:00"
},
"tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_raises_error_given_key_is_disabled": {
"last_validated_date": "2025-03-08T09:26:50+00:00"
},
"tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_raises_error_given_key_that_does_not_exist": {
"last_validated_date": "2025-03-08T09:27:10+00:00"
},
"tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_raises_error_given_key_with_imported_key_material": {
"last_validated_date": "2025-03-08T09:28:13+00:00"
},
"tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_raises_error_given_non_symmetric_key": {
"last_validated_date": "2025-03-08T09:27:44+00:00"
},
"tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_with_symmetric_key_and_automatic_rotation_disabled_preserves_automatic_rotation_schedule_attributes": {
"last_validated_date": "2025-03-08T09:25:30+00:00"
},
"tests/aws/services/kms/test_kms.py::TestKMS::test_rotate_key_on_demand_with_symmetric_key_and_automatic_rotation_enabled_preserves_automatic_rotation_schedule_attributes": {
"last_validated_date": "2025-03-08T09:25:51+00:00"
},
"tests/aws/services/kms/test_kms.py::TestKMS::test_schedule_and_cancel_key_deletion": {
"last_validated_date": "2024-04-11T15:52:36+00:00"
},
Expand Down
0