import hashlib import hmac from datetime import datetime, timedelta from typing import Any, Dict from urllib.parse import quote import httpx class AWSCredentials: _credentials: Dict[str, Any] _credentials_expired_at: datetime = datetime(1970, 1, 1) def __init__(self, url: str) -> None: self._url = url @property def credentials(self) -> Dict[str, Any]: if self._credentials_expired_at < datetime.utcnow() + timedelta(minutes=1): response = httpx.get(self._url) response.raise_for_status() self._credentials = response.json() self._credentials_expired_at = datetime.strptime( self._credentials["Expiration"], "%Y-%m-%dT%H:%M:%SZ", ) return self._credentials class S3AsyncClient: """ Async HTTP client implemention for AWS Simple Storage Service (S3) """ _SERVICE = "s3" _ALGHORITHM = "AWS4-HMAC-SHA256" _SIGN_DATE: str | None = None _SIGN: bytes _REGION: str def __init__( self, aws_region: str, aws_credentials: AWSCredentials, bucket: str, ) -> None: self._REGION = aws_region # pylint: disable=invalid-name self._aws_credentials = aws_credentials self._bucket = bucket self._async_client = httpx.AsyncClient( base_url=f"https://{bucket}.s3.amazonaws.com", timeout=60, ) async def put_object(self, key: str, content: bytes) -> httpx.Response: credentials = self._aws_credentials.credentials method = "PUT" payload_hash = hashlib.sha256(content).hexdigest() now = datetime.utcnow() request_datetime = now.strftime("%Y%m%dT%H%M%SZ") headers = { "Content-Encoding": "gzip", "Content-Type": "application/json", "host": f"{self._async_client.base_url.host}", "x-amz-content-sha256": payload_hash, "x-amz-date": request_datetime, "x-amz-security-token": credentials["Token"], } request = self._async_client.build_request( method=method, url=key, headers=headers, content=content, ) request.headers["Authorization"] = self._get_authorization_header( request, headers, credentials, ) response = await self._async_client.send(request) response.raise_for_status() return response def _get_authorization_header( self, request: httpx.Request, headers: Dict[str, Any], credentials, ) -> 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._REGION}/{self._SERVICE}/aws4_request' ) string_to_sign = ( f"{self._ALGHORITHM}\n" f'{headers["x-amz-date"]}\n' f"{credential_scope}\n" f'{hashlib.sha256(canonical_request.encode("utf-8")).hexdigest()}' ) signing_key = self._get_signature_key( credentials["SecretAccessKey"], headers["x-amz-date"][:8], ) signature = hmac.new( signing_key, (string_to_sign).encode("utf-8"), hashlib.sha256, ).hexdigest() authorization_header = ( f"{self._ALGHORITHM} Credential={credentials['AccessKeyId']}/" 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() def _get_signature_key(self, key: str, date: str) -> bytes: if date == self._SIGN_DATE: return self._SIGN signed_date = self._sign(("AWS4" + key).encode("utf-8"), date) signed_region = self._sign(signed_date, self._REGION) signed_service = self._sign(signed_region, self._SERVICE) signature_key = self._sign(signed_service, "aws4_request") self._SIGN = signature_key # pylint: disable=invalid-name return self._SIGN