leblob 0.4.0.dev1__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.
- leblob/__init__.py +1 -0
- leblob/asgi.py +376 -0
- leblob/blobstore.py +594 -0
- leblob/cli.py +499 -0
- leblob/handlers.py +1119 -0
- leblob/maintenance.py +444 -0
- leblob/meta.py +2327 -0
- leblob/metrics.py +226 -0
- leblob/model.py +339 -0
- leblob/s3/__init__.py +1 -0
- leblob/s3/access.py +110 -0
- leblob/s3/chunked.py +175 -0
- leblob/s3/conditions.py +156 -0
- leblob/s3/cors.py +80 -0
- leblob/s3/errors.py +144 -0
- leblob/s3/lifecycle.py +119 -0
- leblob/s3/listing.py +196 -0
- leblob/s3/multipart.py +184 -0
- leblob/s3/payload.py +328 -0
- leblob/s3/post_form.py +344 -0
- leblob/s3/routing.py +443 -0
- leblob/s3/sigv4.py +333 -0
- leblob/s3/sse.py +229 -0
- leblob/s3/tagging.py +48 -0
- leblob/s3/xml.py +436 -0
- leblob/s3/xml_base.py +64 -0
- leblob/s3/xml_config.py +431 -0
- leblob/streams.py +179 -0
- leblob-0.4.0.dev1.dist-info/METADATA +109 -0
- leblob-0.4.0.dev1.dist-info/RECORD +33 -0
- leblob-0.4.0.dev1.dist-info/WHEEL +4 -0
- leblob-0.4.0.dev1.dist-info/entry_points.txt +3 -0
- leblob-0.4.0.dev1.dist-info/licenses/LICENSE +202 -0
leblob/s3/sigv4.py
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
"""AWS Signature Version 4: parsing and verifying signed requests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
from urllib.parse import quote, unquote_to_bytes
|
|
12
|
+
|
|
13
|
+
from leblob.s3 import errors
|
|
14
|
+
from leblob.s3.errors import S3Error
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
|
|
19
|
+
from leblob.model import HttpRequest
|
|
20
|
+
|
|
21
|
+
ALGORITHM = "AWS4-HMAC-SHA256"
|
|
22
|
+
UNSIGNED_PAYLOAD = "UNSIGNED-PAYLOAD"
|
|
23
|
+
EMPTY_SHA256 = hashlib.sha256(b"").hexdigest()
|
|
24
|
+
|
|
25
|
+
MAX_CLOCK_SKEW_SECONDS = 15 * 60
|
|
26
|
+
MAX_PRESIGN_SECONDS = 7 * 24 * 3600
|
|
27
|
+
|
|
28
|
+
_AMZ_DATE_FORMAT = "%Y%m%dT%H%M%SZ"
|
|
29
|
+
_AUTHORIZATION = re.compile(
|
|
30
|
+
rf"{ALGORITHM} +Credential=([^,\s]+), *SignedHeaders=([^,\s]+), *"
|
|
31
|
+
r"Signature=([0-9a-f]{64})"
|
|
32
|
+
)
|
|
33
|
+
_SIGNATURE = re.compile(r"[0-9a-f]{64}")
|
|
34
|
+
_CREDENTIAL_PARTS = 5
|
|
35
|
+
_PRESIGNED_PARAMETERS = frozenset({
|
|
36
|
+
"X-Amz-Algorithm",
|
|
37
|
+
"X-Amz-Credential",
|
|
38
|
+
"X-Amz-Date",
|
|
39
|
+
"X-Amz-Expires",
|
|
40
|
+
"X-Amz-Signature",
|
|
41
|
+
"X-Amz-SignedHeaders",
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True)
|
|
46
|
+
class Credential:
|
|
47
|
+
access_key: str
|
|
48
|
+
date: str
|
|
49
|
+
region: str
|
|
50
|
+
service: str
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def scope(self) -> str:
|
|
54
|
+
return f"{self.date}/{self.region}/{self.service}/aws4_request"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True)
|
|
58
|
+
class Claim:
|
|
59
|
+
"""What a request says about its own signature; nothing here is verified yet."""
|
|
60
|
+
|
|
61
|
+
credential: Credential
|
|
62
|
+
signed_headers: tuple[str, ...]
|
|
63
|
+
signature: str
|
|
64
|
+
amz_date: str
|
|
65
|
+
expires: int | None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class StreamSeed:
|
|
70
|
+
"""A verified signature's state, from which chunk signatures are chained."""
|
|
71
|
+
|
|
72
|
+
signing_key: bytes
|
|
73
|
+
scope: str
|
|
74
|
+
amz_date: str
|
|
75
|
+
signature: str
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_anonymous(request: HttpRequest) -> bool:
|
|
79
|
+
"""Whether a request claims no signature at all. A wrong one isn't anonymous."""
|
|
80
|
+
return (
|
|
81
|
+
"X-Amz-Algorithm" not in request.query
|
|
82
|
+
and "authorization" not in request.headers
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def post_form_access_key(fields: Mapping[str, str]) -> str:
|
|
87
|
+
"""Name the key a browser form claims, if signed in a way this server reads."""
|
|
88
|
+
if "awsaccesskeyid" in fields:
|
|
89
|
+
raise S3Error(errors.NOT_IMPLEMENTED, "Forms must be signed with SigV4.")
|
|
90
|
+
if "policy" not in fields:
|
|
91
|
+
raise S3Error(errors.ACCESS_DENIED, "Anonymous uploads are not allowed.")
|
|
92
|
+
if "x-amz-credential" not in fields or "x-amz-signature" not in fields:
|
|
93
|
+
raise S3Error(errors.INVALID_ARGUMENT, "A form with a policy must be signed.")
|
|
94
|
+
credential = _parse_credential(fields["x-amz-credential"], errors.INVALID_ARGUMENT)
|
|
95
|
+
return credential.access_key
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def verify_post_form(fields: Mapping[str, str], secret: str) -> None:
|
|
99
|
+
"""Check a form's signature, which covers only its policy, as encoded."""
|
|
100
|
+
credential = _parse_credential(fields["x-amz-credential"], errors.INVALID_ARGUMENT)
|
|
101
|
+
if fields.get("x-amz-algorithm") != ALGORITHM:
|
|
102
|
+
raise S3Error(errors.INVALID_ARGUMENT, "Forms must be signed with SigV4.")
|
|
103
|
+
amz_date = fields.get("x-amz-date", "")
|
|
104
|
+
try:
|
|
105
|
+
datetime.strptime(amz_date, _AMZ_DATE_FORMAT).replace(tzinfo=UTC)
|
|
106
|
+
except ValueError:
|
|
107
|
+
raise S3Error(errors.INVALID_ARGUMENT, "x-amz-date isn't a date.") from None
|
|
108
|
+
if not amz_date.startswith(credential.date):
|
|
109
|
+
raise S3Error(errors.SIGNATURE_MISMATCH, "Credential date is not form date.")
|
|
110
|
+
signature = fields.get("x-amz-signature", "")
|
|
111
|
+
if not is_signature(signature):
|
|
112
|
+
raise S3Error(errors.ACCESS_DENIED, "The form isn't signed.")
|
|
113
|
+
key = derive_signing_key(secret, credential)
|
|
114
|
+
expected = _hmac(key, fields["policy"]).hex()
|
|
115
|
+
if not hmac.compare_digest(expected, signature):
|
|
116
|
+
raise S3Error(errors.SIGNATURE_MISMATCH)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def parse_claim(request: HttpRequest) -> Claim:
|
|
120
|
+
"""Extract the claimed signature from the headers or a presigned URL."""
|
|
121
|
+
if "X-Amz-Algorithm" in request.query:
|
|
122
|
+
return _parse_presigned(request.query)
|
|
123
|
+
authorization = request.headers.get("authorization")
|
|
124
|
+
if authorization is None:
|
|
125
|
+
raise S3Error(errors.ACCESS_DENIED, "Anonymous access is not allowed.")
|
|
126
|
+
return _parse_authorization(authorization, request.headers)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def is_signature(text: str) -> bool:
|
|
130
|
+
"""Check the form of a signature, which `compare_digest` requires to be ASCII."""
|
|
131
|
+
return _SIGNATURE.fullmatch(text) is not None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def verify(request: HttpRequest, claim: Claim, secret: str, now: float) -> StreamSeed:
|
|
135
|
+
"""Check a claim against the secret key, or raise the matching S3 error."""
|
|
136
|
+
_check_time(claim, now)
|
|
137
|
+
_check_signed_headers(request.headers, claim.signed_headers)
|
|
138
|
+
|
|
139
|
+
signing_key = derive_signing_key(secret, claim.credential)
|
|
140
|
+
string_to_sign = "\n".join((
|
|
141
|
+
ALGORITHM,
|
|
142
|
+
claim.amz_date,
|
|
143
|
+
claim.credential.scope,
|
|
144
|
+
# Latin-1 restores the header bytes exactly as sent. Everything else in
|
|
145
|
+
# the canonical request is percent-encoded, so it is plain ASCII.
|
|
146
|
+
_sha256_hex(canonical_request(request, claim).encode("latin-1")),
|
|
147
|
+
))
|
|
148
|
+
expected = _hmac(signing_key, string_to_sign).hex()
|
|
149
|
+
if not hmac.compare_digest(expected, claim.signature):
|
|
150
|
+
raise S3Error(errors.SIGNATURE_MISMATCH)
|
|
151
|
+
return StreamSeed(signing_key, claim.credential.scope, claim.amz_date, expected)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def canonical_request(request: HttpRequest, claim: Claim) -> str:
|
|
155
|
+
return "\n".join((
|
|
156
|
+
request.method,
|
|
157
|
+
canonical_uri(request.raw_path),
|
|
158
|
+
canonical_query(request.raw_query),
|
|
159
|
+
"".join(
|
|
160
|
+
f"{name}:{_normalize_value(request.headers.get(name, ''))}\n"
|
|
161
|
+
for name in claim.signed_headers
|
|
162
|
+
),
|
|
163
|
+
";".join(claim.signed_headers),
|
|
164
|
+
request.headers.get("x-amz-content-sha256", UNSIGNED_PAYLOAD),
|
|
165
|
+
))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def canonical_uri(raw_path: bytes) -> str:
|
|
169
|
+
"""Encode each path segment the way AWS specifies, keeping slashes literal."""
|
|
170
|
+
return "/".join(
|
|
171
|
+
quote(unquote_to_bytes(segment), safe="-_.~")
|
|
172
|
+
for segment in raw_path.split(b"/")
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def canonical_query(raw_query: bytes) -> str:
|
|
177
|
+
"""Sort and normalize the query string, without the signature itself."""
|
|
178
|
+
pairs = []
|
|
179
|
+
for item in raw_query.split(b"&"):
|
|
180
|
+
if not item:
|
|
181
|
+
continue
|
|
182
|
+
name, _, value = item.partition(b"=")
|
|
183
|
+
encoded_name = quote(unquote_to_bytes(name), safe="-_.~")
|
|
184
|
+
if encoded_name != "X-Amz-Signature":
|
|
185
|
+
pairs.append((encoded_name, quote(unquote_to_bytes(value), safe="-_.~")))
|
|
186
|
+
return "&".join(f"{name}={value}" for name, value in sorted(pairs))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def derive_signing_key(secret: str, credential: Credential) -> bytes:
|
|
190
|
+
key = _hmac(f"AWS4{secret}".encode(), credential.date)
|
|
191
|
+
key = _hmac(key, credential.region)
|
|
192
|
+
key = _hmac(key, credential.service)
|
|
193
|
+
return _hmac(key, "aws4_request")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def chunk_signature(seed: StreamSeed, previous: str, chunk_sha256: str) -> str:
|
|
197
|
+
"""Signature of one aws-chunked data chunk, chained from the previous one."""
|
|
198
|
+
return _hmac(
|
|
199
|
+
seed.signing_key,
|
|
200
|
+
"\n".join((
|
|
201
|
+
f"{ALGORITHM}-PAYLOAD",
|
|
202
|
+
seed.amz_date,
|
|
203
|
+
seed.scope,
|
|
204
|
+
previous,
|
|
205
|
+
EMPTY_SHA256,
|
|
206
|
+
chunk_sha256,
|
|
207
|
+
)),
|
|
208
|
+
).hex()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def trailer_signature(seed: StreamSeed, previous: str, trailer: bytes) -> str:
|
|
212
|
+
"""Signature over the canonical trailing headers of a chunked upload."""
|
|
213
|
+
return _hmac(
|
|
214
|
+
seed.signing_key,
|
|
215
|
+
"\n".join((
|
|
216
|
+
f"{ALGORITHM}-TRAILER",
|
|
217
|
+
seed.amz_date,
|
|
218
|
+
seed.scope,
|
|
219
|
+
previous,
|
|
220
|
+
_sha256_hex(trailer),
|
|
221
|
+
)),
|
|
222
|
+
).hex()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _parse_authorization(authorization: str, headers: Mapping[str, str]) -> Claim:
|
|
226
|
+
match = _AUTHORIZATION.fullmatch(authorization.strip())
|
|
227
|
+
if match is None:
|
|
228
|
+
raise S3Error(
|
|
229
|
+
errors.AUTH_HEADER_MALFORMED,
|
|
230
|
+
f"Only {ALGORITHM} authorization is supported, in its standard form.",
|
|
231
|
+
)
|
|
232
|
+
amz_date = headers.get("x-amz-date")
|
|
233
|
+
if amz_date is None:
|
|
234
|
+
raise S3Error(errors.ACCESS_DENIED, "The x-amz-date header is required.")
|
|
235
|
+
credential_text, signed_headers, signature = match.groups()
|
|
236
|
+
credential = _parse_credential(credential_text, errors.AUTH_HEADER_MALFORMED)
|
|
237
|
+
if "x-amz-content-sha256" not in headers:
|
|
238
|
+
# Without it the signature would say nothing about the body we go on to store.
|
|
239
|
+
raise S3Error(
|
|
240
|
+
errors.INVALID_REQUEST, "The x-amz-content-sha256 header is required."
|
|
241
|
+
)
|
|
242
|
+
return Claim(
|
|
243
|
+
credential,
|
|
244
|
+
tuple(signed_headers.split(";")),
|
|
245
|
+
signature,
|
|
246
|
+
amz_date,
|
|
247
|
+
None,
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _parse_presigned(query: Mapping[str, str]) -> Claim:
|
|
252
|
+
if not query.keys() >= _PRESIGNED_PARAMETERS:
|
|
253
|
+
raise S3Error(errors.AUTH_QUERY_ERROR)
|
|
254
|
+
expires = query["X-Amz-Expires"]
|
|
255
|
+
if query["X-Amz-Algorithm"] != ALGORITHM or not is_signature(
|
|
256
|
+
query["X-Amz-Signature"]
|
|
257
|
+
):
|
|
258
|
+
raise S3Error(errors.AUTH_QUERY_ERROR)
|
|
259
|
+
if not (expires.isdecimal() and 1 <= int(expires) <= MAX_PRESIGN_SECONDS):
|
|
260
|
+
raise S3Error(errors.ACCESS_DENIED, "X-Amz-Expires is out of range.")
|
|
261
|
+
return Claim(
|
|
262
|
+
_parse_credential(query["X-Amz-Credential"], errors.AUTH_QUERY_ERROR),
|
|
263
|
+
tuple(query["X-Amz-SignedHeaders"].split(";")),
|
|
264
|
+
query["X-Amz-Signature"],
|
|
265
|
+
query["X-Amz-Date"],
|
|
266
|
+
int(expires),
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _parse_credential(text: str, malformed: errors.ErrorCode) -> Credential:
|
|
271
|
+
parts = text.split("/")
|
|
272
|
+
if (
|
|
273
|
+
len(parts) != _CREDENTIAL_PARTS
|
|
274
|
+
or parts[3] != "s3"
|
|
275
|
+
or parts[4] != "aws4_request"
|
|
276
|
+
):
|
|
277
|
+
raise S3Error(malformed, f"Malformed credential scope: {text!r}.")
|
|
278
|
+
return Credential(*parts[:4])
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _check_time(claim: Claim, now: float) -> None:
|
|
282
|
+
try:
|
|
283
|
+
signed_at = (
|
|
284
|
+
datetime
|
|
285
|
+
.strptime(claim.amz_date, _AMZ_DATE_FORMAT)
|
|
286
|
+
.replace(tzinfo=UTC)
|
|
287
|
+
.timestamp()
|
|
288
|
+
)
|
|
289
|
+
except ValueError:
|
|
290
|
+
raise S3Error(
|
|
291
|
+
errors.ACCESS_DENIED, f"Invalid request date: {claim.amz_date!r}."
|
|
292
|
+
) from None
|
|
293
|
+
if not claim.amz_date.startswith(claim.credential.date):
|
|
294
|
+
raise S3Error(errors.SIGNATURE_MISMATCH, "Credential date is not request date.")
|
|
295
|
+
|
|
296
|
+
if signed_at - now > MAX_CLOCK_SKEW_SECONDS:
|
|
297
|
+
raise S3Error(errors.TIME_TOO_SKEWED)
|
|
298
|
+
if claim.expires is None:
|
|
299
|
+
if now - signed_at > MAX_CLOCK_SKEW_SECONDS:
|
|
300
|
+
raise S3Error(errors.TIME_TOO_SKEWED)
|
|
301
|
+
elif now - signed_at > claim.expires:
|
|
302
|
+
raise S3Error(errors.ACCESS_DENIED, "Request has expired.")
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _check_signed_headers(
|
|
306
|
+
headers: Mapping[str, str], signed_headers: tuple[str, ...]
|
|
307
|
+
) -> None:
|
|
308
|
+
"""Refuse requests whose security-relevant headers escape the signature.
|
|
309
|
+
|
|
310
|
+
Otherwise anyone holding a presigned PUT URL could add `x-amz-copy-source`
|
|
311
|
+
and turn the upload into a read of any object.
|
|
312
|
+
"""
|
|
313
|
+
if "host" not in signed_headers:
|
|
314
|
+
raise S3Error(errors.ACCESS_DENIED, "The Host header must be signed.")
|
|
315
|
+
unsigned = sorted(
|
|
316
|
+
name
|
|
317
|
+
for name in headers
|
|
318
|
+
if name.startswith("x-amz-") and name not in signed_headers
|
|
319
|
+
)
|
|
320
|
+
if unsigned:
|
|
321
|
+
raise S3Error(errors.ACCESS_DENIED, f"Header {unsigned[0]} must be signed.")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _normalize_value(value: str) -> str:
|
|
325
|
+
return " ".join(value.split())
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _sha256_hex(data: bytes) -> str:
|
|
329
|
+
return hashlib.sha256(data).hexdigest()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _hmac(key: bytes, message: str) -> bytes:
|
|
333
|
+
return hmac.digest(key, message.encode(), "sha256")
|
leblob/s3/sse.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Server-side encryption with a key the client sends each time (SSE-C).
|
|
2
|
+
|
|
3
|
+
An encrypted blob is a random salt, then chunks of AES-256-GCM. Each blob has its own
|
|
4
|
+
key, derived from the client's and the salt, so a chunk's number can serve as its nonce.
|
|
5
|
+
A chunk's number and whether it is the last are authenticated, so chunks can't be moved
|
|
6
|
+
or dropped. Reading a range needs only the chunks it touches.
|
|
7
|
+
|
|
8
|
+
The stored bytes are just bytes to everything below this: scrub and repair check them
|
|
9
|
+
against the MD5 of what was stored. The client's key is never stored, only a value that
|
|
10
|
+
shows whether a later request has sent the same one.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import hashlib
|
|
17
|
+
import hmac
|
|
18
|
+
import secrets
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
from cryptography.exceptions import InvalidTag
|
|
23
|
+
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
24
|
+
|
|
25
|
+
from leblob.s3 import errors
|
|
26
|
+
from leblob.s3.errors import S3Error
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from collections.abc import Mapping
|
|
30
|
+
|
|
31
|
+
ALGORITHM = "AES256"
|
|
32
|
+
HEADER_PREFIX = "x-amz-server-side-encryption-customer-"
|
|
33
|
+
COPY_SOURCE_PREFIX = "x-amz-copy-source-server-side-encryption-customer-"
|
|
34
|
+
CHUNK_BYTES = 64 * 1024
|
|
35
|
+
SALT_BYTES = 16
|
|
36
|
+
_TAG_BYTES = 16
|
|
37
|
+
_KEY_BYTES = 32
|
|
38
|
+
_NONCE_BYTES = 12
|
|
39
|
+
_CHECK_LABEL = b"leblob sse-c key check"
|
|
40
|
+
_LAST = b"\x01"
|
|
41
|
+
_NOT_LAST = b"\x00"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True, repr=False)
|
|
45
|
+
class CustomerKey:
|
|
46
|
+
"""A key a client sent. Not printed, so it can't end up in a log by accident."""
|
|
47
|
+
|
|
48
|
+
key: bytes
|
|
49
|
+
key_md5: str
|
|
50
|
+
|
|
51
|
+
def __repr__(self) -> str:
|
|
52
|
+
return "CustomerKey(...)"
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def check(self) -> str:
|
|
56
|
+
"""What is stored, to tell later whether the same key was sent."""
|
|
57
|
+
return hmac.new(self.key, _CHECK_LABEL, hashlib.sha256).hexdigest()
|
|
58
|
+
|
|
59
|
+
def response_headers(self) -> tuple[tuple[str, str], ...]:
|
|
60
|
+
return (
|
|
61
|
+
(f"{HEADER_PREFIX}algorithm", ALGORITHM),
|
|
62
|
+
(f"{HEADER_PREFIX}key-md5", self.key_md5),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def parse_key(
|
|
67
|
+
headers: Mapping[str, str], prefix: str = HEADER_PREFIX
|
|
68
|
+
) -> CustomerKey | None:
|
|
69
|
+
"""Read a key from the headers, or None if they don't try to give one."""
|
|
70
|
+
algorithm = headers.get(f"{prefix}algorithm")
|
|
71
|
+
encoded = headers.get(f"{prefix}key")
|
|
72
|
+
md5 = headers.get(f"{prefix}key-md5")
|
|
73
|
+
if algorithm is None and encoded is None and md5 is None:
|
|
74
|
+
return None
|
|
75
|
+
if algorithm != ALGORITHM:
|
|
76
|
+
raise S3Error(
|
|
77
|
+
errors.INVALID_ARGUMENT, "The encryption algorithm must be AES256."
|
|
78
|
+
)
|
|
79
|
+
if encoded is None or md5 is None:
|
|
80
|
+
raise S3Error(errors.INVALID_ARGUMENT, "Encryption needs a key and its MD5.")
|
|
81
|
+
try:
|
|
82
|
+
key = base64.b64decode(encoded, validate=True)
|
|
83
|
+
except ValueError: # `binascii.Error` is one, and so is what non-ASCII raises.
|
|
84
|
+
raise S3Error(
|
|
85
|
+
errors.INVALID_ARGUMENT, "The encryption key isn't valid."
|
|
86
|
+
) from None
|
|
87
|
+
if len(key) != _KEY_BYTES:
|
|
88
|
+
raise S3Error(errors.INVALID_ARGUMENT, "The encryption key must have 256 bits.")
|
|
89
|
+
actual = base64.b64encode(hashlib.md5(key, usedforsecurity=False).digest()).decode()
|
|
90
|
+
if not hmac.compare_digest(actual.encode(), md5.encode()):
|
|
91
|
+
raise S3Error(
|
|
92
|
+
errors.INVALID_ARGUMENT, "The encryption key's MD5 doesn't match."
|
|
93
|
+
)
|
|
94
|
+
return CustomerKey(key, md5)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def check_key(stored_check: str | None, key: CustomerKey | None) -> None:
|
|
98
|
+
"""Hold a request to what was stored: the same key, or none if none was used."""
|
|
99
|
+
if stored_check is None:
|
|
100
|
+
if key is not None:
|
|
101
|
+
raise S3Error(errors.INVALID_REQUEST, "This object isn't encrypted.")
|
|
102
|
+
return
|
|
103
|
+
if key is None:
|
|
104
|
+
raise S3Error(errors.INVALID_REQUEST, "This object needs its encryption key.")
|
|
105
|
+
if not hmac.compare_digest(stored_check, key.check):
|
|
106
|
+
raise S3Error(
|
|
107
|
+
errors.INVALID_REQUEST, "That isn't this object's encryption key."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def stored_size(plain_size: int) -> int:
|
|
112
|
+
chunks = max(1, (plain_size + CHUNK_BYTES - 1) // CHUNK_BYTES)
|
|
113
|
+
return SALT_BYTES + plain_size + chunks * _TAG_BYTES
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def plain_size(stored: int) -> int:
|
|
117
|
+
body = stored - SALT_BYTES
|
|
118
|
+
full = CHUNK_BYTES + _TAG_BYTES
|
|
119
|
+
chunks, rest = divmod(body, full)
|
|
120
|
+
return chunks * CHUNK_BYTES + max(0, rest - _TAG_BYTES)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _blob_cipher(key: CustomerKey, salt: bytes) -> AESGCM:
|
|
124
|
+
return AESGCM(hmac.digest(key.key, salt, "sha256"))
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _nonce(number: int) -> bytes:
|
|
128
|
+
return number.to_bytes(_NONCE_BYTES)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _authenticated(number: int, *, last: bool) -> bytes:
|
|
132
|
+
return number.to_bytes(8) + (_LAST if last else _NOT_LAST)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class Encryptor:
|
|
136
|
+
"""Turn a stream of plain bytes into what an encrypted blob holds."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, key: CustomerKey) -> None:
|
|
139
|
+
salt = secrets.token_bytes(SALT_BYTES)
|
|
140
|
+
self._cipher = _blob_cipher(key, salt)
|
|
141
|
+
self._pending = bytearray(salt)
|
|
142
|
+
self._plain = bytearray()
|
|
143
|
+
self._number = 0
|
|
144
|
+
|
|
145
|
+
def _seal(self, data: bytes, *, last: bool) -> bytes:
|
|
146
|
+
sealed = self._cipher.encrypt(
|
|
147
|
+
_nonce(self._number), data, _authenticated(self._number, last=last)
|
|
148
|
+
)
|
|
149
|
+
self._number += 1
|
|
150
|
+
return sealed
|
|
151
|
+
|
|
152
|
+
def feed(self, data: bytes) -> bytes:
|
|
153
|
+
self._plain += data
|
|
154
|
+
out = self._pending
|
|
155
|
+
self._pending = bytearray()
|
|
156
|
+
# Keep at least one byte back: only the last chunk may be marked as last.
|
|
157
|
+
while len(self._plain) > CHUNK_BYTES:
|
|
158
|
+
out += self._seal(bytes(self._plain[:CHUNK_BYTES]), last=False)
|
|
159
|
+
del self._plain[:CHUNK_BYTES]
|
|
160
|
+
return bytes(out)
|
|
161
|
+
|
|
162
|
+
def finish(self) -> bytes:
|
|
163
|
+
return bytes(self._pending) + self._seal(bytes(self._plain), last=True)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True, slots=True)
|
|
167
|
+
class ReadPlan:
|
|
168
|
+
"""Which stored bytes a plain range of one blob needs, and what to trim."""
|
|
169
|
+
|
|
170
|
+
stored_offset: int
|
|
171
|
+
stored_length: int
|
|
172
|
+
first_chunk: int
|
|
173
|
+
skip: int
|
|
174
|
+
length: int
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def plan_read(stored: int, start: int, length: int) -> ReadPlan:
|
|
178
|
+
full = CHUNK_BYTES + _TAG_BYTES
|
|
179
|
+
first = start // CHUNK_BYTES
|
|
180
|
+
last = (start + length - 1) // CHUNK_BYTES
|
|
181
|
+
offset = SALT_BYTES + first * full
|
|
182
|
+
end = min(SALT_BYTES + (last + 1) * full, stored)
|
|
183
|
+
return ReadPlan(offset, end - offset, first, start - first * CHUNK_BYTES, length)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class Decryptor:
|
|
187
|
+
"""Turn the stored chunks of a range back into plain bytes, checking each."""
|
|
188
|
+
|
|
189
|
+
def __init__(
|
|
190
|
+
self, key: CustomerKey, salt: bytes, stored: int, plan: ReadPlan
|
|
191
|
+
) -> None:
|
|
192
|
+
self._cipher = _blob_cipher(key, salt)
|
|
193
|
+
self._buffer = bytearray()
|
|
194
|
+
self._number = plan.first_chunk
|
|
195
|
+
self._skip = plan.skip
|
|
196
|
+
self._left = plan.length
|
|
197
|
+
full = CHUNK_BYTES + _TAG_BYTES
|
|
198
|
+
self._last_number = max(0, (stored - SALT_BYTES - 1) // full)
|
|
199
|
+
|
|
200
|
+
def _open(self, chunk: bytes) -> bytes:
|
|
201
|
+
try:
|
|
202
|
+
plain = self._cipher.decrypt(
|
|
203
|
+
_nonce(self._number),
|
|
204
|
+
chunk,
|
|
205
|
+
_authenticated(self._number, last=self._number == self._last_number),
|
|
206
|
+
)
|
|
207
|
+
except InvalidTag:
|
|
208
|
+
message = "An encrypted chunk failed its check."
|
|
209
|
+
raise OSError(message) from None
|
|
210
|
+
self._number += 1
|
|
211
|
+
return plain
|
|
212
|
+
|
|
213
|
+
def _trimmed(self, plain: bytes) -> bytes:
|
|
214
|
+
kept = plain[self._skip : self._skip + self._left]
|
|
215
|
+
self._skip = max(0, self._skip - len(plain))
|
|
216
|
+
self._left -= len(kept)
|
|
217
|
+
return kept
|
|
218
|
+
|
|
219
|
+
def feed(self, data: bytes) -> bytes:
|
|
220
|
+
self._buffer += data
|
|
221
|
+
full = CHUNK_BYTES + _TAG_BYTES
|
|
222
|
+
out = bytearray()
|
|
223
|
+
while len(self._buffer) >= full:
|
|
224
|
+
out += self._trimmed(self._open(bytes(self._buffer[:full])))
|
|
225
|
+
del self._buffer[:full]
|
|
226
|
+
return bytes(out)
|
|
227
|
+
|
|
228
|
+
def finish(self) -> bytes:
|
|
229
|
+
return self._trimmed(self._open(bytes(self._buffer))) if self._buffer else b""
|
leblob/s3/tagging.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Object tags: what S3 allows in them, and their form in a request header."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
from urllib.parse import parse_qsl
|
|
8
|
+
|
|
9
|
+
from leblob.s3 import errors
|
|
10
|
+
from leblob.s3.errors import S3Error
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
|
|
15
|
+
MAX_TAGS = 10
|
|
16
|
+
MAX_KEY_CHARACTERS = 128
|
|
17
|
+
MAX_VALUE_CHARACTERS = 256
|
|
18
|
+
# As AWS allows. This also keeps out what XML can't carry back to the client.
|
|
19
|
+
_ALLOWED = re.compile(r"[\w +\-=.:/@]*")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def build_tags(pairs: Iterable[tuple[str, str]]) -> dict[str, str]:
|
|
23
|
+
"""Return valid tags, ordered by key, or raise InvalidTag."""
|
|
24
|
+
listed = list(pairs)
|
|
25
|
+
tags = dict(sorted(listed))
|
|
26
|
+
if len(tags) < len(listed):
|
|
27
|
+
raise S3Error(errors.INVALID_TAG, "Tag keys must be unique.")
|
|
28
|
+
if len(tags) > MAX_TAGS:
|
|
29
|
+
raise S3Error(
|
|
30
|
+
errors.INVALID_TAG, f"An object can have {MAX_TAGS} tags at most."
|
|
31
|
+
)
|
|
32
|
+
for key, value in tags.items():
|
|
33
|
+
_check_text(key, value)
|
|
34
|
+
return tags
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_header(value: str) -> dict[str, str]:
|
|
38
|
+
"""Read the `x-amz-tagging` header, which is written like a query string."""
|
|
39
|
+
return build_tags(parse_qsl(value, keep_blank_values=True))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _check_text(key: str, value: str) -> None:
|
|
43
|
+
if not 1 <= len(key) <= MAX_KEY_CHARACTERS:
|
|
44
|
+
raise S3Error(errors.INVALID_TAG, "A tag key has 1 to 128 characters.")
|
|
45
|
+
if len(value) > MAX_VALUE_CHARACTERS:
|
|
46
|
+
raise S3Error(errors.INVALID_TAG, "A tag value has 256 characters at most.")
|
|
47
|
+
if not (_ALLOWED.fullmatch(key) and _ALLOWED.fullmatch(value)):
|
|
48
|
+
raise S3Error(errors.INVALID_TAG, "A tag holds a character S3 doesn't allow.")
|