Skip to content

API reference

This page is generated from the source docstrings and type annotations.

Signing

URLAuth

URLAuth(
    keys: KeySet | Iterable,
    *,
    signing_key_id: str = "",
    ignore_query_params: Iterable[str] | None = None,
    ttl: int = 60 * 10,
)

Sign and verify URLs over the URL and an expiry, via a pluggable backend.

A signer is configured with a set of keys. Several keys may be supplied so that keys can be rotated without invalidating signatures that are still in flight: signing uses one key, but :meth:verify accepts any of them. The keys may mix algorithms -- an HMAC key and an Ed25519 key can live in the same :class:~pysigned.backends.KeySet.

The cryptography is delegated to the keyset's :class:~pysigned.backends.Backend, which dispatches on each key's type. Everything else -- query canonicalisation, expiry, rotation -- is backend-agnostic.

Parameters:

Name Type Description Default
keys KeySet | Iterable

A :class:~pysigned.backends.KeySet, or raw values that are wrapped in one. Raw bytes are read as HMAC keys; pass wrapped :class:~pysigned.keys.Ed25519KeyPair / :class:~pysigned.keys.Ed25519PublicKey for Ed25519.

required
signing_key_id str

Id of the key to sign with. Defaults to the most recently added key (which must be able to sign).

''
ttl int

Seconds a signature stays valid (default 10 minutes).

60 * 10
Source code in src/pysigned/signature.py
def __init__(
    self,
    keys: KeySet | Iterable,
    *,
    signing_key_id: str = "",
    ignore_query_params: Iterable[str] | None = None,
    ttl: int = 60 * 10,
) -> None:
    self.keys = keys if isinstance(keys, KeySet) else KeySet(keys)
    self.backend = self.keys.backend
    self.signing_key_id = signing_key_id or next(reversed(self.keys)).id
    # Params excluded from the signed message: our own sig/exp plus any the
    # caller wants ignored. Materialised once so a one-shot iterable works.
    self._excluded = frozenset(("sig", "exp", *(ignore_query_params or ())))
    self.ttl = ttl

sign

sign(url: str) -> str

Sign a URL, returning it with sig and exp query params added.

Parameters:

Name Type Description Default
url str

The URL being signed as a string.

required

Returns:

Type Description
str

The signed URL as a string.

Source code in src/pysigned/signature.py
def sign(self, url: str) -> str:
    """Sign a URL, returning it with ``sig`` and ``exp`` query params added.

    Args:
        url: The URL being signed as a string.

    Returns:
        The signed URL as a string.
    """
    parsed = urlparse(url)
    exp = int(time()) + self.ttl
    signing_key = self.keys[self.signing_key_id]
    signature = self.backend.sign(signing_key, self._message(parsed, exp))
    query = parse_qsl(parsed.query) + [("sig", signature), ("exp", str(exp))]
    return parsed._replace(query=urlencode(query)).geturl()

verify

verify(url: str, *, skew: int = 0) -> bool

Verify a signature produced by :meth:sign.

Every configured key is checked, so signatures made with a rotated-out key still verify.

Source code in src/pysigned/signature.py
def verify(self, url: str, *, skew: int = 0) -> bool:
    """Verify a signature produced by :meth:`sign`.

    Every configured key is checked, so signatures made with a rotated-out
    key still verify.
    """
    parsed = urlparse(url)
    params = dict(parse_qsl(parsed.query))

    sig = params.get("sig")
    try:
        exp = int(params.get("exp", ""))
    except ValueError:
        return False

    if not sig or not exp:
        return False
    if exp + skew <= int(time()):
        return False

    message = self._message(parsed, exp)
    for key in self.keys:
        if self.backend.verify(key, message, sig):
            return True
    return False

Keys

Key dataclass

Key(key: bytes, id: str = '')

Bases: KeyLike

A signing/verifying key: raw bytes plus a stable id.

Subclasses supply two hooks: _validate (raise on bad key material) and _id_bytes (the bytes the id fingerprint is hashed from).

_id_bytes is not "the public part" of the key. It is only what the fingerprint is computed over. A symmetric HMAC key has no public counterpart, so its _id_bytes is the secret key itself -- safe to hash into an id only because SHA-256 is one-way, and safe to show in repr only because repr truncates. An asymmetric Ed25519 key uses its genuinely public bytes.

HMACKey dataclass

HMACKey(key: bytes, id: str = '')

Bases: Key

A symmetric HMAC key.

Ed25519KeyPair

Ed25519KeyPair(
    private_key: Ed25519PrivateKey | bytes,
    public_key: Ed25519PublicKey | bytes | None = None,
    id: str = "",
)

Bases: KeyLike

An Ed25519 keypair, wrapping a private key and its public key.

Can both sign and verify. Its id is fingerprinted from the public key, so it matches the id of the corresponding :class:Ed25519PublicKey, and neither the id nor the repr ever expose the seed.

Source code in src/pysigned/keys.py
def __init__(
    self,
    private_key: ed25519.Ed25519PrivateKey | bytes,
    public_key: ed25519.Ed25519PublicKey | bytes | None = None,
    id: str = "",
):
    if isinstance(private_key, bytes):
        private_key = ed25519.Ed25519PrivateKey.from_private_bytes(private_key)
    if isinstance(public_key, bytes):
        public_key = ed25519.Ed25519PublicKey.from_public_bytes(public_key)

    self.private_key = private_key
    if public_key:
        if (
            cast(ed25519.Ed25519PublicKey, public_key).public_bytes_raw()
            != self.private_key.public_key().public_bytes_raw()
        ):
            raise ValueError("Mismatch private and public ed25519 keys")
    self.public_key = (
        cast(ed25519.Ed25519PublicKey, public_key) or self.private_key.public_key()
    )
    self.id = id or hashlib.sha512(self._id_bytes()).hexdigest()

generate classmethod

generate(id: str = '') -> Self

Generate a new random Ed25519 keypair.

Source code in src/pysigned/keys.py
@classmethod
def generate(cls, id: str = "") -> Self:
    """Generate a new random Ed25519 keypair."""
    priv_key = ed25519.Ed25519PrivateKey.generate()
    pub_key = priv_key.public_key()
    return cls(priv_key, pub_key, id)

from_private_bytes classmethod

from_private_bytes(seed: bytes, id: str = '') -> Self

Build a keypair from a 32-byte Ed25519 private seed.

Source code in src/pysigned/keys.py
@classmethod
def from_private_bytes(cls, seed: bytes, id: str = "") -> Self:
    """Build a keypair from a 32-byte Ed25519 private seed."""
    priv_key = ed25519.Ed25519PrivateKey.from_private_bytes(seed)
    pub_key = priv_key.public_key()
    return cls(priv_key, pub_key, id)

public

public() -> Ed25519PublicKey

The verify-only public key for this pair, sharing its id.

Source code in src/pysigned/keys.py
def public(self) -> "Ed25519PublicKey":
    """The verify-only public key for this pair, sharing its id."""
    return Ed25519PublicKey(self.public_key.public_bytes_raw(), self.id)

__bytes__

__bytes__() -> bytes

The raw public-key bytes (its public identity); never the seed.

Source code in src/pysigned/keys.py
def __bytes__(self) -> bytes:
    """The raw public-key bytes (its public identity); never the seed."""
    return self.public_key.public_bytes_raw()

Ed25519PublicKey dataclass

Ed25519PublicKey(key: bytes, id: str = '')

Bases: Key

A verify-only Ed25519 public key, with no signing capability.

KeySet

KeySet(
    keys: Iterable[KeyValue], backend: Backend | None = None
)

An id-keyed, read-only collection of keys parsed by a backend.

Keys of different algorithms may be mixed freely; signing and verifying each key dispatches on its type via the backend.

Source code in src/pysigned/keys.py
def __init__(self, keys: "Iterable[KeyValue]", backend: "Backend | None" = None):
    if backend is None:
        # Deferred to break the keys <-> backends import cycle: backends
        # imports the key types from this module, so Backend can't be
        # imported here at module load time.
        from .backends import Backend

        backend = Backend()
    self.backend = backend
    self._keys: Mapping[str, Key | Ed25519KeyPair] = MappingProxyType(
        {k.id: k for k in map(backend.parse_key, keys)}
    )

from_jwks classmethod

from_jwks(
    jwks: dict[str, Any], backend: Backend | None = None
) -> Self

Build a KeySet from a JWKS (a {"keys": [...]} mapping of JWKs).

Each JWK becomes an :class:HMACKey, :class:Ed25519KeyPair, or :class:Ed25519PublicKey depending on its kty/crv and whether a private component (d) is present.

Source code in src/pysigned/keys.py
@classmethod
def from_jwks(cls, jwks: dict[str, Any], backend: "Backend | None" = None) -> Self:
    """Build a KeySet from a JWKS (a ``{"keys": [...]}`` mapping of JWKs).

    Each JWK becomes an :class:`HMACKey`, :class:`Ed25519KeyPair`, or
    :class:`Ed25519PublicKey` depending on its ``kty``/``crv`` and whether a
    private component (``d``) is present.
    """
    if not (keys := jwks.get("keys")):
        raise ValueError("No 'keys' provided in JWKS.")
    collected = []
    for key in keys:
        kid: str = key.get("kid", "")
        match key:
            case {"kty": "OKP", "crv": "Ed25519", "x": public, "d": private}:
                private_key = ed25519.Ed25519PrivateKey.from_private_bytes(
                    (cls._unpadded_b64decode(private))
                )
                public_key = ed25519.Ed25519PublicKey.from_public_bytes(
                    cls._unpadded_b64decode(public)
                )
                collected.append(Ed25519KeyPair(private_key, public_key, id=kid))
            case {"kty": "OKP", "crv": "Ed25519", "x": public}:
                collected.append(
                    Ed25519PublicKey(cls._unpadded_b64decode(public), id=kid)
                )
            case {"kty": "oct", "alg": "HS512", "k": private}:
                collected.append(HMACKey(cls._unpadded_b64decode(private), id=kid))
            case _:
                raise NotImplementedError("Unknown key type in jwks")
    return cls(collected, backend=backend)

from_env classmethod

from_env(
    environment_key: str, backend: Backend | None = None
) -> Self

Build a KeySet from a JWKS stored as JSON in an environment variable.

Raises:

Type Description
ValueError

If environment_key is unset or empty.

Source code in src/pysigned/keys.py
@classmethod
def from_env(cls, environment_key: str, backend: "Backend | None" = None) -> Self:
    """Build a KeySet from a JWKS stored as JSON in an environment variable.

    Raises:
        ValueError: If ``environment_key`` is unset or empty.
    """
    if not (val := os.getenv(environment_key)):
        raise ValueError(f"{environment_key} unset, cannot import keyset.")
    jwks = json.loads(val)
    return cls.from_jwks(jwks, backend)

from_url classmethod

from_url(url: str, backend: Backend | None = None) -> Self

Build a KeySet by fetching a JWKS document over HTTP(S).

The body at url must be a JSON JWKS (a {"keys": [...]} mapping), as produced by :meth:from_jwks.

Source code in src/pysigned/keys.py
@classmethod
def from_url(cls, url: str, backend: "Backend | None" = None) -> Self:
    """Build a KeySet by fetching a JWKS document over HTTP(S).

    The body at ``url`` must be a JSON JWKS (a ``{"keys": [...]}`` mapping),
    as produced by :meth:`from_jwks`.
    """
    headers = {
        "User-Agent": f"Pysigned/{__version__}",
        "Accept": "application/json",
    }
    request = Request(url, headers=headers)
    with urlopen(request) as response:
        raw = response.read().decode("utf-8")
        as_json = json.loads(raw)
        return cls.from_jwks(as_json, backend)

Backend

Backend

Backend(digest: str = DIGEST)

Parses key values and signs/verifies with whichever algorithm a key uses.

Every key value already carries its own algorithm -- raw bytes and :class:~pysigned.keys.HMACKey are symmetric HMAC, while :class:~pysigned.keys.Ed25519KeyPair / :class:~pysigned.keys.Ed25519PublicKey are asymmetric -- so the backend dispatches on the key type rather than being fixed to one algorithm. A single :class:KeySet (and therefore a single :class:~pysigned.signature.URLAuth) can hold HMAC and Ed25519 keys together. Everything algorithm-agnostic (URL canonicalisation, expiry, key rotation) lives in :class:~pysigned.signature.URLAuth.

Parameters:

Name Type Description Default
digest str

Hash name used for HMAC keys (default sha512). Ignored by Ed25519 keys.

DIGEST
Source code in src/pysigned/backends.py
def __init__(self, digest: str = DIGEST):
    self.digest = digest

parse_key

parse_key(value: KeyValue) -> Key | Ed25519KeyPair

Wrap a user-supplied key value as a :class:~pysigned.keys.KeyLike.

Already-wrapped keys pass through; raw bytes or a (bytes, id) tuple become an :class:~pysigned.keys.HMACKey. Ed25519 keys must be wrapped explicitly because raw bytes can't distinguish private from public -- and, now, HMAC from Ed25519.

Source code in src/pysigned/backends.py
def parse_key(self, value: KeyValue) -> Key | Ed25519KeyPair:
    """Wrap a user-supplied key value as a :class:`~pysigned.keys.KeyLike`.

    Already-wrapped keys pass through; raw ``bytes`` or a ``(bytes, id)``
    tuple become an :class:`~pysigned.keys.HMACKey`. Ed25519 keys must be
    wrapped explicitly because raw bytes can't distinguish private from
    public -- and, now, HMAC from Ed25519.
    """
    match value:
        case HMACKey() | Ed25519KeyPair() | Ed25519PublicKey():
            return value
        case bytes():
            return HMACKey(value)
        case (_bytes, _id):
            if not isinstance(_bytes, bytes):
                raise ValueError("Keys in tuples must be bytes")
            if not isinstance(_id, str):
                raise ValueError("Key ids must be strings.")
            return HMACKey(_bytes, _id)
        case _:
            raise ValueError(f"Invalid key value: {value}")

sign

sign(key: Key | Ed25519KeyPair, message: bytes) -> str

Sign message with key, returning a hex-encoded signature.

Raises:

Type Description
TypeError

If key cannot sign (e.g. an :class:Ed25519PublicKey).

Source code in src/pysigned/backends.py
def sign(self, key: Key | Ed25519KeyPair, message: bytes) -> str:
    """Sign ``message`` with ``key``, returning a hex-encoded signature.

    Raises:
        TypeError: If ``key`` cannot sign (e.g. an :class:`Ed25519PublicKey`).
    """
    match key:
        case HMACKey():
            return hmac.new(bytes(key), message, self.digest).hexdigest()
        case Ed25519KeyPair():
            return key.private_key.sign(message).hex()
        case _:
            raise TypeError(
                "signing requires an HMACKey or Ed25519KeyPair; "
                f"got {type(key).__name__} (public keys cannot sign)"
            )

verify

verify(
    key: Key | Ed25519KeyPair | Ed25519PublicKey,
    message: bytes,
    signature: str,
) -> bool

Check signature against message for key.

Returns False (rather than raising) for an unrecognised key type or a malformed/invalid signature.

Source code in src/pysigned/backends.py
def verify(
    self,
    key: Key | Ed25519KeyPair | Ed25519PublicKey,
    message: bytes,
    signature: str,
) -> bool:
    """Check ``signature`` against ``message`` for ``key``.

    Returns ``False`` (rather than raising) for an unrecognised key type or
    a malformed/invalid signature.
    """
    match key:
        case HMACKey():
            expected = hmac.new(bytes(key), message, self.digest).hexdigest()
            # Constant-time comparison to avoid timing attacks.
            return hmac.compare_digest(expected, signature)
        case Ed25519KeyPair() | Ed25519PublicKey():
            try:
                key.public_key.verify(bytes.fromhex(signature), message)
            except (InvalidSignature, ValueError):
                return False
        case _:
            return False
    return True

FastAPI extension

SignedRoute

SignedRoute(
    *,
    keyset: KeySet | Iterable | None = None,
    keyset_getter: KeysetGetter | None = None,
    signing_key_id: str = "",
    ignore_query_params: Iterable[str] | None = None,
    error_status: int = HTTP_403_FORBIDDEN,
    ttl: int | None = None,
)

A FastAPI dependency that verifies a request's URL signature.

Wraps :class:~pysigned.URLAuth for use with FastAPI's dependency injection. Wire it in via Depends, either on a single route or globally on a router/app, and it raises an :class:~fastapi.HTTPException when the request's URL fails verification.

Parameters:

Name Type Description Default
keyset KeySet | Iterable | None

A fixed :class:~pysigned.KeySet to verify against. Mutually exclusive with keyset_getter.

None
keyset_getter KeysetGetter | None

An async callable that resolves a :class:~pysigned.KeySet for cases where the keys aren't known until request time. Mutually exclusive with keyset.

None
signing_key_id str

Id of the key new signatures would be signed with. Unused for verification, but forwarded to :class:~pysigned.URLAuth.

''
ignore_query_params Iterable[str] | None

Query params excluded from the signed message, e.g. tracking params appended after signing.

None
error_status int

HTTP status code raised when verification fails. Defaults to 403 Forbidden.

HTTP_403_FORBIDDEN
ttl int | None

Overrides :class:~pysigned.URLAuth's default signature lifetime, in seconds.

None
Source code in src/pysigned/extensions/fastapi.py
def __init__(
    self,
    *,
    keyset: KeySet | collections.abc.Iterable | None = None,
    keyset_getter: KeysetGetter | None = None,
    signing_key_id: str = "",
    ignore_query_params: Iterable[str] | None = None,
    error_status: int = status.HTTP_403_FORBIDDEN,
    ttl: int | None = None,
):
    if keyset is None and keyset_getter is None:
        raise ValueError("Must set one of keyset or keyset_getter.")
    if keyset and keyset_getter:
        raise ValueError("keyset and keyset_getter are mutually exclusive.")
    self.keyset = keyset
    self.keyset_getter = keyset_getter
    self.error_status = error_status
    self.signing_key_id = signing_key_id
    self.ignore_query_params = ignore_query_params
    self.ttl = ttl

__call__ async

__call__(request: Request)

Verify the request's URL, raising on failure.

Parameters:

Name Type Description Default
request Request

The incoming request, supplied by FastAPI.

required

Raises:

Type Description
HTTPException

With error_status if the URL's signature is missing, invalid, or expired.

ValueError

If keyset_getter resolves to an empty keyset.

Source code in src/pysigned/extensions/fastapi.py
async def __call__(self, request: Request):
    """Verify the request's URL, raising on failure.

    Args:
        request: The incoming request, supplied by FastAPI.

    Raises:
        HTTPException: With ``error_status`` if the URL's signature is
            missing, invalid, or expired.
        ValueError: If ``keyset_getter`` resolves to an empty keyset.
    """
    url = str(request.url)
    keys = self.keyset
    if not keys and self.keyset_getter:
        keys = await self.keyset_getter(request)
    if not keys:
        raise ValueError("Could not set keys for verifier.")
    kwargs = {}
    if self.ttl is not None:
        kwargs["ttl"] = self.ttl
    verifier = URLAuth(
        keys=keys,
        signing_key_id=self.signing_key_id,
        ignore_query_params=self.ignore_query_params,
        **kwargs,
    )
    if not verifier.verify(url):
        raise HTTPException(status_code=self.error_status)

KeysetGetter

Bases: Protocol

Callable that resolves a :class:~pysigned.KeySet for a request.

Use this instead of a static keyset when the keys depend on per-request state, e.g. fetching keys for a tenant from a database.