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.
- authweave_http_signatures-7.1.2/.gitignore +36 -0
- authweave_http_signatures-7.1.2/LICENSE +21 -0
- authweave_http_signatures-7.1.2/PKG-INFO +60 -0
- authweave_http_signatures-7.1.2/README.md +28 -0
- authweave_http_signatures-7.1.2/authweave_http_signatures/__init__.py +48 -0
- authweave_http_signatures-7.1.2/authweave_http_signatures/binding.py +42 -0
- authweave_http_signatures-7.1.2/authweave_http_signatures/digest.py +40 -0
- authweave_http_signatures-7.1.2/authweave_http_signatures/errors.py +37 -0
- authweave_http_signatures-7.1.2/authweave_http_signatures/litestar_adapter.py +44 -0
- authweave_http_signatures-7.1.2/authweave_http_signatures/models.py +128 -0
- authweave_http_signatures-7.1.2/authweave_http_signatures/nonce.py +52 -0
- authweave_http_signatures-7.1.2/authweave_http_signatures/proxy_target.py +85 -0
- authweave_http_signatures-7.1.2/authweave_http_signatures/py.typed +0 -0
- authweave_http_signatures-7.1.2/authweave_http_signatures/redis_store.py +42 -0
- authweave_http_signatures-7.1.2/authweave_http_signatures/signer.py +94 -0
- authweave_http_signatures-7.1.2/authweave_http_signatures/verify.py +281 -0
- authweave_http_signatures-7.1.2/pyproject.toml +76 -0
|
@@ -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")
|
|
File without changes
|
|
@@ -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}
|