capo-lambda-web 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. capo_lambda_web/__init__.py +71 -0
  2. capo_lambda_web/_async.py +25 -0
  3. capo_lambda_web/_auth/_identity.py +16 -0
  4. capo_lambda_web/_auth/_providers.py +886 -0
  5. capo_lambda_web/_auth/_signers.py +121 -0
  6. capo_lambda_web/_auth/_sigv4.py +579 -0
  7. capo_lambda_web/_auth/_zapros_handler.py +80 -0
  8. capo_lambda_web/_body.py +221 -0
  9. capo_lambda_web/_iter.py +113 -0
  10. capo_lambda_web/_operations/lambda_web/create_web_function.py +184 -0
  11. capo_lambda_web/_operations/lambda_web/create_web_function_endpoint.py +194 -0
  12. capo_lambda_web/_operations/lambda_web/create_web_function_revision.py +188 -0
  13. capo_lambda_web/_operations/lambda_web/delete_resource_policy.py +147 -0
  14. capo_lambda_web/_operations/lambda_web/delete_web_function.py +145 -0
  15. capo_lambda_web/_operations/lambda_web/delete_web_function_endpoint.py +149 -0
  16. capo_lambda_web/_operations/lambda_web/delete_web_function_revision.py +149 -0
  17. capo_lambda_web/_operations/lambda_web/get_resource_policy.py +165 -0
  18. capo_lambda_web/_operations/lambda_web/get_web_account_settings.py +155 -0
  19. capo_lambda_web/_operations/lambda_web/get_web_function.py +171 -0
  20. capo_lambda_web/_operations/lambda_web/get_web_function_endpoint.py +180 -0
  21. capo_lambda_web/_operations/lambda_web/get_web_function_revision.py +174 -0
  22. capo_lambda_web/_operations/lambda_web/list_tags.py +164 -0
  23. capo_lambda_web/_operations/lambda_web/list_web_function_endpoints.py +176 -0
  24. capo_lambda_web/_operations/lambda_web/list_web_function_revisions.py +176 -0
  25. capo_lambda_web/_operations/lambda_web/list_web_functions.py +168 -0
  26. capo_lambda_web/_operations/lambda_web/put_resource_policy.py +179 -0
  27. capo_lambda_web/_operations/lambda_web/tag_resource.py +155 -0
  28. capo_lambda_web/_operations/lambda_web/untag_resource.py +148 -0
  29. capo_lambda_web/_operations/lambda_web/update_web_function_endpoint.py +196 -0
  30. capo_lambda_web/_pagination.py +21 -0
  31. capo_lambda_web/_protocol/__init__.py +1 -0
  32. capo_lambda_web/_protocol/errors.py +93 -0
  33. capo_lambda_web/_protocol/eventstream.py +240 -0
  34. capo_lambda_web/_protocol/serialize.py +47 -0
  35. capo_lambda_web/_protocol/xml.py +33 -0
  36. capo_lambda_web/_resources/lambda_web/web_function.py +477 -0
  37. capo_lambda_web/_rule_engine/__init__.py +0 -0
  38. capo_lambda_web/_rule_engine/_aws_partition.py +160 -0
  39. capo_lambda_web/_rule_engine/_endpoint_rule_set.py +135 -0
  40. capo_lambda_web/_rule_engine/_endpoint_runtime.py +389 -0
  41. capo_lambda_web/_services/_aws_config.py +160 -0
  42. capo_lambda_web/_services/_pipeline.py +264 -0
  43. capo_lambda_web/_services/async_lambda_web.py +1394 -0
  44. capo_lambda_web/_services/lambda_web.py +1374 -0
  45. capo_lambda_web/errors/__init__.py +36 -0
  46. capo_lambda_web/errors/_base.py +94 -0
  47. capo_lambda_web/errors/access_denied_exception.py +48 -0
  48. capo_lambda_web/errors/conflict_exception.py +58 -0
  49. capo_lambda_web/errors/internal_server_exception.py +47 -0
  50. capo_lambda_web/errors/resource_not_found_exception.py +60 -0
  51. capo_lambda_web/errors/service_quota_exceeded_exception.py +74 -0
  52. capo_lambda_web/errors/throttling_exception.py +60 -0
  53. capo_lambda_web/errors/validation_exception.py +46 -0
  54. capo_lambda_web/py.typed +0 -0
  55. capo_lambda_web/types/_prelude/blob.py +12 -0
  56. capo_lambda_web/types/_prelude/timestamp.py +17 -0
  57. capo_lambda_web/types/account_quotas.py +47 -0
  58. capo_lambda_web/types/account_usage.py +26 -0
  59. capo_lambda_web/types/application_log_level.py +22 -0
  60. capo_lambda_web/types/auth_type.py +18 -0
  61. capo_lambda_web/types/auto_deployment_mode.py +18 -0
  62. capo_lambda_web/types/build_config.py +55 -0
  63. capo_lambda_web/types/code_config.py +37 -0
  64. capo_lambda_web/types/create_web_function_endpoint_request.py +164 -0
  65. capo_lambda_web/types/create_web_function_endpoint_response.py +291 -0
  66. capo_lambda_web/types/create_web_function_request.py +72 -0
  67. capo_lambda_web/types/create_web_function_response.py +144 -0
  68. capo_lambda_web/types/create_web_function_revision_request.py +76 -0
  69. capo_lambda_web/types/create_web_function_revision_response.py +157 -0
  70. capo_lambda_web/types/date_time.py +20 -0
  71. capo_lambda_web/types/delete_resource_policy_request.py +29 -0
  72. capo_lambda_web/types/delete_web_function_endpoint_request.py +27 -0
  73. capo_lambda_web/types/delete_web_function_request.py +24 -0
  74. capo_lambda_web/types/delete_web_function_revision_request.py +27 -0
  75. capo_lambda_web/types/description.py +5 -0
  76. capo_lambda_web/types/domain_name.py +5 -0
  77. capo_lambda_web/types/endpoint_arn.py +5 -0
  78. capo_lambda_web/types/endpoint_config.py +134 -0
  79. capo_lambda_web/types/endpoint_name.py +5 -0
  80. capo_lambda_web/types/endpoint_state.py +20 -0
  81. capo_lambda_web/types/endpoint_type.py +19 -0
  82. capo_lambda_web/types/endpoint_update_status.py +19 -0
  83. capo_lambda_web/types/environment_variables.py +22 -0
  84. capo_lambda_web/types/filter.py +46 -0
  85. capo_lambda_web/types/filter_list.py +29 -0
  86. capo_lambda_web/types/filter_value_list.py +14 -0
  87. capo_lambda_web/types/function_arn.py +5 -0
  88. capo_lambda_web/types/function_endpoint_summary.py +249 -0
  89. capo_lambda_web/types/function_endpoint_summary_list.py +33 -0
  90. capo_lambda_web/types/function_name.py +5 -0
  91. capo_lambda_web/types/function_revision_summary.py +83 -0
  92. capo_lambda_web/types/function_revision_summary_list.py +33 -0
  93. capo_lambda_web/types/function_state.py +20 -0
  94. capo_lambda_web/types/function_summary.py +91 -0
  95. capo_lambda_web/types/function_summary_list.py +31 -0
  96. capo_lambda_web/types/get_resource_policy_request.py +24 -0
  97. capo_lambda_web/types/get_resource_policy_response.py +39 -0
  98. capo_lambda_web/types/get_web_account_settings_request.py +18 -0
  99. capo_lambda_web/types/get_web_account_settings_response.py +59 -0
  100. capo_lambda_web/types/get_web_function_endpoint_request.py +27 -0
  101. capo_lambda_web/types/get_web_function_endpoint_response.py +285 -0
  102. capo_lambda_web/types/get_web_function_request.py +24 -0
  103. capo_lambda_web/types/get_web_function_response.py +91 -0
  104. capo_lambda_web/types/get_web_function_revision_request.py +27 -0
  105. capo_lambda_web/types/get_web_function_revision_response.py +155 -0
  106. capo_lambda_web/types/kms_key_arn.py +5 -0
  107. capo_lambda_web/types/list_tags_request.py +24 -0
  108. capo_lambda_web/types/list_tags_response.py +32 -0
  109. capo_lambda_web/types/list_web_function_endpoints_request.py +54 -0
  110. capo_lambda_web/types/list_web_function_endpoints_response.py +52 -0
  111. capo_lambda_web/types/list_web_function_revisions_request.py +54 -0
  112. capo_lambda_web/types/list_web_function_revisions_response.py +52 -0
  113. capo_lambda_web/types/list_web_functions_request.py +51 -0
  114. capo_lambda_web/types/list_web_functions_response.py +46 -0
  115. capo_lambda_web/types/logging_config.py +67 -0
  116. capo_lambda_web/types/max_results.py +5 -0
  117. capo_lambda_web/types/next_token.py +5 -0
  118. capo_lambda_web/types/policy_revision_id.py +5 -0
  119. capo_lambda_web/types/put_resource_policy_request.py +43 -0
  120. capo_lambda_web/types/put_resource_policy_response.py +39 -0
  121. capo_lambda_web/types/region.py +5 -0
  122. capo_lambda_web/types/region_list.py +17 -0
  123. capo_lambda_web/types/regional_endpoint.py +140 -0
  124. capo_lambda_web/types/regional_endpoints.py +33 -0
  125. capo_lambda_web/types/resource_arn.py +5 -0
  126. capo_lambda_web/types/resource_policy.py +5 -0
  127. capo_lambda_web/types/revision_arn.py +5 -0
  128. capo_lambda_web/types/revision_config.py +69 -0
  129. capo_lambda_web/types/revision_error.py +40 -0
  130. capo_lambda_web/types/revision_errors.py +29 -0
  131. capo_lambda_web/types/revision_id.py +5 -0
  132. capo_lambda_web/types/revision_state.py +19 -0
  133. capo_lambda_web/types/revision_weight.py +38 -0
  134. capo_lambda_web/types/revision_weight_list.py +31 -0
  135. capo_lambda_web/types/role_arn.py +5 -0
  136. capo_lambda_web/types/runtime_config.py +26 -0
  137. capo_lambda_web/types/s3_object.py +39 -0
  138. capo_lambda_web/types/scaling_config.py +23 -0
  139. capo_lambda_web/types/service_config.py +87 -0
  140. capo_lambda_web/types/system_log_level.py +19 -0
  141. capo_lambda_web/types/tag_key.py +5 -0
  142. capo_lambda_web/types/tag_key_list.py +17 -0
  143. capo_lambda_web/types/tag_resource_request.py +38 -0
  144. capo_lambda_web/types/tags.py +25 -0
  145. capo_lambda_web/types/telemetry_config.py +36 -0
  146. capo_lambda_web/types/throttle_config.py +23 -0
  147. capo_lambda_web/types/untag_resource_request.py +27 -0
  148. capo_lambda_web/types/update_web_function_endpoint_request.py +121 -0
  149. capo_lambda_web/types/update_web_function_endpoint_response.py +291 -0
  150. capo_lambda_web-0.2.0.dist-info/METADATA +104 -0
  151. capo_lambda_web-0.2.0.dist-info/RECORD +154 -0
  152. capo_lambda_web-0.2.0.dist-info/WHEEL +5 -0
  153. capo_lambda_web-0.2.0.dist-info/licenses/LICENSE +21 -0
  154. capo_lambda_web-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,121 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any, Generic, TypeVar
5
+
6
+ from zapros import Request
7
+
8
+ from capo_lambda_web._auth._identity import Credentials, Identity
9
+ from capo_lambda_web._auth._providers import IdentityProvider
10
+ from capo_lambda_web._auth._sigv4 import S3_SIGNING_NAMES, SigV4AuthContext, sign_sigv4
11
+
12
+ IdentityT = TypeVar("IdentityT", bound="Identity")
13
+
14
+
15
+ class Signer(ABC, Generic[IdentityT]):
16
+ """Per-request request signer. Holds an IdentityProvider plus static config."""
17
+
18
+ def __init__(self, provider: IdentityProvider[IdentityT]) -> None:
19
+ self.provider = provider
20
+
21
+ @abstractmethod
22
+ async def asign(self, req: Request) -> Request: ...
23
+ @abstractmethod
24
+ def sign(self, req: Request) -> Request: ...
25
+
26
+
27
+ class SigV4Signer(Signer[Credentials]):
28
+ """aws.auth#sigv4 — AWS Signature Version 4.
29
+
30
+ The full auth scheme (``name`` variant, ``signingName``, ``signingRegion``,
31
+ encoding/normalization flags) is provided by the caller — either from the
32
+ endpoint rule-set's ``authSchemes`` property or built by the generated
33
+ ``get_signer`` from operation defaults. ``unsigned_payload`` mirrors the
34
+ operation's ``aws.auth#unsignedPayload`` trait: the body is sent but left
35
+ out of the signature. ``event_stream`` marks a request event stream: the
36
+ request is signed with the ``STREAMING-AWS4-HMAC-SHA256-EVENTS`` payload
37
+ marker and every event with a signature chained from it.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ provider: IdentityProvider[Credentials],
43
+ *,
44
+ auth_scheme: dict[str, Any],
45
+ unsigned_payload: bool = False,
46
+ event_stream: bool = False,
47
+ ) -> None:
48
+ super().__init__(provider)
49
+ self._auth_scheme = auth_scheme
50
+ self._unsigned_payload = unsigned_payload
51
+ self._event_stream = event_stream
52
+
53
+ async def asign(self, req: Request) -> Request:
54
+ creds = await self.provider.aresolve_identity()
55
+ ctx: SigV4AuthContext = {
56
+ "type": "sig_v4",
57
+ "access_key_id": creds["access_key"],
58
+ "secret_access_key": creds["secret_key"],
59
+ "session_token": creds.get("session_token"),
60
+ "signing_region": self._auth_scheme["signingRegion"],
61
+ "signing_name": self._auth_scheme["signingName"],
62
+ "disable_double_encoding": self._auth_scheme.get(
63
+ "disableDoubleEncoding", False
64
+ ),
65
+ "disable_normalize_path": self._auth_scheme.get(
66
+ "disableNormalizePath", False
67
+ ),
68
+ }
69
+ unsigned_payload = self._unsigned_payload
70
+ if (
71
+ not unsigned_payload
72
+ and not self._event_stream
73
+ and req.body is not None
74
+ and not isinstance(req.body, bytes)
75
+ ):
76
+ if self._auth_scheme["signingName"] in S3_SIGNING_NAMES:
77
+ # S3 accepts UNSIGNED-PAYLOAD for any operation; streamed bodies rely on it.
78
+ unsigned_payload = True
79
+ else:
80
+ raise NotImplementedError(
81
+ "Currently we don't support signed chunked payloads, so buffer the body and "
82
+ "pass bytes as a workaround; chunked signed implementation coming soon"
83
+ )
84
+ return sign_sigv4(
85
+ req, ctx, unsigned_payload=unsigned_payload, event_stream=self._event_stream
86
+ )
87
+
88
+ def sign(self, req: Request) -> Request:
89
+ creds = self.provider.resolve_identity()
90
+ ctx: SigV4AuthContext = {
91
+ "type": "sig_v4",
92
+ "access_key_id": creds["access_key"],
93
+ "secret_access_key": creds["secret_key"],
94
+ "session_token": creds.get("session_token"),
95
+ "signing_region": self._auth_scheme["signingRegion"],
96
+ "signing_name": self._auth_scheme["signingName"],
97
+ "disable_double_encoding": self._auth_scheme.get(
98
+ "disableDoubleEncoding", False
99
+ ),
100
+ "disable_normalize_path": self._auth_scheme.get(
101
+ "disableNormalizePath", False
102
+ ),
103
+ }
104
+ unsigned_payload = self._unsigned_payload
105
+ if (
106
+ not unsigned_payload
107
+ and not self._event_stream
108
+ and req.body is not None
109
+ and not isinstance(req.body, bytes)
110
+ ):
111
+ if self._auth_scheme["signingName"] in S3_SIGNING_NAMES:
112
+ # S3 accepts UNSIGNED-PAYLOAD for any operation; streamed bodies rely on it.
113
+ unsigned_payload = True
114
+ else:
115
+ raise NotImplementedError(
116
+ "Currently we don't support signed chunked payloads, so buffer the body and "
117
+ "pass bytes as a workaround; chunked signed implementation coming soon"
118
+ )
119
+ return sign_sigv4(
120
+ req, ctx, unsigned_payload=unsigned_payload, event_stream=self._event_stream
121
+ )
@@ -0,0 +1,579 @@
1
+ """AWS Signature Version 4 — single-chunk and event-stream signing.
2
+
3
+ Reference:
4
+ https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_sigv-create-signed-request.html
5
+
6
+ Verified byte-for-byte against ``botocore.auth.S3SigV4Auth`` / ``SigV4Auth``
7
+ across S3 GET/PUT/POST, query-string, session-token, and non-S3 (IAM)
8
+ canonicalization paths.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import datetime as _dt
14
+ import functools
15
+ import hashlib
16
+ import hmac
17
+ import re
18
+ from collections.abc import AsyncIterator, Iterator
19
+ from typing import Any, Literal, TypedDict, cast
20
+ from urllib.parse import quote, unquote
21
+
22
+ import zapros
23
+ from pywhatwgurl import URLSearchParams
24
+ from zapros import Headers, Request
25
+ from zapros._utils import get_host_header_value
26
+
27
+ from .._iter import AnyIterator
28
+ from .._protocol.eventstream import HeaderValue, Message, encode_headers
29
+
30
+
31
+ def build_sigv4_auth_scheme(
32
+ signing_name: str, region: str | None, endpoint_scheme: dict[str, Any] | None = None
33
+ ) -> dict[str, Any] | None:
34
+ """Return the sigv4 auth scheme for ``signing_name``/``region``, with the
35
+ endpoint rule set's matching ``authSchemes`` entry overlaid on top.
36
+
37
+ The rule set only *modifies* signing properties of the resolved scheme
38
+ (Smithy rules engine, ``authSchemes``): keys it carries win, keys it omits
39
+ keep the operation defaults — IAM's global endpoint names only
40
+ ``signingRegion``, for instance. Returns None when no signing region is
41
+ known from either source.
42
+ """
43
+ scheme: dict[str, Any] = {
44
+ "name": "sigv4",
45
+ "signingName": signing_name,
46
+ "signingRegion": region,
47
+ "disableDoubleEncoding": False,
48
+ "disableNormalizePath": False,
49
+ }
50
+ if endpoint_scheme:
51
+ scheme.update(endpoint_scheme)
52
+ if scheme.get("signingRegion") is None:
53
+ return None
54
+ return scheme
55
+
56
+
57
+ class SigV4AuthContext(TypedDict):
58
+ type: Literal["sig_v4"]
59
+ access_key_id: str
60
+ secret_access_key: str
61
+ session_token: str | None
62
+ signing_region: str
63
+ signing_name: str
64
+ # Rules-engine sigv4 auth-scheme flags (False = standard SigV4: normalize
65
+ # dot segments, double-encode the path). S3-family endpoint rulesets set
66
+ # ``disableDoubleEncoding`` so the path is signed exactly as sent.
67
+ disable_double_encoding: bool
68
+ disable_normalize_path: bool
69
+
70
+
71
+ _SIGV4_ALGORITHM = "AWS4-HMAC-SHA256"
72
+ # Per-event algorithm and request payload marker of a signed event stream.
73
+ _EVENT_ALGORITHM = "AWS4-HMAC-SHA256-PAYLOAD"
74
+ _EVENT_STREAM_PAYLOAD = "STREAMING-AWS4-HMAC-SHA256-EVENTS"
75
+ _AMZ_DATE_FORMAT = "%Y%m%dT%H%M%SZ"
76
+ _EMPTY_PAYLOAD_SHA256 = hashlib.sha256(b"").hexdigest()
77
+
78
+ # Headers excluded from the signed-headers set. Mirrors botocore's denylist:
79
+ # these are hop-by-hop / mutable in transit, so signing them would break
80
+ # proxies or duplicate values already added by the transport layer.
81
+ _UNSIGNED_HEADERS = frozenset(
82
+ {
83
+ "authorization",
84
+ "cache-control",
85
+ "connection",
86
+ "expect",
87
+ "from",
88
+ "keep-alive",
89
+ "max-forwards",
90
+ "pragma",
91
+ "referer",
92
+ "te",
93
+ "trailer",
94
+ "transfer-encoding",
95
+ "upgrade",
96
+ "user-agent",
97
+ "x-amzn-trace-id",
98
+ "content-length",
99
+ "accept",
100
+ "accept-encoding",
101
+ }
102
+ )
103
+
104
+ _MULTI_SPACE = re.compile(r" +")
105
+
106
+ # Services that require the payload hash to travel in ``x-amz-content-sha256``
107
+ # on every request, and that accept ``UNSIGNED-PAYLOAD`` for any operation.
108
+ # Other services sign the hash into the canonical request without sending it,
109
+ # and only see the header when the payload is left unsigned.
110
+ S3_SIGNING_NAMES = frozenset({"s3", "s3express", "s3-outposts", "s3-object-lambda"})
111
+
112
+
113
+ def _uri_encode(value: str) -> str:
114
+ """RFC 3986 percent-encoding using only the unreserved set as safe."""
115
+ return quote(value, safe="-_.~")
116
+
117
+
118
+ def _remove_dot_segments(path: str) -> str:
119
+ """RFC 3986 §5.2.4 dot-segment removal (mirrors botocore's normalize_url_path)."""
120
+ segments: list[str] = []
121
+ for seg in path.split("/"):
122
+ if not seg or seg == ".":
123
+ continue
124
+ if seg == "..":
125
+ if segments:
126
+ segments.pop()
127
+ else:
128
+ segments.append(seg)
129
+ first = "/" if path.startswith("/") else ""
130
+ last = "/" if path.endswith("/") and segments else ""
131
+ return first + "/".join(segments) + last
132
+
133
+
134
+ def _canonical_path(path: str, *, double_encode: bool, normalize: bool) -> str:
135
+ """Build CanonicalURI.
136
+
137
+ Per the SigV4 spec, every segment is URI-encoded; standard services
138
+ normalize dot segments and URI-encode each segment **twice**. The
139
+ rules-engine ``disableNormalizePath`` / ``disableDoubleEncoding`` flags
140
+ turn those steps off — S3-family services set both, so the path is
141
+ signed exactly as provided.
142
+ """
143
+ if not path:
144
+ return "/"
145
+ if not path.startswith("/"):
146
+ path = "/" + path
147
+ if normalize:
148
+ path = _remove_dot_segments(path)
149
+ if not double_encode:
150
+ return path
151
+ decoded = unquote(path)
152
+ first = quote(decoded, safe="/~")
153
+ return quote(first, safe="/~")
154
+
155
+
156
+ def _canonical_query(query: str) -> str:
157
+ """Build CanonicalQueryString from a raw query string (with or without ``?``)."""
158
+ if not query:
159
+ return ""
160
+ if query.startswith("?"):
161
+ query = query[1:]
162
+ if not query:
163
+ return ""
164
+ sp = URLSearchParams(query)
165
+ encoded = sorted((_uri_encode(k), _uri_encode(v)) for k, v in sp.entries())
166
+ return "&".join(f"{k}={v}" for k, v in encoded)
167
+
168
+
169
+ def _trim_header_value(value: str) -> str:
170
+ """Trim leading/trailing whitespace and collapse internal whitespace runs.
171
+
172
+ Spec note: the canonical form treats sequential whitespace inside an
173
+ unquoted value as a single space. We do not parse quoted-string syntax;
174
+ the conservative collapse is correct for every header AWS actually signs.
175
+ """
176
+ return _MULTI_SPACE.sub(" ", value.strip())
177
+
178
+
179
+ def _canonical_headers(headers: Headers) -> tuple[str, str]:
180
+ """Return ``(canonical_headers, signed_headers)``."""
181
+ grouped: dict[str, list[str]] = {}
182
+ for name in headers:
183
+ lname = name.lower()
184
+ if lname in _UNSIGNED_HEADERS:
185
+ continue
186
+ grouped[lname] = [_trim_header_value(v) for v in headers.getall(name)]
187
+
188
+ signed = sorted(grouped)
189
+ canonical = "".join(f"{name}:{','.join(grouped[name])}\n" for name in signed)
190
+ return canonical, ";".join(signed)
191
+
192
+
193
+ def _build_canonical_request(
194
+ *,
195
+ method: str,
196
+ path: str,
197
+ query: str,
198
+ headers: Headers,
199
+ payload_hash: str,
200
+ double_encode: bool,
201
+ normalize: bool,
202
+ ) -> tuple[str, str]:
203
+ canonical_uri = _canonical_path(
204
+ path, double_encode=double_encode, normalize=normalize
205
+ )
206
+ canonical_query = _canonical_query(query)
207
+ canonical_headers, signed_headers = _canonical_headers(headers)
208
+ canonical_request = (
209
+ f"{method}\n"
210
+ f"{canonical_uri}\n"
211
+ f"{canonical_query}\n"
212
+ f"{canonical_headers}\n"
213
+ f"{signed_headers}\n"
214
+ f"{payload_hash}"
215
+ )
216
+ return canonical_request, signed_headers
217
+
218
+
219
+ @functools.lru_cache(maxsize=8)
220
+ def _derive_signing_key(secret: str, date: str, region: str, service: str) -> bytes:
221
+ # The key depends only on the UTC date stamp (not the full timestamp),
222
+ # so it is reused for every request in the same day/region/service.
223
+ k_date = hmac.new(
224
+ b"AWS4" + secret.encode("utf-8"), date.encode("ascii"), hashlib.sha256
225
+ ).digest()
226
+ k_region = hmac.new(k_date, region.encode("utf-8"), hashlib.sha256).digest()
227
+ k_service = hmac.new(k_region, service.encode("utf-8"), hashlib.sha256).digest()
228
+ return hmac.new(k_service, b"aws4_request", hashlib.sha256).digest()
229
+
230
+
231
+ def _amz_now() -> _dt.datetime:
232
+ return _dt.datetime.now(_dt.timezone.utc)
233
+
234
+
235
+ def _canonical_query_from_pairs(pairs: list[tuple[str, str]]) -> str:
236
+ """CanonicalQueryString from raw (unencoded) key/value pairs."""
237
+ encoded = sorted((_uri_encode(k), _uri_encode(v)) for k, v in pairs)
238
+ return "&".join(f"{k}={v}" for k, v in encoded)
239
+
240
+
241
+ def sign_sigv4(
242
+ request: Request,
243
+ ctx: SigV4AuthContext,
244
+ *,
245
+ unsigned_payload: bool = False,
246
+ event_stream: bool = False,
247
+ ) -> Request:
248
+ """Return a new ``Request`` carrying SigV4 single-chunk auth headers.
249
+
250
+ The payload hash covers ``request.body``, which must be ``bytes`` or
251
+ ``None``. ``unsigned_payload`` signs ``UNSIGNED-PAYLOAD`` instead and
252
+ forwards any body unchanged (streaming S3 requests and operations carrying
253
+ ``aws.auth#unsignedPayload``). ``event_stream`` signs a request event
254
+ stream: the ``STREAMING-AWS4-HMAC-SHA256-EVENTS`` marker stands in for the
255
+ payload hash and the body — an iterator of encoded event messages — is
256
+ wrapped in an :class:`EventStreamIterator` that signs every event with a
257
+ signature chained from this request's.
258
+ """
259
+ service = ctx["signing_name"]
260
+ region = ctx["signing_region"]
261
+
262
+ headers = request.headers.copy()
263
+
264
+ # X-Amz-Date — honor caller-supplied value (allows deterministic tests).
265
+ existing_date = headers.get("X-Amz-Date")
266
+ if existing_date:
267
+ amz_date = existing_date
268
+ date_stamp = amz_date[:8]
269
+ else:
270
+ now = _amz_now()
271
+ amz_date = now.strftime(_AMZ_DATE_FORMAT)
272
+ date_stamp = now.strftime("%Y%m%d")
273
+ headers["X-Amz-Date"] = amz_date
274
+
275
+ # Payload hash. For S3-family services, x-amz-content-sha256 is mandatory;
276
+ # for every service it is the only way to announce an unsigned payload or
277
+ # an event stream.
278
+ # Either way it must be set BEFORE computing the canonical request (it
279
+ # gets signed).
280
+ payload_hash = headers.get("X-Amz-Content-SHA256")
281
+ if payload_hash is None:
282
+ if event_stream:
283
+ payload_hash = _EVENT_STREAM_PAYLOAD
284
+ elif unsigned_payload:
285
+ payload_hash = "UNSIGNED-PAYLOAD"
286
+ elif request.body is None:
287
+ payload_hash = _EMPTY_PAYLOAD_SHA256
288
+ elif isinstance(request.body, bytes):
289
+ payload_hash = hashlib.sha256(request.body).hexdigest()
290
+ else:
291
+ raise TypeError(
292
+ "sign_sigv4 hashes a bytes body only; pass unsigned_payload=True "
293
+ "to send a streamed body without a payload hash"
294
+ )
295
+ if service in S3_SIGNING_NAMES or unsigned_payload or event_stream:
296
+ headers["X-Amz-Content-SHA256"] = payload_hash
297
+
298
+ # Session token (STS / assumed-role credentials).
299
+ session_token = ctx.get("session_token")
300
+ if session_token:
301
+ headers["X-Amz-Security-Token"] = session_token
302
+
303
+ # Host header is added by Request.__init__ from the URL; defensive fallback.
304
+ if "host" not in headers and request.url.hostname:
305
+ headers["Host"] = get_host_header_value(request.url)
306
+
307
+ canonical_request, signed_headers = _build_canonical_request(
308
+ method=request.method.upper(),
309
+ path=request.url.pathname,
310
+ query=request.url.search,
311
+ headers=headers,
312
+ payload_hash=payload_hash,
313
+ double_encode=not ctx["disable_double_encoding"],
314
+ normalize=not ctx["disable_normalize_path"],
315
+ )
316
+
317
+ credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
318
+ string_to_sign = (
319
+ f"{_SIGV4_ALGORITHM}\n"
320
+ f"{amz_date}\n"
321
+ f"{credential_scope}\n"
322
+ f"{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}"
323
+ )
324
+
325
+ signing_key = _derive_signing_key(
326
+ ctx["secret_access_key"], date_stamp, region, service
327
+ )
328
+ signature = hmac.new(
329
+ signing_key, string_to_sign.encode("utf-8"), hashlib.sha256
330
+ ).hexdigest()
331
+
332
+ headers["Authorization"] = (
333
+ f"{_SIGV4_ALGORITHM} "
334
+ f"Credential={ctx['access_key_id']}/{credential_scope},"
335
+ f"SignedHeaders={signed_headers},"
336
+ f"Signature={signature}"
337
+ )
338
+
339
+ body = request.body
340
+ if event_stream:
341
+ if not isinstance(body, (Iterator, AsyncIterator)):
342
+ raise TypeError(
343
+ "event_stream requires an iterator of encoded events as the body"
344
+ )
345
+ body = EventStreamIterator(
346
+ cast("Iterator[bytes] | AsyncIterator[bytes]", body), ctx, signature
347
+ )
348
+ if body is not None:
349
+ return Request(
350
+ request.url,
351
+ request.method,
352
+ headers,
353
+ body=body,
354
+ context=request.context,
355
+ )
356
+ return Request(request.url, request.method, headers, context=request.context)
357
+
358
+
359
+ def _sign_event(
360
+ ctx: SigV4AuthContext, prior_signature: str, payload: bytes
361
+ ) -> tuple[bytes, str]:
362
+ """Wrap one encoded event in a signed outer message.
363
+
364
+ Returns ``(frame, signature)``; the hex signature seeds the next event.
365
+ The string to sign chains every frame to its predecessor — the first one
366
+ to the request's ``Authorization`` signature — so the service can verify
367
+ the stream incrementally::
368
+
369
+ AWS4-HMAC-SHA256-PAYLOAD
370
+ <date, YYYYMMDDTHHMMSSZ>
371
+ <credential scope>
372
+ <prior signature, hex>
373
+ <hex sha256 of the encoded ``:date`` header>
374
+ <hex sha256 of the payload>
375
+
376
+ The AWS SDKs (and the service) use the ``-PAYLOAD`` algorithm name; the
377
+ Transcribe developer guide's pseudocode omits the suffix.
378
+ """
379
+ service = ctx["signing_name"]
380
+ region = ctx["signing_region"]
381
+ now = _amz_now()
382
+ amz_date = now.strftime(_AMZ_DATE_FORMAT)
383
+ date_stamp = amz_date[:8]
384
+ credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
385
+ date_header: dict[str, HeaderValue] = {":date": now}
386
+ string_to_sign = "\n".join(
387
+ (
388
+ _EVENT_ALGORITHM,
389
+ amz_date,
390
+ credential_scope,
391
+ prior_signature,
392
+ hashlib.sha256(encode_headers(date_header)).hexdigest(),
393
+ hashlib.sha256(payload).hexdigest(),
394
+ )
395
+ )
396
+ signing_key = _derive_signing_key(
397
+ ctx["secret_access_key"], date_stamp, region, service
398
+ )
399
+ signature = hmac.new(
400
+ signing_key, string_to_sign.encode("utf-8"), hashlib.sha256
401
+ ).hexdigest()
402
+ frame = Message(
403
+ {**date_header, ":chunk-signature": bytes.fromhex(signature)}, payload
404
+ ).encode()
405
+ return frame, signature
406
+
407
+
408
+ class EventStreamIterator(AnyIterator[bytes]):
409
+ """Sign a request event stream, one frame per event.
410
+
411
+ Wraps an iterator of encoded event messages (as produced by the generated
412
+ ``serialize_event_*`` functions). Each event becomes the payload of an
413
+ outer message carrying ``:date`` and ``:chunk-signature`` headers, the
414
+ signature chained from the previous frame's and seeded by the request's
415
+ ``Authorization`` signature. Once the events are exhausted, one empty
416
+ signed frame is yielded to mark the end of the stream.
417
+
418
+ Like :class:`~capo._body.Body`, it is both a sync and an async iterator:
419
+ iterate it the way the wrapped source is iterable (``next`` over a sync
420
+ source, ``async for`` over an async one).
421
+
422
+ Reference:
423
+ https://docs.aws.amazon.com/transcribe/latest/dg/streaming-setting-up.html
424
+ """
425
+
426
+ def __init__(
427
+ self,
428
+ events: Iterator[bytes] | AsyncIterator[bytes],
429
+ ctx: SigV4AuthContext,
430
+ seed_signature: str,
431
+ ) -> None:
432
+ self._events = events
433
+ self._ctx = ctx
434
+ self._prior_signature = seed_signature
435
+ self._ended = False
436
+
437
+ def _frame(self, payload: bytes) -> bytes:
438
+ frame, self._prior_signature = _sign_event(
439
+ self._ctx, self._prior_signature, payload
440
+ )
441
+ return frame
442
+
443
+ def __next__(self) -> bytes:
444
+ if self._ended:
445
+ raise StopIteration
446
+ if not isinstance(self._events, Iterator):
447
+ raise TypeError("this event stream wraps an async source; use `async for`")
448
+ try:
449
+ payload = next(cast(Iterator[bytes], self._events))
450
+ except StopIteration:
451
+ self._ended = True
452
+ payload = b""
453
+ return self._frame(payload)
454
+
455
+ async def __anext__(self) -> bytes:
456
+ if self._ended:
457
+ raise StopAsyncIteration
458
+ if not isinstance(self._events, AsyncIterator):
459
+ raise TypeError("this event stream wraps a sync source; use `for`")
460
+ try:
461
+ payload = await cast(AsyncIterator[bytes], self._events).__anext__()
462
+ except StopAsyncIteration:
463
+ self._ended = True
464
+ payload = b""
465
+ return self._frame(payload)
466
+
467
+
468
+ def presign_sigv4(
469
+ request: Request,
470
+ ctx: SigV4AuthContext,
471
+ *,
472
+ expires_in: int = 3600,
473
+ now: _dt.datetime | None = None,
474
+ ) -> Request:
475
+ """Return a new ``Request`` whose URL carries SigV4 query-string auth.
476
+
477
+ The signature travels in the URL (``X-Amz-*`` query params), so the result
478
+ is usable standalone (browser, curl). Payload is signed as
479
+ ``UNSIGNED-PAYLOAD``, so the body is not bound by the signature.
480
+
481
+ ``expires_in`` is the validity window in seconds; range 1..604800 (7 days),
482
+ bounded by the max lifetime of the derived signing key.
483
+ """
484
+ if not 1 <= expires_in <= 604800:
485
+ raise ValueError(f"expires_in must be in [1, 604800], got {expires_in}")
486
+
487
+ sign_time = now or _amz_now()
488
+ if sign_time.tzinfo is None:
489
+ raise ValueError("now must be timezone-aware (UTC)")
490
+
491
+ service = ctx["signing_name"]
492
+ region = ctx["signing_region"]
493
+
494
+ amz_date = sign_time.strftime(_AMZ_DATE_FORMAT)
495
+ date_stamp = sign_time.strftime("%Y%m%d")
496
+ credential_scope = f"{date_stamp}/{region}/{service}/aws4_request"
497
+
498
+ # Canonical headers: host is mandatory; anything else already on the request
499
+ # is signed too (and must then be sent alongside the URL). Strip the headers
500
+ # that belong in the query string to avoid header/query value conflicts
501
+ # (which AWS rejects as InvalidRequest).
502
+ headers = request.headers.copy()
503
+ for h in (
504
+ "Authorization",
505
+ "X-Amz-Date",
506
+ "X-Amz-Content-SHA256",
507
+ "X-Amz-Security-Token",
508
+ ):
509
+ if h in headers:
510
+ del headers[h]
511
+ if "host" not in headers and request.url.hostname:
512
+ headers["Host"] = get_host_header_value(request.url)
513
+
514
+ canonical_headers, signed_headers = _canonical_headers(headers)
515
+
516
+ # Signed query params (raw values). X-Amz-Signature is appended afterwards.
517
+ amz_params: list[tuple[str, str]] = [
518
+ ("X-Amz-Algorithm", _SIGV4_ALGORITHM),
519
+ ("X-Amz-Credential", f"{ctx['access_key_id']}/{credential_scope}"),
520
+ ("X-Amz-Date", amz_date),
521
+ ("X-Amz-Expires", str(expires_in)),
522
+ ("X-Amz-SignedHeaders", signed_headers),
523
+ ]
524
+ session_token = ctx.get("session_token")
525
+ if session_token:
526
+ # S3 and most services require the token inside the canonical query.
527
+ # (A few — e.g. iotdevicegateway — want it appended post-signature
528
+ # instead; handle those as a special case if you ever target them.)
529
+ amz_params.append(("X-Amz-Security-Token", session_token))
530
+
531
+ existing = list(URLSearchParams(request.url.search).entries())
532
+ canonical_query = _canonical_query_from_pairs(existing + amz_params)
533
+
534
+ canonical_uri = _canonical_path(
535
+ request.url.pathname,
536
+ double_encode=not ctx["disable_double_encoding"],
537
+ normalize=not ctx["disable_normalize_path"],
538
+ )
539
+ canonical_request = (
540
+ f"{request.method.upper()}\n"
541
+ f"{canonical_uri}\n"
542
+ f"{canonical_query}\n"
543
+ f"{canonical_headers}\n"
544
+ f"{signed_headers}\n"
545
+ f"UNSIGNED-PAYLOAD"
546
+ )
547
+
548
+ string_to_sign = (
549
+ f"{_SIGV4_ALGORITHM}\n"
550
+ f"{amz_date}\n"
551
+ f"{credential_scope}\n"
552
+ f"{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}"
553
+ )
554
+ signing_key = _derive_signing_key(
555
+ ctx["secret_access_key"], date_stamp, region, service
556
+ )
557
+ signature = hmac.new(
558
+ signing_key, string_to_sign.encode("utf-8"), hashlib.sha256
559
+ ).hexdigest()
560
+
561
+ # X-Amz-Signature is hex (no encoding needed) and is NOT part of the
562
+ # canonical query. The sorted canonical query doubles as the URL query.
563
+ final_query = f"{canonical_query}&X-Amz-Signature={signature}"
564
+
565
+ url = request.url
566
+ fragment = url.hash or ""
567
+ presigned_href = zapros.URL(
568
+ f"{url.protocol}//{url.host}{url.pathname}?{final_query}{fragment}"
569
+ )
570
+
571
+ if request.body is not None:
572
+ return Request(
573
+ presigned_href,
574
+ request.method,
575
+ headers,
576
+ body=request.body,
577
+ context=request.context,
578
+ )
579
+ return Request(presigned_href, request.method, headers, context=request.context)