import hashlib import hmac from dataclasses import dataclass from datetime import UTC, date, datetime from functools import cached_property from typing import Any from urllib.parse import quote import httpx @dataclass(kw_only=True) class S3Client: web_client: httpx.AsyncClient aws_region: str aws_access_key_id: str aws_secret_access_key: str aws_session_token: str _signing_key: bytes | None = None _signing_key_date: date | None = None async def put_object( self, bucket: str, key: str, content: bytes, content_type: str ) -> httpx.Response: payload_hash = hashlib.sha256(content).hexdigest() now = datetime.now(UTC) request_datetime = now.strftime("%Y%m%dT%H%M%SZ") headers = { "Content-Type": content_type, "host": f"{bucket}.s3.amazonaws.com", "x-amz-content-sha256": payload_hash, "x-amz-date": request_datetime, "x-amz-security-token": self.aws_session_token, } request = self.web_client.build_request( method="PUT", url=f"https://{bucket}.s3.amazonaws.com/{key}", headers=headers, content=content, ) request.headers["Authorization"] = self._get_authorization_header( request, headers, ) response = await self.web_client.send(request) response.raise_for_status() return response def _get_authorization_header( self, request: httpx.Request, headers: dict[str, Any], ) -> str: canonical_headers = ( "\n".join((f"{k.lower()}:{v}" for k, v in headers.items())) + "\n" ) signed_headers = ";".join([header.lower() for header in headers.keys()]) canonical_request = ( f"{request.method}\n" f"{quote(request.url.path)}\n" f"\n" f"{canonical_headers}\n" f"{signed_headers}\n" f"{headers['x-amz-content-sha256']}" ) credential_scope = ( f"{headers['x-amz-date'][:8]}/{self.aws_region}/s3/aws4_request" ) string_to_sign = ( f"AWS4-HMAC-SHA256\n" f"{headers['x-amz-date']}\n" f"{credential_scope}\n" f"{hashlib.sha256(canonical_request.encode('utf-8')).hexdigest()}" ) signature = hmac.new( self.signing_key, (string_to_sign).encode("utf-8"), hashlib.sha256, ).hexdigest() authorization_header = ( f"AWS4-HMAC-SHA256 Credential={self.aws_access_key_id}/" f"{credential_scope}, SignedHeaders={signed_headers}, Signature={signature}" ) return authorization_header def _sign(self, key: bytes, msg: str) -> bytes: return hmac.new(key, msg.encode("utf-8"), hashlib.sha256).digest() @cached_property def signing_key(self) -> bytes: if self._signing_key and date.today() == self._signing_key_date: return self._signing_key date_str = date.today().strftime("%Y%m%d") signed_date = self._sign( ("AWS4" + self.aws_secret_access_key).encode("utf-8"), date_str ) signed_region = self._sign(signed_date, self.aws_region) signed_service = self._sign(signed_region, "s3") signature_key = self._sign(signed_service, "aws4_request") self._signing_key = signature_key self._signing_key_date = date.today() return self._signing_key