Source code for imgproxykit.signing

import contextlib
import hmac
from typing import Final

from imgproxykit.internal.encoding import encode_base64

# TODO: move to init?
# imgproxy verifies URL signatures using HMAC-SHA256.
_SIGNATURE_DIGEST: Final = 'sha256'


[docs] class UrlSigner: """ Sign imgproxy paths using a reusable key and salt pair. Used by :class:`~imgproxykit.Client` after path construction. Credentials are decoded once during initialization and reused for subsequent signatures. Passing ``None`` for both credentials enables unsigned mode. Subclass this signer and override :meth:`__call__` to customize signing. Set the subclass on :attr:`imgproxykit.Client.url_signer_cls` to use it in a client. """ __slots__ = ( '_credentials', '_signature_size', '_unsigned_signature', ) def __init__( self, *, key: str | None, salt: str | None, signature_size: int, unsigned_signature: str, ) -> None: """ Configure signing credentials or enable unsigned mode. Supply both ``key`` and ``salt`` to generate signatures. Pass ``None`` for both to return the configured unsigned marker. The server must accept unsigned requests or trust that marker. Args: key: Nonempty hex-encoded signing key matching the server. Supply together with ``salt``; ``None`` selects unsigned mode. salt: Nonempty hex-encoded signing salt matching the server. Supply together with ``key``; ``None`` selects unsigned mode. signature_size: Positive digest byte count to encode. Must match the server's `IMGPROXY_SIGNATURE_SIZE <https://docs.imgproxy.net/latest/configuration/options#url-signature>`__. Values of 32 or more use the full digest. unsigned_signature: URL marker used when key and salt are both ``None``. With server signing enabled, configure this marker in `IMGPROXY_TRUSTED_SIGNATURES <https://docs.imgproxy.net/latest/configuration/options#url-signature>`__. Raises: ValueError: If only one signing credential is supplied, either is empty or invalid hex, or ``signature_size`` is not positive, even in unsigned mode. """ # Signing requires a positive digest byte count. if signature_size < 1: raise ValueError('signature_size must be positive.') # Signing requires a complete key and salt pair. if (key is None) != (salt is None): raise ValueError('key and salt must be supplied together.') self._unsigned_signature = unsigned_signature self._signature_size = signature_size # Passing None for both credentials selects unsigned mode. if key is None or salt is None: self._credentials = None return self._credentials = ( self._decode_hex(key), self._decode_hex(salt), )
[docs] def __call__(self, path: str) -> str: """ Sign the exact supplied path or return the unsigned marker. The signature is an HMAC-SHA256 digest of the salt followed by the UTF-8 encoded path. The path is neither normalized nor modified. Args: path: Finalized path beginning with ``/``, including processing options, the encoded source, and any output extension. Excludes the server address and signature segment. Returns: URL-safe Base64 signature without padding, or the unsigned marker when both signing credentials are ``None``. """ if self._credentials is None: return self._unsigned_signature key, salt = self._credentials signature = hmac.digest( key, salt + path.encode(), _SIGNATURE_DIGEST, ) return encode_base64(signature[: self._signature_size])
def _decode_hex(self, value: str) -> bytes: """ Decode a nonempty hexadecimal credential. Invalid and empty values raise :class:`ValueError`. """ with contextlib.suppress(ValueError): decoded = bytes.fromhex(value) if decoded: return decoded raise ValueError('Signing credentials must be nonempty hex strings.')