Skip to content

Commit 9b28c3a

Browse files
authored
Add SigV4 event stream empty-frame signing (#692)
* Add SigV4 event stream empty-frame signing Add AsyncEventSigner.sign_empty for SigV4-signed Amazon Event Stream terminator frames with zero-byte payloads. Share the common signing flow between normal event messages and empty terminator payloads, and cover both behaviors with tests. * Trim sign_empty docstring * Address PR feedback
1 parent fa01fd8 commit 9b28c3a

3 files changed

Lines changed: 168 additions & 5 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"type": "enhancement",
3+
"description": "Added `AsyncEventSigner.sign_empty` for SigV4-signed Amazon Event Stream terminator frames. This supports services that require a final signed empty message before the HTTP body stream closes."
4+
}

packages/aws-sdk-signers/src/aws_sdk_signers/signers.py

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -835,17 +835,69 @@ def __init__(
835835
self._prior_signature = initial_signature
836836
self._signing_lock = asyncio.Lock()
837837
self._event_encoder_cls = event_encoder_cls
838+
self._is_finalized = False
838839

839840
async def sign(
840841
self,
841842
*,
842843
event: "EventMessage",
843844
identity: _AWSCredentialsIdentity,
844845
properties: SigV4SigningProperties,
846+
) -> "EventMessage":
847+
return await self._sign_payload(
848+
event=event,
849+
payload=event.encode(),
850+
identity=identity,
851+
properties=properties,
852+
)
853+
854+
async def sign_empty(
855+
self,
856+
*,
857+
event: "EventMessage",
858+
identity: _AWSCredentialsIdentity,
859+
properties: SigV4SigningProperties,
860+
) -> "EventMessage":
861+
"""Sign an Amazon Event Stream final empty message.
862+
863+
The supplied ``event`` must have no headers and an empty payload.
864+
Calling ``sign`` with an empty event message is not equivalent because
865+
``sign`` wraps the encoded event message as the payload.
866+
867+
The returned message contains ``:date`` and ``:chunk-signature``
868+
headers and advances the running signature chain. After this method
869+
returns, the signer cannot sign additional event stream messages.
870+
"""
871+
if event.headers or event.payload:
872+
raise ValueError(
873+
"sign_empty requires an event with no headers and an empty payload."
874+
)
875+
876+
return await self._sign_payload(
877+
event=event,
878+
payload=b"",
879+
identity=identity,
880+
properties=properties,
881+
is_final=True,
882+
)
883+
884+
async def _sign_payload(
885+
self,
886+
*,
887+
event: "EventMessage",
888+
payload: bytes,
889+
identity: _AWSCredentialsIdentity,
890+
properties: SigV4SigningProperties,
891+
is_final: bool = False,
845892
) -> "EventMessage":
846893
async with self._signing_lock:
847-
# Copy and prepopulate any missing values in the
848-
# signing properties.
894+
if self._is_finalized:
895+
raise RuntimeError(
896+
"Cannot sign event stream messages after the final empty event "
897+
"has been signed."
898+
)
899+
900+
# Copy and prepopulate any missing values in the signing properties.
849901
new_signing_properties = SigV4SigningProperties( # type: ignore
850902
**properties
851903
)
@@ -862,8 +914,6 @@ async def sign(
862914
encoder.encode_headers(headers)
863915
encoded_headers = encoder.get_result()
864916

865-
payload = event.encode()
866-
867917
string_to_sign = await self._event_string_to_sign(
868918
timestamp=timestamp,
869919
scope=self._scope(new_signing_properties),
@@ -882,8 +932,10 @@ async def sign(
882932
event.headers = headers
883933
event.payload = payload
884934

885-
# set new prior signature before releasing the lock
935+
# Set new prior signature before releasing the lock.
886936
self._prior_signature = hexlify(event_signature)
937+
if is_final:
938+
self._is_finalized = True
887939

888940
return event
889941

packages/aws-sdk-signers/tests/unit/test_signers.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import pytest
1111
from aws_sdk_signers import (
12+
AsyncEventSigner,
1213
AsyncSigV4Signer,
1314
AWSCredentialIdentity,
1415
AWSRequest,
@@ -18,6 +19,7 @@
1819
SigV4SigningProperties,
1920
URI,
2021
)
22+
from smithy_aws_event_stream.events import Event, EventHeaderEncoder, EventMessage
2123

2224
SIGV4_RE = re.compile(
2325
r"AWS4-HMAC-SHA256 "
@@ -146,6 +148,111 @@ async def __anext__(self) -> bytes:
146148
raise Exception("Read should not have been called!")
147149

148150

151+
class TestAsyncEventSigner:
152+
async def test_sign_wraps_event_message_payload(
153+
self,
154+
aws_identity: AWSCredentialIdentity,
155+
) -> None:
156+
signer = AsyncEventSigner(
157+
initial_signature=b"initial-signature",
158+
event_encoder_cls=EventHeaderEncoder,
159+
)
160+
message = EventMessage(
161+
headers={":message-type": "event", ":event-type": "AudioEvent"},
162+
payload=b"audio",
163+
)
164+
165+
signed = await signer.sign(
166+
event=message,
167+
identity=aws_identity,
168+
properties=SigV4SigningProperties(region="us-west-2", service="transcribe"),
169+
)
170+
171+
assert (
172+
signed.payload
173+
== EventMessage(
174+
headers={":message-type": "event", ":event-type": "AudioEvent"},
175+
payload=b"audio",
176+
).encode()
177+
)
178+
assert ":date" in signed.headers
179+
assert ":chunk-signature" in signed.headers
180+
181+
async def test_sign_empty_uses_zero_byte_outer_payload(
182+
self,
183+
aws_identity: AWSCredentialIdentity,
184+
) -> None:
185+
signer = AsyncEventSigner(
186+
initial_signature=b"initial-signature",
187+
event_encoder_cls=EventHeaderEncoder,
188+
)
189+
190+
signed = await signer.sign_empty(
191+
event=EventMessage(),
192+
identity=aws_identity,
193+
properties=SigV4SigningProperties(region="us-west-2", service="transcribe"),
194+
)
195+
196+
decoded = Event.decode(BytesIO(signed.encode()))
197+
assert decoded is not None
198+
assert decoded.message.payload == b""
199+
assert ":date" in decoded.message.headers
200+
assert ":chunk-signature" in decoded.message.headers
201+
202+
async def test_sign_empty_rejects_non_empty_event(
203+
self,
204+
aws_identity: AWSCredentialIdentity,
205+
) -> None:
206+
signer = AsyncEventSigner(
207+
initial_signature=b"initial-signature",
208+
event_encoder_cls=EventHeaderEncoder,
209+
)
210+
211+
with pytest.raises(ValueError):
212+
await signer.sign_empty(
213+
event=EventMessage(headers={"foo": "bar"}),
214+
identity=aws_identity,
215+
properties=SigV4SigningProperties(
216+
region="us-west-2", service="transcribe"
217+
),
218+
)
219+
220+
with pytest.raises(ValueError):
221+
await signer.sign_empty(
222+
event=EventMessage(payload=b"audio"),
223+
identity=aws_identity,
224+
properties=SigV4SigningProperties(
225+
region="us-west-2", service="transcribe"
226+
),
227+
)
228+
229+
async def test_sign_rejects_events_after_empty_event(
230+
self,
231+
aws_identity: AWSCredentialIdentity,
232+
) -> None:
233+
signer = AsyncEventSigner(
234+
initial_signature=b"initial-signature",
235+
event_encoder_cls=EventHeaderEncoder,
236+
)
237+
properties = SigV4SigningProperties(region="us-west-2", service="transcribe")
238+
239+
await signer.sign_empty(
240+
event=EventMessage(),
241+
identity=aws_identity,
242+
properties=properties,
243+
)
244+
245+
with pytest.raises(RuntimeError, match="final empty event"):
246+
await signer.sign(
247+
event=EventMessage(
248+
headers={":message-type": "event", ":event-type": "AudioEvent"},
249+
payload=b"audio",
250+
),
251+
identity=aws_identity,
252+
properties=properties,
253+
)
254+
255+
149256
class TestAsyncSigV4Signer:
150257
SIGV4_ASYNC_SIGNER = AsyncSigV4Signer()
151258

0 commit comments

Comments
 (0)