authweave-http-signatures 7.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,36 @@
1
+ # macOS
2
+ .DS_Store
3
+
4
+ # Python bytecode and native build products
5
+ __pycache__/
6
+ *.py[cod]
7
+ *.so
8
+
9
+ # Local environments and secrets
10
+ /.venv/
11
+ /.uv-cache/
12
+ .env
13
+ .env.*
14
+ !.env.example
15
+
16
+ # Packaging output
17
+ /build/
18
+ /dist/
19
+ *.egg-info/
20
+
21
+ # Test, coverage, and tool caches
22
+ .coverage
23
+ .coverage.*
24
+ /coverage.xml
25
+ /htmlcov/
26
+ /.pytest_cache/
27
+ /.ruff_cache/
28
+ /.cache/
29
+
30
+ # Generated documentation and code intelligence
31
+ /site/
32
+ /docs/_include/
33
+ /.codegraph/
34
+
35
+ # Docker reference ephemeral key material
36
+ /docker/reference/http-signatures/_runtime/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Vladislav Shepilov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: authweave-http-signatures
3
+ Version: 7.1.2
4
+ Summary: Payment HTTP Message Signatures profile for AuthWeave
5
+ Project-URL: homepage, https://github.com/ZYLVEXT/litestar-auth
6
+ Project-URL: documentation, https://zylvext.github.io/litestar-auth/
7
+ Project-URL: source, https://github.com/ZYLVEXT/litestar-auth
8
+ Project-URL: tracker, https://github.com/ZYLVEXT/litestar-auth/issues
9
+ Author-email: Vladislav Shepilov <shepilov.v@protonmail.com>
10
+ Maintainer-email: Vladislav Shepilov <shepilov.v@protonmail.com>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: content-digest,http-message-signatures,payments,rfc9421,security
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Topic :: Security
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Requires-Python: <3.15.0,>=3.12.0
23
+ Requires-Dist: authweave-core==7.1.2
24
+ Requires-Dist: cryptography<51.0,>=50.0.0
25
+ Requires-Dist: http-message-signatures<3.0,>=2.0.1
26
+ Requires-Dist: typing-extensions<5.0,>=4.16.0
27
+ Provides-Extra: litestar
28
+ Requires-Dist: litestar<3.0,>=2.24.0; extra == 'litestar'
29
+ Provides-Extra: redis
30
+ Requires-Dist: redis<9.0,>=8.1.0; extra == 'redis'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # authweave-http-signatures
34
+
35
+ Payment HTTP Message Signatures profile (`authweave-payment-http-sig-v1`)
36
+ for AuthWeave. Verifies RFC 9530 `Content-Digest` and RFC 9421 signatures after
37
+ machine authentication (mTLS or DPoP), then binds `keyid` to the authenticated
38
+ principal and consumes a signature nonce.
39
+
40
+ This package does **not** authenticate callers and does **not** implement business
41
+ idempotency. Structured Fields parsing uses the maintained
42
+ `http-message-signatures` library (RFC 8941 / RFC 9421).
43
+
44
+ `PaymentHttpSignatureVerifier(..., observer=...)` emits logical integrity and
45
+ nonce-replay observations through the neutral `authweave-core` observer seam.
46
+ `verify(..., links=(TraceCorrelation(...),))` supports async/retry causality;
47
+ linked trace context never participates in authentication or key binding.
48
+
49
+ Frozen/custom verifier clocks are request-local; the package does not mutate the
50
+ process-global clock of the Structured Fields/signature implementation.
51
+
52
+ `sign_payment_message` is a local reference/test helper and accepts an in-process
53
+ `Ed25519PrivateKey`. Production producers keep non-exportable keys in their own KMS/HSM
54
+ signer and emit the same documented RFC 9421 profile outside this verifier package.
55
+
56
+ ```bash
57
+ uv add authweave-http-signatures
58
+ ```
59
+
60
+ See ADR 0004 and `docs/roadmap.md`. Optional extras: `[redis]`, `[litestar]`.
@@ -0,0 +1,28 @@
1
+ # authweave-http-signatures
2
+
3
+ Payment HTTP Message Signatures profile (`authweave-payment-http-sig-v1`)
4
+ for AuthWeave. Verifies RFC 9530 `Content-Digest` and RFC 9421 signatures after
5
+ machine authentication (mTLS or DPoP), then binds `keyid` to the authenticated
6
+ principal and consumes a signature nonce.
7
+
8
+ This package does **not** authenticate callers and does **not** implement business
9
+ idempotency. Structured Fields parsing uses the maintained
10
+ `http-message-signatures` library (RFC 8941 / RFC 9421).
11
+
12
+ `PaymentHttpSignatureVerifier(..., observer=...)` emits logical integrity and
13
+ nonce-replay observations through the neutral `authweave-core` observer seam.
14
+ `verify(..., links=(TraceCorrelation(...),))` supports async/retry causality;
15
+ linked trace context never participates in authentication or key binding.
16
+
17
+ Frozen/custom verifier clocks are request-local; the package does not mutate the
18
+ process-global clock of the Structured Fields/signature implementation.
19
+
20
+ `sign_payment_message` is a local reference/test helper and accepts an in-process
21
+ `Ed25519PrivateKey`. Production producers keep non-exportable keys in their own KMS/HSM
22
+ signer and emit the same documented RFC 9421 profile outside this verifier package.
23
+
24
+ ```bash
25
+ uv add authweave-http-signatures
26
+ ```
27
+
28
+ See ADR 0004 and `docs/roadmap.md`. Optional extras: `[redis]`, `[litestar]`.
@@ -0,0 +1,48 @@
1
+ """Payment HTTP Message Signatures profile for AuthWeave."""
2
+
3
+ from authweave_http_signatures.binding import SignatureKeyBinding
4
+ from authweave_http_signatures.digest import CONTENT_DIGEST_SHA256, content_digest_sha256, verify_content_digest
5
+ from authweave_http_signatures.errors import HttpSignatureFailureCode, HttpSignatureVerificationError
6
+ from authweave_http_signatures.models import (
7
+ DEFAULT_MAX_BODY_BYTES,
8
+ DEFAULT_MAX_CLOCK_SKEW_SECONDS,
9
+ DEFAULT_MAX_SIGNATURE_LIFETIME_SECONDS,
10
+ DEFAULT_NONCE_TTL_SECONDS,
11
+ JSON_MEDIA_TYPES,
12
+ PROFILE_TAG,
13
+ SIGNATURE_LABEL,
14
+ HttpMessageView,
15
+ PaymentSignaturePolicy,
16
+ VerifiedHttpSignature,
17
+ )
18
+ from authweave_http_signatures.nonce import SignatureNonceGuard
19
+ from authweave_http_signatures.proxy_target import UNIX_SOCKET_PROXY, AllowlistedProxyExternalTarget
20
+ from authweave_http_signatures.signer import sign_payment_message
21
+ from authweave_http_signatures.verify import PaymentHttpSignatureVerifier
22
+
23
+ __version__ = "7.1.2"
24
+
25
+ __all__ = (
26
+ "CONTENT_DIGEST_SHA256",
27
+ "DEFAULT_MAX_BODY_BYTES",
28
+ "DEFAULT_MAX_CLOCK_SKEW_SECONDS",
29
+ "DEFAULT_MAX_SIGNATURE_LIFETIME_SECONDS",
30
+ "DEFAULT_NONCE_TTL_SECONDS",
31
+ "JSON_MEDIA_TYPES",
32
+ "PROFILE_TAG",
33
+ "SIGNATURE_LABEL",
34
+ "UNIX_SOCKET_PROXY",
35
+ "AllowlistedProxyExternalTarget",
36
+ "HttpMessageView",
37
+ "HttpSignatureFailureCode",
38
+ "HttpSignatureVerificationError",
39
+ "PaymentHttpSignatureVerifier",
40
+ "PaymentSignaturePolicy",
41
+ "SignatureKeyBinding",
42
+ "SignatureNonceGuard",
43
+ "VerifiedHttpSignature",
44
+ "__version__",
45
+ "content_digest_sha256",
46
+ "sign_payment_message",
47
+ "verify_content_digest",
48
+ )
@@ -0,0 +1,42 @@
1
+ """Bind HTTP signature ``keyid`` to an authenticated machine principal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ from authweave_http_signatures.errors import HttpSignatureFailureCode, HttpSignatureVerificationError
9
+
10
+ if TYPE_CHECKING:
11
+ from authweave_core import AuthenticationContext
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class SignatureKeyBinding:
16
+ """Exact ownership of one signing key for one authenticated context."""
17
+
18
+ key_id: str
19
+ application_id: str
20
+ principal_subject: str
21
+ environment: str
22
+
23
+ def __post_init__(self) -> None:
24
+ """Reject empty binding components."""
25
+ if not self.key_id or not self.application_id or not self.principal_subject or not self.environment:
26
+ msg = "signature key binding fields are required"
27
+ raise ValueError(msg)
28
+
29
+ def assert_matches(self, context: AuthenticationContext) -> None:
30
+ """Fail closed unless ``context`` matches this binding.
31
+
32
+ Raises:
33
+ HttpSignatureVerificationError: On identity mismatch.
34
+ """
35
+ evidence = context.evidence
36
+ application_id = evidence.extensions.get("authweave-workload:application_id")
37
+ if (
38
+ context.subject.subject != self.principal_subject
39
+ or evidence.environment != self.environment
40
+ or application_id != self.application_id
41
+ ):
42
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.KEY_BINDING_MISMATCH)
@@ -0,0 +1,40 @@
1
+ """RFC 9530 ``Content-Digest`` helpers for sha-256."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import hashlib
7
+ import re
8
+
9
+ from authweave_http_signatures.errors import HttpSignatureFailureCode, HttpSignatureVerificationError
10
+
11
+ CONTENT_DIGEST_SHA256 = "sha-256"
12
+ _DIGEST_PATTERN = re.compile(r"^sha-256=:(?P<digest>[A-Za-z0-9+/=]+):$")
13
+
14
+
15
+ def content_digest_sha256(body: bytes) -> str:
16
+ """Return an RFC 9530 ``Content-Digest`` header value for ``body``.
17
+
18
+ Returns:
19
+ ``sha-256=:base64:`` for the exact raw body.
20
+ """
21
+ digest = base64.b64encode(hashlib.sha256(body).digest()).decode("ascii")
22
+ return f"{CONTENT_DIGEST_SHA256}=:{digest}:"
23
+
24
+
25
+ def verify_content_digest(*, header_value: str, body: bytes) -> None:
26
+ """Validate ``Content-Digest`` against the exact raw body.
27
+
28
+ Raises:
29
+ HttpSignatureVerificationError: On malformed or mismatched digests.
30
+ """
31
+ match = _DIGEST_PATTERN.fullmatch(header_value.strip())
32
+ if match is None:
33
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.MALFORMED)
34
+ try:
35
+ presented = base64.b64decode(match.group("digest"), validate=True)
36
+ except ValueError as exc:
37
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.MALFORMED) from exc
38
+ expected = hashlib.sha256(body).digest()
39
+ if len(presented) != len(expected) or presented != expected:
40
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.DIGEST_MISMATCH)
@@ -0,0 +1,37 @@
1
+ """Typed failure codes for payment HTTP Message Signature verification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import StrEnum
6
+
7
+
8
+ class HttpSignatureFailureCode(StrEnum):
9
+ """Stable integrity failure codes (secret-free)."""
10
+
11
+ MALFORMED = "malformed"
12
+ BODY_TOO_LARGE = "body_too_large"
13
+ DIGEST_MISMATCH = "digest_mismatch"
14
+ CONTENT_TYPE_REJECTED = "content_type_rejected"
15
+ CONTENT_ENCODING_REJECTED = "content_encoding_rejected"
16
+ QUERY_REJECTED = "query_rejected"
17
+ SIGNATURE_INVALID = "signature_invalid"
18
+ SIGNATURE_EXPIRED = "signature_expired"
19
+ NOT_YET_VALID = "not_yet_valid"
20
+ PROFILE_MISMATCH = "profile_mismatch"
21
+ KEY_BINDING_MISMATCH = "key_binding_mismatch"
22
+ NONCE_REPLAY = "nonce_replay"
23
+ STORE_UNAVAILABLE = "store_unavailable"
24
+ MISSING_COMPONENT = "missing_component"
25
+
26
+
27
+ class HttpSignatureVerificationError(Exception):
28
+ """Fail-closed integrity verification error."""
29
+
30
+ def __init__(self, code: HttpSignatureFailureCode) -> None:
31
+ """Bind a secret-free failure code."""
32
+ self.code = code
33
+ super().__init__(code.value)
34
+
35
+ def __repr__(self) -> str:
36
+ """Omit request material from representations."""
37
+ return f"HttpSignatureVerificationError(code={self.code!r})"
@@ -0,0 +1,44 @@
1
+ """Litestar helpers for reading an exact raw body before verification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Protocol
6
+
7
+ from authweave_http_signatures.errors import HttpSignatureFailureCode, HttpSignatureVerificationError
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import AsyncIterator
11
+
12
+
13
+ class _LitestarRequest(Protocol):
14
+ def stream(self) -> AsyncIterator[bytes]: ...
15
+
16
+
17
+ async def read_bounded_raw_body(connection: _LitestarRequest, *, maximum_bytes: int) -> bytes:
18
+ """Stream the request body up to ``maximum_bytes`` without JSON parsing.
19
+
20
+ Returns:
21
+ The exact raw body bytes.
22
+
23
+ Raises:
24
+ HttpSignatureVerificationError: If the body is incomplete or too large.
25
+ """
26
+ if maximum_bytes <= 0:
27
+ msg = "maximum_bytes must be positive"
28
+ raise ValueError(msg)
29
+ chunks: list[bytes] = []
30
+ total = 0
31
+ oversized = False
32
+ stream: AsyncIterator[bytes] = connection.stream()
33
+ try:
34
+ async for chunk in stream:
35
+ total += len(chunk)
36
+ if total > maximum_bytes:
37
+ oversized = True
38
+ break
39
+ chunks.append(chunk)
40
+ except Exception as exc:
41
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.MALFORMED) from exc
42
+ if oversized:
43
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.BODY_TOO_LARGE)
44
+ return b"".join(chunks)
@@ -0,0 +1,128 @@
1
+ """Immutable HTTP message view and payment signature policy."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Final
7
+
8
+ PROFILE_TAG: Final = "authweave-payment-http-sig-v1"
9
+ SIGNATURE_LABEL: Final = "payment"
10
+ DEFAULT_MAX_BODY_BYTES: Final = 262_144
11
+ DEFAULT_MAX_CLOCK_SKEW_SECONDS: Final = 30
12
+ DEFAULT_MAX_SIGNATURE_LIFETIME_SECONDS: Final = 300
13
+ DEFAULT_NONCE_TTL_SECONDS: Final = 600
14
+ JSON_MEDIA_TYPES: Final = frozenset({"application/json", "application/json; charset=utf-8"})
15
+
16
+ _COVERED_BASE: Final = ("@method", "@target-uri", "content-digest", "content-type", "idempotency-key")
17
+ _COVERED_DPOP: Final = (*_COVERED_BASE, "authorization")
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class HttpMessageView:
22
+ """Exact raw HTTP request projection used for digest and signature checks."""
23
+
24
+ method: str
25
+ target_uri: str = field(repr=False)
26
+ headers: tuple[tuple[str, str], ...] = field(default=(), repr=False)
27
+ body: bytes = field(default=b"", repr=False)
28
+
29
+ def __post_init__(self) -> None:
30
+ """Reject empty method/target or oversized metadata."""
31
+ if not self.method or not self.method.isascii() or any(ch.isspace() for ch in self.method):
32
+ msg = "method must be non-empty printable ASCII without whitespace"
33
+ raise ValueError(msg)
34
+ if not self.target_uri or not self.target_uri.startswith("https://"):
35
+ msg = "target_uri must be an absolute https URI"
36
+ raise ValueError(msg)
37
+ if len(self.body) > DEFAULT_MAX_BODY_BYTES * 2:
38
+ msg = "body exceeds absolute safety ceiling"
39
+ raise ValueError(msg)
40
+
41
+ def header(self, name: str) -> str | None:
42
+ """Return the single value for ``name`` (case-insensitive) or ``None``.
43
+
44
+ Returns:
45
+ The header value when present exactly once.
46
+ """
47
+ needle = name.lower()
48
+ matches = [value for header, value in self.headers if header.lower() == needle]
49
+ if len(matches) > 1:
50
+ msg = f"duplicate header: {name}"
51
+ raise ValueError(msg)
52
+ return matches[0] if matches else None
53
+
54
+ def headers_dict(self) -> dict[str, str]:
55
+ """Return a case-preserving single-value header mapping.
56
+
57
+ Returns:
58
+ Header name to value.
59
+
60
+ Raises:
61
+ ValueError: If any header name is duplicated.
62
+ """
63
+ result: dict[str, str] = {}
64
+ for name, value in self.headers:
65
+ key = name.lower()
66
+ if key in {item.lower() for item in result}:
67
+ msg = f"duplicate header: {name}"
68
+ raise ValueError(msg)
69
+ result[name] = value
70
+ return result
71
+
72
+
73
+ @dataclass(frozen=True, slots=True)
74
+ class PaymentSignaturePolicy:
75
+ """Exact payment HTTP signature profile v1 policy."""
76
+
77
+ require_authorization_component: bool = False
78
+ allow_query: bool = False
79
+ max_body_bytes: int = DEFAULT_MAX_BODY_BYTES
80
+ max_clock_skew_seconds: int = DEFAULT_MAX_CLOCK_SKEW_SECONDS
81
+ max_signature_lifetime_seconds: int = DEFAULT_MAX_SIGNATURE_LIFETIME_SECONDS
82
+ nonce_ttl_seconds: int = DEFAULT_NONCE_TTL_SECONDS
83
+ json_media_types: frozenset[str] = field(default_factory=lambda: JSON_MEDIA_TYPES)
84
+ profile_tag: str = PROFILE_TAG
85
+ signature_label: str = SIGNATURE_LABEL
86
+
87
+ def __post_init__(self) -> None:
88
+ """Reject non-positive bounds or empty profile identity."""
89
+ if self.max_body_bytes <= 0 or self.max_clock_skew_seconds < 0:
90
+ msg = "body/clock policy is invalid"
91
+ raise ValueError(msg)
92
+ if self.max_signature_lifetime_seconds <= 0 or self.nonce_ttl_seconds <= 0:
93
+ msg = "lifetime policy is invalid"
94
+ raise ValueError(msg)
95
+ if not self.profile_tag or not self.signature_label or not self.json_media_types:
96
+ msg = "profile identity and media types are required"
97
+ raise ValueError(msg)
98
+
99
+ @property
100
+ def covered_components(self) -> tuple[str, ...]:
101
+ """The exact covered-component list for this route variant.
102
+
103
+ Returns:
104
+ Covered component identifiers in profile order.
105
+ """
106
+ return _COVERED_DPOP if self.require_authorization_component else _COVERED_BASE
107
+
108
+
109
+ @dataclass(frozen=True, slots=True)
110
+ class VerifiedHttpSignature:
111
+ """Secret-free result after digest, signature, binding, and nonce checks."""
112
+
113
+ key_id: str
114
+ nonce: str
115
+ profile_tag: str
116
+ created: int
117
+ expires: int
118
+ idempotency_key: str
119
+ content_type: str
120
+ body: bytes
121
+
122
+ def __repr__(self) -> str:
123
+ """Omit raw body bytes from representations."""
124
+ return (
125
+ "VerifiedHttpSignature("
126
+ f"key_id={self.key_id!r}, nonce=..., profile_tag={self.profile_tag!r}, "
127
+ f"idempotency_key={self.idempotency_key!r})"
128
+ )
@@ -0,0 +1,52 @@
1
+ """Signature nonce replay guard (not business idempotency)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from authweave_core import ReplayOutcome, SecurityOperation, SecurityOutcome, observe_security
8
+
9
+ from authweave_http_signatures.errors import HttpSignatureFailureCode, HttpSignatureVerificationError
10
+
11
+ if TYPE_CHECKING:
12
+ from authweave_core import ReplayStore, SecurityObserver
13
+
14
+
15
+ class SignatureNonceGuard:
16
+ """Consume one signature nonce exactly once per profile/key namespace."""
17
+
18
+ __slots__ = ("_profile_tag", "_store", "_ttl_seconds")
19
+
20
+ def __init__(self, store: ReplayStore, *, profile_tag: str, ttl_seconds: float) -> None:
21
+ """Bind the replay store and profile namespace."""
22
+ if ttl_seconds <= 0 or not profile_tag:
23
+ msg = "nonce guard requires positive ttl and profile_tag"
24
+ raise ValueError(msg)
25
+ self._store = store
26
+ self._profile_tag = profile_tag
27
+ self._ttl_seconds = ttl_seconds
28
+
29
+ async def consume(self, *, key_id: str, nonce: str, observer: SecurityObserver | None = None) -> None:
30
+ """Record ``nonce`` for ``key_id`` or raise on replay/outage.
31
+
32
+ Raises:
33
+ HttpSignatureVerificationError: Mapped from the replay store outcome.
34
+ """
35
+ if not key_id or not nonce:
36
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.MALFORMED)
37
+ key = f"http-sig:{self._profile_tag}:{key_id}:{nonce}"
38
+ with observe_security(observer, SecurityOperation.REPLAY_CHECK, profile=self._profile_tag) as observation:
39
+ outcome = await self._store.check_and_store(key, ttl_seconds=self._ttl_seconds)
40
+ if outcome is ReplayOutcome.STORED:
41
+ observation.set_outcome(SecurityOutcome.STORED)
42
+ elif outcome is ReplayOutcome.REPLAY:
43
+ observation.set_outcome(SecurityOutcome.REPLAY)
44
+ elif outcome is ReplayOutcome.CAPACITY_EXCEEDED:
45
+ observation.set_outcome(SecurityOutcome.CAPACITY_EXCEEDED)
46
+ else:
47
+ observation.set_outcome(SecurityOutcome.UNAVAILABLE)
48
+ if outcome is ReplayOutcome.STORED:
49
+ return
50
+ if outcome is ReplayOutcome.REPLAY:
51
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.NONCE_REPLAY)
52
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.STORE_UNAVAILABLE)
@@ -0,0 +1,85 @@
1
+ """Allowlisted reverse-proxy projection of the external HTTPS request target."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+ from urllib.parse import urlsplit
8
+
9
+ if TYPE_CHECKING:
10
+ from collections.abc import Mapping
11
+ from typing import Any
12
+
13
+ UNIX_SOCKET_PROXY = "unix"
14
+ _DEFAULT_HEADER = b"x-auth-external-target"
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class AllowlistedProxyExternalTarget:
19
+ """Project ``target_uri`` only from an allowlisted proxy identity header.
20
+
21
+ Client-controlled ``Host`` / ``Forwarded`` are ignored. The proxy must strip
22
+ and re-inject ``x-auth-external-target`` (ADR 0005).
23
+ """
24
+
25
+ proxy_addresses: frozenset[str]
26
+ header_name: bytes = _DEFAULT_HEADER
27
+
28
+ def __post_init__(self) -> None:
29
+ """Require a non-empty proxy allowlist and header name."""
30
+ if not self.proxy_addresses or not self.header_name:
31
+ msg = "proxy addresses and header name are required"
32
+ raise ValueError(msg)
33
+
34
+ def __call__(self, scope: Mapping[str, Any]) -> str | None:
35
+ """Return the absolute HTTPS target or ``None`` when absent.
36
+
37
+ Raises:
38
+ ValueError: If the proxy is untrusted or the header is ambiguous/malformed.
39
+ """
40
+ values = _header_values(scope, self.header_name)
41
+ if not values:
42
+ return None
43
+ client_address = _client_address(scope)
44
+ if client_address not in self.proxy_addresses:
45
+ msg = "external request target is not trusted"
46
+ raise ValueError(msg)
47
+ if len(values) != 1:
48
+ msg = "external request target is ambiguous"
49
+ raise ValueError(msg)
50
+ try:
51
+ target = values[0].decode("ascii")
52
+ except UnicodeDecodeError as exc:
53
+ msg = "external request target is invalid"
54
+ raise ValueError(msg) from exc
55
+ _validate_https_target(target)
56
+ return target
57
+
58
+
59
+ def _client_address(scope: Mapping[str, Any]) -> str | None:
60
+ client = scope.get("client")
61
+ if client is None:
62
+ return UNIX_SOCKET_PROXY
63
+ if isinstance(client, tuple) and client and isinstance(client[0], str):
64
+ # Uvicorn/ASGI often reports an empty host for Unix-domain peers.
65
+ return UNIX_SOCKET_PROXY if client[0] in {"", UNIX_SOCKET_PROXY} else client[0]
66
+ return None
67
+
68
+
69
+ def _header_values(scope: Mapping[str, Any], name: bytes) -> list[bytes]:
70
+ wanted = name.lower()
71
+ found: list[bytes] = []
72
+ for raw_name, value in scope.get("headers", ()):
73
+ if raw_name.lower() == wanted:
74
+ found.append(value)
75
+ return found
76
+
77
+
78
+ def _validate_https_target(target: str) -> None:
79
+ parts = urlsplit(target)
80
+ if parts.scheme != "https" or not parts.netloc or parts.fragment or parts.username or parts.password:
81
+ msg = "external request target must be an absolute https URL"
82
+ raise ValueError(msg)
83
+
84
+
85
+ __all__ = ("UNIX_SOCKET_PROXY", "AllowlistedProxyExternalTarget")
@@ -0,0 +1,42 @@
1
+ """Optional Redis-backed signature nonce replay store."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from authweave_core import ReplayOutcome, validate_replay_key
6
+
7
+
8
+ class RedisHttpSignatureReplayStore:
9
+ """Thin Redis SET NX EX adapter for HTTP signature nonces.
10
+
11
+ Requires the ``authweave-http-signatures[redis]`` extra.
12
+ """
13
+
14
+ __slots__ = ("_redis",)
15
+
16
+ def __init__(self, redis: object) -> None:
17
+ """Bind an async Redis client exposing ``set(..., nx=True, ex=...)``."""
18
+ self._redis = redis
19
+
20
+ async def check_and_store(self, key: str, *, ttl_seconds: float) -> ReplayOutcome:
21
+ """Atomically claim ``key`` with a TTL.
22
+
23
+ Returns:
24
+ The typed put-if-absent outcome.
25
+
26
+ Raises:
27
+ ValueError: If the key or TTL is invalid.
28
+ """
29
+ validate_replay_key(key)
30
+ if ttl_seconds <= 0:
31
+ msg = "ttl_seconds must be positive"
32
+ raise ValueError(msg)
33
+ try:
34
+ created = await self._redis.set( # ty: ignore[unresolved-attribute]
35
+ key,
36
+ "1",
37
+ nx=True,
38
+ ex=max(1, int(ttl_seconds)),
39
+ )
40
+ except Exception: # ruff: ignore[blind-except] - transport failures are Unavailable
41
+ return ReplayOutcome.UNAVAILABLE
42
+ return ReplayOutcome.STORED if created else ReplayOutcome.REPLAY
@@ -0,0 +1,94 @@
1
+ """Local Ed25519 payment-message signing helper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import UTC, datetime, timedelta
7
+
8
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
9
+ from http_message_signatures import HTTPMessageSigner, HTTPSignatureKeyResolver, algorithms
10
+
11
+ from authweave_http_signatures.digest import content_digest_sha256
12
+ from authweave_http_signatures.models import HttpMessageView, PaymentSignaturePolicy
13
+
14
+
15
+ @dataclass
16
+ class _MessageAdapter:
17
+ """Minimal request shape accepted by ``http-message-signatures``."""
18
+
19
+ method: str
20
+ url: str = field(repr=False)
21
+ headers: dict[str, str] = field(repr=False)
22
+
23
+
24
+ class _KeyResolver(HTTPSignatureKeyResolver):
25
+ def __init__(self, keys: dict[str, Ed25519PrivateKey]) -> None:
26
+ self._keys = keys
27
+
28
+ def resolve_private_key(self, key_id: str) -> Ed25519PrivateKey:
29
+ return self._keys[key_id]
30
+
31
+ def resolve_public_key(self, key_id: str) -> object:
32
+ return self._keys[key_id].public_key()
33
+
34
+
35
+ def sign_payment_message(
36
+ *,
37
+ view: HttpMessageView,
38
+ policy: PaymentSignaturePolicy,
39
+ key_id: str,
40
+ private_key: Ed25519PrivateKey,
41
+ nonce: str,
42
+ now: datetime | None = None,
43
+ lifetime: timedelta | None = None,
44
+ ) -> HttpMessageView:
45
+ """Attach profile-v1 digest and signature headers with a local private key.
46
+
47
+ This reference helper is for tests and in-process signers. Production KMS/HSM
48
+ integrations should construct the same RFC 9421 profile outside AuthWeave so
49
+ private key material remains non-exportable.
50
+
51
+ Returns:
52
+ A new ``HttpMessageView`` with digest and signature headers applied.
53
+
54
+ Raises:
55
+ ValueError: If required application headers are missing.
56
+ """
57
+ created = now or datetime.now(UTC)
58
+ signature_lifetime = timedelta(seconds=policy.max_signature_lifetime_seconds) if lifetime is None else lifetime
59
+ if not timedelta(0) < signature_lifetime <= timedelta(seconds=policy.max_signature_lifetime_seconds):
60
+ msg = "signature lifetime is invalid"
61
+ raise ValueError(msg)
62
+ expires = created + signature_lifetime
63
+ headers = dict(view.headers_dict())
64
+ headers["content-digest"] = content_digest_sha256(view.body)
65
+ if "content-type" not in {name.lower() for name in headers}:
66
+ msg = "content-type is required"
67
+ raise ValueError(msg)
68
+ if "idempotency-key" not in {name.lower() for name in headers}:
69
+ msg = "idempotency-key is required"
70
+ raise ValueError(msg)
71
+ if policy.require_authorization_component and "authorization" not in {name.lower() for name in headers}:
72
+ msg = "authorization is required for DPoP variant"
73
+ raise ValueError(msg)
74
+ # Canonical lowercase names for covered components.
75
+ normalized = {name.lower(): value for name, value in headers.items()}
76
+ adapter = _MessageAdapter(method=view.method, url=view.target_uri, headers=normalized)
77
+ signer = HTTPMessageSigner(
78
+ signature_algorithm=algorithms.ED25519,
79
+ key_resolver=_KeyResolver({key_id: private_key}),
80
+ )
81
+ signer.sign(
82
+ adapter,
83
+ key_id=key_id,
84
+ created=created,
85
+ expires=expires,
86
+ nonce=nonce,
87
+ label=policy.signature_label,
88
+ tag=policy.profile_tag,
89
+ covered_component_ids=list(policy.covered_components),
90
+ include_alg=True,
91
+ )
92
+ # Reconstruct header tuple preserving signature headers from the adapter.
93
+ final_headers = tuple((name, value) for name, value in adapter.headers.items())
94
+ return HttpMessageView(method=view.method, target_uri=view.target_uri, headers=final_headers, body=view.body)
@@ -0,0 +1,281 @@
1
+ """Payment HTTP Message Signature verifier."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Mapping, Sequence
6
+ from dataclasses import dataclass, field
7
+ from datetime import UTC, datetime, timedelta
8
+ from typing import TYPE_CHECKING, Any
9
+ from urllib.parse import urlsplit
10
+
11
+ from authweave_core import SecurityOperation, SecurityOutcome, observe_security
12
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
13
+ from http_message_signatures import (
14
+ HTTPMessageVerifier,
15
+ HTTPSignatureKeyResolver,
16
+ InvalidSignature,
17
+ algorithms,
18
+ http_sfv,
19
+ )
20
+ from http_message_signatures.structures import CaseInsensitiveDict
21
+
22
+ from authweave_http_signatures.digest import verify_content_digest
23
+ from authweave_http_signatures.errors import HttpSignatureFailureCode, HttpSignatureVerificationError
24
+ from authweave_http_signatures.models import VerifiedHttpSignature
25
+
26
+ if TYPE_CHECKING:
27
+ from authweave_core import AuthenticationContext, SecurityObserver, TraceCorrelation
28
+
29
+ from authweave_http_signatures.binding import SignatureKeyBinding
30
+ from authweave_http_signatures.models import HttpMessageView, PaymentSignaturePolicy
31
+ from authweave_http_signatures.nonce import SignatureNonceGuard
32
+
33
+
34
+ @dataclass
35
+ class _MessageAdapter:
36
+ method: str
37
+ url: str = field(repr=False)
38
+ headers: Mapping[str, str] = field(repr=False)
39
+
40
+
41
+ class _PublicKeyResolver(HTTPSignatureKeyResolver):
42
+ def __init__(self, keys: Mapping[str, Ed25519PublicKey]) -> None:
43
+ self._keys = keys
44
+
45
+ def resolve_public_key(self, key_id: str) -> Ed25519PublicKey:
46
+ try:
47
+ return self._keys[key_id]
48
+ except KeyError as exc:
49
+ msg = "unknown key id"
50
+ raise KeyError(msg) from exc
51
+
52
+ def resolve_private_key(self, key_id: str) -> Ed25519PublicKey:
53
+ raise KeyError(key_id)
54
+
55
+
56
+ class _ProfileHTTPMessageVerifier(HTTPMessageVerifier):
57
+ """Delegate parsing/crypto while AuthWeave owns request-local time policy."""
58
+
59
+ def validate_created_and_expires(
60
+ self,
61
+ sig_input: Any,
62
+ max_age: timedelta | None = None,
63
+ ) -> None:
64
+ """Skip the upstream process-global wall clock check.
65
+
66
+ AuthWeave validates required ``created``/``expires``, lifetime, expiry,
67
+ future time, and configured skew immediately after signature verification.
68
+ """
69
+ _ = self, sig_input, max_age
70
+
71
+
72
+ class PaymentHttpSignatureVerifier:
73
+ """Verify Content-Digest + RFC 9421 signature for profile v1."""
74
+
75
+ __slots__ = ("_bindings", "_nonce_guard", "_observer", "_policy", "_public_keys", "_time_source")
76
+
77
+ def __init__(
78
+ self,
79
+ *,
80
+ policy: PaymentSignaturePolicy,
81
+ public_keys: Mapping[str, Ed25519PublicKey],
82
+ bindings: Mapping[str, SignatureKeyBinding],
83
+ nonce_guard: SignatureNonceGuard,
84
+ time_source: Callable[[], datetime] | None = None,
85
+ observer: SecurityObserver | None = None,
86
+ ) -> None:
87
+ """Bind profile policy, public keys, ownership, and nonce store."""
88
+ if not public_keys or not bindings:
89
+ msg = "public keys and bindings are required"
90
+ raise ValueError(msg)
91
+ self._policy = policy
92
+ self._public_keys = dict(public_keys)
93
+ self._bindings = dict(bindings)
94
+ self._nonce_guard = nonce_guard
95
+ self._time_source = time_source or (lambda: datetime.now(UTC))
96
+ self._observer = observer
97
+
98
+ async def verify(
99
+ self,
100
+ view: HttpMessageView,
101
+ *,
102
+ context: AuthenticationContext,
103
+ links: Sequence[TraceCorrelation] = (),
104
+ ) -> VerifiedHttpSignature:
105
+ """Verify digest, signature, key binding, and nonce fail-closed.
106
+
107
+ Returns:
108
+ A secret-free verified integrity envelope retaining the exact body.
109
+
110
+ Raises:
111
+ HttpSignatureVerificationError: On any integrity failure.
112
+ """
113
+ with observe_security(
114
+ self._observer,
115
+ SecurityOperation.VERIFY_HTTP_SIGNATURE,
116
+ profile=self._policy.profile_tag,
117
+ credential_kind="http_message_signature",
118
+ links=links,
119
+ ) as observation:
120
+ try:
121
+ result = await self._verify(view, context=context)
122
+ except HttpSignatureVerificationError as exc:
123
+ outcome = (
124
+ SecurityOutcome.UNAVAILABLE
125
+ if exc.code is HttpSignatureFailureCode.STORE_UNAVAILABLE
126
+ else SecurityOutcome.INVALID
127
+ )
128
+ observation.set_outcome(outcome, reason_code=exc.code.value)
129
+ raise
130
+ observation.set_outcome(SecurityOutcome.VERIFIED)
131
+ return result
132
+
133
+ async def _verify(self, view: HttpMessageView, *, context: AuthenticationContext) -> VerifiedHttpSignature:
134
+ self._check_body_and_encoding(view)
135
+ self._check_query(view.target_uri)
136
+ content_type = self._require_header(view, "content-type")
137
+ if content_type.lower() not in {item.lower() for item in self._policy.json_media_types}:
138
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.CONTENT_TYPE_REJECTED)
139
+ idempotency_key = self._require_header(view, "idempotency-key")
140
+ digest = self._require_header(view, "content-digest")
141
+ verify_content_digest(header_value=digest, body=view.body)
142
+ if self._policy.require_authorization_component:
143
+ self._require_header(view, "authorization")
144
+ signature_input = self._require_header(view, "signature-input")
145
+ signature = self._require_header(view, "signature")
146
+ self._assert_single_profile_signature(signature_input=signature_input, signature=signature)
147
+ headers = CaseInsensitiveDict(dict(view.headers_dict().items()))
148
+ adapter = _MessageAdapter(method=view.method, url=view.target_uri, headers=headers)
149
+ verifier = _ProfileHTTPMessageVerifier(
150
+ signature_algorithm=algorithms.ED25519,
151
+ key_resolver=_PublicKeyResolver(self._public_keys),
152
+ )
153
+ try:
154
+ results = verifier.verify(
155
+ adapter,
156
+ max_age=timedelta(seconds=self._policy.max_signature_lifetime_seconds),
157
+ expect_tag=self._policy.profile_tag,
158
+ expect_label=self._policy.signature_label,
159
+ )
160
+ except InvalidSignature as exc:
161
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.SIGNATURE_INVALID) from exc
162
+ except Exception as exc:
163
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.MALFORMED) from exc
164
+ if len(results) != 1:
165
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.MALFORMED)
166
+ result = results[0]
167
+ params = dict(result.parameters)
168
+ key_id = params.get("keyid")
169
+ nonce = params.get("nonce")
170
+ created = params.get("created")
171
+ expires = params.get("expires")
172
+ tag = params.get("tag")
173
+ if not isinstance(key_id, str) or not isinstance(nonce, str) or tag != self._policy.profile_tag:
174
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.PROFILE_MISMATCH)
175
+ created_i = _structured_field_integer(created)
176
+ expires_i = _structured_field_integer(expires)
177
+ if created_i is None or expires_i is None:
178
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.MALFORMED)
179
+ now = int(self._time_source().timestamp())
180
+ if created_i > now + self._policy.max_clock_skew_seconds:
181
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.NOT_YET_VALID)
182
+ if expires_i <= now - self._policy.max_clock_skew_seconds:
183
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.SIGNATURE_EXPIRED)
184
+ if expires_i <= created_i or expires_i - created_i > self._policy.max_signature_lifetime_seconds:
185
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.MALFORMED)
186
+ covered = tuple(
187
+ key.strip('"') for key in result.covered_components if str(key).strip('"') != "@signature-params"
188
+ )
189
+ if covered != self._policy.covered_components:
190
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.PROFILE_MISMATCH)
191
+ binding = self._bindings.get(key_id)
192
+ if binding is None:
193
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.KEY_BINDING_MISMATCH)
194
+ binding.assert_matches(context)
195
+ await self._nonce_guard.consume(key_id=key_id, nonce=nonce, observer=self._observer)
196
+ return VerifiedHttpSignature(
197
+ key_id=key_id,
198
+ nonce=nonce,
199
+ profile_tag=self._policy.profile_tag,
200
+ created=created_i,
201
+ expires=expires_i,
202
+ idempotency_key=idempotency_key,
203
+ content_type=content_type,
204
+ body=view.body,
205
+ )
206
+
207
+ def _check_body_and_encoding(self, view: HttpMessageView) -> None:
208
+ if len(view.body) > self._policy.max_body_bytes:
209
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.BODY_TOO_LARGE)
210
+ encoding = view.header("content-encoding")
211
+ if encoding is not None and encoding.lower() not in {"", "identity"}:
212
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.CONTENT_ENCODING_REJECTED)
213
+
214
+ def _check_query(self, target_uri: str) -> None:
215
+ if self._policy.allow_query:
216
+ return
217
+ if urlsplit(target_uri).query:
218
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.QUERY_REJECTED)
219
+
220
+ def _assert_single_profile_signature(self, *, signature_input: str, signature: str) -> None:
221
+ """Require exactly one SF dictionary member equal to the profile label.
222
+
223
+ Upstream parsers collapse duplicate keys; count top-level members so
224
+ ``label=..., label=...`` cannot silently overwrite.
225
+ """
226
+ label = self._policy.signature_label
227
+ if _sf_dictionary_member_count(signature_input) != 1 or _sf_dictionary_member_count(signature) != 1:
228
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.MALFORMED)
229
+ try:
230
+ input_node = http_sfv.Dictionary()
231
+ input_node.parse(signature_input.encode("ascii"))
232
+ signature_node = http_sfv.Dictionary()
233
+ signature_node.parse(signature.encode("ascii"))
234
+ except Exception as exc:
235
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.MALFORMED) from exc
236
+ if list(input_node.keys()) != [label] or list(signature_node.keys()) != [label]:
237
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.PROFILE_MISMATCH)
238
+
239
+ @staticmethod
240
+ def _require_header(view: HttpMessageView, name: str) -> str:
241
+ try:
242
+ value = view.header(name)
243
+ except ValueError as exc:
244
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.MALFORMED) from exc
245
+ if value is None or not value.strip():
246
+ raise HttpSignatureVerificationError(HttpSignatureFailureCode.MISSING_COMPONENT)
247
+ return value
248
+
249
+
250
+ def _sf_dictionary_member_count(raw: str) -> int:
251
+ """Count RFC 8941 dictionary members separated by top-level commas."""
252
+ text = raw.strip()
253
+ if not text:
254
+ return 0
255
+ depth = 0
256
+ in_string = False
257
+ escape = False
258
+ members = 1
259
+ for char in text:
260
+ if in_string:
261
+ if escape:
262
+ escape = False
263
+ elif char == "\\":
264
+ escape = True
265
+ elif char == '"':
266
+ in_string = False
267
+ continue
268
+ if char == '"':
269
+ in_string = True
270
+ elif char == "(":
271
+ depth += 1
272
+ elif char == ")" and depth:
273
+ depth -= 1
274
+ elif char == "," and depth == 0:
275
+ members += 1
276
+ return members
277
+
278
+
279
+ def _structured_field_integer(value: object) -> int | None:
280
+ """Accept only RFC 8941 Integer values, excluding Python ``bool``."""
281
+ return value if isinstance(value, int) and not isinstance(value, bool) else None
@@ -0,0 +1,76 @@
1
+ [project]
2
+ name = "authweave-http-signatures"
3
+ version = "7.1.2"
4
+ description = "Payment HTTP Message Signatures profile for AuthWeave"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [
9
+ {name = "Vladislav Shepilov", email = "shepilov.v@protonmail.com"},
10
+ ]
11
+ maintainers = [
12
+ {name = "Vladislav Shepilov", email = "shepilov.v@protonmail.com"},
13
+ ]
14
+ keywords = ["http-message-signatures", "rfc9421", "content-digest", "payments", "security"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Topic :: Security",
19
+ "Topic :: Software Development :: Libraries",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
24
+ ]
25
+ requires-python = "<3.15.0,>=3.12.0"
26
+ dependencies = [
27
+ "authweave-core==7.1.2",
28
+ "cryptography>=50.0.0,<51.0",
29
+ "http-message-signatures>=2.0.1,<3.0",
30
+ "typing-extensions>=4.16.0,<5.0",
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ redis = ["redis>=8.1.0,<9.0"]
35
+ litestar = ["litestar>=2.24.0,<3.0"]
36
+
37
+ [project.urls]
38
+ homepage = "https://github.com/ZYLVEXT/litestar-auth"
39
+ documentation = "https://zylvext.github.io/litestar-auth/"
40
+ source = "https://github.com/ZYLVEXT/litestar-auth"
41
+ tracker = "https://github.com/ZYLVEXT/litestar-auth/issues"
42
+
43
+ [build-system]
44
+ requires = ["hatchling==1.31.0"]
45
+ build-backend = "hatchling.build"
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["authweave_http_signatures"]
49
+
50
+ [tool.hatch.build.targets.sdist]
51
+ only-include = ["authweave_http_signatures"]
52
+
53
+ [tool.coverage.run]
54
+ source = ["authweave_http_signatures"]
55
+ branch = true
56
+
57
+ [tool.coverage.report]
58
+ fail_under = 100
59
+ show_missing = true
60
+
61
+ [tool.ty.environment]
62
+ python-version = "3.12"
63
+
64
+ [tool.ty.src]
65
+ include = ["authweave_http_signatures", "tests"]
66
+
67
+ [tool.deptry]
68
+ experimental_namespace_package = true
69
+
70
+ [tool.deptry.per_rule_ignores]
71
+ # http-message-signatures 2.0.1 vendors http_sfv code that imports typing_extensions but omits it
72
+ # from Requires-Dist; keep the direct dependency until upstream metadata includes it.
73
+ DEP002 = ["litestar", "redis", "typing-extensions"]
74
+
75
+ [tool.uv.sources]
76
+ authweave-core = {workspace = true}