diff --git a/pyproject.toml b/pyproject.toml index 874cd3883..988f2d9b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,7 +33,7 @@ dependencies = [ "pydantic >= 2,< 3", "pyjwt >= 2.1", "pyOpenSSL >= 23.0.0", - "requests", + "urllib3 >= 2.6.3", "rich >= 13,< 15", "rfc8785 ~= 0.1.2", "rfc3161-client >= 1.0.3,< 1.1.0", diff --git a/sigstore/_internal/__init__.py b/sigstore/_internal/__init__.py index 31e5d8cc2..9d178254a 100644 --- a/sigstore/_internal/__init__.py +++ b/sigstore/_internal/__init__.py @@ -19,8 +19,6 @@ subject to any stability guarantees. """ -from requests import __version__ as requests_version +from sigstore._internal.http import USER_AGENT -from sigstore import __version__ as sigstore_version - -USER_AGENT = f"sigstore-python/{sigstore_version} (python-requests/{requests_version})" +__all__ = ["USER_AGENT"] diff --git a/sigstore/_internal/fulcio/client.py b/sigstore/_internal/fulcio/client.py index 75da5114f..6fb18089e 100644 --- a/sigstore/_internal/fulcio/client.py +++ b/sigstore/_internal/fulcio/client.py @@ -25,7 +25,6 @@ from dataclasses import dataclass from urllib.parse import urljoin -import requests from cryptography.hazmat.primitives import serialization from cryptography.x509 import ( Certificate, @@ -33,7 +32,7 @@ load_pem_x509_certificate, ) -from sigstore._internal import USER_AGENT +from sigstore._internal import http from sigstore._utils import B64Str from sigstore.oidc import IdentityToken @@ -71,9 +70,8 @@ class FulcioClientError(Exception): class _Endpoint(ABC): - def __init__(self, url: str, session: requests.Session) -> None: + def __init__(self, url: str) -> None: self.url = url - self.session = session def _serialize_cert_request(req: CertificateSigningRequest) -> str: @@ -102,17 +100,20 @@ def post( "Content-Type": "application/json", "Accept": "application/pem-certificate-chain", } - resp: requests.Response = self.session.post( - url=self.url, data=_serialize_cert_request(req), headers=headers + resp = http.post( + url=self.url, data=_serialize_cert_request(req).encode(), headers=headers ) try: resp.raise_for_status() - except requests.HTTPError as http_error: + except http.HTTPError as http_error: # See if we can optionally add a message - if http_error.response: - text = json.loads(http_error.response.text) - if "message" in http_error.response.text: - raise FulcioClientError(text["message"]) from http_error + if http_error.body: + try: + text = json.loads(http_error.body) + if "message" in text: + raise FulcioClientError(text["message"]) from http_error + except (json.JSONDecodeError, KeyError): + pass raise FulcioClientError from http_error try: @@ -141,10 +142,10 @@ class FulcioTrustBundle(_Endpoint): def get(self) -> FulcioTrustBundleResponse: """Get the certificate chains from Fulcio""" - resp: requests.Response = self.session.get(self.url) + resp = http.get(self.url) try: resp.raise_for_status() - except requests.HTTPError as http_error: + except http.HTTPError as http_error: raise FulcioClientError from http_error trust_bundle_json = resp.json() @@ -165,33 +166,17 @@ def __init__(self, url: str) -> None: """Initialize the client""" _logger.debug(f"Fulcio client using URL: {url}") self.url = url - self.session = requests.Session() - self.session.headers.update( - { - "User-Agent": USER_AGENT, - } - ) - - def __del__(self) -> None: - """ - Destroys the underlying network session. - """ - self.session.close() @property def signing_cert(self) -> FulcioSigningCert: """ Returns a model capable of interacting with Fulcio's signing certificate endpoints. """ - return FulcioSigningCert( - urljoin(self.url, SIGNING_CERT_ENDPOINT), session=self.session - ) + return FulcioSigningCert(urljoin(self.url, SIGNING_CERT_ENDPOINT)) @property def trust_bundle(self) -> FulcioTrustBundle: """ Returns a model capable of interacting with Fulcio's trust bundle endpoints. """ - return FulcioTrustBundle( - urljoin(self.url, TRUST_BUNDLE_ENDPOINT), session=self.session - ) + return FulcioTrustBundle(urljoin(self.url, TRUST_BUNDLE_ENDPOINT)) diff --git a/sigstore/_internal/http.py b/sigstore/_internal/http.py new file mode 100644 index 000000000..9af890c1a --- /dev/null +++ b/sigstore/_internal/http.py @@ -0,0 +1,204 @@ +# Copyright 2022 The Sigstore Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +HTTP client utilities for sigstore-python using urllib3. +""" + +from __future__ import annotations + +import json +from typing import Any + +import urllib3 + +from sigstore import __version__ as sigstore_version + +# Global PoolManager for all HTTP requests +_pool_manager: urllib3.PoolManager | None = None + +# User-Agent header for all requests +USER_AGENT = f"sigstore-python/{sigstore_version} (urllib3/{urllib3.__version__})" # type: ignore[attr-defined] + + +def _get_pool_manager() -> urllib3.PoolManager: + """ + Get or create the global PoolManager instance. + + Returns: + The global urllib3.PoolManager instance. + """ + global _pool_manager + if _pool_manager is None: + _pool_manager = urllib3.PoolManager( + headers={"User-Agent": USER_AGENT}, + timeout=urllib3.Timeout(connect=30.0, read=30.0), + ) + return _pool_manager + + +class HTTPError(Exception): + """ + Represents an HTTP error response. + """ + + def __init__(self, status: int, reason: str, body: str | None = None): + """ + Create a new HTTPError. + + Args: + status: HTTP status code + reason: HTTP status reason phrase + body: Optional response body + """ + self.status = status + self.reason = reason + self.body = body + super().__init__(f"HTTP {status}: {reason}") + + +class HTTPResponse: + """ + Wrapper around urllib3 HTTPResponse for easier usage. + """ + + def __init__(self, response: urllib3.BaseHTTPResponse): + """ + Create a new HTTPResponse. + + Args: + response: The underlying urllib3.HTTPResponse + """ + self._response = response + self.status_code = response.status + self.reason = response.reason + self._data = response.data + + def raise_for_status(self) -> None: + """ + Raise an HTTPError if the response status indicates an error. + + Raises: + HTTPError: If status code is 4xx or 5xx + """ + if 400 <= self.status_code < 600: + raise HTTPError(self.status_code, self.reason or "", self.text) + + @property + def text(self) -> str: + """ + Get the response body as text. + + Returns: + The response body decoded as UTF-8 + """ + return self._response.data.decode("utf-8") + + def json(self) -> Any: + """ + Parse the response body as JSON. + + Returns: + The parsed JSON data + """ + return self._response.json() + + +def request( + method: str, + url: str, + *, + headers: dict[str, str] | None = None, + json_data: Any | None = None, + data: bytes | None = None, + params: dict[str, Any] | None = None, + timeout: float | None = None, +) -> HTTPResponse: + """ + Make an HTTP request using the global PoolManager. + + Args: + method: HTTP method (GET, POST, etc.) + url: URL to request + headers: Optional additional headers + json_data: Optional JSON data to send (will be serialized) + data: Optional raw bytes to send + params: Optional query parameters + timeout: Optional timeout in seconds + + Returns: + HTTPResponse object + + Raises: + urllib3.exceptions.HTTPError: On connection errors + HTTPError: On HTTP error status codes (if raise_for_status is called) + """ + pool = _get_pool_manager() + + # Build request headers + request_headers = {} + if json_data is not None: + request_headers["Content-Type"] = "application/json" + data = json.dumps(json_data).encode("utf-8") + if headers: + request_headers.update(headers) + + # Build fields for query parameters + fields = None + if params: + fields = params + + # Create timeout object if specified + timeout_obj = None + if timeout is not None: + timeout_obj = urllib3.Timeout(connect=timeout, read=timeout) + + response = pool.request( + method, + url, + headers=request_headers, + body=data, + fields=fields if method.upper() == "GET" else None, + timeout=timeout_obj, + ) + + return HTTPResponse(response) + + +def get(url: str, **kwargs: Any) -> HTTPResponse: + """ + Make a GET request. + + Args: + url: URL to request + **kwargs: Additional arguments to pass to request() + + Returns: + HTTPResponse object + """ + return request("GET", url, **kwargs) + + +def post(url: str, **kwargs: Any) -> HTTPResponse: + """ + Make a POST request. + + Args: + url: URL to request + **kwargs: Additional arguments to pass to request() + + Returns: + HTTPResponse object + """ + return request("POST", url, **kwargs) diff --git a/sigstore/_internal/rekor/__init__.py b/sigstore/_internal/rekor/__init__.py index 50bdad768..d7a41ed25 100644 --- a/sigstore/_internal/rekor/__init__.py +++ b/sigstore/_internal/rekor/__init__.py @@ -23,9 +23,9 @@ from abc import ABC, abstractmethod import rekor_types -import requests from cryptography.x509 import Certificate +from sigstore._internal import http from sigstore._utils import base64_encode_pem_cert from sigstore.dsse import Envelope from sigstore.hashes import Hashed @@ -45,20 +45,17 @@ class RekorClientError(Exception): A generic error in the Rekor client. """ - def __init__(self, http_error: requests.HTTPError): + def __init__(self, http_error: http.HTTPError): """ - Create a new `RekorClientError` from the given `requests.HTTPError`. + Create a new `RekorClientError` from the given `http.HTTPError`. """ - if http_error.response is not None: - try: - error = rekor_types.Error.model_validate_json(http_error.response.text) - super().__init__(f"{error.code}: {error.message}") - except Exception: - super().__init__( - f"Rekor returned an unknown error with HTTP {http_error.response.status_code}" - ) - else: - super().__init__(f"Unexpected Rekor error: {http_error}") + try: + error = rekor_types.Error.model_validate_json(http_error.body or "") + super().__init__(f"{error.code}: {error.message}") + except Exception: + super().__init__( + f"Rekor returned an unknown error with HTTP {http_error.status}" + ) class RekorLogSubmitter(ABC): diff --git a/sigstore/_internal/rekor/client.py b/sigstore/_internal/rekor/client.py index 57a321885..14ad6608b 100644 --- a/sigstore/_internal/rekor/client.py +++ b/sigstore/_internal/rekor/client.py @@ -26,11 +26,10 @@ from typing import Any import rekor_types -import requests from cryptography.hazmat.primitives import serialization from cryptography.x509 import Certificate -from sigstore._internal import USER_AGENT +from sigstore._internal import http from sigstore._internal.rekor import ( EntryRequestBody, RekorClientError, @@ -73,21 +72,8 @@ def from_response(cls, dict_: dict[str, Any]) -> RekorLogInfo: class _Endpoint(ABC): - def __init__(self, url: str, session: requests.Session | None = None) -> None: - # Note that _Endpoint may not be thread be safe if the same Session is provided - # to an _Endpoint in multiple threads + def __init__(self, url: str) -> None: self.url = url - if session is None: - session = requests.Session() - session.headers.update( - { - "Content-Type": "application/json", - "Accept": "application/json", - "User-Agent": USER_AGENT, - } - ) - - self.session = session class RekorLog(_Endpoint): @@ -99,10 +85,10 @@ def get(self) -> RekorLogInfo: """ Returns information about the Rekor instance's log. """ - resp: requests.Response = self.session.get(self.url) + resp = http.get(self.url) try: resp.raise_for_status() - except requests.HTTPError as http_error: + except http.HTTPError as http_error: raise RekorClientError(http_error) return RekorLogInfo.from_response(resp.json()) @@ -112,7 +98,7 @@ def entries(self) -> RekorEntries: Returns a `RekorEntries` capable of accessing detailed information about individual log entries. """ - return RekorEntries(f"{self.url}/entries", session=self.session) + return RekorEntries(f"{self.url}/entries") class RekorEntries(_Endpoint): @@ -131,16 +117,14 @@ def get( if not (bool(uuid) ^ bool(log_index)): raise ValueError("uuid or log_index required, but not both") - resp: requests.Response - if uuid is not None: - resp = self.session.get(f"{self.url}/{uuid}") + resp = http.get(f"{self.url}/{uuid}") else: - resp = self.session.get(self.url, params={"logIndex": log_index}) + resp = http.get(self.url, params={"logIndex": log_index}) try: resp.raise_for_status() - except requests.HTTPError as http_error: + except http.HTTPError as http_error: raise RekorClientError(http_error) return TransparencyLogEntry._from_v1_response(resp.json()) @@ -154,10 +138,10 @@ def post( _logger.debug(f"proposed: {json.dumps(payload)}") - resp: requests.Response = self.session.post(self.url, json=payload) + resp = http.post(self.url, json_data=payload) try: resp.raise_for_status() - except requests.HTTPError as http_error: + except http.HTTPError as http_error: raise RekorClientError(http_error) integrated_entry = resp.json() @@ -169,7 +153,7 @@ def retrieve(self) -> RekorEntriesRetrieve: """ Returns a `RekorEntriesRetrieve` capable of retrieving entries. """ - return RekorEntriesRetrieve(f"{self.url}/retrieve/", session=self.session) + return RekorEntriesRetrieve(f"{self.url}/retrieve/") class RekorEntriesRetrieve(_Endpoint): @@ -190,11 +174,11 @@ def post( """ data = {"entries": [expected_entry.model_dump(mode="json", by_alias=True)]} - resp: requests.Response = self.session.post(self.url, json=data) + resp = http.post(self.url, json_data=data) try: resp.raise_for_status() - except requests.HTTPError as http_error: - if http_error.response and http_error.response.status_code == 404: + except http.HTTPError as http_error: + if http_error.status == 404: return None raise RekorClientError(http_error) diff --git a/sigstore/_internal/rekor/client_v2.py b/sigstore/_internal/rekor/client_v2.py index d4a4d0e10..985ca3bfc 100644 --- a/sigstore/_internal/rekor/client_v2.py +++ b/sigstore/_internal/rekor/client_v2.py @@ -22,14 +22,13 @@ import json import logging -import requests from cryptography.hazmat.primitives import serialization from cryptography.x509 import Certificate from sigstore_models.common import v1 as common_v1 from sigstore_models.rekor import v2 as rekor_v2 from sigstore_models.rekor.v1 import TransparencyLogEntry as _TransparencyLogEntry -from sigstore._internal import USER_AGENT +from sigstore._internal import http from sigstore._internal.key_details import _get_key_details from sigstore._internal.rekor import ( EntryRequestBody, @@ -66,25 +65,14 @@ def create_entry(self, payload: EntryRequestBody) -> TransparencyLogEntry: """ _logger.debug(f"proposed: {json.dumps(payload)}") - # Use a short lived session to avoid potential issues with multi-threading: - # Session thread-safety is ambiguous - session = requests.Session() - session.headers.update( - { - "Content-Type": "application/json", - "Accept": "application/json", - "User-Agent": USER_AGENT, - } - ) - - resp = session.post( + resp = http.post( f"{self.url}/log/entries", - json=payload, + json_data=payload, ) try: resp.raise_for_status() - except requests.HTTPError as http_error: + except http.HTTPError as http_error: raise RekorClientError(http_error) integrated_entry = resp.json() diff --git a/sigstore/_internal/timestamp.py b/sigstore/_internal/timestamp.py index 62883636e..361081c83 100644 --- a/sigstore/_internal/timestamp.py +++ b/sigstore/_internal/timestamp.py @@ -20,7 +20,7 @@ from dataclasses import dataclass from datetime import datetime -import requests +import urllib3 from rfc3161_client import ( TimestampRequestBuilder, TimeStampResponse, @@ -28,7 +28,7 @@ ) from rfc3161_client.base import HashAlgorithm -from sigstore._internal import USER_AGENT +from sigstore._internal import http CLIENT_TIMEOUT: int = 5 @@ -91,30 +91,22 @@ def request_timestamp(self, signature: bytes) -> TimeStampResponse: msg = f"invalid request: {error}" raise TimestampError(msg) - # Use single use session to avoid potential Session thread safety issues - session = requests.Session() - session.headers.update( - { - "Content-Type": "application/timestamp-query", - "User-Agent": USER_AGENT, - } - ) - # Send it to the TSA for signing try: - response = session.post( + response = http.post( self.url, data=timestamp_request.as_bytes(), + headers={"Content-Type": "application/timestamp-query"}, timeout=CLIENT_TIMEOUT, ) response.raise_for_status() - except requests.RequestException as error: + except (urllib3.exceptions.HTTPError, http.HTTPError) as error: msg = f"error while sending the request to the TSA: {error}" raise TimestampError(msg) # Check that we can parse the response but do not *verify* it try: - timestamp_response = decode_timestamp_response(response.content) + timestamp_response = decode_timestamp_response(response._data) except ValueError as e: msg = f"invalid response: {e}" raise TimestampError(msg) diff --git a/sigstore/oidc.py b/sigstore/oidc.py index 9636d06d2..ce23a4e0f 100644 --- a/sigstore/oidc.py +++ b/sigstore/oidc.py @@ -18,6 +18,7 @@ from __future__ import annotations +import base64 import logging import sys import time @@ -28,10 +29,10 @@ import id import jwt -import requests +import urllib3 from pydantic import BaseModel, StrictStr -from sigstore._internal import USER_AGENT +from sigstore._internal import http from sigstore.errors import Error, NetworkError # See: https://github.com/sigstore/fulcio/blob/b2186c0/pkg/config/config.go#L182-L201 @@ -244,21 +245,18 @@ def __init__(self, base_url: str) -> None: which is then used to bootstrap the issuer's state (such as authorization and token endpoints). """ - self.session = requests.Session() - self.session.headers.update({"User-Agent": USER_AGENT}) - oidc_config_url = urllib.parse.urljoin( f"{base_url}/", ".well-known/openid-configuration" ) try: - resp: requests.Response = self.session.get(oidc_config_url, timeout=30) - except (requests.ConnectionError, requests.Timeout) as exc: + resp = http.get(oidc_config_url, timeout=30) + except (urllib3.exceptions.HTTPError, urllib3.exceptions.TimeoutError) as exc: raise NetworkError from exc try: resp.raise_for_status() - except requests.HTTPError as http_error: + except http.HTTPError as http_error: raise IssuerError from http_error try: @@ -329,19 +327,32 @@ def identity_token( # nosec: B107 client_secret, ) logging.debug(f"PAYLOAD: data={data}") + + # Build Authorization header for basic auth + credentials = f"{auth[0]}:{auth[1]}" + encoded_credentials = base64.b64encode(credentials.encode()).decode() + headers = { + "Authorization": f"Basic {encoded_credentials}", + "Content-Type": "application/x-www-form-urlencoded", + } + + # Convert data dict to URL-encoded form data + encoded_data = urllib.parse.urlencode(data).encode() + try: - resp = self.session.post( + resp = http.request( + "POST", self.oidc_config.token_endpoint, - data=data, - auth=auth, + data=encoded_data, + headers=headers, timeout=30, ) - except (requests.ConnectionError, requests.Timeout) as exc: + except (urllib3.exceptions.HTTPError, urllib3.exceptions.TimeoutError) as exc: raise NetworkError from exc try: resp.raise_for_status() - except requests.HTTPError as http_error: + except http.HTTPError as http_error: raise IdentityError( f"Token request failed with {resp.status_code}" ) from http_error diff --git a/test/unit/test_sign.py b/test/unit/test_sign.py index 0b039b119..a6f9da143 100644 --- a/test/unit/test_sign.py +++ b/test/unit/test_sign.py @@ -251,7 +251,7 @@ def test_with_timestamp_error(self, sig_ctx, identity, hashed, caplog): with sig_ctx.signer(identity) as signer: bundle = signer.sign_artifact(hashed) - assert caplog.records[0].message.startswith("Unable to use invalid-url") + assert caplog.records[0].message.startswith("Failed to resolve 'invalid-url'") assert ( bundle.verification_material.timestamp_verification_data.rfc3161_timestamps )